Init base project files
This commit is contained in:
85
frontend/src/App.vue
Normal file
85
frontend/src/App.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<el-config-provider :locale="locale">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeMount, onMounted, provide, watch } from 'vue';
|
||||
import { dayjs } from 'element-plus';
|
||||
import { useRoute } from 'vue-router';
|
||||
import ru from 'element-plus/es/locale/lang/ru';
|
||||
import en from 'element-plus/es/locale/lang/en';
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
import { type L10Service, updateMessages, useUserStore, websocketService } from '@itprom/core';
|
||||
import { router, setupAxiosInterceptors, setupBearerTokenHeader } from '@/router/router.ts';
|
||||
|
||||
/** Store */
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** Provide */
|
||||
provide('navigation', {
|
||||
goToLogin() {
|
||||
router.push('/login');
|
||||
},
|
||||
goToRoot() {
|
||||
router.push('/');
|
||||
}
|
||||
});
|
||||
|
||||
/** Model & Injects */
|
||||
const l10Service = inject<L10Service>('l10Service');
|
||||
|
||||
/** Data */
|
||||
const route = useRoute();
|
||||
|
||||
/** Computed */
|
||||
const locale = computed(() => {
|
||||
if (userStore.currentLocale.includes('en')) {
|
||||
dayjs.locale('en');
|
||||
return en;
|
||||
} else if (userStore.currentLocale.includes('ru')) {
|
||||
dayjs.locale('ru');
|
||||
return ru;
|
||||
}
|
||||
});
|
||||
|
||||
/** Configuration */
|
||||
dayjs.locale(locale.value.name);
|
||||
|
||||
|
||||
/** Watch */
|
||||
watch(() => userStore.currentLocale, () => updateMessages(l10Service));
|
||||
|
||||
// Отслеживаем изменение темы, чтобы установить необходимые атрибуты для css
|
||||
watch(() => userStore.theme, (val) => {
|
||||
const isDark = val === 'dark';
|
||||
document.documentElement.dataset.colorScheme = isDark ? 'dark' : 'light';
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => userStore.token, () => {
|
||||
setupAxiosInterceptors();
|
||||
setupBearerTokenHeader();
|
||||
}, { immediate: true });
|
||||
|
||||
/** Hooks */
|
||||
onBeforeMount(() => {
|
||||
websocketService.activate();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// отслеживаем нажатие кнопки назад: если пользователь выполнил вход,
|
||||
// то не даём перейти ему на страницу входа
|
||||
window.onpopstate = () => {
|
||||
if (userStore.token && route.path === '/login') {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
426
frontend/src/components/Main.vue
Normal file
426
frontend/src/components/Main.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<!-- Основное окно программы -->
|
||||
<template>
|
||||
<el-container class="supp-app-container">
|
||||
<!-- Шапка -->
|
||||
<el-header>
|
||||
<Logo type="menu" />
|
||||
<SuppSectionMenu
|
||||
class="section-menu"
|
||||
@section-menu-clicked="onSectionMenuClicked" />
|
||||
<el-switch
|
||||
class="user-theme-switch"
|
||||
inline-prompt
|
||||
:style="switchStyle"
|
||||
:inactive-action-icon="Sunny"
|
||||
:active-action-icon="Moon"
|
||||
:model-value="activeDarkTheme"
|
||||
@change="onThemeChanged" />
|
||||
<SuppUserMenu
|
||||
:places="placeItems"
|
||||
class="user-menu"
|
||||
@logout-menu-clicked="logout"
|
||||
@user-notifications-clicked="openUserNotificationsTab"
|
||||
@favorite-menu-clicked="onSectionMenuClicked"
|
||||
@user-account-menu-clicked="openUserAccountTab"
|
||||
@place-menu-clicked="onGlobalPlaceChange" />
|
||||
</el-header>
|
||||
<!-- Содержимое -->
|
||||
<el-main class="supp-main-content">
|
||||
<div class="supp-content-wrapper">
|
||||
<el-tabs
|
||||
class="main-tabs"
|
||||
type="card"
|
||||
editable :addable="false"
|
||||
@edit="onEdit"
|
||||
v-model:model-value="activePaneKey">
|
||||
<template #add-icon>
|
||||
<div class="supp-tabs-menu">
|
||||
<el-dropdown>
|
||||
<el-button class="supp-action-button" size="small" :icon="ArrowDown" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="p in panes"
|
||||
:key="p.key"
|
||||
@click.native="activePaneKey = p.key">
|
||||
<span :style="{fontWeight: activePaneKey === p.key ? 'bold' : ''}">
|
||||
{{ p.key === '1' ? t('home_page') : t(p.title) }}
|
||||
</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-tooltip effect="dark" placement="top" :content="t('close_all')" :open-delay="100">
|
||||
<el-button class="supp-action-button" size="small" :icon="Close" @click="closeAllTabs" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tab-pane
|
||||
v-for="pane in panes"
|
||||
:key="pane.key"
|
||||
:closable="pane.closable"
|
||||
:name="pane.key">
|
||||
|
||||
<template #label>
|
||||
<div class="supp-pain-content-label" v-middle-click-close="pane.closable ? () => remove(pane.key) : null">
|
||||
<el-icon>
|
||||
<HomeFilled v-if="pane.key === '1'" />
|
||||
<Grid v-else />
|
||||
</el-icon>
|
||||
<span v-if="pane.key !== '1'">{{ pane.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Компоненты для отображения во вкладках -->
|
||||
<loading-view v-if="loading" />
|
||||
<main-tab-item
|
||||
v-show="!loading"
|
||||
:pane="pane"
|
||||
@help="onHelp(pane?.key)"
|
||||
@close="remove(pane?.key)"
|
||||
@closeEditForm="pane.additionalData = {}"
|
||||
@update:additionalData="(additionalData?: any) => pane.additionalData = additionalData"
|
||||
@update:entityId="(id?: number) => pane.entityId = id" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-main>
|
||||
<!-- Нижняя секция / футер -->
|
||||
<el-footer>
|
||||
<el-row style="height: 100%; align-items: center">
|
||||
<el-col :span="12">
|
||||
{{ t('supp_forms') }}. {{ t('version') }} {{ suppVersion }}
|
||||
</el-col>
|
||||
<el-col :span="12" style="text-align: right">
|
||||
{{ t('it_prom') }} 2023 - {{ dayjs().format('YYYY') }}
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-footer>
|
||||
|
||||
<el-dialog v-model="helpVisible" width="768px" :close-on-click-modal="false">
|
||||
<template #header>
|
||||
<div style="display: flex; align-items: center; font-size: 18px;">
|
||||
<el-icon style="color: var(--el-color-primary); font-size: 24px; margin-right: 10px;">
|
||||
<QuestionFilled />
|
||||
</el-icon>
|
||||
<span style="font-weight: 500;">{{ t('help_title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-html="helpText" style="word-break: break-word; line-height: 1.5;"></div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button v-if="userStore.canEdit('15')" type="primary" @click="openHelpTab">
|
||||
{{ t('edit') }}
|
||||
</el-button>
|
||||
<el-button @click="helpVisible = false">
|
||||
{{ t('close') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeMount, onBeforeUnmount, provide, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import type { StompSubscription } from '@stomp/stompjs';
|
||||
import {
|
||||
AdminSection,
|
||||
messageError,
|
||||
t,
|
||||
useAdminStore,
|
||||
useUserStore,
|
||||
websocketService,
|
||||
UserSection, navigate
|
||||
} from '@itprom/core';
|
||||
import { LoadingView, Logo } from '@itprom/core-components';
|
||||
import { placeService } from '@itprom/place';
|
||||
import { suppSettingService } from '@itprom/settings';
|
||||
import MainTabItem from '@/components/MainTabItem.vue';
|
||||
import { ArrowDown, Close, Grid, HomeFilled, Moon, QuestionFilled, Sunny } from '@element-plus/icons-vue';
|
||||
import { router } from '@/router/router.ts';
|
||||
import { menuItemService, SuppSectionMenu, SuppUserMenu } from '@itprom/menu';
|
||||
import { helpService } from '@itprom/help';
|
||||
import { userProfileService } from '@itprom/user-profile';
|
||||
|
||||
/** Store */
|
||||
const userStore = useUserStore();
|
||||
const adminStore = useAdminStore();
|
||||
|
||||
/** Inject */
|
||||
const suppVersion = inject<string | number>('suppVersion', '1.0.0');
|
||||
|
||||
/** Data */
|
||||
const loading = ref(false);
|
||||
const globalPlaceChangeToggle = ref(false);
|
||||
const helpId = ref(-1);
|
||||
const helpText = ref('');
|
||||
const paneStack = ref(['1']);
|
||||
const helpVisible = ref(false);
|
||||
const activeDarkTheme = ref(userStore.theme === 'dark');
|
||||
|
||||
let placeSubscription: StompSubscription | undefined;
|
||||
let settingsSubscription: StompSubscription | undefined;
|
||||
let menuItemSubscription: StompSubscription | undefined;
|
||||
|
||||
/** Computed */
|
||||
const panes = computed(() => userStore.panes);
|
||||
const placeItems = computed(() => adminStore.placeOptions);
|
||||
|
||||
const activePaneKey = computed({
|
||||
get() {
|
||||
if (panes.value.length < 2) {
|
||||
return '1';
|
||||
}
|
||||
return userStore.activePaneKey;
|
||||
},
|
||||
set(key) {
|
||||
userStore.activePaneKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
const switchStyle = computed(() => {
|
||||
return {
|
||||
'--el-switch-on-color': activeDarkTheme.value ? '#2c2c2c' : '#f2f2f2',
|
||||
'--el-switch-off-color': activeDarkTheme.value ? '#2c2c2c' : '#b2b2b2',
|
||||
'--el-switch-border-color': activeDarkTheme.value ? '#4c4d4f' : '#dcdfe6'
|
||||
};
|
||||
});
|
||||
|
||||
/** Functions */
|
||||
async function onThemeChanged(active: any) {
|
||||
activeDarkTheme.value = active;
|
||||
try {
|
||||
const value = active ? 'dark' : 'default';
|
||||
await userProfileService.updateUserTheme(userStore.userId, value);
|
||||
userStore.setTheme(value);
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
function add(key: string, title: string) {
|
||||
userStore.addPane({ title, content: '', key, closable: +key > 1 });
|
||||
}
|
||||
|
||||
function remove(key: string) {
|
||||
// Удаляем вкладку из стека
|
||||
removeFromPaneStack(key);
|
||||
|
||||
// Возвращаем из стека последнюю активную вкладку
|
||||
if (key === activePaneKey.value) {
|
||||
activePaneKey.value = paneStack.value.pop() as string;
|
||||
}
|
||||
|
||||
// По умолчанию переходим к начальной вкладке
|
||||
if (!activePaneKey.value) {
|
||||
activePaneKey.value = '1';
|
||||
}
|
||||
|
||||
return userStore.removePane(key);
|
||||
}
|
||||
|
||||
function removeFromPaneStack(val: string) {
|
||||
const index = paneStack.value.indexOf(val);
|
||||
if (index > -1) {
|
||||
paneStack.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(targetKey: string) {
|
||||
if (targetKey) {
|
||||
remove(targetKey);
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllTabs() {
|
||||
userStore.removeAllPanes();
|
||||
}
|
||||
|
||||
function onHelp(key: string) {
|
||||
// Подстановка номера страницы для начальной страницы
|
||||
if (key === '1') {
|
||||
key = userStore.defaultPage?.num ?? '';
|
||||
}
|
||||
|
||||
helpText.value = '';
|
||||
helpId.value = -1;
|
||||
|
||||
|
||||
helpService.getByPageNum(key)
|
||||
.then((response: any) => {
|
||||
helpText.value = response.data.text;
|
||||
helpId.value = response.data.id;
|
||||
})
|
||||
.catch(() => helpText.value = t('no_help_text'));
|
||||
|
||||
helpVisible.value = true;
|
||||
}
|
||||
|
||||
function openHelpTab() {
|
||||
helpVisible.value = false;
|
||||
|
||||
// Ищем, открыта ли уже вкладка помощи
|
||||
const found = panes.value.find(p => p.key === '15');
|
||||
|
||||
// Подстановка ключа текущего раздела для возврата/привязки
|
||||
let paneKey = activePaneKey.value;
|
||||
if (paneKey === '1') {
|
||||
paneKey = userStore.defaultPage?.num ?? '';
|
||||
}
|
||||
|
||||
if (found) {
|
||||
found.entityId = helpId.value;
|
||||
found.additionalData = { pageNum: paneKey };
|
||||
activePaneKey.value = '15';
|
||||
} else {
|
||||
userStore.addPane({
|
||||
title: t('supp-page-15'),
|
||||
content: '',
|
||||
key: '15',
|
||||
closable: true,
|
||||
entityId: helpId.value,
|
||||
additionalData: { pageNum: paneKey }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionMenuClicked(num: string) {
|
||||
if (num) {
|
||||
add(num, t(`supp-page-${num}`));
|
||||
}
|
||||
}
|
||||
|
||||
function openUserAccountTab() {
|
||||
add(UserSection.USER_MENU.num, t(UserSection.USER_MENU.suppPageNum));
|
||||
}
|
||||
|
||||
function openUserNotificationsTab() {
|
||||
navigate('/user-settings/notifications');
|
||||
}
|
||||
|
||||
async function loadPlaceOptions() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await placeService.getPlaceOptions();
|
||||
adminStore.placeOptions = response ? response.data : [];
|
||||
} catch (error) {
|
||||
messageError(error, 'places_loading_error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await suppSettingService.getAll();
|
||||
adminStore.settings = response ? response.data : [];
|
||||
} catch (error) {
|
||||
messageError(error, 'supp_setting_loading_error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMenuItems() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await menuItemService.getTree();
|
||||
adminStore.menuItemOptions = response ? response.data : [];
|
||||
} catch (error) {
|
||||
messageError(error, 'menu_item_loading_error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
userStore.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
function onGlobalPlaceChange(placeId: number | undefined) {
|
||||
userStore.setGlobalPlaceId(placeId);
|
||||
globalPlaceChangeToggle.value = !globalPlaceChangeToggle.value;
|
||||
}
|
||||
|
||||
/** Директива для обработки клика колесиком по всей вкладке */
|
||||
const vMiddleClickClose = {
|
||||
mounted: (el: HTMLElement, binding: any) => {
|
||||
if (!binding.value) return;
|
||||
|
||||
const tabItem = el.closest('.el-tabs__item') as HTMLElement;
|
||||
if (tabItem) {
|
||||
// Вешаем событие mousedown (нажатие)
|
||||
tabItem.onmousedown = (e: MouseEvent) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
binding.value();
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
beforeUnmount: (el: HTMLElement) => {
|
||||
const tabItem = el.closest('.el-tabs__item') as HTMLElement;
|
||||
if (tabItem) {
|
||||
tabItem.onmousedown = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Hooks */
|
||||
onBeforeMount(async () => {
|
||||
await loadPlaceOptions();
|
||||
await loadSettings();
|
||||
await loadMenuItems();
|
||||
|
||||
placeSubscription = websocketService.subscribe(AdminSection.PLACE.num, loadPlaceOptions);
|
||||
settingsSubscription = websocketService.subscribe(AdminSection.SETTINGS.num, loadSettings);
|
||||
menuItemSubscription = websocketService.subscribe(AdminSection.PROGRAM_MENU.num, loadMenuItems);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (placeSubscription) {
|
||||
websocketService.unsubscribe(placeSubscription);
|
||||
}
|
||||
|
||||
if (settingsSubscription) {
|
||||
websocketService.unsubscribe(settingsSubscription);
|
||||
}
|
||||
|
||||
if (menuItemSubscription) {
|
||||
websocketService.unsubscribe(menuItemSubscription);
|
||||
}
|
||||
});
|
||||
|
||||
/** Watch */
|
||||
watch(activePaneKey, (newKey) => {
|
||||
removeFromPaneStack(newKey);
|
||||
paneStack.value.push(newKey);
|
||||
});
|
||||
|
||||
/** Provide */
|
||||
provide('globalPlaceChangeToggle', globalPlaceChangeToggle);
|
||||
</script>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style scoped>
|
||||
.user-menu {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section-menu {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.user-theme-switch :deep(.el-icon) > svg {
|
||||
color: #4a4a4a
|
||||
}
|
||||
</style>
|
||||
94
frontend/src/components/MainTabItem.vue
Normal file
94
frontend/src/components/MainTabItem.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<!-- Компонент временно лежит в корне приложения для удобства (в дальнейшем необходимо перенести в core-components) -->
|
||||
<template>
|
||||
<component
|
||||
v-if="currentComponent"
|
||||
:is="currentComponent"
|
||||
:entity-id="pane?.entityId"
|
||||
:additional-data="pane?.additionalData"
|
||||
@closeEditForm="$emit('@closeEditForm')"
|
||||
@update:componentInnerTabKey="onInnerKeyUpdate"
|
||||
@update:additionalData="(additionalData?: any) => $emit('@update:additionalData', additionalData)"
|
||||
@update:entityId="(id?: number) => $emit('@update:entityId', id)" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, inject, provide, type Ref, ref, watch } from 'vue';
|
||||
import { ModuleRegistry, useUserStore } from '@itprom/core';
|
||||
|
||||
/** Props */
|
||||
const props = defineProps(['pane']);
|
||||
|
||||
/** Store */
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** Inject */
|
||||
const moduleRegistry: ModuleRegistry | undefined = inject('moduleRegistry');
|
||||
|
||||
/** Data */
|
||||
const currentTabSuppPageKey = ref(props.pane?.key);
|
||||
const childComponentInnerTabKey = ref<string | undefined>();
|
||||
|
||||
/*
|
||||
Словарь ключей для проверки доступов к компонентам. Если ключ компонента для его монтирования
|
||||
(который объявляется в массивах *-tab-items) не совпадает с номером секции (supp-page-num),
|
||||
то необходимо для этого ключа указать список номеров соответствующих разделов приложения.
|
||||
|
||||
Например, компонент под номером '17' содержит сразу 2 страницы приложения - '17-language' и '17-messageL10n'.
|
||||
Для каждой из них права доступа через роли устанавливаются отдельно, поэтому они должны быть определены в списке ниже.
|
||||
*/
|
||||
const specialSectionsMap: Map<string, any> = new Map([
|
||||
]);
|
||||
|
||||
/** Computed */
|
||||
const currentTabKey = computed(() => {
|
||||
// Специальная обработка для домашней страницы
|
||||
if (props.pane?.key === '1') {
|
||||
return userStore.defaultPage?.num;
|
||||
}
|
||||
|
||||
return props.pane?.key;
|
||||
});
|
||||
|
||||
const currentComponent = computed(() => {
|
||||
const moduleInfo = moduleRegistry?.getModule(currentTabKey.value);
|
||||
return moduleInfo?.component
|
||||
? defineAsyncComponent(moduleInfo.component)
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const canEdit: Ref<boolean> = ref(userStore.canEdit(currentTabKey.value));
|
||||
|
||||
/** Functions */
|
||||
function onInnerKeyUpdate(val: string) {
|
||||
childComponentInnerTabKey.value = val;
|
||||
}
|
||||
|
||||
/**
|
||||
Если ключ компонента для его монтирования (который объявляется в массивах *-tab-items)
|
||||
не совпадает с номером секции (supp-page-num), то при открытии такого компонента необходимо получить из него
|
||||
соответствующий номер секции и с помощью specialSectionsMap обновить состояние canEdit.
|
||||
|
||||
@see LocalizationComponent.vue
|
||||
*/
|
||||
function reEvaluateCanEditCondition() {
|
||||
const paneKey = props.pane?.key;
|
||||
const val = childComponentInnerTabKey.value;
|
||||
|
||||
// Специальная обработка для домашней страницы
|
||||
if (props.pane?.key === '1') {
|
||||
currentTabSuppPageKey.value = val;
|
||||
} else if (specialSectionsMap.get(paneKey)?.includes(val)) {
|
||||
currentTabSuppPageKey.value = val;
|
||||
} else {
|
||||
currentTabSuppPageKey.value = paneKey;
|
||||
}
|
||||
|
||||
canEdit.value = userStore.canEdit(currentTabSuppPageKey.value);
|
||||
}
|
||||
|
||||
/** Watch */
|
||||
watch(() => childComponentInnerTabKey.value, reEvaluateCanEditCondition);
|
||||
|
||||
/** Provide */
|
||||
provide('canEdit', canEdit);
|
||||
</script>
|
||||
61
frontend/src/main.ts
Normal file
61
frontend/src/main.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { createPinia } from 'pinia';
|
||||
import { messageL10nService } from '@itprom/message';
|
||||
import { router } from '@/router/router.ts';
|
||||
import { TABLE_LAYOUT_SERVICE_TOKEN, ModuleRegistry, DependencyRegistry, updateMessages } from '@itprom/core';
|
||||
import { layoutCoreAdapter } from '@itprom/tablelayout';
|
||||
|
||||
// Импорт глобальных стилей element plus
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles/itprom-modules.css';
|
||||
import './styles/index.scss';
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
import { initModuleRegistry } from '@/modules.ts';
|
||||
import { favoriteService } from '@itprom/favorites';
|
||||
|
||||
/**
|
||||
* Основная точка входа Vue-приложения.
|
||||
* Инициализация приложения, глобальных плагинов и настроек.
|
||||
*
|
||||
* @author Sergey Verbitsky
|
||||
* @since 09.12.2025
|
||||
*/
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// Локальное хранилище
|
||||
app.use(createPinia());
|
||||
|
||||
// Роутер
|
||||
app.use(router);
|
||||
|
||||
// Сервис локализации для корректной работы i18n (см. core/src/i18n/translation.ts)
|
||||
app.provide('l10Service', messageL10nService);
|
||||
app.provide('favoriteService', favoriteService);
|
||||
|
||||
// Регистрация сервисов внешних модулей, нужных в core и core-components
|
||||
DependencyRegistry.register(TABLE_LAYOUT_SERVICE_TOKEN, layoutCoreAdapter);
|
||||
|
||||
// Версия СУПП
|
||||
app.provide('suppVersion', '1.0.0');
|
||||
|
||||
// Подключение компонентов Element plus
|
||||
app.use(ElementPlus);
|
||||
|
||||
// Асинхронная инициализация и запуск
|
||||
async function startApp() {
|
||||
// Подключаем СУПП модули
|
||||
const registry: ModuleRegistry = await initModuleRegistry();
|
||||
app.provide('moduleRegistry', registry);
|
||||
|
||||
// Загружаем локализацию до монтирования
|
||||
await updateMessages(messageL10nService);
|
||||
|
||||
// Монтирование главного компонента приложения
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
startApp()
|
||||
.catch((err: any) => console.log(err));
|
||||
42
frontend/src/modules.ts
Normal file
42
frontend/src/modules.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ModuleRegistry } from '@itprom/core';
|
||||
|
||||
/**
|
||||
* Список подключаемых/импортируемых модулей СУПП
|
||||
*/
|
||||
const modules: Record<string, () => Promise<any>> = {
|
||||
coreComponents: () => import('@itprom/core-components'),
|
||||
menu: () => import('@itprom/menu'),
|
||||
favorites: () => import('@itprom/favorites'),
|
||||
place: () => import('@itprom/place'),
|
||||
settings: () => import('@itprom/settings'),
|
||||
language: () => import('@itprom/language'),
|
||||
user: () => import('@itprom/user'),
|
||||
userProfile: () => import('@itprom/user-profile'),
|
||||
department: () => import('@itprom/department'),
|
||||
person: () => import('@itprom/person'),
|
||||
role: () => import('@itprom/role'),
|
||||
help: () => import('@itprom/help'),
|
||||
message: () => import('@itprom/message'),
|
||||
// shift: () => import('@itprom/shift'),
|
||||
// shiftSeq: () => import('@itprom/shift-seq'),
|
||||
// profession: () => import('@itprom/profession'),
|
||||
};
|
||||
|
||||
/**
|
||||
* Реестр конфигураций подключаемых модулей СУПП
|
||||
*/
|
||||
export async function initModuleRegistry(): Promise<ModuleRegistry> {
|
||||
const registry = new ModuleRegistry();
|
||||
|
||||
for (const loadModule of Object.values(modules)) {
|
||||
const mod = await loadModule();
|
||||
const info = mod.moduleInfo;
|
||||
|
||||
// если нет номера — это общий/утилитарный модуль
|
||||
if (!info?.num) continue;
|
||||
|
||||
registry.registerModule(info);
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
2
frontend/src/modules/readme.md
Normal file
2
frontend/src/modules/readme.md
Normal file
@@ -0,0 +1,2 @@
|
||||
В папке modules находятся модули, специфичные для конкретного проекта.
|
||||
Прочие универсальные модули должны подключаться как npm зависимости.
|
||||
120
frontend/src/router/router.ts
Normal file
120
frontend/src/router/router.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { AxiosError, AxiosResponse } from 'axios';
|
||||
import { Login, Register, PageNotFound } from '@itprom/core-components';
|
||||
import { useUserStore, AXIOS, isNotBlank, authenticationService } from '@itprom/core';
|
||||
import Main from '@/components/Main.vue';
|
||||
|
||||
/**
|
||||
* Базовый маршрутизатор программы для навигации по страницам приложения.
|
||||
*
|
||||
* @author Sergey Verbitsky
|
||||
* @since 09.12.2025
|
||||
*/
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: Main
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'pageNotFound',
|
||||
component: PageNotFound
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Конфигурация проверки аутентификации пользователей перед каждым переходом между страницами
|
||||
router.beforeEach(async (to, _from) => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
// Публичные страницы не нуждаются в проверке
|
||||
if (['login', 'register', 'pageNotFound'].includes(to.name as string)) return;
|
||||
|
||||
// Если токена нет вовсе - немедленно перенаправляем пользователя на страницу входа
|
||||
if (!userStore.token) {
|
||||
return { name: 'login' };
|
||||
}
|
||||
|
||||
// Если токен есть, но статус аутентификации в течение текущей сессии еще не был проверен - отправляем запрос на проверку
|
||||
if (!userStore.isAuthChecked) {
|
||||
try {
|
||||
const res: AxiosResponse<boolean> = await authenticationService.checkAuthentication();
|
||||
if (res.data) {
|
||||
// Если пользователь аутентифицирован - выставляем флаг в userStore, а также заголовок 'Bearer'
|
||||
// для всех последующих запросов. И разрешаем переход на нужную страницу
|
||||
userStore.isAuthChecked = true;
|
||||
setupBearerTokenHeader();
|
||||
return;
|
||||
} else {
|
||||
// Иначе (и при исключении) - очищаем статус аутентификации и перенаправляем на страницу входа
|
||||
userStore.logout();
|
||||
return { name: 'login' };
|
||||
}
|
||||
} catch {
|
||||
userStore.logout();
|
||||
return { name: 'login' };
|
||||
}
|
||||
}
|
||||
|
||||
// Если статус аутентификации уже проверен - выставляем заголовок 'Bearer' для всех последующих запросов.
|
||||
setupBearerTokenHeader();
|
||||
});
|
||||
|
||||
/**
|
||||
* Централизованное место управления конфигурацией AXIOS.
|
||||
* Позволяет устанавливать/сбрасывать необходимые дефолтные заголовки или обработчики событий
|
||||
*
|
||||
* @author Ilia Malafeev
|
||||
* @since 15.08.2025
|
||||
*/
|
||||
|
||||
// Флаг хранит отметку о том, что перехватчики уже установлены, чтобы не устанавливать их дважды
|
||||
let interceptorsInstalled = false;
|
||||
|
||||
// Перехват ошибок запросов к серверу
|
||||
export function setupAxiosInterceptors() {
|
||||
if (interceptorsInstalled) return;
|
||||
interceptorsInstalled = true;
|
||||
|
||||
AXIOS.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
// При 401 статусе нужно сбросить состояния аутентификации пользователя и перенаправить его на страницу входа
|
||||
if (error?.response?.status === 401) {
|
||||
const userStore = useUserStore();
|
||||
userStore.logout();
|
||||
|
||||
// Тк происходит logout, нужно сбросить заголовок с jwt перед следующей аутентификацией
|
||||
delete AXIOS.defaults.headers.common.Authorization;
|
||||
|
||||
if (router.currentRoute.value?.name !== 'login') {
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setupBearerTokenHeader() {
|
||||
const userStore = useUserStore();
|
||||
if (isNotBlank(userStore.token)) {
|
||||
AXIOS.defaults.headers.common['Authorization'] = `Bearer ${userStore.token}`;
|
||||
} else {
|
||||
delete AXIOS.defaults.headers.common.Authorization;
|
||||
}
|
||||
}
|
||||
59
frontend/src/scripts/build-itprom-css.js
Normal file
59
frontend/src/scripts/build-itprom-css.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Скрипт собирает все scoped стили модулей в 1 файл для подключения всех стилей в main.ts
|
||||
*
|
||||
* @author Sergey Verbitsky
|
||||
* @since 12.01.2026
|
||||
*/
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const workspaceModules = path.join(root, 'src/modules');
|
||||
const nodeModules = path.join(root, 'node_modules/@itprom');
|
||||
const outFile = path.join(root, 'src/styles/itprom-modules.css');
|
||||
|
||||
let cssContent = '';
|
||||
|
||||
function collectCss(distDir) {
|
||||
if (!fs.existsSync(distDir)) return;
|
||||
|
||||
const files = fs.readdirSync(distDir);
|
||||
|
||||
files
|
||||
.filter(f => f.endsWith('.css'))
|
||||
.forEach(css => {
|
||||
const fullPath = path.join(distDir, css);
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
|
||||
cssContent += `/* ${path.basename(distDir)} */\n`;
|
||||
cssContent += content + '\n\n';
|
||||
});
|
||||
}
|
||||
|
||||
if (fs.existsSync(workspaceModules)) {
|
||||
const modules = fs.readdirSync(workspaceModules);
|
||||
|
||||
modules.forEach(m => {
|
||||
const dist = path.join(workspaceModules, m, 'dist');
|
||||
collectCss(dist);
|
||||
});
|
||||
}
|
||||
|
||||
if (fs.existsSync(nodeModules)) {
|
||||
const modules = fs.readdirSync(nodeModules);
|
||||
|
||||
modules.forEach(m => {
|
||||
const dist = path.join(nodeModules, m, 'dist');
|
||||
collectCss(dist);
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||
fs.writeFileSync(outFile, cssContent, 'utf8');
|
||||
|
||||
console.log(`✔ modules CSS bundle generated: ${outFile}`);
|
||||
3
frontend/src/shims-vue.d.ts
vendored
Normal file
3
frontend/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module '*.vue';
|
||||
declare module '*.ts';
|
||||
declare module '*.js';
|
||||
111
frontend/src/styles/_components.scss
Normal file
111
frontend/src/styles/_components.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
.supp-action-button,
|
||||
.supp-tabs-menu-button {
|
||||
height: 32px !important;
|
||||
width: 32px !important;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
vertical-align: middle;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Стили для кнопок у которых не задан type primary, danger и т.д.
|
||||
&:not(.el-button--primary):not(.el-button--danger):not(.el-button--success):not(.el-button--warning) {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--el-border-color);
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--el-color-primary-dark-2);
|
||||
color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.supp-header-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.bordered-table {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-collapse: collapse;
|
||||
padding: 0.3rem;
|
||||
|
||||
td {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-collapse: collapse;
|
||||
padding: 0.3rem;
|
||||
|
||||
&.td-padding {
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-collapse: collapse;
|
||||
padding: 0.2rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-primary) !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Адаптация el-form-item для компактного отображения внутри ячеек таблицы
|
||||
========================================================================== */
|
||||
.el-form-item.mb-disable {
|
||||
margin-bottom: 0;
|
||||
|
||||
.el-form-item__content {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.el-form-item__error {
|
||||
position: static;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease, margin-top 0.3s ease;
|
||||
}
|
||||
|
||||
&.is-error .el-form-item__error {
|
||||
max-height: 150px;
|
||||
opacity: 1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Стиль для кнопок, используемых в таблицах детальных записей (т.к. size="small" выдает прямоугольную кнопку ) */
|
||||
.supp-small-button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
147
frontend/src/styles/_layout.scss
Normal file
147
frontend/src/styles/_layout.scss
Normal file
@@ -0,0 +1,147 @@
|
||||
:root {
|
||||
--bg-header: #001529;
|
||||
--bg-content: #FFFFFF;
|
||||
--bg-footer: #f5f5f5;
|
||||
|
||||
/* Цвета личного кабинета */
|
||||
--supp-user-profile-drag-border: #ccc;
|
||||
--supp-user-profile-drag-bg: #f0f0f0;
|
||||
--supp-user-profile-danger: #c12127;
|
||||
--supp-user-profile-danger-hover: #bd565a;
|
||||
--supp-user-profile-primary: #1668dc;
|
||||
--supp-user-profile-primary-hover: #5c90d9;
|
||||
--supp-user-profile-success: #3aaf0c;
|
||||
--supp-user-profile-success-hover: #6bb74d;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] {
|
||||
--bg-header: #1d1e1f;
|
||||
--bg-content: #141414;
|
||||
--bg-footer: #1d1e1f;
|
||||
}
|
||||
|
||||
.supp-app-container {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
.el-main {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
background-color: var(--bg-header);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--el-menu-bg-color: var(--bg-header);
|
||||
--el-menu-text-color: rgba(255, 255, 255, 0.88);
|
||||
--el-menu-hover-text-color: #ffffff;
|
||||
--el-menu-active-color: rgba(255, 255, 255, 0.88);
|
||||
--el-menu-hover-bg-color: transparent;
|
||||
--el-menu-item-font-size: 12px;
|
||||
--el-menu-border-color: transparent;
|
||||
}
|
||||
|
||||
.el-footer {
|
||||
background-color: var(--bg-footer);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.supp-main-content {
|
||||
height: 100%;
|
||||
padding: 0 !important;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.supp-content-wrapper {
|
||||
background: var(--bg-content);
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* El-tabs должен занимать все доступное пространство */
|
||||
> .el-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.el-tabs__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.supp-tabs-menu {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.supp-actions-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.supp-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.supp-form-item-actions {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
|
||||
.el-form-item__content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.supp-pain-content-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Класс для строки, которая должна растянуться на всю высоту (например, с таблицей) */
|
||||
.fill-height-row {
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Колонка внутри такой строки тоже должна растянуться */
|
||||
.fill-height-row > .el-col {
|
||||
flex: 1 !important;
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
316
frontend/src/styles/_overrides.scss
Normal file
316
frontend/src/styles/_overrides.scss
Normal file
@@ -0,0 +1,316 @@
|
||||
.el-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-tabs__new-tab {
|
||||
cursor: unset !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.main-tabs > .el-tabs__header {
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.main-tabs > .el-tabs__header .el-tabs__nav-scroll {
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.main-tabs > .el-tabs__header .el-tabs__item {
|
||||
height: 32px !important;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
color: var(--el-text-color-regular);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-bottom: 1px solid var(--el-border-color) !important;
|
||||
font-size: 12px !important;
|
||||
padding: 0 12px !important;
|
||||
|
||||
&#tab-1 .el-icon {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-icon:not(.is-icon-close) {
|
||||
font-size: 14px !important;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.is-icon-close {
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
margin-left: 4px !important;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
color: var(--el-text-color-primary) !important;
|
||||
font-weight: 900 !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--bg-content) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
|
||||
border-bottom: 1px solid var(--bg-content) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-card,
|
||||
.el-card.is-always-shadow,
|
||||
.el-card.is-hover-shadow:hover {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Темная тема для библиотек */
|
||||
[data-color-scheme="dark"] {
|
||||
.el-select .el-input__inner,
|
||||
.el-input.is-disabled .el-input__inner,
|
||||
.apexcharts-legend-text {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
.apexcharts-menu {
|
||||
background: rgba(0, 0, 0) !important;
|
||||
}
|
||||
|
||||
.apexcharts-theme-light .apexcharts-menu-item:hover {
|
||||
background: #353535;
|
||||
}
|
||||
|
||||
.el-table__summary {
|
||||
background: #1d1d1d !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Светлая тема для библиотек */
|
||||
[data-color-scheme="light"] {
|
||||
.el-select .el-input__inner,
|
||||
.el-input.is-disabled .el-input__inner,
|
||||
.apexcharts-legend-text {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
|
||||
.apexcharts-menu {
|
||||
background: rgba(255, 255, 255) !important;
|
||||
}
|
||||
|
||||
.apexcharts-theme-light .apexcharts-menu-item:hover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.el-table__summary {
|
||||
background: #fafafa !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-popper:has(.el-menu--popup) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.el-menu--popup {
|
||||
background-color: var(--bg-header) !important;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
|
||||
--el-menu-bg-color: var(--bg-header);
|
||||
--el-menu-text-color: rgba(255, 255, 255, 0.88);
|
||||
--el-menu-hover-text-color: #ffffff;
|
||||
--el-menu-active-color: rgba(255, 255, 255, 0.88);
|
||||
--el-menu-item-font-size: 12px;
|
||||
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu__title {
|
||||
&:hover {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Заголовок карточки таблицы и формы редактирования */
|
||||
.table-card-content-wrapper,
|
||||
.edit-card-content-wrapper {
|
||||
.el-card__header {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Заголовок карточки личного кабинета (уведомления) */
|
||||
.user-profile-card {
|
||||
.el-card__header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-card {
|
||||
.el-card__header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-item-card {
|
||||
.el-card__body {
|
||||
padding: 0 1rem;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для компоненты выбора дат/времени */
|
||||
.date-picker-container .el-date-editor {
|
||||
width: 260px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 0 0 8px;
|
||||
border-radius: 6px;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.date-picker-container .el-range-separator {
|
||||
flex: none;
|
||||
width: auto;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.date-picker-container .el-range-input {
|
||||
flex: none;
|
||||
width: 110px !important;
|
||||
text-align: center !important;
|
||||
color: var(--el-text-color-primary) !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.date-picker-container .el-range__close-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Базовые стили для тела карточки */
|
||||
.table-card-content-wrapper .el-card__body,
|
||||
.edit-card-content-wrapper .el-card__body {
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
width: 100%;
|
||||
padding: 12px !important;
|
||||
box-sizing: border-box !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Обычные строки внутри карточек (поиск, фильтры) не должны сжиматься */
|
||||
.table-card-content-wrapper .el-card__body > .el-row:not(.fill-height-row) {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Стили для поповера фильтрации в таблице */
|
||||
.el-popover.table-filter-popover {
|
||||
padding: 0 !important;
|
||||
min-width: unset !important;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.table-filter-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Основной контейнер */
|
||||
.filter-content {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Список радио-кнопок */
|
||||
.filter-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
.el-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-right: 0;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.el-radio__label {
|
||||
font-weight: normal;
|
||||
padding-left: 8px;
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-radio__input {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Разделитель */
|
||||
.filter-divider {
|
||||
height: 1px;
|
||||
background-color: var(--el-border-color-lighter);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Футер с кнопками */
|
||||
.filter-footer {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
|
||||
.btn-reset {
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
font-weight: normal;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Теги колонки TagsTableColumn */
|
||||
.supp-ellipsis-tag .el-tag__content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
55
frontend/src/styles/_reset.scss
Normal file
55
frontend/src/styles/_reset.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
:root {
|
||||
--el-border-radius-base: 6px !important;
|
||||
--el-border-radius-small: 4px !important;
|
||||
--el-border-radius-round: 20px !important;
|
||||
|
||||
--el-text-color-primary: rgba(0, 0, 0, 0.88);
|
||||
--el-text-color-regular: rgba(0, 0, 0, 0.88);
|
||||
--el-text-color-secondary: rgba(0, 0, 0, 0.65);
|
||||
--el-text-color-placeholder: rgba(0, 0, 0, 0.25);
|
||||
--el-border-color: #d9d9d9;
|
||||
--el-border-color-light: #e5e5e5;
|
||||
--el-border-color-lighter: #f0f0f0;
|
||||
--el-border-color-extra-light: #f5f5f5;
|
||||
|
||||
--el-color-primary: #1677ff;
|
||||
--el-color-primary-light-3: #4096ff;
|
||||
--el-color-primary-light-5: #69b1ff;
|
||||
--el-color-primary-light-7: #91caff;
|
||||
--el-color-primary-light-8: #bae0ff;
|
||||
--el-color-primary-light-9: #e6f4ff;
|
||||
--el-color-primary-dark-2: #0958d9;
|
||||
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-fill-color: #f5f5f5;
|
||||
--el-fill-color-light: #fafafa;
|
||||
--el-fill-color-lighter: #ffffff;
|
||||
--el-fill-color-extra-light: #ffffff;
|
||||
|
||||
--el-box-shadow-light: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
|
||||
--el-box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] {
|
||||
--el-text-color-primary: rgba(255, 255, 255, 0.90);
|
||||
--el-text-color-regular: rgba(255, 255, 255, 0.75);
|
||||
--el-text-color-secondary: rgba(255, 255, 255, 0.45);
|
||||
--el-text-color-placeholder: rgba(255, 255, 255, 0.25);
|
||||
|
||||
--el-border-color: rgba(255, 255, 255, 0.15);
|
||||
--el-border-color-light: rgba(255, 255, 255, 0.1);
|
||||
--el-border-color-lighter: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--el-fill-color: rgba(255, 255, 255, 0.04);
|
||||
--el-fill-color-light: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body, #app {
|
||||
margin: 0;
|
||||
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
5
frontend/src/styles/index.scss
Normal file
5
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
// Точка входа стилей
|
||||
@use './reset';
|
||||
@use './layout';
|
||||
@use './components';
|
||||
@use './overrides';
|
||||
24
frontend/src/styles/itprom-modules.css
Normal file
24
frontend/src/styles/itprom-modules.css
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user