работа на ui фронтом и автогенерацией api клиента
This commit is contained in:
parent
4032586662
commit
b46d1bc359
|
|
@ -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,11 +73,14 @@ function populateMethods(serviceName) {
|
|||
$methodSelect.empty()
|
||||
$methodSelect.append('<option value="">Выберите метод</option>')
|
||||
|
||||
if (serviceName && apiSpec[serviceName] && apiSpec[serviceName].methods) {
|
||||
apiSpec[serviceName].methods.forEach(method => {
|
||||
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>`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createParamInputs(methodName, serviceName) {
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,38 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<FooterBar @toggle-menu="toggleMenu" />
|
||||
<LoginForm />
|
||||
<Notifications />
|
||||
|
||||
<template v-if="!authStore.showLoginForm && !appStore.isLoading">
|
||||
<div class="app-layout">
|
||||
<!-- Часть А: Меню (слева для ПК, шторка для мобильных) -->
|
||||
<NavigationMenu
|
||||
ref="navigationMenu"
|
||||
@update-collapsed="isCollapsed = $event"
|
||||
@toggle-menu="toggleMobileMenu"
|
||||
/>
|
||||
|
||||
<!-- Часть Б: Футер + Контент -->
|
||||
<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,
|
||||
'collapsed-menu': !isMobile && isCollapsed
|
||||
'with-menu': !isMobile
|
||||
}"
|
||||
>
|
||||
<RouterView />
|
||||
</v-main>
|
||||
|
||||
<NavigationMenu ref="navigationMenu" @update-collapsed="isCollapsed = $event" />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue