работа на ui фронтом и автогенерацией api клиента

This commit is contained in:
kirillius 2026-01-28 12:02:46 +03:00
parent 4032586662
commit b46d1bc359
17 changed files with 946 additions and 84 deletions

View File

@ -48,11 +48,10 @@ async function loadApiSpec() {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
apiSpec = await response.json()
console.log('API Spec loaded:', apiSpec)
let moduleSpecs = {};
apiSpec.modules.forEach(m => moduleSpecs[m.name] = m);
return moduleSpecs
const data = await response.json()
apiSpec = data.modules
console.log('API Spec loaded:', data)
return apiSpec
} catch (error) {
console.error('Ошибка загрузки API спецификации:', error)
$('#result').text('Ошибка загрузки API спецификации: ' + error.message)
@ -64,8 +63,8 @@ function populateServices() {
$serviceSelect.empty()
$serviceSelect.append('<option value="">Выберите сервис</option>')
Object.keys(apiSpec).forEach(serviceName => {
$serviceSelect.append(`<option value="${serviceName}">${serviceName}</option>`)
apiSpec.forEach(service => {
$serviceSelect.append(`<option value="${service.name}">${service.name}</option>`)
})
}
@ -74,10 +73,13 @@ function populateMethods(serviceName) {
$methodSelect.empty()
$methodSelect.append('<option value="">Выберите метод</option>')
if (serviceName && apiSpec[serviceName] && apiSpec[serviceName].methods) {
apiSpec[serviceName].methods.forEach(method => {
$methodSelect.append(`<option value="${method.name}">${method.name}</option>`)
})
if (serviceName) {
const service = apiSpec.find(s => s.name === serviceName)
if (service && service.methods) {
service.methods.forEach(method => {
$methodSelect.append(`<option value="${method.name}">${method.name}</option>`)
})
}
}
}
@ -87,7 +89,7 @@ function createParamInputs(methodName, serviceName) {
if (!serviceName || !methodName) return
const service = apiSpec[serviceName]
const service = apiSpec.find(s => s.name === serviceName)
if (!service || !service.methods) return
const method = service.methods.find(m => m.name === methodName)
@ -160,7 +162,7 @@ async function sendRequest() {
const paramType = $input.attr('id').replace('param-', '')
// Find parameter type from method definition
const service = apiSpec[serviceName]
const service = apiSpec.find(s => s.name === serviceName)
const method = service.methods.find(m => m.name === methodName)
const paramDef = method.params.find(p => p.name === paramName)
const isObjectOrArray = paramDef && (paramDef.type === 'object' || paramDef.type === 'array')
@ -240,7 +242,7 @@ async function sendRequest() {
}
$(document).ready(async () => {
await loadApiSpec()
apiSpec = await loadApiSpec()
if (apiSpec) {
populateServices()

View File

@ -27,6 +27,10 @@ public interface ApiToken extends PersistenceEntity {
@JsonIgnore
default boolean isExpired() {
return getExpirationDate().toInstant().isBefore(Instant.now());
var expirationDate = getExpirationDate();
if (expirationDate == null) {
return false;
}
return expirationDate.toInstant().isBefore(Instant.now());
}
}

View File

@ -4,10 +4,11 @@ import ru.kirillius.XCP.Commons.GenerateApiSpec;
import java.util.UUID;
@GenerateApiSpec
public interface PersistenceEntity {
@GenerateApiSpec
long getId();
@GenerateApiSpec
@GenerateApiSpec(type = String.class)
UUID getUuid();
}

View File

@ -111,6 +111,7 @@ public class Auth extends JsonRpcService {
token.setExpirationDate(permanent ? null : Date.from(Instant.now().plus(30, ChronoUnit.DAYS)));
token.setName(name);
token.setUser(call.getCurrentUser());
tokenRepository.save(token);

View File

@ -18,6 +18,7 @@ public class Profile extends JsonRpcService {
@JsonRpcMethod.Parameter(name = "login", description = "User login. Have to be unique", type = String.class),
@JsonRpcMethod.Parameter(name = "name", description = "User display name", type = String.class),
@JsonRpcMethod.Parameter(name = "password", description = "Change user password if defined", type = String.class, optional = true),
@JsonRpcMethod.Parameter(name = "currentPassword", description = "Current user password", type = String.class, optional = true),
@JsonRpcMethod.Parameter(name = "values", description = "User custom values", type = ObjectNode.class)
},
returnType = boolean.class)
@ -28,6 +29,8 @@ public class Profile extends JsonRpcService {
var login = requireParam(call, "login", JsonNode::asString);
var name = requireParam(call, "name", JsonNode::asString);
var passwordOptional = getParam(call, "password", JsonNode::asString);
var currentPassword = passwordOptional.isPresent() ? requireParam(call, "currentPassword", JsonNode::asString) : null;
var values = requireParam(call, "values", n -> (ObjectNode) n);
if (!user.getLogin().equals(login) && userRepository.getByLogin(login) != null) {
@ -50,6 +53,9 @@ public class Profile extends JsonRpcService {
if (password.isBlank()) {
throw new RuntimeException("Password is blank");
}
if (!user.verifyPassword(currentPassword)) {
throw new RuntimeException("Current user password is invalid");
}
user.setPassword(password);
}

View File

@ -27,9 +27,19 @@ interface ApiModule {
methods: ApiMethod[]
}
interface ApiType {
name: string
type: 'class' | 'enum'
fields?: Array<{
name: string
type: string
}>
values?: string[]
}
interface ApiSpec {
modules: ApiModule[]
types: any[]
types: ApiType[]
}
function generateMethod(method: ApiMethod): string {
@ -59,6 +69,27 @@ ${sortedParams.map((param) => ` * @param ${param.name} ${param.description}`
}`
}
function generateType(apiType: ApiType): string {
if (apiType.type === 'enum') {
const values = apiType.values || []
return `export enum ${apiType.name} {
${values.map((value) => ` ${value} = "${value}"`).join(',\n')}
}`
} else if (apiType.type === 'class') {
const fields = apiType.fields || []
const fieldDefinitions = fields.map((field) => ` ${field.name}: ${field.type};`).join('\n')
return `export interface ${apiType.name} {
${fieldDefinitions}
}`
}
return ''
}
function generateTypes(spec: ApiSpec): string {
const types = spec.types.map(generateType).join('\n\n')
return types ? `\n// Generated Types\n${types}` : ''
}
function generateModule(module: ApiModule): string {
const className = `${module.name}Module`
const methods = module.methods.map(generateMethod).join('\n')
@ -88,9 +119,11 @@ function generateClient(spec: ApiSpec): string {
.join('\n')
const moduleClasses = spec.modules.map(generateModule).join('\n\n')
const types = generateTypes(spec)
return `import {RpcClientBase} from "@/api/RpcClientBase.ts";
import {RpcModuleBase} from "@/api/RpcModuleBase.ts";
${types}
export class RpcClient extends RpcClientBase {${modules}
@ -127,6 +160,11 @@ function main() {
fs.writeFileSync(outputPath, clientCode)
console.log(`✅ RPC client generated successfully: ${outputPath}`)
console.log(`📋 Generated ${spec.modules.length} API modules`)
if (spec.types.length > 0) {
console.log(
`📝 Generated ${spec.types.length} types (${spec.types.filter((t) => t.type === 'class').length} interfaces, ${spec.types.filter((t) => t.type === 'enum').length} enums)`
)
}
} catch (error) {
console.error('❌ Error generating RPC client:', error)
process.exit(1)

View File

@ -1,17 +1,38 @@
<template>
<v-app>
<FooterBar @toggle-menu="toggleMenu" />
<LoginForm />
<Notifications />
<v-main
:class="{
'mobile-main': isMobile,
'collapsed-menu': !isMobile && isCollapsed
}"
>
<RouterView />
</v-main>
<template v-if="!authStore.showLoginForm && !appStore.isLoading">
<div class="app-layout">
<!-- Часть А: Меню (слева для ПК, шторка для мобильных) -->
<NavigationMenu
ref="navigationMenu"
@update-collapsed="isCollapsed = $event"
@toggle-menu="toggleMobileMenu"
/>
<NavigationMenu ref="navigationMenu" @update-collapsed="isCollapsed = $event" />
<!-- Часть Б: Футер + Контент -->
<div
class="content-section"
:class="{ 'with-menu': !isMobile, 'menu-collapsed': !isMobile && isCollapsed }"
:style="{ marginLeft: !isMobile ? (isCollapsed ? '80px' : '250px') : '0' }"
>
<!-- Футер (HeaderBar) - скрыт для ПК, показан для мобильных -->
<HeaderBar v-if="isMobile || showFooter" @toggle-menu="toggleMobileMenu" />
<!-- Основной контент -->
<v-main
:class="{
'mobile-main': isMobile,
'with-menu': !isMobile
}"
>
<RouterView />
</v-main>
</div>
</div>
</template>
</v-app>
</template>
@ -19,15 +40,24 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router'
import NavigationMenu from './components/NavigationMenu.vue'
import FooterBar from './components/FooterBar.vue'
import HeaderBar from './components/HeaderBar.vue'
import LoginForm from './components/LoginForm.vue'
import Notifications from './components/Notifications.vue'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notification'
const appStore = useAppStore()
const authStore = useAuthStore()
const navigationMenu = ref()
const windowWidth = ref(window.innerWidth)
const isCollapsed = ref(false)
const showFooter = ref(false)
const isMobile = computed(() => windowWidth.value < 768)
const toggleMenu = () => {
const toggleMobileMenu = () => {
if (navigationMenu.value) {
navigationMenu.value.toggle()
}
@ -37,8 +67,11 @@ const updateWidth = () => {
windowWidth.value = window.innerWidth
}
onMounted(() => {
onMounted(async () => {
window.addEventListener('resize', updateWidth)
appStore.initializeApi('http://localhost:8080/api')
await authStore.initializeAuth()
})
onUnmounted(() => {
@ -48,17 +81,43 @@ onUnmounted(() => {
<style>
#app {
height: 100vh;
overflow: hidden;
min-height: 100vh;
}
.v-application {
height: 100vh;
min-height: 100vh;
width: 100%;
}
/* Основной layout контейнер */
.app-layout {
display: flex;
min-height: 100vh;
width: 100%;
}
/* Часть Б: Контентная секция */
.content-section {
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
background: rgb(var(--v-theme-background));
transition: margin-left 0.3s ease;
}
/* Для ПК - основной контент без отступов */
.v-main.with-menu {
padding: 16px;
flex: 1;
background: transparent !important;
}
/* Для мобильных - контент с отступом под футером */
.mobile-main {
padding-top: 64px !important;
height: calc(100vh - 64px) !important;
padding-top: 45px !important;
flex: 1 !important;
}
.mobile-main :deep(.v-main__content) {
@ -68,13 +127,4 @@ onUnmounted(() => {
.mobile-main :deep(.v-container) {
padding: 0 !important;
}
/* Исправляем контент для ПК */
.v-main:not(.mobile-main) {
max-width: calc(100% - 250px) !important;
}
.v-main:not(.mobile-main).collapsed-menu {
max-width: calc(100% - 80px) !important;
}
</style>

View File

@ -9,6 +9,7 @@ export class RpcClientBase {
protected async call<T>(method: string, params?: any): Promise<T> {
const response = await fetch(this.baseUrl, {
method: 'POST',
credentials: "include",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',

View File

@ -16,17 +16,15 @@
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
margin: 0;
padding: 0;
text-align: left;
}
a {

View File

@ -1,9 +1,16 @@
<template>
<v-app-bar v-if="isMobile" app color="primary" class="mobile-header-bar" elevation="4">
<v-app-bar
v-if="isMobile"
app
color="grey-darken-3"
class="mobile-header-bar"
elevation="4"
height="45"
>
<v-container fluid class="pa-0">
<v-row align="center" no-gutters>
<v-col cols="8" class="d-flex align-center">
<v-icon :icon="currentPageInfo.icon" color="white" class="me-2" />
<v-icon :icon="currentPageInfo.icon" color="white" class="ms-4 me-2" />
<span class="current-page-text">{{ currentPageInfo.title }}</span>
</v-col>
<v-col cols="4" class="text-end">
@ -17,15 +24,27 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const isMobile = ref(window.innerWidth < 768)
const getUserDisplayName = () => {
const user = appStore.currentUser
if (user?.name && user.name.trim()) {
return user.name
}
return user?.login || 'Профиль'
}
const currentPageInfo = computed(() => {
const routeMap: Record<string, { title: string; icon: string }> = {
'/': { title: 'Главная', icon: 'mdi-home' },
'/profile': { title: 'Профиль', icon: 'mdi-account' },
'/profile': { title: getUserDisplayName(), icon: 'mdi-account' },
'/settings': { title: 'Настройки', icon: 'mdi-cog' },
'/dashboard': { title: 'Панель управления', icon: 'mdi-view-dashboard' }
}
@ -55,7 +74,7 @@ onUnmounted(() => {
</script>
<style scoped>
.footer-bar {
.header-bar {
z-index: 1001;
}

View File

@ -0,0 +1,83 @@
<template>
<v-dialog v-model="show" persistent max-width="400">
<v-card>
<v-card-title class="text-h5">Авторизация</v-card-title>
<v-card-text>
<v-form @submit.prevent="handleSubmit">
<v-text-field
ref="loginField"
v-model="loginForm.login"
label="Логин"
prepend-inner-icon="mdi-account"
variant="outlined"
:disabled="isLoading"
class="mb-3"
@keyup.enter="focusPassword"
/>
<v-text-field
ref="passwordField"
v-model="loginForm.password"
label="Пароль"
prepend-inner-icon="mdi-lock"
type="password"
variant="outlined"
:disabled="isLoading"
class="mb-3"
@keyup.enter="handleSubmit"
/>
<v-checkbox v-model="loginForm.rememberMe" label="Запомнить меня" :disabled="isLoading" />
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="handleSubmit" :loading="isLoading" color="primary"> Войти </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
const authStore = useAuthStore()
const appStore = useAppStore()
const loginField = ref()
const passwordField = ref()
const loginForm = computed(() => authStore.loginForm)
const show = computed({
get: () => authStore.showLoginForm,
set: (value) => {
authStore.showLoginForm = value
}
})
const isLoading = computed(() => appStore.isLoading)
// Фокус на поле логина при открытии диалога
watch(show, (newShow) => {
if (newShow) {
nextTick(() => {
loginField.value?.focus()
})
}
})
const focusPassword = () => {
passwordField.value?.focus()
}
const handleSubmit = async () => {
const success = await authStore.login()
if (success) {
// Успешная авторизация
} else {
// Ошибка авторизации
}
}
</script>

View File

@ -37,14 +37,12 @@
</v-container>
</v-navigation-drawer>
<!-- Десктопное меню - боковая панель -->
<v-navigation-drawer
<!-- Десктопное меню - обычный div -->
<div
v-if="!isMobile"
permanent
location="left"
class="desktop-menu-drawer"
:width="collapsed ? 80 : 250"
:rail="collapsed"
class="desktop-menu"
:class="{ collapsed: collapsed }"
:style="{ width: collapsed ? '80px' : '250px' }"
>
<div class="menu-header">
<v-btn
@ -80,12 +78,13 @@
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
interface MenuItem {
title: string
@ -93,14 +92,19 @@ interface MenuItem {
path: string
}
const menuItems: MenuItem[] = [
{ title: 'Профиль', icon: 'mdi-account', path: '/profile' },
const menuItems = computed(() => [
{
title: getUserDisplayName(),
icon: 'mdi-account',
path: '/profile'
},
{ title: 'Настройки', icon: 'mdi-cog', path: '/settings' },
{ title: 'Панель управления', icon: 'mdi-view-dashboard', path: '/dashboard' }
]
])
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const menuOpen = ref(false)
const collapsed = ref(false)
@ -109,8 +113,17 @@ const isMobile = ref(window.innerWidth < 768)
const currentPath = computed(() => route.path)
const selectedItem = computed(() => currentPath.value)
const getUserDisplayName = () => {
const user = appStore.currentUser
if (user?.name && user.name.trim()) {
return user.name
}
return user?.login || 'Профиль'
}
const emit = defineEmits<{
updateCollapsed: [value: boolean]
toggleMenu: []
}>()
const navigateTo = (path: string) => {
@ -140,6 +153,10 @@ onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
const toggleMobileMenu = () => {
emit('toggleMenu')
}
defineExpose({
toggle: () => {
menuOpen.value = !menuOpen.value
@ -155,8 +172,19 @@ defineExpose({
border-radius: 0 0 16px 16px;
}
.desktop-menu-drawer {
.desktop-menu {
background: rgb(var(--v-theme-surface));
border-right: 1px solid rgb(var(--v-theme-outline));
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 1000;
transition: width 0.3s ease;
}
.desktop-menu.collapsed {
width: 80px !important;
}
.menu-tile {

View File

@ -0,0 +1,110 @@
<template>
<div
v-if="currentNotification"
class="notification-toast"
:style="{ background: getColor(currentNotification.color) }"
>
{{ currentNotification.message }}
<button @click="hide" class="close-btn">×</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useNotificationStore } from '@/stores/notification'
interface Notification {
id: string
message: string
color: string
timeout: number
}
const notificationStore = useNotificationStore()
const notifications = computed(() => notificationStore.notifications)
const removeNotification = (id: string) => notificationStore.removeNotification(id)
const currentNotification = ref<Notification | null>(null)
watch(
notifications,
(newNotifications) => {
if (newNotifications && newNotifications.length > 0) {
currentNotification.value = newNotifications[0]
// Автоматически скрываем через timeout
const notif = newNotifications[0]
if (notif.timeout > 0) {
setTimeout(() => {
hide()
}, notif.timeout)
}
} else {
currentNotification.value = null
}
},
{ deep: true }
)
const hide = () => {
if (currentNotification.value) {
removeNotification(currentNotification.value.id)
currentNotification.value = null
}
}
const getColor = (color: string) => {
const colors = {
success: '#4caf50',
error: '#f44336',
warning: '#ff9800',
info: '#2196f3'
}
return colors[color] || colors.info
}
</script>
<style scoped>
.notification-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
color: white;
padding: 16px 24px;
border-radius: 4px;
z-index: 9999999;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
min-width: 300px;
animation: slideIn 0.3s ease-out;
}
.close-btn {
background: none;
border: none;
color: white;
margin-left: 16px;
cursor: pointer;
font-size: 18px;
font-weight: bold;
opacity: 0.8;
}
.close-btn:hover {
opacity: 1;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View File

@ -0,0 +1,36 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { RpcClient, UserRole } from '@/generated/RpcClient'
import type { User } from '@/generated/RpcClient'
import { RpcClientBase } from '@/api/RpcClientBase'
export const useAppStore = defineStore('app', () => {
const api = ref<RpcClient | null>(null)
const currentUser = ref<User | null>(null)
const isAuthenticated = computed(
() => currentUser.value?.role !== UserRole.Guest && currentUser.value !== null
)
const isLoading = ref(false)
const initializeApi = (baseUrl: string) => {
api.value = new RpcClient(baseUrl)
}
const setCurrentUser = (user: User) => {
currentUser.value = user
}
const setLoading = (loading: boolean) => {
isLoading.value = loading
}
return {
api,
currentUser,
isAuthenticated,
isLoading,
initializeApi,
setCurrentUser,
setLoading
}
})

View File

@ -0,0 +1,121 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useAppStore } from './app'
import { useNotificationStore } from './notification'
import { UserRole } from '@/generated/RpcClient'
interface LoginFormData {
login: string
password: string
rememberMe: boolean
}
export const useAuthStore = defineStore('auth', () => {
const appStore = useAppStore()
const loginForm = ref<LoginFormData>({
login: '',
password: '',
rememberMe: false
})
const showLoginForm = ref(false)
const notificationStore = useNotificationStore()
const showSuccess = notificationStore.showSuccess
const showError = notificationStore.showError
const initializeAuth = async () => {
if (!appStore.api) return
appStore.setLoading(true)
try {
const user = await appStore.api.Profile.get()
appStore.setCurrentUser(user)
if (user.role === UserRole.Guest) {
const authToken = localStorage.getItem('auth-token')
if (authToken) {
const success = await appStore.api.Auth.authenticateByToken(authToken)
if (success) {
const updatedUser = await appStore.api.Profile.get()
appStore.setCurrentUser(updatedUser)
} else {
localStorage.removeItem('auth-token')
showLoginForm.value = true
}
} else {
showLoginForm.value = true
}
}
} catch (error) {
console.error('Auth initialization error:', error)
showLoginForm.value = true
} finally {
appStore.setLoading(false)
}
}
const login = async () => {
if (!appStore.api) return false
appStore.setLoading(true)
try {
const success = await appStore.api.Auth.authenticateByPassword(
loginForm.value.login,
loginForm.value.password
)
if (success) {
const user = await appStore.api.Profile.get()
appStore.setCurrentUser(user)
if (loginForm.value.rememberMe) {
const tokenData = await appStore.api.Auth.generateToken(true)
localStorage.setItem('auth-token', tokenData.uuid)
}
showSuccess('Авторизация успешна!')
resetLoginForm()
showLoginForm.value = false
return true
} else {
showError('Неверный логин или пароль')
return false
}
} catch (error) {
console.error('Login error:', error)
showError('Ошибка авторизации')
return false
} finally {
appStore.setLoading(false)
}
}
const logout = async () => {
if (!appStore.api) return
try {
await appStore.api.Auth.logout()
localStorage.removeItem('auth-token')
appStore.setCurrentUser({ ...appStore.currentUser!, role: UserRole.Guest })
showLoginForm.value = true
} catch (error) {
console.error('Logout error:', error)
}
}
const resetLoginForm = () => {
loginForm.value = {
login: '',
password: '',
rememberMe: false
}
}
return {
loginForm,
showLoginForm,
initializeAuth,
login,
logout,
resetLoginForm
}
})

View File

@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
interface Notification {
id: string
message: string
color: string
timeout: number
}
export const useNotificationStore = defineStore('notification', () => {
const notifications = ref<Notification[]>([])
const showNotification = (message: string, color: string = 'info', timeout: number = 3000) => {
const id = Date.now().toString()
notifications.value.push({ id, message, color, timeout })
if (timeout > 0) {
setTimeout(() => {
removeNotification(id)
}, timeout)
}
}
const removeNotification = (id: string) => {
const index = notifications.value.findIndex((n) => n.id === id)
if (index > -1) {
notifications.value.splice(index, 1)
}
}
const showSuccess = (message: string) => showNotification(message, 'success')
const showError = (message: string) => showNotification(message, 'error')
const showWarning = (message: string) => showNotification(message, 'warning')
const showInfo = (message: string) => showNotification(message, 'info')
return {
notifications,
showNotification,
removeNotification,
showSuccess,
showError,
showWarning,
showInfo
}
})

View File

@ -1,29 +1,347 @@
<template>
<div class="mobile-full-height">
<div class="d-flex justify-center align-center fill-height">
<div class="text-center">
<v-icon size="64" color="primary" class="mb-4"> mdi-account </v-icon>
<h1 class="text-h4 mb-2">Профиль</h1>
<p class="text-body-1 text-medium-emphasis">Страница профиля пользователя</p>
</div>
</div>
<div class="profile-container">
<v-container fluid>
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<v-card elevation="2" class="profile-card">
<v-card-text>
<!-- Форма профиля -->
<v-form ref="profileForm" v-model="isFormValid">
<!-- Аватар и имя -->
<div class="text-center mb-6">
<v-avatar size="120" color="primary" class="mb-3">
<v-icon size="60" color="white">mdi-account</v-icon>
</v-avatar>
<div class="text-h6">{{ getUserDisplayName() }}</div>
<div class="text-body-2 text-medium-emphasis">{{ userRole }}</div>
</div>
<!-- Поля для редактирования -->
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="profileData.login"
:rules="loginRules"
:disabled="!isEditing"
label="Логин"
prepend-inner-icon="mdi-account"
variant="outlined"
hint="Только буквы, цифры, точка и дефис"
persistent-hint
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="profileData.name"
:rules="nameRules"
:disabled="!isEditing"
label="Отображаемое имя"
prepend-inner-icon="mdi-account-circle"
variant="outlined"
hint="Имя для отображения в интерфейсе"
persistent-hint
/>
</v-col>
</v-row>
<!-- Поля для смены пароля (только в режиме редактирования) -->
<template v-if="isEditing">
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-3">Смена пароля</div>
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="profileData.currentPassword"
:disabled="!isEditing"
label="Текущий пароль"
type="password"
prepend-inner-icon="mdi-lock"
variant="outlined"
hint="Введите текущий пароль для смены"
persistent-hint
/>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="profileData.newPassword"
:rules="passwordRules"
:disabled="!isEditing"
label="Новый пароль"
type="password"
prepend-inner-icon="mdi-lock-reset"
variant="outlined"
hint="Минимум 6 символов"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="profileData.confirmPassword"
:rules="confirmPasswordRules"
:disabled="!isEditing"
label="Подтверждение пароля"
type="password"
prepend-inner-icon="mdi-lock-check"
variant="outlined"
hint="Повторите новый пароль"
persistent-hint
/>
</v-col>
</v-row>
</template>
<!-- Кнопки действий -->
<div class="d-flex justify-end mt-4">
<v-btn
v-if="!isEditing"
@click="startEditing"
color="primary"
prepend-icon="mdi-pencil"
>
Редактировать
</v-btn>
<template v-else>
<v-btn @click="cancelEditing" variant="outlined" class="me-2"> Отмена </v-btn>
<v-btn
@click="saveProfile"
color="primary"
:loading="isSaving"
:disabled="!isFormValid"
prepend-icon="mdi-content-save"
>
Сохранить
</v-btn>
</template>
</div>
</v-form>
</v-card-text>
</v-card>
<!-- Карта с дополнительной информацией -->
<v-card elevation="2" class="mt-4">
<v-card-text>
<v-list density="compact">
<v-list-item>
<template #prepend>
<v-icon>mdi-fingerprint</v-icon>
</template>
<v-list-item-title>ID пользователя</v-list-item-title>
<v-list-item-subtitle>{{ currentUser?.id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-identifier</v-icon>
</template>
<v-list-item-title>UUID</v-list-item-title>
<v-list-item-subtitle>{{ currentUser?.uuid }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-shield-account</v-icon>
</template>
<v-list-item-title>Роль</v-list-item-title>
<v-list-item-subtitle>{{ userRole }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
import { useNotificationStore } from '@/stores/notification'
const appStore = useAppStore()
const { showSuccess, showError } = useNotificationStore()
// Данные формы
const profileForm = ref()
const isFormValid = ref(false)
const isEditing = ref(false)
const isSaving = ref(false)
const currentUser = computed(() => appStore.currentUser)
const userRole = computed(() => {
const role = currentUser.value?.role
const roleMap: Record<string, string> = {
Admin: 'Администратор',
Operator: 'Оператор',
User: 'Пользователь',
Guest: 'Гость'
}
return roleMap[role || ''] || role || 'Неизвестно'
})
// Данные профиля для редактирования
const profileData = ref({
login: '',
name: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
const originalData = ref({
login: '',
name: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
// Валидация логина: только точка, дефис, A-z, 0-9
const loginRules = [
(v: string) => !!v || 'Логин обязателен',
(v: string) =>
/^[a-zA-Z0-9.-]+$/.test(v) || 'Логин может содержать только буквы, цифры, точку и дефис',
(v: string) => v.length >= 3 || 'Минимальная длина логина - 3 символа',
(v: string) => v.length <= 50 || 'Максимальная длина логина - 50 символов'
]
const nameRules = [
(v: string) => !!v || 'Имя обязательно',
(v: string) => v.length >= 2 || 'Минимальная длина имени - 2 символа',
(v: string) => v.length <= 100 || 'Максимальная длина имени - 100 символов'
]
const passwordRules = [
(v: string) => !v || v.length >= 6 || 'Минимальная длина пароля - 6 символов'
]
const confirmPasswordRules = [
(v: string) => !profileData.newPassword || v === profileData.newPassword || 'Пароли не совпадают'
]
const getUserDisplayName = () => {
const user = currentUser.value
if (user?.name && user.name.trim()) {
return user.name
}
return user?.login || 'Профиль'
}
const startEditing = () => {
originalData.value = {
login: profileData.value.login,
name: profileData.value.name
}
isEditing.value = true
}
const cancelEditing = () => {
profileData.value = { ...originalData.value }
isEditing.value = false
profileForm.value?.resetValidation()
}
const saveProfile = async () => {
if (!profileForm.value?.validate() || !appStore.api) {
return
}
// Проверяем, если хочет сменить пароль - нужен текущий пароль
if (profileData.value.newPassword && !profileData.value.currentPassword) {
showError('Для смены пароля введите текущий пароль')
return
}
isSaving.value = true
try {
// Определяем параметр password только если меняем пароль
const password = profileData.value.newPassword ? profileData.value.newPassword : undefined
const success = await appStore.api.Profile.save(
profileData.value.login,
profileData.value.name,
currentUser.value?.values || {},
password,
profileData.value.currentPassword
)
if (success) {
// Обновляем данные пользователя в store
const updatedUser = await appStore.api.Profile.get()
appStore.setCurrentUser(updatedUser)
showSuccess('Профиль успешно обновлен!')
isEditing.value = false
// Очищаем поля пароля после успешного сохранения
profileData.value.currentPassword = ''
profileData.value.newPassword = ''
profileData.value.confirmPassword = ''
originalData.value = { ...profileData.value }
} else {
showError('Не удалось обновить профиль')
}
} catch (error) {
console.error('Error saving profile:', error)
showError('Ошибка при сохранении профиля')
} finally {
isSaving.value = false
}
}
const loadProfileData = () => {
const user = currentUser.value
if (user) {
profileData.value = {
login: user.login || '',
name: user.name || '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
}
originalData.value = { ...profileData.value }
}
}
// Загружаем данные при монтировании
onMounted(() => {
loadProfileData()
})
// Следим за изменениями currentUser
watch(
currentUser,
() => {
loadProfileData()
},
{ immediate: true }
)
</script>
<style scoped>
.mobile-full-height {
height: 100%;
width: 100%;
.profile-container {
padding: 16px;
}
.profile-card {
border-radius: 12px;
}
@media (max-width: 768px) {
.mobile-full-height {
padding: 0;
.profile-container {
padding: 8px;
}
}
</style>
<script setup lang="ts">
// Профиль пользователя
</script>