mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 12:52:26 +00:00
refactor: 完成项目前缀从Art到Fa的全面重构
本次提交完成了代码库中所有以Art为前缀的组件、类名、样式变量等的统一替换,改为Fa前缀,包括: 1. 替换所有ArtException、ArtDataListCard等组件引用 2. 统一样式变量名从art-*为fa-* 3. 修改配置文件、工具函数中的相关命名 4. 调整国际化文案与组件命名对齐 5. 修复部分样式和逻辑代码因命名变更导致的问题 6. 清理冗余代码与废弃的类型定义
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
<RouterView></RouterView>
|
||||
|
||||
<!-- AI 助手 -->
|
||||
<FaAiAssistant v-if="enableAiAssistant" />
|
||||
<AiAssistant v-if="enableAiAssistant" />
|
||||
</ElWatermark>
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
@@ -27,7 +27,7 @@ import { useAppStore, useUserStore } from "./store";
|
||||
import { useSettingsStore } from "./store/modules/setting.store";
|
||||
import { defaultSettings } from "./config/setting";
|
||||
import { ComponentSize } from "./enums/settings/layout.enum";
|
||||
import FaAiAssistant from "./components/others/fa-ai-assistant/index.vue";
|
||||
import AiAssistant from "./components/others/fa-ai-assistant/index.vue";
|
||||
import { hexToRgba, toggleTransition } from "./utils/ui";
|
||||
import { checkStorageCompatibility } from "./utils/storage";
|
||||
import { initializeTheme } from "./hooks/core/useTheme";
|
||||
|
||||
@@ -29,4 +29,4 @@ export const fourDotsSpinnerSvg = `
|
||||
<circle class="dot" cx="8" cy="20" r="4"/>
|
||||
</g>
|
||||
</svg>
|
||||
`
|
||||
`;
|
||||
|
||||
@@ -9,7 +9,7 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border: 1px solid var(--fa-gray-300);
|
||||
border-radius: $box-radius !important;
|
||||
|
||||
.w-e-bar {
|
||||
@@ -53,12 +53,12 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
background-color: var(--fa-gray-200);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
--w-e-toolbar-active-bg-color: var(--fa-gray-200);
|
||||
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--w-e-toolbar-color: #000;
|
||||
@@ -67,22 +67,22 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
--w-e-textarea-selected-border-color: #ddd;
|
||||
|
||||
/* 表格头背景颜色 */
|
||||
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
||||
--w-e-textarea-slight-bg-color: var(--fa-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏按钮样式 */
|
||||
.w-e-bar-item svg {
|
||||
fill: var(--art-gray-800);
|
||||
fill: var(--fa-gray-800);
|
||||
}
|
||||
|
||||
.w-e-bar-item button {
|
||||
color: var(--art-gray-800);
|
||||
color: var(--fa-gray-800);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
background-color: var(--fa-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏分割线 */
|
||||
@@ -110,7 +110,7 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-50);
|
||||
background-color: var(--fa-gray-50);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
@@ -210,8 +210,8 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: var(--art-gray-200);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
background-color: var(--fa-gray-200);
|
||||
border-left: 4px solid var(--fa-gray-300);
|
||||
}
|
||||
|
||||
/* 输入区域弹出 bar */
|
||||
|
||||
@@ -367,27 +367,27 @@ const highlightOnHoverHistory = (index: number) => {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.layout-search {
|
||||
:deep(.search-modal) {
|
||||
::deep(.search-modal) {
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
::deep(.el-dialog__body) {
|
||||
padding: 5px 0 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
::deep(.el-dialog__header) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
::deep(.el-input__wrapper) {
|
||||
background-color: var(--fa-gray-200);
|
||||
border: 1px solid var(--default-border-dashed);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
::deep(.el-input__inner) {
|
||||
color: var(--fa-gray-800) !important;
|
||||
}
|
||||
}
|
||||
@@ -395,18 +395,18 @@ const highlightOnHoverHistory = (index: number) => {
|
||||
|
||||
.dark .layout-search {
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
::deep(.el-input__wrapper) {
|
||||
background-color: #333;
|
||||
border: 1px solid #4c4d50;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.search-modal) {
|
||||
::deep(.search-modal) {
|
||||
background-color: rgb(23 23 26 / 60%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
:deep(.el-dialog) {
|
||||
::deep(.el-dialog) {
|
||||
background-color: #252526;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,35 +463,35 @@ const openChat = (): void => {
|
||||
}
|
||||
|
||||
/* Hover animation classes */
|
||||
.refresh-btn:hover :deep(.fa-svg-icon) {
|
||||
.refresh-btn:hover ::deep(.fa-svg-icon) {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
|
||||
.language-btn:hover :deep(.fa-svg-icon) {
|
||||
.language-btn:hover ::deep(.fa-svg-icon) {
|
||||
animation: moveUp 0.4s;
|
||||
}
|
||||
|
||||
.setting-btn:hover :deep(.fa-svg-icon) {
|
||||
.setting-btn:hover ::deep(.fa-svg-icon) {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
|
||||
.full-screen-btn:hover :deep(.fa-svg-icon) {
|
||||
.full-screen-btn:hover ::deep(.fa-svg-icon) {
|
||||
animation: expand 0.6s forwards;
|
||||
}
|
||||
|
||||
:deep(.size-select-btn:hover .fa-svg-icon) {
|
||||
::deep(.size-select-btn:hover .fa-svg-icon) {
|
||||
animation: expand 0.6s forwards;
|
||||
}
|
||||
|
||||
.exit-full-screen-btn:hover :deep(.fa-svg-icon) {
|
||||
.exit-full-screen-btn:hover ::deep(.fa-svg-icon) {
|
||||
animation: shrink 0.6s forwards;
|
||||
}
|
||||
|
||||
.notice-button:hover :deep(.fa-svg-icon) {
|
||||
.notice-button:hover ::deep(.fa-svg-icon) {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.chat-button:hover :deep(.fa-svg-icon) {
|
||||
.chat-button:hover ::deep(.fa-svg-icon) {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ function closeUserMenu(): void {
|
||||
}
|
||||
|
||||
/* ElPopover 基于 Tooltip:触发层默认 inline-block,与顶栏 flex 图标中线对齐 */
|
||||
.fa-user-menu :deep(.el-tooltip__trigger) {
|
||||
.fa-user-menu ::deep(.el-tooltip__trigger) {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
@@ -92,18 +92,18 @@ const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
|
||||
<style scoped>
|
||||
/* Remove el-menu bottom border */
|
||||
:deep(.el-menu) {
|
||||
::deep(.el-menu) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* Remove default styles for first-level menu items */
|
||||
:deep(.el-menu-item[tabindex="0"]) {
|
||||
::deep(.el-menu-item[tabindex="0"]) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Remove bottom border from submenu titles */
|
||||
:deep(.el-menu--horizontal .el-sub-menu__title) {
|
||||
::deep(.el-menu--horizontal .el-sub-menu__title) {
|
||||
padding: 0 30px 0 10px !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
+2
-1
@@ -49,6 +49,7 @@ import { computed, type PropType } from "vue";
|
||||
import { AppRouteRecord } from "@/types/router";
|
||||
import { handleMenuJump } from "@utils/navigation";
|
||||
import { formatMenuTitle } from "@utils/navigation";
|
||||
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
@@ -105,7 +106,7 @@ const closeMenu = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||
::deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||
right: 10px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useThrottleFn } from "@vueuse/core";
|
||||
import { formatMenuTitle } from "@utils/navigation";
|
||||
import { handleMenuJump } from "@utils/navigation";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
|
||||
|
||||
defineOptions({ name: "FaMixedMenu" });
|
||||
|
||||
@@ -247,13 +248,13 @@ onMounted(initScrollState);
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-scrollbar__bar.is-horizontal) {
|
||||
::deep(.el-scrollbar__bar.is-horizontal) {
|
||||
bottom: 5px;
|
||||
display: none;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
:deep(.scrollbar-wrapper) {
|
||||
::deep(.scrollbar-wrapper) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 50px 0 30px;
|
||||
@@ -272,7 +273,7 @@ onMounted(initScrollState);
|
||||
}
|
||||
|
||||
@media (width <= 1440px) {
|
||||
:deep(.scrollbar-wrapper) {
|
||||
::deep(.scrollbar-wrapper) {
|
||||
margin: 0 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
border-right: 1px solid var(--art-card-border);
|
||||
border-right: 1px solid var(--fa-card-border);
|
||||
|
||||
&.no-border {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
// 自定义滚动条宽度
|
||||
:deep(.el-scrollbar__bar.is-vertical) {
|
||||
::deep(.el-scrollbar__bar.is-vertical) {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
::deep(.el-scrollbar__thumb) {
|
||||
right: -2px;
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
@@ -24,7 +24,7 @@
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
border-right: 1px solid var(--fa-card-border) !important;
|
||||
transition: width 0.25s;
|
||||
|
||||
.logo {
|
||||
@@ -114,7 +114,7 @@
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
background-color: var(--default-box-color);
|
||||
border: 1px solid var(--art-card-border);
|
||||
border: 1px solid var(--fa-card-border);
|
||||
border-radius: 0 15px 15px 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-50%);
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
&:hover {
|
||||
.art-svg-icon {
|
||||
color: var(--art-gray-800) !important;
|
||||
color: var(--fa-gray-800) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid rgb(255 255 255 / 13%);
|
||||
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
::deep(.el-scrollbar__thumb) {
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
$menu-height: 42px;
|
||||
$menu-icon-size: 20px;
|
||||
$menu-font-size: 14px;
|
||||
$hover-bg-color: var(--art-gray-200);
|
||||
$hover-bg-color: var(--fa-gray-200);
|
||||
$popup-menu-height: 40px;
|
||||
$popup-menu-padding: 8px;
|
||||
$popup-menu-margin: 5px;
|
||||
@@ -158,7 +158,7 @@ $popup-menu-radius: 6px;
|
||||
@include menu-hover($hover-bg-color);
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
color: var(--fa-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ $popup-menu-radius: 6px;
|
||||
@include menu-hover(#0f1015);
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-400);
|
||||
color: var(--fa-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ $popup-menu-radius: 6px;
|
||||
@include menu-hover($hover-bg-color);
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
color: var(--fa-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,21 +225,21 @@ $popup-menu-radius: 6px;
|
||||
// 菜单折叠 hover 弹窗样式(浅色主题)
|
||||
.el-menu--vertical,
|
||||
.el-menu--popup-container {
|
||||
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
|
||||
@include popup-menu-base(var(--fa-gray-200), var(--fa-gray-900), var(--fa-gray-200));
|
||||
}
|
||||
|
||||
// 暗黑模式菜单样式
|
||||
.dark {
|
||||
.el-menu--vertical,
|
||||
.el-menu--popup-container {
|
||||
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
|
||||
@include popup-menu-base(var(--fa-gray-200), var(--fa-gray-900), #292a2e);
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
// 图标颜色、文字颜色
|
||||
.menu-icon .art-svg-icon,
|
||||
.menu-name {
|
||||
color: var(--art-gray-800) !important;
|
||||
color: var(--fa-gray-800) !important;
|
||||
}
|
||||
|
||||
// 选中的文字颜色跟图标颜色
|
||||
|
||||
@@ -15,20 +15,26 @@
|
||||
</div>
|
||||
|
||||
<RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle">
|
||||
<Transition :name="actualTransition" mode="out-in" appear>
|
||||
<div class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
|
||||
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
||||
<Transition :name="actualTransition" mode="out-in">
|
||||
<div v-if="Component" class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
|
||||
<!-- 是否缓存以后端菜单 keep_alive → meta.keepAlive 为准;此处 !== false 即包 KeepAlive(与 MenuProcessor 一致) -->
|
||||
<KeepAlive
|
||||
v-if="wrapPageWithKeepAlive"
|
||||
:max="10"
|
||||
:include="keepAliveInclude"
|
||||
:exclude="keepAliveExclude"
|
||||
>
|
||||
<component
|
||||
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
|
||||
:is="Component"
|
||||
:key="router.fullPath"
|
||||
:key="routeLeafCacheKey(router)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
v-else
|
||||
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
|
||||
:is="Component"
|
||||
:key="router.fullPath"
|
||||
:key="routeLeafCacheKey(router)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -47,6 +53,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 布局滚动容器 + 业务路由出口;与 settings.refresh 联动可整体重建 RouterView。
|
||||
*
|
||||
* 缓存开关数据源:后端菜单 `keep_alive` → MenuProcessor 写入 `meta.keepAlive`;工作栏 tab 随路由写入同一 meta。
|
||||
* include / wrapPageWithKeepAlive 均用 `!== false`,与静态路由里显式 `keepAlive: false`、后端布尔字段对齐。
|
||||
*/
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
@@ -56,18 +68,47 @@ import { useWorktabStore } from "@stores/modules/worktab.store";
|
||||
|
||||
defineOptions({ name: "FaPageContent" });
|
||||
|
||||
/**
|
||||
* KeepAlive / RouterView 子节点缓存键。
|
||||
* - `meta.remountOnFullPath === true`:整 URL 参与键(依赖 query 初值或须随 query 重建的页)。
|
||||
* - 有 `name`:`name` + `params`,同一路由仅 query 变化不换实例。
|
||||
* - 无 `name`:`path`(勿用 fullPath,否则仅 query 变化也会反复挂载)。
|
||||
*/
|
||||
function routeLeafCacheKey(r: RouteLocationNormalizedLoaded): string {
|
||||
if (r.meta.remountOnFullPath === true) {
|
||||
return r.fullPath;
|
||||
}
|
||||
if (r.name != null) {
|
||||
return `${String(r.name)}:${JSON.stringify(r.params ?? {})}`;
|
||||
}
|
||||
return r.path;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
/** 动态菜单 meta.keepAlive(后端 keep_alive);仅显式 false 时不包 KeepAlive */
|
||||
const wrapPageWithKeepAlive = computed(() => route.meta.keepAlive !== false);
|
||||
|
||||
const isNarrowViewport = useMediaQuery("(max-width: 800px)");
|
||||
const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-scroll-main"));
|
||||
const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"));
|
||||
|
||||
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingsStore());
|
||||
const { keepAliveExclude } = storeToRefs(useWorktabStore());
|
||||
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
|
||||
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
|
||||
|
||||
/** 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。 */
|
||||
const keepAliveInclude = computed(() => {
|
||||
if (!showWorkTab.value) return undefined;
|
||||
const names = new Set<string>();
|
||||
for (const t of opened.value) {
|
||||
if (t.name && t.keepAlive !== false) names.add(String(t.name));
|
||||
}
|
||||
return names.size ? Array.from(names) : undefined;
|
||||
});
|
||||
|
||||
const isRefresh = shallowRef(true);
|
||||
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO;
|
||||
|
||||
// 标记是否是首次加载(浏览器刷新)
|
||||
/** 浏览器首次进入:关闭路由过渡动画,避免首屏闪动 */
|
||||
const isFirstLoad = ref(true);
|
||||
|
||||
const actualTransition = computed(() => {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 解锁全屏(旧版 LockPage 样式) -->
|
||||
<div v-else class="lockpage">
|
||||
<div v-else class="lockpage" :style="lockPageBgStyle">
|
||||
<div v-show="showClock" class="unlock-container" @click="showUnlockForm">
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
<span>{{ t("lock.unlock") }}</span>
|
||||
@@ -148,8 +148,11 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { mittBus } from "@utils/sys";
|
||||
import { useNow } from "@utils/common";
|
||||
import bgDark from "@imgs/lock/bg_dark.webp";
|
||||
import bgLight from "@imgs/lock/bg_light.webp";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -158,7 +161,20 @@ const router = useRouter();
|
||||
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY;
|
||||
|
||||
const userStore = useUserStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore);
|
||||
const { isDark } = storeToRefs(settingsStore);
|
||||
|
||||
/** 锁屏全屏背景:随明暗主题切换壁纸,叠加半透明罩层保证时钟/文案可读 */
|
||||
const lockPageBgStyle = computed(() => {
|
||||
const url = isDark.value ? bgDark : bgLight;
|
||||
return {
|
||||
backgroundImage: `linear-gradient(rgb(0 0 0 / 46%), rgb(0 0 0 / 46%)), url(${url})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
};
|
||||
});
|
||||
|
||||
const { hour, month, minute, meridiem, year, day, week } = useNow(true);
|
||||
|
||||
@@ -187,7 +203,7 @@ const unlockLoading = ref(false);
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const formData = ref({
|
||||
const formData = reactive({
|
||||
password: "",
|
||||
});
|
||||
|
||||
@@ -525,8 +541,6 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-color-white);
|
||||
background-color: rgb(0 0 0 / 90%);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
.unlock-container {
|
||||
position: absolute;
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
// 去除火狐浏览器滚动条
|
||||
:deep(.el-drawer__body) {
|
||||
::deep(.el-drawer__body) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 三种模式与 fa-design-pro 一致:tab-default / tab-card / tab-google(参考 fa-design-pro/src/components/layouts/fa-work-tab) -->
|
||||
<!-- 三种模式与 art-design-pro 一致:tab-default / tab-card / tab-google(参考 art-design-pro/src/components/core/layouts/art-work-tab) -->
|
||||
<template>
|
||||
<div
|
||||
v-if="showWorkTab"
|
||||
@@ -542,7 +542,15 @@ const useTabOperations = (adjustPositionAfterClose: () => void) => {
|
||||
const shouldNavigate = navigationRules[key as keyof typeof navigationRules];
|
||||
|
||||
if (shouldNavigate) {
|
||||
router.push(clickedPath.value);
|
||||
const dest = list.value.find((tab) => tab.path === clickedPath.value);
|
||||
if (dest) {
|
||||
router.push({
|
||||
path: dest.path,
|
||||
query: dest.query as LocationQueryRaw,
|
||||
});
|
||||
} else {
|
||||
router.push(clickedPath.value);
|
||||
}
|
||||
}
|
||||
|
||||
closeWorktab(key as TabCloseType, clickedPath.value);
|
||||
@@ -563,7 +571,7 @@ const { setupEventListeners, cleanupEventListeners, adjustPositionAfterClose } =
|
||||
const { clickTab, closeWorktab, showMenu, handleSelect } =
|
||||
useTabOperations(adjustPositionAfterClose);
|
||||
|
||||
/** 与旧版 TagsView 一致的横向滚动步长 */
|
||||
/** 标签横向滚动步长(px) */
|
||||
const SCROLL_STEP = 200;
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
|
||||
:deep(.layout-content) {
|
||||
::deep(.layout-content) {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -80,7 +80,7 @@
|
||||
overflow: visible;
|
||||
|
||||
#app-content {
|
||||
:deep(.layout-content) {
|
||||
::deep(.layout-content) {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@
|
||||
.app-layout {
|
||||
#app-main {
|
||||
#app-content {
|
||||
:deep(.layout-content) {
|
||||
::deep(.layout-content) {
|
||||
width: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,11 +283,11 @@ function downloadImg() {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.toolBoxControl) {
|
||||
::deep(.toolBoxControl) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:deep(.dockMain) {
|
||||
::deep(.dockMain) {
|
||||
right: 0;
|
||||
bottom: -40px;
|
||||
left: 0;
|
||||
@@ -297,15 +297,15 @@ function downloadImg() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.copyright) {
|
||||
::deep(.copyright) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.i-dialog-footer) {
|
||||
::deep(.i-dialog-footer) {
|
||||
margin-top: 60px !important;
|
||||
}
|
||||
|
||||
:deep(.dockBtn) {
|
||||
::deep(.dockBtn) {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
@@ -315,31 +315,31 @@ function downloadImg() {
|
||||
border: 1px solid var(--el-color-primary-light-4) !important;
|
||||
}
|
||||
|
||||
:deep(.dockBtnScrollBar) {
|
||||
::deep(.dockBtnScrollBar) {
|
||||
margin: 0 10px 0 6px;
|
||||
background-color: var(--el-color-primary-light-1);
|
||||
}
|
||||
|
||||
:deep(.scrollBarControl) {
|
||||
::deep(.scrollBarControl) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
:deep(.closeIcon) {
|
||||
::deep(.closeIcon) {
|
||||
line-height: 15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.cutter-container {
|
||||
:deep(.toolBox) {
|
||||
::deep(.toolBox) {
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
:deep(.dialogMain) {
|
||||
::deep(.dialogMain) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.i-dialog-footer) {
|
||||
::deep(.i-dialog-footer) {
|
||||
.btn {
|
||||
background-color: var(--el-color-primary) !important;
|
||||
border: transparent;
|
||||
|
||||
@@ -119,7 +119,7 @@ const dialogAttrs = computed(() => {
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
:deep(.core-overlay-icon-btn.el-button) {
|
||||
::deep(.core-overlay-icon-btn.el-button) {
|
||||
min-width: 32px;
|
||||
padding: 6px;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
|
||||
@@ -99,7 +99,7 @@ const drawerAttrs = computed(() => {
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
|
||||
:deep(.core-overlay-icon-btn.el-button) {
|
||||
::deep(.core-overlay-icon-btn.el-button) {
|
||||
min-width: 32px;
|
||||
padding: 6px;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { dayjs } from "element-plus";
|
||||
|
||||
defineOptions({ name: "FaCalendar" });
|
||||
@@ -204,45 +205,45 @@ const handleDeleteEvent = () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-calendar__header) {
|
||||
::deep(.el-calendar__header) {
|
||||
padding: 6px 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.el-calendar__title) {
|
||||
::deep(.el-calendar__title) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-calendar__header .el-button) {
|
||||
::deep(.el-calendar__header .el-button) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
:deep(.el-calendar__body) {
|
||||
::deep(.el-calendar__body) {
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
|
||||
:deep(.el-calendar-table thead th) {
|
||||
::deep(.el-calendar-table thead th) {
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.is-selected) {
|
||||
::deep(.is-selected) {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
}
|
||||
|
||||
:deep(.el-calendar-day) {
|
||||
::deep(.el-calendar-day) {
|
||||
height: auto;
|
||||
min-height: 3rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-calendar-day:hover) {
|
||||
::deep(.el-calendar-day:hover) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
::deep(.el-dialog__body) {
|
||||
padding-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -181,8 +181,8 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reference :deep(.el-input__wrapper),
|
||||
.reference :deep(.el-input__inner) {
|
||||
.reference ::deep(.el-input__wrapper),
|
||||
.reference ::deep(.el-input__inner) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const renderedContent = computed(() => {
|
||||
color: var(--el-text-color-primary);
|
||||
word-wrap: break-word;
|
||||
|
||||
:deep(pre) {
|
||||
::deep(pre) {
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
@@ -83,7 +83,7 @@ const renderedContent = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
::deep(code) {
|
||||
padding: 2px 6px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 13px;
|
||||
@@ -91,21 +91,21 @@ const renderedContent = computed(() => {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
::deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
::deep(ul),
|
||||
::deep(ol) {
|
||||
padding-left: 24px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
::deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
::deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -114,14 +114,14 @@ const renderedContent = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
::deep(blockquote) {
|
||||
padding: 8px 16px;
|
||||
margin: 12px 0;
|
||||
background: var(--el-fill-color-light);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
::deep(table) {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -84,7 +84,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
|
||||
defineOptions({ name: "FaMenuRight" });
|
||||
|
||||
@@ -382,7 +384,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.menu-item.is-disabled i:not(.submenu-arrow),
|
||||
.menu-item.is-disabled :deep(.fa-svg-icon) {
|
||||
.menu-item.is-disabled ::deep(.fa-svg-icon) {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,5 +100,4 @@ function handleCurrentChange(val: number) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -352,8 +352,8 @@ const popoverContentRef = ref();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reference :deep(.el-input__wrapper),
|
||||
.reference :deep(.el-input__inner) {
|
||||
.reference ::deep(.el-input__wrapper),
|
||||
.reference ::deep(.el-input__inner) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ const popoverContentRef = ref();
|
||||
margin-top: 6px;
|
||||
}
|
||||
// 隐藏全选按钮
|
||||
.radio :deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
|
||||
.radio ::deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -464,7 +464,7 @@ const onError = (error: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload--picture-card) {
|
||||
::deep(.el-upload--picture-card) {
|
||||
position: relative;
|
||||
width: v-bind("props.style.width ?? '150px'");
|
||||
height: v-bind("props.style.height ?? '150px'");
|
||||
|
||||
@@ -16,59 +16,51 @@
|
||||
>
|
||||
<ElTable ref="elTableRef" v-loading="!!loading" v-bind="mergedTableProps">
|
||||
<template v-for="col in columns" :key="col.prop || col.type">
|
||||
<!-- 渲染全局序号列 -->
|
||||
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
||||
<template #default="{ $index }">
|
||||
<span>{{ getGlobalIndex($index) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<!-- 渲染展开行 -->
|
||||
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
||||
<template #default="{ row: expandRow }">
|
||||
<component :is="col.formatter ? col.formatter(expandRow) : null" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<!-- 渲染普通列:default 插槽须紧凑书写,避免空白文本抢占 formatter(见 renderColumnFormatter 注释) -->
|
||||
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
|
||||
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
||||
<slot
|
||||
:name="col.headerSlotName || `${col.prop}-header`"
|
||||
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
||||
>
|
||||
{{ col.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<!-- 整段单行:插槽内兄弟节点之间的空白文本会让 EP 误判单元格内容 -->
|
||||
<!-- eslint-disable-next-line vue/max-attributes-per-line, prettier/prettier -->
|
||||
<template #default="slotScope">
|
||||
<slot
|
||||
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
|
||||
:name="col.slotName || col.prop"
|
||||
v-bind="{
|
||||
...slotScope,
|
||||
prop: col.prop,
|
||||
value: col.prop ? slotScope.row[col.prop] : undefined,
|
||||
}"
|
||||
/>
|
||||
<TableFormatterOutlet
|
||||
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
|
||||
:column="col"
|
||||
:record="slotScope.row"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.default" #default><slot /></template>
|
||||
|
||||
<template #empty>
|
||||
<div v-if="loading"></div>
|
||||
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</VueDraggable>
|
||||
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
||||
<template #default="{ row: expandRow }">
|
||||
<component :is="col.formatter ? col.formatter(expandRow) : null" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
|
||||
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
||||
<slot
|
||||
:name="col.headerSlotName || `${col.prop}-header`"
|
||||
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
||||
>
|
||||
{{ col.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<template #default="slotScope">
|
||||
<slot
|
||||
v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)"
|
||||
:name="col.slotName || col.prop"
|
||||
v-bind="{
|
||||
...slotScope,
|
||||
prop: col.prop,
|
||||
value: col.prop ? slotScope.row[col.prop] : undefined,
|
||||
}"
|
||||
/>
|
||||
<TableFormatterOutlet
|
||||
v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)"
|
||||
:column="col"
|
||||
:record="slotScope.row"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
<template v-if="$slots.default" #default><slot /></template>
|
||||
<template #empty>
|
||||
<div v-if="loading"></div>
|
||||
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pagination custom-pagination"
|
||||
@@ -112,13 +104,11 @@ import { storeToRefs } from "pinia";
|
||||
import { ColumnOption } from "@/types";
|
||||
import { useTableStore } from "@stores/modules/table.store";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { useTableHeight } from "@/hooks/core/useTableHeight";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import FaPagination from "@/components/others/fa-pagination/index.vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
|
||||
/** 空状态插画高度(列表页统一常量) */
|
||||
const ART_TABLE_EMPTY_IMAGE_SIZE = 120;
|
||||
|
||||
defineOptions({ name: "FaTable" });
|
||||
|
||||
const { width } = useWindowSize();
|
||||
@@ -281,7 +271,7 @@ useResizeObserver(tableHeaderRef, (entries) => {
|
||||
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15));
|
||||
|
||||
// 使用表格高度计算 Hook
|
||||
const { containerHeight } = useTableHeight({
|
||||
useTableHeight({
|
||||
showTableHeader: computed(() => props.showTableHeader),
|
||||
paginationHeight,
|
||||
tableHeaderHeight,
|
||||
@@ -449,7 +439,7 @@ const findTableHeader = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableHeader = document.getElementById("art-table-header");
|
||||
const tableHeader = document.getElementById("fa-table-header");
|
||||
if (tableHeader) {
|
||||
tableHeaderRef.value = tableHeader;
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.art-table {
|
||||
.fa-table {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-loading-mask) {
|
||||
::deep(.el-loading-mask) {
|
||||
z-index: 100;
|
||||
background-color: var(--default-box-color) !important;
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
// 空状态垂直居中
|
||||
&.is-empty {
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
::deep(.el-scrollbar__wrap) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
display: flex;
|
||||
margin-top: 13px;
|
||||
|
||||
:deep(.el-select) {
|
||||
::deep(.el-select) {
|
||||
width: 102px !important;
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@
|
||||
|
||||
// 自定义分页组件样式
|
||||
&.custom-pagination {
|
||||
:deep(.el-pagination) {
|
||||
::deep(.el-pagination) {
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border: 1px solid var(--fa-gray-300);
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
@@ -68,7 +68,7 @@
|
||||
box-sizing: border-box;
|
||||
font-weight: 400 !important;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border: 1px solid var(--fa-gray-300);
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&.is-active {
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
// 移动端分页
|
||||
@media (width <= 640px) {
|
||||
:deep(.el-pagination) {
|
||||
::deep(.el-pagination) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px 0;
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { useCeremony } from "@/hooks/core/useCeremony";
|
||||
import { WEB_LINKS } from "@utils/constants";
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import {
|
||||
useElementSize,
|
||||
useRafFn,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
useTimeoutFn,
|
||||
} from "@vueuse/core";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
|
||||
type ThemeType =
|
||||
| "theme"
|
||||
|
||||
@@ -88,7 +88,7 @@ watchEffect(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(svg) {
|
||||
::deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import FaLogo from "@/components/base/fa-logo/index.vue";
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
|
||||
@@ -252,7 +256,7 @@ const changeThemeColor = (color: string) => {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.auth-top-bar__action:hover :deep(.fa-svg-icon) {
|
||||
.auth-top-bar__action:hover ::deep(.fa-svg-icon) {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@@ -310,15 +314,15 @@ const changeThemeColor = (color: string) => {
|
||||
}
|
||||
|
||||
/* 调色盘:图标颜色与当前主题主色一致(含单独悬浮、整块调色区悬浮) */
|
||||
.palette-btn :deep(.fa-svg-icon) {
|
||||
.palette-btn ::deep(.fa-svg-icon) {
|
||||
color: v-bind("themeColorForCss");
|
||||
}
|
||||
|
||||
.auth-top-bar__action.palette-btn:hover :deep(.fa-svg-icon) {
|
||||
.auth-top-bar__action.palette-btn:hover ::deep(.fa-svg-icon) {
|
||||
color: v-bind("themeColorForCss");
|
||||
}
|
||||
|
||||
.color-picker-expandable:hover .palette-btn :deep(.fa-svg-icon) {
|
||||
.color-picker-expandable:hover .palette-btn ::deep(.fa-svg-icon) {
|
||||
color: v-bind("themeColorForCss");
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,10 +78,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import AppConfig from "@/config";
|
||||
import loginIcon from "@imgs/svg/login_icon.svg";
|
||||
import { useConfigStore } from "@stores/modules/config.store";
|
||||
import { themeAnimation } from "@utils/ui";
|
||||
import FaLogo from "@/components/base/fa-logo/index.vue";
|
||||
import FaThemeSvg from "@/components/theme/fa-theme-svg/index.vue";
|
||||
|
||||
defineProps<{
|
||||
@@ -233,17 +235,11 @@ $bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
|
||||
|
||||
@if $direction == "up" {
|
||||
transform: translateY(30px) rotate($rotation);
|
||||
}
|
||||
|
||||
@else if $direction == "down" {
|
||||
} @else if $direction == "down" {
|
||||
transform: translateY(-30px) rotate($rotation);
|
||||
}
|
||||
|
||||
@else if $direction == "left" {
|
||||
} @else if $direction == "left" {
|
||||
transform: translateX(-30px) rotate($rotation);
|
||||
}
|
||||
|
||||
@else if $direction == "right" {
|
||||
} @else if $direction == "right" {
|
||||
transform: translateX(30px) rotate($rotation);
|
||||
}
|
||||
}
|
||||
@@ -253,9 +249,7 @@ $bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
|
||||
|
||||
@if $direction == "up" or $direction == "down" {
|
||||
transform: translateY(0) rotate($rotation);
|
||||
}
|
||||
|
||||
@else {
|
||||
} @else {
|
||||
transform: translateX(0) rotate($rotation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
|
||||
defineOptions({ name: "FaResultPage" });
|
||||
|
||||
// 显式声明插槽类型
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
|
||||
defineOptions({ name: "FaIconButton" });
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -24,13 +24,17 @@ export const globalComponentsConfig: GlobalComponentConfig[] = [
|
||||
{
|
||||
name: "设置面板",
|
||||
key: "settings-panel",
|
||||
component: defineAsyncComponent(() => import("@/components/layouts/fa-settings-panel/index.vue")),
|
||||
component: defineAsyncComponent(
|
||||
() => import("@/components/layouts/fa-settings-panel/index.vue")
|
||||
),
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "全局搜索",
|
||||
key: "global-search",
|
||||
component: defineAsyncComponent(() => import("@/components/layouts/fa-global-search/index.vue")),
|
||||
component: defineAsyncComponent(
|
||||
() => import("@/components/layouts/fa-global-search/index.vue")
|
||||
),
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
@@ -48,7 +52,9 @@ export const globalComponentsConfig: GlobalComponentConfig[] = [
|
||||
{
|
||||
name: "礼花效果",
|
||||
key: "fireworks-effect",
|
||||
component: defineAsyncComponent(() => import("@/components/layouts/fa-fireworks-effect/index.vue")),
|
||||
component: defineAsyncComponent(
|
||||
() => import("@/components/layouts/fa-fireworks-effect/index.vue")
|
||||
),
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ const fastEnterConfig: FastEnterConfig = {
|
||||
{
|
||||
name: "工作台",
|
||||
description: "系统概览与数据统计",
|
||||
icon: "ri:pie-chfa-line",
|
||||
icon: "ri:pie-chart-line",
|
||||
iconColor: "#377dff",
|
||||
enabled: true,
|
||||
order: 1,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* - `image` 为红包碎屑 hb、雪花 sd、礼花筒 yd 等时,须在下方 import,勿手写路径字符串。
|
||||
* - 全局快捷键:**Ctrl + Shift + P**(Windows / Linux)或 **⌘ + Shift + P**(macOS)立即触发一发礼花;
|
||||
* 若当天命中某条「带礼花」的节日配置则使用该配置的 `image`,否则为几何礼花。
|
||||
* - 实现:`src/layouts/fa-fireworks-effect/index.vue`(键盘)、`src/hooks/core/useCeremony.ts`(自动连发)。
|
||||
* - 实现:`src/layouts/art-fireworks-effect/index.vue`(键盘)、`src/hooks/core/useCeremony.ts`(自动连发)。
|
||||
*
|
||||
* ## 自动公历节日
|
||||
*
|
||||
@@ -47,9 +47,6 @@
|
||||
*/
|
||||
|
||||
import { FestivalConfig } from "@/types/config";
|
||||
import hb from "@imgs/ceremony/hb.png";
|
||||
import sd from "@imgs/ceremony/sd.png";
|
||||
import yd from "@imgs/ceremony/yd.png";
|
||||
|
||||
export const festivalConfigList: FestivalConfig[] = [
|
||||
/**
|
||||
@@ -67,46 +64,6 @@ export const festivalConfigList: FestivalConfig[] = [
|
||||
'🎉 {{version}}版本正式上线!能力全面提升,配套完整交付方案,助力高效开发与商业落地。 <a href="{{introduceUrl}}" target="_blank" rel="noopener noreferrer">👉 立即体验演示</a>',
|
||||
},
|
||||
|
||||
/**
|
||||
* 春节示例:红包碎屑图 + 略快节奏(上线前请改为真实农历档期的 start/end)
|
||||
*/
|
||||
{
|
||||
name: "春节祝福",
|
||||
date: "2026-02-15",
|
||||
endDate: "2026-02-23",
|
||||
image: hb,
|
||||
count: 6,
|
||||
fireworkInterval: 850,
|
||||
scrollText:
|
||||
"🧧 新春快乐!FastAPI Admin 祝您阖家安康、迭代顺利,新的一年代码零告警、上线零事故。",
|
||||
},
|
||||
|
||||
/**
|
||||
* 初夏示例(含 5 月):礼花筒图案,标准间隔
|
||||
*/
|
||||
{
|
||||
name: "初夏有礼",
|
||||
date: "2026-05-01",
|
||||
endDate: "2026-05-31",
|
||||
image: yd,
|
||||
count: 4,
|
||||
fireworkInterval: 1000,
|
||||
scrollText: "🎆 五月愉快!感谢使用本系统,记得关注备份与安全策略,遇到问题先查文档或联系运维。",
|
||||
},
|
||||
|
||||
/**
|
||||
* 冬日示例:雪花图 + 更短间隔,视觉上更接近连续飘落
|
||||
*/
|
||||
{
|
||||
name: "冬日暖心",
|
||||
date: "2026-12-20",
|
||||
endDate: "2026-12-26",
|
||||
image: sd,
|
||||
count: 8,
|
||||
fireworkInterval: 700,
|
||||
scrollText: "❄️ 岁末将至,敬颂冬绥。感谢一路相伴,祝交付顺利、心情像雪花一样轻盈~",
|
||||
},
|
||||
|
||||
/**
|
||||
* 以下为历史示例片段(默认注释),需要时可取消注释并酌情调整日期 / import:
|
||||
*
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* ## 注意事项
|
||||
*
|
||||
* 1. 修改此文件的配置项时,需要同步更新以下文件:
|
||||
* - src/layouts/fa-settings-panel/widget/SettingActions.vue(复制配置和重置配置逻辑)
|
||||
* - src/layouts/art-settings-panel/widget/SettingActions.vue(复制配置和重置配置逻辑)
|
||||
* - src/store/modules/setting.ts(Store 状态定义)
|
||||
* 2. 可以通过设置面板的"复制配置"按钮快速生成配置代码
|
||||
* 3. 枚举类型的值需要与 src/enums/appEnum.ts 中的定义保持一致
|
||||
|
||||
@@ -54,7 +54,7 @@ export const useAuth = () => {
|
||||
: [];
|
||||
|
||||
/**
|
||||
* 检查是否拥有某权限标识(与 v-hasPerm / 表格内 FaButtonMore 全码一致)
|
||||
* 检查是否拥有某权限标识(与 v-hasPerm / 表格内 ArtButtonMore 全码一致)
|
||||
* @param auth 权限标识(多为后端菜单 permission 全码,如 module_system:role:update)
|
||||
*/
|
||||
const hasAuth = (auth: string): boolean => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* ## 手动礼花快捷键
|
||||
*
|
||||
* 全局 **Ctrl+Shift+P** / **⌘+Shift+P** 由 `fa-fireworks-effect` 监听;礼花图与当天节日配置一致,
|
||||
* 全局 **Ctrl+Shift+P** / **⌘+Shift+P** 由 `art-fireworks-effect` 监听;礼花图与当天节日配置一致,
|
||||
* 详见 `src/config/modules/festival.ts` 文件头说明。
|
||||
*
|
||||
* ## 使用示例
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
/**
|
||||
* useCommon - 通用功能集合
|
||||
*
|
||||
* 提供常用的页面操作功能,包括页面刷新、滚动控制、路径获取等。
|
||||
* 这些功能在多个页面和组件中都会用到,统一封装便于复用。
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* 1. 首页路径 - 获取系统配置的首页路径
|
||||
* 2. 页面刷新 - 刷新当前页面内容
|
||||
* 3. 滚动控制 - 提供多种滚动到顶部和指定位置的方法
|
||||
* 4. 平滑滚动 - 支持平滑滚动动画效果
|
||||
*
|
||||
* @module useCommon
|
||||
* @author FastapiAdmin Team
|
||||
* 通用页面能力:首页路径、触发整页刷新(settings)、主滚动容器滚动。
|
||||
*/
|
||||
|
||||
import { computed } from "vue";
|
||||
@@ -23,13 +10,9 @@ export function useCommon() {
|
||||
const menuStore = useMenuStore();
|
||||
const settingStore = useSettingsStore();
|
||||
|
||||
/**
|
||||
* 首页路径
|
||||
* 从菜单 store 中获取配置的首页路径
|
||||
*/
|
||||
const homePath = computed(() => menuStore.getHomePath());
|
||||
|
||||
/** 触发 `FaPageContent` 级重建(与布局 settings 联动) */
|
||||
/** 触发 `ArtPageContent` 级重建(与布局 settings 联动) */
|
||||
const refresh = () => {
|
||||
settingStore.reload();
|
||||
};
|
||||
@@ -38,10 +21,6 @@ export function useCommon() {
|
||||
const getMainScrollEl = (): HTMLElement | null =>
|
||||
document.getElementById("app-scroll-main") ?? document.getElementById("app-main");
|
||||
|
||||
/**
|
||||
* 滚动到页面顶部
|
||||
* 查找主内容区域并将其滚动位置重置为顶部
|
||||
*/
|
||||
const scrollToTop = () => {
|
||||
const scrollContainer = getMainScrollEl();
|
||||
if (scrollContainer) {
|
||||
@@ -49,10 +28,6 @@ export function useCommon() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 平滑滚动到页面顶部
|
||||
* 使用 smooth 行为实现平滑滚动效果
|
||||
*/
|
||||
const smoothScrollToTop = () => {
|
||||
const scrollContainer = getMainScrollEl();
|
||||
if (scrollContainer) {
|
||||
@@ -63,11 +38,6 @@ export function useCommon() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 滚动到指定位置
|
||||
* @param top 目标滚动位置(像素)
|
||||
* @param smooth 是否使用平滑滚动
|
||||
*/
|
||||
const scrollTo = (top: number, smooth: boolean = false) => {
|
||||
const scrollContainer = getMainScrollEl();
|
||||
if (scrollContainer) {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useHeaderBar() {
|
||||
return isFeatureEnabled("breadcrumb") && showCrumbs.value;
|
||||
});
|
||||
|
||||
// 全局搜索:新版 FaGlobalSearch 体验更好,显示条件 = 配置开启 ∧ 旧版「菜单搜索」开关
|
||||
// 全局搜索:新版 ArtGlobalSearch 体验更好,显示条件 = 配置开启 ∧ 旧版「菜单搜索」开关
|
||||
const shouldShowGlobalSearch = computed(() => {
|
||||
return isFeatureEnabled("globalSearch") && showMenuSearch.value;
|
||||
});
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* useTable - 企业级表格数据管理方案
|
||||
* 表格列表数据组合式函数。
|
||||
*
|
||||
* 功能完整的表格数据管理解决方案,专为后台管理系统设计。
|
||||
* 封装了表格开发中的所有常见需求,让你专注于业务逻辑。
|
||||
* 请求路径:业务均通过内部 `fetchData` → `apiFn`,可选 TableCache;对外暴露的 `getData` / `fetchData` 经
|
||||
* `fetchDataQuiet` 吞掉 reject,避免页面重复 try/catch。
|
||||
*
|
||||
* ## 主要功能
|
||||
* 去重:
|
||||
* - 同实例:`inFlightDedupePromise` + `inFlightDedupeKey`
|
||||
* - 跨实例(同参):模块级 `globalListNetworkInflight`(布局短时多挂载时合并为一条 HTTP)
|
||||
*
|
||||
* 1. 数据管理 - 自动处理 API 请求、响应转换、加载状态和错误处理
|
||||
* 2. 分页控制 - 自动同步分页状态、移动端适配、智能页码边界处理
|
||||
* 3. 搜索功能 - 防抖搜索优化、参数管理、一键重置、参数过滤
|
||||
* 4. 缓存系统 - 智能请求缓存、多种清理策略、自动过期管理、统计信息
|
||||
* 5. 刷新策略 - 提供 5 种刷新方法适配不同业务场景(新增/更新/删除/手动/定时)
|
||||
* 6. 列配置管理 - 动态显示/隐藏列、列排序、配置持久化、批量操作(可选)
|
||||
*
|
||||
* @module useTable
|
||||
* @author FastapiAdmin Team
|
||||
* KeepAlive:`onDeactivated` 会取消请求并置 `tableViewActive`,失活期间 `fetchData` 直接返回当前快照不发 HTTP。
|
||||
*/
|
||||
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from "vue";
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
nextTick,
|
||||
readonly,
|
||||
toRaw,
|
||||
} from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTableColumns } from "./useTableColumns";
|
||||
@@ -37,7 +42,10 @@ import {
|
||||
} from "@utils/table";
|
||||
import { tableConfig } from "@utils/table";
|
||||
|
||||
// 类型推导工具类型
|
||||
/** 跨组件实例:同一 dedupeKey 仅一条进行中的网络请求 */
|
||||
const globalListNetworkInflight = new Map<string, Promise<TableApiResponse<unknown>>>();
|
||||
|
||||
// --- 类型推导(由 apiFn / 响应类型反推记录类型) ---
|
||||
type InferApiParams<T> = T extends (params: infer P) => any ? P : never;
|
||||
type UnwrapAxiosResponse<T> = T extends AxiosResponse<infer D> ? D : T;
|
||||
type InferApiResponse<T> = T extends (params: any) => Promise<infer R>
|
||||
@@ -60,7 +68,7 @@ type PaginationState = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
// 优化的配置接口 - 支持自动类型推导
|
||||
/** 表格 Hook 配置(泛型由 apiFn 推导) */
|
||||
export interface UseTableConfig<
|
||||
TApiFn extends (params: any) => Promise<any> = (params: any) => Promise<any>,
|
||||
TRecord = InferRecordType<InferApiResponse<TApiFn>>,
|
||||
@@ -131,23 +139,13 @@ export interface UseTableConfig<
|
||||
};
|
||||
}
|
||||
|
||||
/** 对外入口(实现为内部 `useTableImpl`) */
|
||||
export function useTable<TApiFn extends (params: any) => Promise<any>>(
|
||||
config: UseTableConfig<TApiFn>
|
||||
) {
|
||||
return useTableImpl(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* useTable 的核心实现 - 强大的表格数据管理 Hook
|
||||
*
|
||||
* 提供完整的表格解决方案,包括:
|
||||
* - 数据获取与缓存
|
||||
* - 分页控制
|
||||
* - 搜索功能
|
||||
* - 智能刷新策略
|
||||
* - 错误处理
|
||||
* - 列配置管理
|
||||
*/
|
||||
function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
config: UseTableConfig<TApiFn>
|
||||
) {
|
||||
@@ -216,6 +214,31 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
// 请求取消控制器
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
/** 同参且尚未结束的请求共用一个 Promise,避免并发重复打接口 */
|
||||
let inFlightDedupeKey: string | null = null;
|
||||
let inFlightDedupePromise: Promise<TableApiResponse<TRecord>> | null = null;
|
||||
|
||||
/** KeepAlive 失活时不再发请求(组件侧 cancelRequest 会 abort) */
|
||||
let tableViewActive = true;
|
||||
|
||||
/** 稳定序列化请求参,供 in-flight 去重用 */
|
||||
function stableDedupeKeyFromParams(params: TParams): string {
|
||||
const normalize = (input: unknown): unknown => {
|
||||
if (input === null || typeof input !== "object") return input;
|
||||
if (Array.isArray(input)) return input.map(normalize);
|
||||
const raw = input as Record<string, unknown>;
|
||||
const keys = Object.keys(raw).sort();
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of keys) {
|
||||
const v = raw[k];
|
||||
if (v === undefined) continue;
|
||||
out[k] = normalize(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return JSON.stringify(normalize(toRaw(params) as unknown));
|
||||
}
|
||||
|
||||
// 缓存清理定时器
|
||||
let cacheCleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -294,52 +317,122 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
cacheUpdateTrigger.value++;
|
||||
};
|
||||
|
||||
// 获取数据的核心方法
|
||||
/**
|
||||
* 将标准化响应写入本实例的 `data` / `pagination`,并可选写入缓存。
|
||||
* 全局去重时每个参与合并的实例都会各自调用一次。
|
||||
*/
|
||||
function commitNetworkSuccess(
|
||||
standardResponse: TableApiResponse<TRecord>,
|
||||
paramsForCache: TParams,
|
||||
useCacheFlag: boolean
|
||||
): void {
|
||||
let tableData = extractTableData(standardResponse);
|
||||
if (dataTransformer) {
|
||||
tableData = dataTransformer(tableData);
|
||||
}
|
||||
data.value = tableData;
|
||||
updatePaginationFromResponse(pagination, standardResponse);
|
||||
|
||||
const paramsRecord = searchParams as Record<string, unknown>;
|
||||
if (paramsRecord[pageKey] !== pagination.current) {
|
||||
paramsRecord[pageKey] = pagination.current;
|
||||
}
|
||||
if (paramsRecord[sizeKey] !== pagination.size) {
|
||||
paramsRecord[sizeKey] = pagination.size;
|
||||
}
|
||||
|
||||
if (useCacheFlag && cache) {
|
||||
cache.set(paramsForCache, tableData, standardResponse);
|
||||
cacheUpdateTrigger.value++;
|
||||
logger.log(`数据已缓存`);
|
||||
}
|
||||
|
||||
loadingState.value = "success";
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(tableData, standardResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表请求唯一入口:合并参数 → 读缓存或发起 `apiFn`。
|
||||
* 先进单实例去重,再进全局 Map;若已有同键进行中的 Promise,则等待并复用结果。
|
||||
*/
|
||||
const fetchData = async (
|
||||
params?: Partial<TParams>,
|
||||
useCache = enableCache
|
||||
): Promise<TableApiResponse<TRecord>> => {
|
||||
// 取消上一个请求
|
||||
let requestParams = Object.assign(
|
||||
{},
|
||||
searchParams,
|
||||
{
|
||||
[pageKey]: pagination.current,
|
||||
[sizeKey]: pagination.size,
|
||||
},
|
||||
params || {}
|
||||
) as TParams;
|
||||
|
||||
if (excludeParams.length > 0) {
|
||||
const filteredParams = { ...requestParams };
|
||||
excludeParams.forEach((key) => {
|
||||
delete (filteredParams as Record<string, unknown>)[key];
|
||||
});
|
||||
requestParams = filteredParams as TParams;
|
||||
}
|
||||
|
||||
const dedupeKey = stableDedupeKeyFromParams(requestParams);
|
||||
|
||||
if (inFlightDedupePromise !== null && inFlightDedupeKey === dedupeKey) {
|
||||
logger.log("合并同参进行中的列表请求");
|
||||
return inFlightDedupePromise;
|
||||
}
|
||||
|
||||
const sharedGlobal = globalListNetworkInflight.get(dedupeKey);
|
||||
if (sharedGlobal) {
|
||||
loadingState.value = "loading";
|
||||
error.value = null;
|
||||
try {
|
||||
const standardResponse = (await sharedGlobal) as TableApiResponse<TRecord>;
|
||||
commitNetworkSuccess(standardResponse, requestParams, useCache);
|
||||
return standardResponse;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "请求已取消") {
|
||||
loadingState.value = "idle";
|
||||
return { records: [], total: 0, current: 1, size: 10 };
|
||||
}
|
||||
loadingState.value = "error";
|
||||
data.value = [];
|
||||
const tableError = handleError(err, "获取表格数据失败");
|
||||
throw tableError;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tableViewActive) {
|
||||
return {
|
||||
records: [...data.value],
|
||||
total: pagination.total,
|
||||
current: pagination.current,
|
||||
size: pagination.size,
|
||||
} as TableApiResponse<TRecord>;
|
||||
}
|
||||
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
// 创建新的取消控制器
|
||||
const currentController = new AbortController();
|
||||
abortController = currentController;
|
||||
|
||||
// 状态机:进入 loading 状态
|
||||
loadingState.value = "loading";
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
let requestParams = Object.assign(
|
||||
{},
|
||||
searchParams,
|
||||
{
|
||||
[pageKey]: pagination.current,
|
||||
[sizeKey]: pagination.size,
|
||||
},
|
||||
params || {}
|
||||
) as TParams;
|
||||
|
||||
// 剔除不需要的参数
|
||||
if (excludeParams.length > 0) {
|
||||
const filteredParams = { ...requestParams };
|
||||
excludeParams.forEach((key) => {
|
||||
delete (filteredParams as Record<string, unknown>)[key];
|
||||
});
|
||||
requestParams = filteredParams as TParams;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
if (useCache && cache) {
|
||||
const cachedItem = cache.get(requestParams);
|
||||
if (cachedItem) {
|
||||
data.value = cachedItem.data;
|
||||
updatePaginationFromResponse(pagination, cachedItem.response);
|
||||
|
||||
// 修复:避免重复设置相同的值,防止响应式循环更新
|
||||
const paramsRecord = searchParams as Record<string, unknown>;
|
||||
if (paramsRecord[pageKey] !== pagination.current) {
|
||||
paramsRecord[pageKey] = pagination.current;
|
||||
@@ -348,10 +441,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
paramsRecord[sizeKey] = pagination.size;
|
||||
}
|
||||
|
||||
// 状态机:缓存命中,进入 success 状态
|
||||
loadingState.value = "success";
|
||||
|
||||
// 缓存命中时触发专门的回调,而不是 onSuccess
|
||||
if (onCacheHit) {
|
||||
onCacheHit(cachedItem.data, cachedItem.response);
|
||||
}
|
||||
@@ -361,100 +452,87 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiFn(requestParams);
|
||||
inFlightDedupeKey = dedupeKey;
|
||||
|
||||
// 检查请求是否被取消
|
||||
if (currentController.signal.aborted) {
|
||||
throw new Error("请求已取消");
|
||||
const networkPromise = (async (): Promise<TableApiResponse<TRecord>> => {
|
||||
try {
|
||||
logger.log("HTTP 请求", dedupeKey);
|
||||
const response = await apiFn(requestParams);
|
||||
|
||||
if (currentController.signal.aborted) {
|
||||
throw new Error("请求已取消");
|
||||
}
|
||||
|
||||
return responseAdapter(response);
|
||||
} finally {
|
||||
if (inFlightDedupeKey === dedupeKey) {
|
||||
inFlightDedupeKey = null;
|
||||
inFlightDedupePromise = null;
|
||||
}
|
||||
if (abortController === currentController) {
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
globalListNetworkInflight.set(
|
||||
dedupeKey,
|
||||
networkPromise as Promise<TableApiResponse<unknown>>
|
||||
);
|
||||
networkPromise.finally(() => {
|
||||
globalListNetworkInflight.delete(dedupeKey);
|
||||
});
|
||||
|
||||
inFlightDedupePromise = networkPromise;
|
||||
|
||||
try {
|
||||
const standardResponse = await networkPromise;
|
||||
commitNetworkSuccess(standardResponse, requestParams, useCache);
|
||||
return standardResponse;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "请求已取消") {
|
||||
loadingState.value = "idle";
|
||||
return { records: [], total: 0, current: 1, size: 10 };
|
||||
}
|
||||
loadingState.value = "error";
|
||||
data.value = [];
|
||||
const tableError = handleError(err, "获取表格数据失败");
|
||||
throw tableError;
|
||||
}
|
||||
|
||||
// 使用响应适配器转换为标准格式
|
||||
const standardResponse = responseAdapter(response);
|
||||
|
||||
// 处理响应数据
|
||||
let tableData = extractTableData(standardResponse);
|
||||
|
||||
// 应用数据转换函数
|
||||
if (dataTransformer) {
|
||||
tableData = dataTransformer(tableData);
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
data.value = tableData;
|
||||
updatePaginationFromResponse(pagination, standardResponse);
|
||||
|
||||
// 修复:避免重复设置相同的值,防止响应式循环更新
|
||||
const paramsRecord = searchParams as Record<string, unknown>;
|
||||
if (paramsRecord[pageKey] !== pagination.current) {
|
||||
paramsRecord[pageKey] = pagination.current;
|
||||
}
|
||||
if (paramsRecord[sizeKey] !== pagination.size) {
|
||||
paramsRecord[sizeKey] = pagination.size;
|
||||
}
|
||||
|
||||
// 缓存数据
|
||||
if (useCache && cache) {
|
||||
cache.set(requestParams, tableData, standardResponse);
|
||||
// 手动触发缓存状态更新
|
||||
cacheUpdateTrigger.value++;
|
||||
logger.log(`数据已缓存`);
|
||||
}
|
||||
|
||||
// 状态机:请求成功,进入 success 状态
|
||||
loadingState.value = "success";
|
||||
|
||||
// 成功回调
|
||||
if (onSuccess) {
|
||||
onSuccess(tableData, standardResponse);
|
||||
}
|
||||
|
||||
return standardResponse;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "请求已取消") {
|
||||
// 请求被取消,回到 idle 状态
|
||||
loadingState.value = "idle";
|
||||
return { records: [], total: 0, current: 1, size: 10 };
|
||||
}
|
||||
|
||||
// 状态机:请求失败,进入 error 状态
|
||||
loadingState.value = "error";
|
||||
data.value = [];
|
||||
const tableError = handleError(err, "获取表格数据失败");
|
||||
throw tableError;
|
||||
} finally {
|
||||
// 只有当前控制器是活跃的才清空
|
||||
if (abortController === currentController) {
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取数据 (保持当前页)
|
||||
const getData = async (params?: Partial<TParams>): Promise<TableApiResponse<TRecord> | void> => {
|
||||
/**
|
||||
* 包装一层:reject 转为 `undefined`,错误展示已在 fetchData / handleError 内完成。
|
||||
*/
|
||||
async function fetchDataQuiet(
|
||||
params?: Partial<TParams>,
|
||||
useCache = enableCache
|
||||
): Promise<TableApiResponse<TRecord> | void> {
|
||||
try {
|
||||
return await fetchData(params);
|
||||
return await fetchData(params, useCache);
|
||||
} catch {
|
||||
// 错误已在 fetchData 中处理
|
||||
return Promise.resolve();
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 分页获取数据 (重置到第一页) - 专门用于搜索场景
|
||||
/** 按当前分页拉取(默认走缓存策略 enableCache) */
|
||||
const getData = (params?: Partial<TParams>) => fetchDataQuiet(params);
|
||||
|
||||
/** 搜索场景:回到第一页、清当前条件缓存,再拉取(禁用缓存) */
|
||||
const getDataByPage = async (
|
||||
params?: Partial<TParams>
|
||||
): Promise<TableApiResponse<TRecord> | void> => {
|
||||
pagination.current = 1;
|
||||
(searchParams as Record<string, unknown>)[pageKey] = 1;
|
||||
|
||||
// 搜索时清空当前搜索条件的缓存,确保获取最新数据
|
||||
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "搜索数据");
|
||||
|
||||
try {
|
||||
return await fetchData(params, false); // 搜索时不使用缓存
|
||||
} catch {
|
||||
// 错误已在 fetchData 中处理
|
||||
return Promise.resolve();
|
||||
}
|
||||
return fetchDataQuiet(params, false);
|
||||
};
|
||||
|
||||
// 智能防抖搜索函数
|
||||
@@ -524,10 +602,9 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
pagination.size = currentSize;
|
||||
};
|
||||
|
||||
// 防重复调用的标志
|
||||
/** 防止分页页码变更回调重入(EP 或联动可能短时触发多次) */
|
||||
let isCurrentChanging = false;
|
||||
|
||||
// 处理分页大小变化
|
||||
const handleSizeChange = async (newSize: number): Promise<void> => {
|
||||
if (newSize <= 0) return;
|
||||
|
||||
@@ -544,16 +621,13 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
await getData();
|
||||
};
|
||||
|
||||
// 处理当前页变化
|
||||
const handleCurrentChange = async (newCurrent: number): Promise<void> => {
|
||||
if (newCurrent <= 0) return;
|
||||
|
||||
// 修复:防止重复调用
|
||||
if (isCurrentChanging) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 修复:如果当前页没有变化,不需要重新请求
|
||||
if (pagination.current === newCurrent) {
|
||||
logger.log("分页页码未变化,跳过请求");
|
||||
return;
|
||||
@@ -562,10 +636,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
try {
|
||||
isCurrentChanging = true;
|
||||
|
||||
// 修复:只更新必要的状态
|
||||
const paramsRecord = searchParams as Record<string, unknown>;
|
||||
pagination.current = newCurrent;
|
||||
// 只有当 searchParams 的分页字段与新值不同时才更新
|
||||
if (paramsRecord[pageKey] !== newCurrent) {
|
||||
paramsRecord[pageKey] = newCurrent;
|
||||
}
|
||||
@@ -576,9 +648,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
}
|
||||
};
|
||||
|
||||
// 针对不同业务场景的刷新方法
|
||||
// --- 刷新策略(命名表达业务语义,便于页面按钮选用) ---
|
||||
|
||||
// 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后)
|
||||
const refreshCreate = async (): Promise<void> => {
|
||||
debouncedGetDataByPage.cancel();
|
||||
pagination.current = 1;
|
||||
@@ -587,13 +658,11 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
await getData();
|
||||
};
|
||||
|
||||
// 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后)
|
||||
const refreshUpdate = async (): Promise<void> => {
|
||||
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "编辑数据");
|
||||
await getData();
|
||||
};
|
||||
|
||||
// 删除后刷新:智能处理页码,避免空页面(适用于删除数据后)
|
||||
const refreshRemove = async (): Promise<void> => {
|
||||
const { current } = pagination;
|
||||
|
||||
@@ -601,7 +670,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "删除数据");
|
||||
await getData();
|
||||
|
||||
// 如果当前页为空且不是第一页,回到上一页
|
||||
if (data.value.length === 0 && current > 1) {
|
||||
pagination.current = current - 1;
|
||||
(searchParams as Record<string, unknown>)[pageKey] = current - 1;
|
||||
@@ -609,14 +677,12 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
}
|
||||
};
|
||||
|
||||
// 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮)
|
||||
const refreshData = async (): Promise<void> => {
|
||||
debouncedGetDataByPage.cancel();
|
||||
clearCache(CacheInvalidationStrategy.CLEAR_ALL, "手动刷新");
|
||||
await getData();
|
||||
};
|
||||
|
||||
// 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新)
|
||||
const refreshSoft = async (): Promise<void> => {
|
||||
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, "软刷新");
|
||||
await getData();
|
||||
@@ -630,6 +696,15 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
debouncedGetDataByPage.cancel();
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
tableViewActive = true;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
tableViewActive = false;
|
||||
cancelRequest();
|
||||
});
|
||||
|
||||
// 清空数据
|
||||
const clearData = (): void => {
|
||||
data.value = [];
|
||||
@@ -660,14 +735,12 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
}, cacheTime / 2); // 每半个缓存周期清理一次
|
||||
}
|
||||
|
||||
// 挂载时自动加载数据
|
||||
if (immediate) {
|
||||
onMounted(async () => {
|
||||
await getData();
|
||||
});
|
||||
}
|
||||
|
||||
// 组件卸载时彻底清理
|
||||
onUnmounted(() => {
|
||||
cancelRequest();
|
||||
if (cache) {
|
||||
@@ -678,9 +751,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
}
|
||||
});
|
||||
|
||||
// 优化的返回值结构
|
||||
return {
|
||||
// 数据相关
|
||||
// --- 数据 ---
|
||||
/** 表格数据 */
|
||||
data,
|
||||
/** 数据加载状态 */
|
||||
@@ -692,7 +764,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
/** 是否有数据 */
|
||||
hasData,
|
||||
|
||||
// 分页相关
|
||||
// --- 分页 ---
|
||||
/** 分页状态信息 */
|
||||
pagination: readonly(pagination),
|
||||
/** 移动端分页配置 */
|
||||
@@ -702,7 +774,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
/** 当前页变化处理 */
|
||||
handleCurrentChange,
|
||||
|
||||
// 搜索相关 - 统一前缀
|
||||
// --- 查询条件 ---
|
||||
/** 搜索参数 */
|
||||
searchParams,
|
||||
/** 替换搜索参数(适用于表单查询,避免旧字段残留) */
|
||||
@@ -710,17 +782,16 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
/** 重置搜索参数 */
|
||||
resetSearchParams,
|
||||
|
||||
// 数据操作 - 更明确的操作意图
|
||||
/** 加载数据 */
|
||||
/** 当前页加载(错误已内部处理) */
|
||||
fetchData: getData,
|
||||
/** 获取数据 */
|
||||
/** 回到第一页并查询(搜索 / 重置页码场景) */
|
||||
getData: getDataByPage,
|
||||
/** 获取数据(防抖) */
|
||||
getDataDebounced: debouncedGetDataByPage,
|
||||
/** 清空数据 */
|
||||
clearData,
|
||||
|
||||
// 刷新策略
|
||||
// --- 刷新 ---
|
||||
/** 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) */
|
||||
refreshData,
|
||||
/** 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) */
|
||||
@@ -732,24 +803,17 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
/** 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) */
|
||||
refreshRemove,
|
||||
|
||||
// 缓存控制
|
||||
/** 缓存统计信息 */
|
||||
// --- 缓存(策略见 CacheInvalidationStrategy) ---
|
||||
cacheInfo,
|
||||
/** 清除缓存,根据不同的业务场景选择性地清理缓存: */
|
||||
clearCache,
|
||||
// 支持4种清理策略
|
||||
// clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') // 清空所有缓存
|
||||
// clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') // 只清空当前搜索条件的缓存
|
||||
// clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') // 清空分页相关缓存
|
||||
// clearCache(CacheInvalidationStrategy.KEEP_ALL, '保持缓存') // 不清理任何缓存
|
||||
/** 清理已过期的缓存条目,释放内存空间 */
|
||||
clearExpiredCache,
|
||||
|
||||
// 请求控制
|
||||
// --- 请求 ---
|
||||
/** 取消当前请求 */
|
||||
cancelRequest,
|
||||
|
||||
// 列配置 (如果提供了 columnsFactory)
|
||||
// --- 列配置(仅传入 columnsFactory 时展开) ---
|
||||
...(columnConfig && {
|
||||
/** 表格列配置 */
|
||||
columns,
|
||||
@@ -777,7 +841,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
|
||||
};
|
||||
}
|
||||
|
||||
// 重新导出类型和枚举,方便使用
|
||||
export { CacheInvalidationStrategy } from "@utils/table";
|
||||
export type { ApiResponse, CacheItem } from "@utils/table";
|
||||
export type { BaseRequestParams, TableError } from "@utils/table";
|
||||
|
||||
@@ -1,72 +1,30 @@
|
||||
/**
|
||||
* useTableColumns - 表格列配置管理
|
||||
*
|
||||
* 提供动态的表格列配置管理能力,支持运行时灵活控制列的显示、隐藏、排序等操作。
|
||||
* 通常与 useTable 配合使用,为表格提供完整的列管理功能。
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* 1. 列显示控制 - 动态显示/隐藏列,支持批量操作
|
||||
* 2. 列排序 - 拖拽或编程方式重新排列列顺序
|
||||
* 3. 列配置管理 - 新增、删除、更新列配置
|
||||
* 4. 特殊列支持 - 自动处理 selection、expand、index 等特殊列
|
||||
* 5. 状态持久化 - 保持列的显示状态,支持重置到初始状态
|
||||
*
|
||||
* ## 使用示例
|
||||
*
|
||||
* ```typescript
|
||||
* const { columns, columnChecks, toggleColumn, reorderColumns } = useTableColumns(() => [
|
||||
* { prop: 'name', label: '姓名', visible: true },
|
||||
* { prop: 'email', label: '邮箱', visible: true },
|
||||
* { prop: 'status', label: '状态', visible: false }
|
||||
* ])
|
||||
*
|
||||
* // 切换列显示
|
||||
* toggleColumn('email', false)
|
||||
*
|
||||
* // 重新排序
|
||||
* reorderColumns(0, 2)
|
||||
* ```
|
||||
*
|
||||
* @module useTableColumns
|
||||
* @author FastapiAdmin Team
|
||||
* 表格列配置:显隐、拖拽排序、增删改;与 `useTable` 的 `columnsFactory` 配合。
|
||||
* 导出 `getColumnVisibility` / `getColumnChecks` 供表头等处复用同一套 visible/checked 规则。
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { $t } from "@/locales";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
|
||||
/**
|
||||
* 特殊列类型
|
||||
*/
|
||||
/** selection / expand / index 等占位 prop,避免与业务列冲突 */
|
||||
const SPECIAL_COLUMNS: Record<string, { prop: string; label: string }> = {
|
||||
selection: { prop: "__selection__", label: $t("table.column.selection") },
|
||||
expand: { prop: "__expand__", label: $t("table.column.expand") },
|
||||
index: { prop: "__index__", label: $t("table.column.index") },
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列的唯一标识
|
||||
*/
|
||||
export const getColumnKey = <T>(col: ColumnOption<T>) =>
|
||||
SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string);
|
||||
|
||||
/**
|
||||
* 获取列的显示状态
|
||||
* 优先使用 visible 字段,如果不存在则使用 checked 字段
|
||||
*/
|
||||
/** `visible` 优先;否则回退 `checked`,默认展示 */
|
||||
export const getColumnVisibility = <T>(col: ColumnOption<T>): boolean => {
|
||||
// visible 优先级高于 checked
|
||||
if (col.visible !== undefined) {
|
||||
return col.visible;
|
||||
}
|
||||
// 如果 visible 未定义,使用 checked,默认为 true
|
||||
return col.checked ?? true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列的检查状态
|
||||
*/
|
||||
export const getColumnChecks = <T>(columns: ColumnOption<T>[]) =>
|
||||
columns.map((col) => {
|
||||
const special = col.type && SPECIAL_COLUMNS[col.type];
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
../components/layouts/index.vue
|
||||
@@ -14,6 +14,14 @@
|
||||
"requestFailed": "Request failed",
|
||||
"requestConfigError": "Request configuration error"
|
||||
},
|
||||
"manualPage": {
|
||||
"title": "User manual",
|
||||
"intro": "Watch the walkthrough video and browse the built-in checklist mapped to implemented UI and permission codes (coverage / completeness, not a quality score). Replace the demo video URL with your own CDN in production.",
|
||||
"videoTab": "Video",
|
||||
"manualTab": "Acceptance checklist",
|
||||
"videoHint": "Pause, change speed, or go fullscreen anytime.",
|
||||
"manualHint": "Use the TOC, filter, and anchors. “Functional completeness” items mirror implemented search bars, toolbars, row actions, and dialogs for gap checking."
|
||||
},
|
||||
"topBar": {
|
||||
"search": {
|
||||
"title": "Search"
|
||||
@@ -542,7 +550,7 @@
|
||||
"ecommerce": "Ecommerce",
|
||||
"map": "Map",
|
||||
"pricing": "Pricing",
|
||||
"tutorial": "Tutorial"
|
||||
"tutorial": "User manual"
|
||||
},
|
||||
"article": {
|
||||
"title": "Article",
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
"requestFailed": "请求失败",
|
||||
"requestConfigError": "请求配置错误"
|
||||
},
|
||||
"manualPage": {
|
||||
"title": "操作手册",
|
||||
"intro": "视频演示常用操作流程;「功能验收」正文按模块列出已实现界面与权限,用于核对功能是否覆盖完整(非评价好坏)。正式环境可将演示视频替换为自有 CDN / 静态资源地址。",
|
||||
"videoTab": "视频演示",
|
||||
"manualTab": "功能验收",
|
||||
"videoHint": "可随时暂停、调速与全屏观看。",
|
||||
"manualHint": "支持目录锚点与筛选;正文「功能完整性验收」与代码已实现的检索、工具栏、行操作、弹层等逐项对应,便于漏项检查。"
|
||||
},
|
||||
"topBar": {
|
||||
"search": {
|
||||
"title": "搜索"
|
||||
@@ -542,7 +550,7 @@
|
||||
"ecommerce": "电子商务",
|
||||
"map": "地图",
|
||||
"pricing": "定价",
|
||||
"tutorial": "操作教程"
|
||||
"tutorial": "操作手册"
|
||||
},
|
||||
"article": {
|
||||
"title": "文章管理",
|
||||
|
||||
@@ -4,13 +4,14 @@ import "@styles/core/tailwind.css";
|
||||
import "@styles/index.scss";
|
||||
import "element-plus/theme-chalk/dark/css-vars.css";
|
||||
import "animate.css";
|
||||
|
||||
import { printConsoleBanner } from "@utils/sys";
|
||||
import { initPlugins } from "@/plugins";
|
||||
import { useConfigStore } from "./store/modules/config.store";
|
||||
|
||||
document.addEventListener("touchstart", function () {}, { passive: false });
|
||||
|
||||
const app = createApp(App);
|
||||
printConsoleBanner();
|
||||
initPlugins(app);
|
||||
app.mount("#app");
|
||||
|
||||
@@ -18,7 +19,7 @@ app.mount("#app");
|
||||
const setTitleAndFavicon = async () => {
|
||||
try {
|
||||
const configStore = useConfigStore();
|
||||
await configStore.getConfig(true);
|
||||
await configStore.getConfig();
|
||||
|
||||
const webTitle = configStore.configData.sys_web_title?.config_value;
|
||||
const webFavicon = configStore.configData.sys_web_favicon?.config_value;
|
||||
|
||||
@@ -150,7 +150,7 @@ export const ArticleList = [
|
||||
create_time: "2023-04-01T00:00:00.000Z",
|
||||
home_img: cover8,
|
||||
brief:
|
||||
"原理是改变 css 变量 + window.matchMedia 来监听系统主题变,从而实现点击改变主题和监听系统主题变化1、首先定义 css 全局变量创建 variables.scss 文件light color(浅色模式)定义浅色模式下 css 主题变量dark color(深色模式)定义深色模式下 css 主题变量// css全局变量\n:root {\n // 文字大小\n --fa-fo",
|
||||
"原理是改变 css 变量 + window.matchMedia 来监听系统主题变,从而实现点击改变主题和监听系统主题变化1、首先定义 css 全局变量创建 variables.scss 文件light color(浅色模式)定义浅色模式下 css 主题变量dark color(深色模式)定义深色模式下 css 主题变量// css全局变量\n:root {\n // 文字大小\n --art-fo",
|
||||
type_name: "CSS",
|
||||
html_content: "",
|
||||
},
|
||||
|
||||
@@ -65,7 +65,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
"性能提升:优化核心代码逻辑,提升系统运行效率",
|
||||
"设计系统:重构颜色体系,统一 UI 视觉规范,提升界面一致性",
|
||||
"菜单优化:细化菜单样式,优化交互体验与视觉呈现",
|
||||
"组件重构:重构 FaTextScroll 组件,提升性能与可维护性",
|
||||
"组件重构:重构 ArtTextScroll 组件,提升性能与可维护性",
|
||||
"问题修复:修复 FaForm、FaSearchBar 自定义组件渲染异常",
|
||||
"功能增强:FaForm、FaSearchBar 新增 render 属性,支持自定义组件渲染",
|
||||
"功能增强:useTable hooks 新增 visible 属性,用于控制列默认是否显示",
|
||||
@@ -164,7 +164,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
"优化 FaTable 顶部按钮换行无法自适应问题(示例:功能示例 / 左右布局表格)",
|
||||
"FaTable 分页组件选中样式优化",
|
||||
"FaTable 空状态高度默认撑满",
|
||||
"FaButtonMore 组件新增图标、颜色配置",
|
||||
"ArtButtonMore 组件新增图标、颜色配置",
|
||||
"FaTableHeader 新增搜索按钮,用于控制顶部搜索栏的显示与隐藏",
|
||||
"FaSearchBar label 为空时不占空间",
|
||||
"表格操作栏拖拽禁止固定列拖拽",
|
||||
@@ -195,7 +195,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
"修复多个富文本编辑器图标不统一问题",
|
||||
"优化颜色选择器圆角",
|
||||
"el-radio、el-checkbox 统一大小",
|
||||
"fa-stats-card 新增小数位、分隔符配置",
|
||||
"art-stats-card 新增小数位、分隔符配置",
|
||||
"路由配置示例优化",
|
||||
"高级表格新增自定义获取数据示例(等待其他请求完成后执行 useTable 数据获取)",
|
||||
"useTable 新增 excludeParams,用于排除某些参数不参与请求",
|
||||
@@ -203,7 +203,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
"本地开发跨域配置优化",
|
||||
"修复 useTable 删除最后一整页数据没有返回上一页的问题",
|
||||
"修复 echarts 图表数据初始化、更新数据浏览器报错",
|
||||
"删除 fa-chfa-empty 组件",
|
||||
"删除 art-chart-empty 组件",
|
||||
"新增 FaSearchBar 组件示例",
|
||||
"网络请求支持 http 状态码为 401 时退出登录",
|
||||
"优化网络请求退出登录多次提示问题",
|
||||
@@ -286,7 +286,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
date: "2025-07-06",
|
||||
remark: "建议升级,带来更高效、更智能的表格开发体验",
|
||||
detail: [
|
||||
"重构 FaTable、FaTableHeader、FaNotification 组件",
|
||||
"重构 FaTable、FaTableHeader、ArtNotification 组件",
|
||||
"新增 useTable hooks 表格封装,支持数据获取、转换、响应适配、智能缓存(基于 LRU 算法)、错误处理、列配置与插槽、分页控制、刷新策略等核心功能,全面提升开发效率与用户体验",
|
||||
"修复菜单管理搜索直接修改 pinia 数据的问题",
|
||||
"移除 CountTo 插件,替换为 FaCountTo 组件",
|
||||
@@ -337,10 +337,10 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
title: "组件重构与性能优化",
|
||||
date: "2025-06-23",
|
||||
detail: [
|
||||
"重构 components/forms 文件夹下的表单相关组件,提升可维护性与一致性",
|
||||
"重构 FaBreadcrumb 面包屑导航组件,优化逻辑结构与样式",
|
||||
"优化 FaChatWindow 与 FaFastEnter 组件代码,提升可读性与性能",
|
||||
"重构 FaFireworksEffect 烟花效果组件,显著提升渲染性能与动画流畅度",
|
||||
"重构 components/core/forms 文件夹下的表单相关组件,提升可维护性与一致性",
|
||||
"重构 ArtBreadcrumb 面包屑导航组件,优化逻辑结构与样式",
|
||||
"优化 ArtChatWindow 与 ArtFastEnter 组件代码,提升可读性与性能",
|
||||
"重构 ArtFireworksEffect 烟花效果组件,显著提升渲染性能与动画流畅度",
|
||||
"README 文档新增官方网站链接,便于用户查看项目文档",
|
||||
],
|
||||
},
|
||||
@@ -359,8 +359,8 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
date: "2025-06-18",
|
||||
detail: [
|
||||
"FaMenuRight 组件重构",
|
||||
"FaWatermark 增加类型注释",
|
||||
"components/cards 下面的组件重构,代码优化",
|
||||
"ArtWatermark 增加类型注释",
|
||||
"components/core/cards 下面的组件重构,代码优化",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -369,7 +369,7 @@ export const upgradeLogList = ref<UpgradeLog[]>([
|
||||
date: "2025-06-18",
|
||||
detail: [
|
||||
"FaResultPage 组件重构",
|
||||
"FaTextScroll 组件代码优化",
|
||||
"ArtTextScroll 组件代码优化",
|
||||
"FaException 组件增加类型提示",
|
||||
"FaCutterImg 组件样式优化、增加类型定义",
|
||||
"FaVideoPlayer 组件增加类型定义",
|
||||
|
||||
@@ -19,11 +19,8 @@ import { initCodeMirror } from "./codemirror";
|
||||
import { initElementPlus } from "./element-plus";
|
||||
import { initElIcons } from "./icons";
|
||||
import { initTerminal } from "./terminal";
|
||||
import { printConsoleBanner } from "@utils/sys";
|
||||
|
||||
export function initPlugins(app: App<Element>): void {
|
||||
// 打印欢迎使用信息
|
||||
printConsoleBanner();
|
||||
initElIcons(app);
|
||||
initStore(app);
|
||||
initRouter(app);
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
/**
|
||||
* 菜单处理器(含后端菜单 → AppRouteRecord 转换)
|
||||
*
|
||||
* @module router/MenuProcessor
|
||||
*/
|
||||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||||
import type { UserInfo } from "@/api/module_system/user";
|
||||
import type { MenuTable } from "@/api/module_system/menu";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { useAppMode } from "@/hooks/core/useAppMode";
|
||||
import MenuAPI from "@/api/module_system/menu";
|
||||
|
||||
import {
|
||||
mergeAppRouteRecords,
|
||||
ROUTE_COMPONENT_LAYOUT,
|
||||
@@ -17,6 +12,11 @@ import {
|
||||
import { formatMenuTitle } from "@utils";
|
||||
import { MenuTypeEnum } from "@/enums/system/menu.enum";
|
||||
|
||||
/**
|
||||
* 菜单 → `AppRouteRecord`:后端 `MenuTable`、前端内置路由、混合模式合并;供守卫注册动态路由。
|
||||
* `getMenuList` 依 `useAppMode` 分支;meta 对齐后端 keep_alive、目录占位组件。
|
||||
*/
|
||||
|
||||
/** 前端模式并入菜单的内置路由(扩展点,默认空) */
|
||||
export const builtinFrontendRoutes: AppRouteRecord[] = [];
|
||||
|
||||
@@ -122,6 +122,7 @@ function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
|
||||
title: item.title ?? "",
|
||||
icon: item.icon || undefined,
|
||||
hidden: !!item.hidden,
|
||||
/** 与菜单 API `keep_alive` 一致;缺省 true 仅在后端未下发该字段时兜底 */
|
||||
keepAlive: item.keep_alive ?? true,
|
||||
affix: !!item.affix,
|
||||
fixedTab: !!item.affix,
|
||||
@@ -148,9 +149,6 @@ function backendMenusToAppRoutes(menus: MenuTable[]): AppRouteRecord[] {
|
||||
}
|
||||
|
||||
export class MenuProcessor {
|
||||
/**
|
||||
* 获取菜单数据
|
||||
*/
|
||||
async getMenuList(): Promise<AppRouteRecord[]> {
|
||||
const { isFrontendMode, isMixedMenuMode } = useAppMode();
|
||||
|
||||
@@ -212,11 +210,15 @@ export class MenuProcessor {
|
||||
return this.filterEmptyMenus(merged);
|
||||
}
|
||||
|
||||
/** 优先用用户信息里附带的 `menus`,与守卫拉用户信息顺序一致,避免重复打菜单树接口 */
|
||||
private async processBackendMenu(): Promise<AppRouteRecord[]> {
|
||||
const response = await MenuAPI.listMenu({ menu_client: "pc" });
|
||||
const list = response.data.data || [];
|
||||
const routes = backendMenusToAppRoutes(list);
|
||||
return this.filterEmptyMenus(routes);
|
||||
const userStore = useUserStore();
|
||||
const fromUser = userStore.routeList;
|
||||
if (Array.isArray(fromUser) && fromUser.length > 0) {
|
||||
const routes = backendMenusToAppRoutes(fromUser);
|
||||
return this.filterEmptyMenus(routes);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private filterMenuByRoles(menu: AppRouteRecord[], roleCodes: string[]): AppRouteRecord[] {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 路由后置守卫:滚动置顶、结束 NProgress、关闭 beforeEach 开启的全局 loading。
|
||||
*/
|
||||
import { nextTick } from "vue";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { Router } from "vue-router";
|
||||
@@ -6,24 +9,31 @@ import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { loadingService } from "@utils/ui";
|
||||
import { getPendingLoading, resetPendingLoading } from "./beforeEach";
|
||||
|
||||
/** 路由全局后置守卫 */
|
||||
/** 防止重复注册 afterEach(与 beforeEach 同理) */
|
||||
let afterEachGuardRegistered = false;
|
||||
|
||||
export function setupAfterEachGuard(router: Router) {
|
||||
if (afterEachGuardRegistered) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("[Router] setupAfterEachGuard 已注册,跳过重复调用");
|
||||
}
|
||||
return;
|
||||
}
|
||||
afterEachGuardRegistered = true;
|
||||
|
||||
const { scrollToTop } = useCommon();
|
||||
|
||||
router.afterEach(() => {
|
||||
scrollToTop();
|
||||
|
||||
// 关闭进度条
|
||||
const settingStore = useSettingsStore();
|
||||
if (settingStore.showNprogress) {
|
||||
NProgress.done();
|
||||
// 确保进度条完全移除,避免残影
|
||||
setTimeout(() => {
|
||||
NProgress.remove();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// 关闭 loading 效果
|
||||
if (getPendingLoading()) {
|
||||
nextTick(() => {
|
||||
loadingService.hideLoading();
|
||||
|
||||
@@ -1,39 +1,6 @@
|
||||
/**
|
||||
* 路由全局前置守卫模块
|
||||
*
|
||||
* 提供完整的路由导航守卫功能
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 登录状态验证和重定向
|
||||
* - 动态路由注册和权限控制
|
||||
* - 菜单数据获取和处理(前端/后端模式)
|
||||
* - 用户信息获取和缓存
|
||||
* - 页面标题设置
|
||||
* - 工作标签页管理
|
||||
* - 进度条和加载动画控制
|
||||
* - 静态路由识别和处理
|
||||
* - 错误处理和异常跳转
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 路由跳转前的权限验证
|
||||
* - 动态菜单加载和路由注册
|
||||
* - 用户登录状态管理
|
||||
* - 页面访问控制
|
||||
* - 路由级别的加载状态管理
|
||||
*
|
||||
* ## 工作流程
|
||||
*
|
||||
* 1. 检查登录状态,未登录跳转到登录页
|
||||
* 2. 首次访问时获取用户信息和菜单数据
|
||||
* 3. 根据权限动态注册路由
|
||||
* 4. 设置页面标题和工作标签页
|
||||
* 5. 处理根路径重定向到首页
|
||||
* 6. 未匹配路由跳转到 404 页面
|
||||
*
|
||||
* @module router/beforeEach
|
||||
* @author FastapiAdmin Team
|
||||
* 路由前置守卫:登录态、动态路由注册(菜单)、根路径重定向、进度条、标签页与标题。
|
||||
* 入口 `setupBeforeEachGuard`;核心流程见 `handleRouteGuard`。
|
||||
*/
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import type { Router, RouteLocationNormalized, NavigationGuardNext } from "vue-router";
|
||||
@@ -53,56 +20,50 @@ import { ApiStatus, isHttpError } from "@utils/http";
|
||||
import { RouteRegistry } from "./dynamicRoutes";
|
||||
import { MenuProcessor } from "./MenuProcessor";
|
||||
|
||||
// 路由注册器实例
|
||||
let routeRegistry: RouteRegistry | null = null;
|
||||
// --- 模块级单例与守卫状态 ---
|
||||
|
||||
// 菜单处理器实例
|
||||
let routeRegistry: RouteRegistry | null = null;
|
||||
const menuProcessor = new MenuProcessor();
|
||||
|
||||
// 跟踪是否需要关闭 loading
|
||||
/** 供 afterEach 关闭全局 loading */
|
||||
let pendingLoading = false;
|
||||
|
||||
// 路由初始化失败标记,防止死循环
|
||||
// 一旦设置为 true,只有刷新页面或重新登录才能重置
|
||||
/** 动态路由拉取失败后为 true,避免反复请求造成死循环 */
|
||||
let routeInitFailed = false;
|
||||
|
||||
// 路由初始化进行中标记,防止并发请求
|
||||
/** 并发导航时只允许一路执行动态路由初始化 */
|
||||
let routeInitInProgress = false;
|
||||
|
||||
/**
|
||||
* 获取 pendingLoading 状态
|
||||
*/
|
||||
export function getPendingLoading(): boolean {
|
||||
return pendingLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 pendingLoading 状态
|
||||
*/
|
||||
export function resetPendingLoading(): void {
|
||||
pendingLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由初始化失败状态
|
||||
*/
|
||||
export function getRouteInitFailed(): boolean {
|
||||
return routeInitFailed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置路由初始化状态(用于重新登录场景)
|
||||
*/
|
||||
/** 重新登录等场景重置初始化标记 */
|
||||
export function resetRouteInitState(): void {
|
||||
routeInitFailed = false;
|
||||
routeInitInProgress = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置路由全局前置守卫
|
||||
*/
|
||||
/** 防止 dev/HMR 或异常重复 init 导致多个 beforeEach 叠加(导航副作用与请求会成倍增长) */
|
||||
let beforeEachGuardRegistered = false;
|
||||
|
||||
export function setupBeforeEachGuard(router: Router): void {
|
||||
// 初始化路由注册器
|
||||
if (beforeEachGuardRegistered) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("[Router] setupBeforeEachGuard 已注册,跳过重复调用");
|
||||
}
|
||||
return;
|
||||
}
|
||||
beforeEachGuardRegistered = true;
|
||||
|
||||
routeRegistry = new RouteRegistry(router);
|
||||
|
||||
router.beforeEach(
|
||||
@@ -122,9 +83,6 @@ export function setupBeforeEachGuard(router: Router): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 loading 效果
|
||||
*/
|
||||
function closeLoading(): void {
|
||||
if (pendingLoading) {
|
||||
nextTick(() => {
|
||||
@@ -147,50 +105,40 @@ function applySafeTitleFromQuery(to: RouteLocationNormalized): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理路由守卫逻辑
|
||||
*/
|
||||
async function handleRouteGuard(
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext,
|
||||
router: Router
|
||||
): Promise<void> {
|
||||
// 顺序:登录 → 动态路由初始化失败兜底 → 动态路由注册 → 根路径 → 已匹配页 → 404
|
||||
const settingStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 启动进度条
|
||||
if (settingStore.showNprogress) {
|
||||
NProgress.start();
|
||||
}
|
||||
|
||||
// 1. 检查登录状态
|
||||
if (!handleLoginStatus(to, userStore, next)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查路由初始化是否已失败(防止死循环)
|
||||
if (routeInitFailed) {
|
||||
// 已经失败过,直接放行到错误页面,不再重试
|
||||
if (to.matched.length > 0) {
|
||||
next();
|
||||
} else {
|
||||
// 未匹配到路由,跳转到 500 页面
|
||||
next({ name: "500", replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 处理动态路由注册
|
||||
const menuStore = useMenuStore();
|
||||
/** 未注册动态路由,或菜单已被清空(登出延迟 reset 与再登录竞态下可能出现「已注册但 menuList 为空」) */
|
||||
const shouldInitRoutes =
|
||||
userStore.isLogin && (!routeRegistry?.isRegistered() || menuStore.menuList.length === 0);
|
||||
|
||||
if (shouldInitRoutes) {
|
||||
// 防止并发请求(快速连续导航场景)
|
||||
if (routeInitInProgress) {
|
||||
// 正在初始化中,等待完成后重新导航
|
||||
next(false);
|
||||
return;
|
||||
}
|
||||
@@ -198,12 +146,10 @@ async function handleRouteGuard(
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 处理根路径重定向
|
||||
if (handleRootPathRedirect(to, next)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 处理已匹配的路由
|
||||
if (to.matched.length > 0) {
|
||||
applySafeTitleFromQuery(to);
|
||||
setWorktab(to);
|
||||
@@ -212,27 +158,20 @@ async function handleRouteGuard(
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 未匹配到路由,跳转到 404
|
||||
next({ name: "404" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录状态
|
||||
* @returns true 表示可以继续,false 表示已处理跳转
|
||||
*/
|
||||
/** @returns 是否继续守卫;false 表示已 `next` 跳转 */
|
||||
function handleLoginStatus(
|
||||
to: RouteLocationNormalized,
|
||||
userStore: ReturnType<typeof useUserStore>,
|
||||
next: NavigationGuardNext
|
||||
): boolean {
|
||||
// 已登录 / 登录相关路由 / 明确配置的匿名白名单,放行。
|
||||
// 注意:不能使用「整条 staticRoutes 都算静态」——否则 `/`、`/` 重定向到的业务页也会被当成免登录,
|
||||
// 未登录用户会先被放过再跳首页(见 issue:打开站点先进首页而非登录页)。
|
||||
// 勿把「整条 staticRoutes」当匿名:否则 `/` 重定向到的业务页会被误放行(未登录先进首页)。
|
||||
if (userStore.isLogin || isLoginRoute(to) || isAnonymousPublicPath(to.path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
|
||||
userStore.resetAllState();
|
||||
next({
|
||||
name: "Login",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 动态路由一站式:菜单 → 组件解析 → 挂到 RootLayout 下注册。
|
||||
*(老 web 里分散在 permission / transform 的职责,这里收进一个文件,少跳类、少找文件。)
|
||||
* 动态路由:`AppRouteRecord` → `RouteTransformer`(字符串路径 → import.glob)→ `router.addRoute(RootLayout)`。
|
||||
* 含校验(RouteValidator)、组件加载(ComponentLoader)、注册表(RouteRegistry)。
|
||||
*/
|
||||
import { defineComponent, h, onMounted, ref } from "vue";
|
||||
import type { RouteRecordRaw, Router } from "vue-router";
|
||||
@@ -20,6 +20,7 @@ export interface ValidationResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 菜单注册前校验:重名、缺少 component、深层误用 layout 占位等 */
|
||||
export class RouteValidator {
|
||||
private warnedRoutes = new Set<string>();
|
||||
|
||||
@@ -150,7 +151,7 @@ export class RouteValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- ComponentLoader
|
||||
// --- ComponentLoader:菜单字符串路径 → views 懒加载 ---
|
||||
export class ComponentLoader {
|
||||
private modules: Record<string, () => Promise<any>>;
|
||||
|
||||
@@ -186,7 +187,7 @@ export class ComponentLoader {
|
||||
}
|
||||
|
||||
loadLayout(): () => Promise<any> {
|
||||
return () => import("@/components/layouts/index.vue");
|
||||
return () => import("@/layouts/index.vue");
|
||||
}
|
||||
|
||||
loadIframe(): () => Promise<any> {
|
||||
@@ -249,7 +250,7 @@ export class ComponentLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- RouteTransformer
|
||||
// --- RouteTransformer:菜单树 → vue-router 记录 ---
|
||||
interface ConvertedRoute extends Omit<RouteRecordRaw, "children"> {
|
||||
id?: number;
|
||||
children?: ConvertedRoute[];
|
||||
@@ -370,8 +371,8 @@ export class RouteTransformer {
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- RouteRegistry
|
||||
/** 与静态壳层冲突的一级 path 段,后端动态挂载时跳过以免覆盖首页/仪表盘等 */
|
||||
// --- RouteRegistry:校验通过后批量 addRoute ---
|
||||
/** 与静态壳层冲突的一级 path 段,动态注册时跳过以免覆盖首页 / 仪表盘等 */
|
||||
const RESERVED_SHELL_SEGMENTS = new Set(["home", "profile", "changelog", "dashboard"]);
|
||||
|
||||
function pathFirstSegment(path: string): string {
|
||||
|
||||
@@ -6,29 +6,27 @@ import { setupAfterEachGuard } from "./afterEach";
|
||||
import "@utils/ui";
|
||||
|
||||
/**
|
||||
* 路由分层:`staticRoutes` 一次性注册(登录页、首页、仪表盘、异常页等);
|
||||
* 业务菜单来自后端或壳层合并后再由守卫动态 `addRoute`。
|
||||
* 路由入口:`staticRoutes` 首屏注册;业务路由由 `beforeEach` 内 `RouteRegistry` 动态挂载。
|
||||
* `initRouter` 注册前置/后置守卫并 `app.use(router)`。
|
||||
*/
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: staticRoutes,
|
||||
// 刷新时,滚动条位置还原
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
// 初始化路由
|
||||
export function initRouter(app: App<Element>): void {
|
||||
setupBeforeEachGuard(router); // 路由前置守卫
|
||||
setupAfterEachGuard(router); // 路由后置守卫
|
||||
setupBeforeEachGuard(router);
|
||||
setupAfterEachGuard(router);
|
||||
app.use(router);
|
||||
}
|
||||
|
||||
// 主页路径;须与 `staticRoutes` 中首页子路由一致(当前为 `/home`)
|
||||
/** 须与 `staticRoutes` 首页子路由 path 一致 */
|
||||
export const HOME_PAGE_PATH = "/home";
|
||||
|
||||
export { HOME_ROUTE_NAME, ROOT_LAYOUT_ROUTE_NAME };
|
||||
|
||||
/** 供 `@/router` 一处导入的运行时模块 */
|
||||
/** 动态路由注册与菜单转换(一般从 `@/router` 按需导入) */
|
||||
export { RouteRegistry, ComponentLoader, RouteTransformer, RouteValidator } from "./dynamicRoutes";
|
||||
export type { ValidationResult } from "./dynamicRoutes";
|
||||
export { IframeRouteManager } from "./staticRoutes";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const HOME_MENU_META: RouteMeta = {
|
||||
|
||||
export const DASHBOARD_PARENT_META: RouteMeta = {
|
||||
title: "menus.dashboard.title",
|
||||
icon: "ri:pie-chfa-line",
|
||||
icon: "ri:pie-chart-line",
|
||||
alwaysShow: true,
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ROUTE_COMPONENT_NESTED_PARENT = "/nested/router-view-parent";
|
||||
export const ROUTE_PATH_LOGIN_ALT = "/auth/login";
|
||||
|
||||
/**
|
||||
* 主框架布局:新版 art 体系(`src/layouts/index.vue` + `src/layouts/fa-*` 组件)。
|
||||
* 主框架布局:新版 art 体系(`src/layouts/index.vue` + `src/layouts/art-*` 组件)。
|
||||
* 旧版 Left/Top/Mix 壳子仍在 `@/layouts/index.vue`,路由不再默认使用。
|
||||
*/
|
||||
export const Layout = () => import("@/components/layouts/index.vue");
|
||||
@@ -172,7 +172,7 @@ export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
|
||||
component: () => import("@views/dashboard/ecommerce/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.ecommerce",
|
||||
icon: "ri:bar-chfa-box-line",
|
||||
icon: "ri:bar-chart-box-line",
|
||||
keepAlive: false,
|
||||
},
|
||||
},
|
||||
@@ -229,7 +229,7 @@ export const dashboardLayoutChildren: AppRouteRecordRaw[] = [
|
||||
component: () => import("@views/dashboard/tutorial/index.vue"),
|
||||
meta: {
|
||||
title: "menus.dashboard.tutorial",
|
||||
icon: "ri:movie-2-line",
|
||||
icon: "ri:book-2-line",
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
@@ -386,7 +386,7 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
meta: { hidden: true, title: "menus.login.title" },
|
||||
meta: { hidden: true, isHideTab: true, title: "menus.login.title" },
|
||||
component: () => import("@views/module_system/auth/login/index.vue"),
|
||||
},
|
||||
/** 无 Layout 全屏异常页;守卫与白名单跳转使用(勿再在 RootLayout 下重复挂载同组件) */
|
||||
@@ -458,7 +458,7 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: "/outside",
|
||||
component: () => import("@/components/layouts/index.vue"),
|
||||
component: () => import("@/layouts/index.vue"),
|
||||
name: "Outside",
|
||||
meta: { title: "menus.outside.title" },
|
||||
children: [
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserStore } from "./modules/user.store";
|
||||
import { useDictStore } from "./modules/dict.store";
|
||||
import { useNoticeStore } from "./modules/notice.store";
|
||||
import { useConfigStore } from "./modules/config.store";
|
||||
import { useTagsViewStore } from "./modules/tags-view.store";
|
||||
import { useWorktabStore } from "./modules/worktab.store";
|
||||
|
||||
const store = createPinia();
|
||||
|
||||
@@ -20,17 +20,15 @@ export function initStore(app: App<Element>) {
|
||||
export * from "./modules/app.store";
|
||||
export * from "./modules/config.store";
|
||||
export * from "./modules/dict.store";
|
||||
export * from "./modules/lock.store";
|
||||
export * from "./modules/menu.store";
|
||||
export * from "./modules/notice.store";
|
||||
export * from "./modules/setting.store";
|
||||
export * from "./modules/table.store";
|
||||
export * from "./modules/tags-view.store";
|
||||
export * from "./modules/user.store";
|
||||
export * from "./modules/worktab.store";
|
||||
|
||||
export { store };
|
||||
export { useUserStore, useDictStore, useNoticeStore, useConfigStore, useTagsViewStore };
|
||||
export { useUserStore, useDictStore, useNoticeStore, useConfigStore, useWorktabStore };
|
||||
|
||||
export interface RefreshCacheOptions {
|
||||
dictTypes?: string[];
|
||||
@@ -57,7 +55,6 @@ export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
|
||||
const dictStore = useDictStore(store);
|
||||
const noticeStore = useNoticeStore(store);
|
||||
const configStore = useConfigStore(store);
|
||||
const tagsViewStore = useTagsViewStore(store);
|
||||
|
||||
const tasks: Promise<any>[] = [];
|
||||
|
||||
@@ -87,6 +84,6 @@ export async function refreshAppCaches(opts: RefreshCacheOptions = {}) {
|
||||
}
|
||||
|
||||
if (clearTags) {
|
||||
await tagsViewStore.delAllViews();
|
||||
useWorktabStore(store).clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 锁屏状态管理模块
|
||||
*
|
||||
* 提供锁屏功能的状态管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 锁屏状态管理
|
||||
* - 锁屏密码验证
|
||||
* - 锁屏信息存储
|
||||
* - 解锁功能
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 用户离开时锁定屏幕
|
||||
* - 保护敏感操作
|
||||
* - 临时离开保护
|
||||
*
|
||||
* ## 持久化
|
||||
*
|
||||
* - 使用 localStorage 存储
|
||||
* - 锁屏状态跨页面保持
|
||||
*
|
||||
* @module store/modules/lock.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useLockStore = defineStore(
|
||||
"lockStore",
|
||||
() => {
|
||||
// 是否锁定屏幕
|
||||
const isLock = ref(false);
|
||||
// 锁屏密码
|
||||
const password = ref("");
|
||||
|
||||
/**
|
||||
* 获取锁屏信息
|
||||
*/
|
||||
const getLockInfo = () => ({
|
||||
isLock: isLock.value,
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置锁屏信息
|
||||
* @param lockInfo 锁屏信息对象
|
||||
*/
|
||||
const setLockInfo = (lockInfo: { isLock?: boolean; password?: string }) => {
|
||||
if (lockInfo.isLock !== undefined) {
|
||||
isLock.value = lockInfo.isLock;
|
||||
}
|
||||
if (lockInfo.password !== undefined) {
|
||||
password.value = lockInfo.password;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置锁屏信息
|
||||
*/
|
||||
const resetLockInfo = () => {
|
||||
isLock.value = false;
|
||||
password.value = "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 解锁屏幕
|
||||
* @param inputPassword 输入的解锁密码
|
||||
* @returns 是否解锁成功
|
||||
*/
|
||||
const unLock = (inputPassword: string) => {
|
||||
if (password.value === inputPassword) {
|
||||
resetLockInfo();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isLock,
|
||||
password,
|
||||
getLockInfo,
|
||||
setLockInfo,
|
||||
resetLockInfo,
|
||||
unLock,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 全局界面设置:布局、主题、页签、水印、工具栏显隐等;部分字段持久化(useStorage / persist)。
|
||||
* `reload` 翻转 `refresh` 布尔值,触发 `FaPageContent` 重建 RouterView(与 useCommon.refresh 一致)。
|
||||
* `reload` 翻转 `refresh` 布尔值,触发 `ArtPageContent` 重建 RouterView(与 useCommon.refresh 一致)。
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, watch } from "vue";
|
||||
@@ -50,6 +50,7 @@ export const useSettingsStore = defineStore(
|
||||
const autoClose = ref(SETTING_DEFAULT_CONFIG.autoClose);
|
||||
const uniqueOpened = ref(SETTING_DEFAULT_CONFIG.uniqueOpened);
|
||||
const colorWeak = ref(SETTING_DEFAULT_CONFIG.colorWeak);
|
||||
/** 供布局监听:`reload()` 取反以触发主内容区整页刷新 */
|
||||
const refresh = ref(SETTING_DEFAULT_CONFIG.refresh);
|
||||
const holidayFireworksLoaded = ref(SETTING_DEFAULT_CONFIG.holidayFireworksLoaded);
|
||||
|
||||
@@ -288,7 +289,7 @@ export const useSettingsStore = defineStore(
|
||||
menuOpen.value = open;
|
||||
};
|
||||
|
||||
/** 切换 `refresh`,驱动 `layouts/fa-page-content` 内 `v-if="isRefresh"` 重建视图 */
|
||||
/** 切换 `refresh`,驱动 `layouts/art-page-content` 内 `v-if="isRefresh"` 重建视图 */
|
||||
const reload = () => {
|
||||
refresh.value = !refresh.value;
|
||||
};
|
||||
|
||||
@@ -6,54 +6,25 @@ import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { TableSizeEnum } from "@/enums/formEnum";
|
||||
|
||||
// 表格
|
||||
export const useTableStore = defineStore(
|
||||
"tableStore",
|
||||
() => {
|
||||
// 表格大小
|
||||
// --- 表格样式 ---
|
||||
const tableSize = ref(TableSizeEnum.DEFAULT);
|
||||
// 斑马纹
|
||||
const isZebra = ref(false);
|
||||
// 边框
|
||||
const isBorder = ref(false);
|
||||
// 表头背景
|
||||
const isHeaderBackground = ref(false);
|
||||
|
||||
// 是否全屏
|
||||
// --- 交互 ---
|
||||
const isFullScreen = ref(false);
|
||||
// 是否开启表格行拖拽排序(工具栏开关,与 ArtTable + VueDraggable 配合)
|
||||
/** 工具栏「行拖拽」;仅当表格数据为可变数组时有效 */
|
||||
const isRowDrag = ref(false);
|
||||
|
||||
/**
|
||||
* 设置表格大小
|
||||
* @param size 表格大小枚举值
|
||||
*/
|
||||
const setTableSize = (size: TableSizeEnum) => (tableSize.value = size);
|
||||
|
||||
/**
|
||||
* 设置斑马纹显示状态
|
||||
* @param value 是否显示斑马纹
|
||||
*/
|
||||
const setIsZebra = (value: boolean) => (isZebra.value = value);
|
||||
|
||||
/**
|
||||
* 设置表格边框显示状态
|
||||
* @param value 是否显示边框
|
||||
*/
|
||||
const setIsBorder = (value: boolean) => (isBorder.value = value);
|
||||
|
||||
/**
|
||||
* 设置表头背景显示状态
|
||||
* @param value 是否显示表头背景
|
||||
*/
|
||||
const setIsHeaderBackground = (value: boolean) => (isHeaderBackground.value = value);
|
||||
|
||||
/**
|
||||
* 设置是否全屏
|
||||
* @param value 是否全屏
|
||||
*/
|
||||
const setIsFullScreen = (value: boolean) => (isFullScreen.value = value);
|
||||
|
||||
const setIsRowDrag = (value: boolean) => (isRowDrag.value = value);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* 标签页状态管理模块
|
||||
*
|
||||
* 提供标签页的状态管理和缓存机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 已访问视图列表管理
|
||||
* - 缓存视图列表管理
|
||||
* - 添加、删除、更新标签页
|
||||
* - 关闭当前标签页并跳转
|
||||
* - 关闭其他标签页/关闭左侧/关闭右侧
|
||||
* - 固定标签页支持
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 多标签页导航
|
||||
* - 页面缓存管理
|
||||
* - 标签页右键菜单操作
|
||||
* - 路由切换时的标签状态同步
|
||||
*
|
||||
* ## 核心特性
|
||||
*
|
||||
* - 支持固定标签(affix)
|
||||
* - 支持keepAlive缓存
|
||||
* - 关闭标签时自动跳转上一个标签
|
||||
* - 区分已访问视图和缓存视图
|
||||
*
|
||||
* @module store/modules/tags-view.store
|
||||
* @author FastapiAdmin Team
|
||||
*/
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { store } from "@stores";
|
||||
|
||||
export const useTagsViewStore = defineStore(
|
||||
"tagsViewStore",
|
||||
() => {
|
||||
const visitedViews = ref<TagView[]>([]);
|
||||
const cachedViews = ref<string[]>([]);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
/**
|
||||
* 添加已访问视图到已访问视图列表中
|
||||
*/
|
||||
function addVisitedView(view: TagView) {
|
||||
// 如果已经存在于已访问的视图列表中或者是重定向地址,则不再添加
|
||||
if (view.path.startsWith("/redirect")) {
|
||||
return;
|
||||
}
|
||||
if (visitedViews.value.some((v) => v.name === view.name)) {
|
||||
return;
|
||||
}
|
||||
// 如果视图是固定的(affix),则在已访问的视图列表的开头添加
|
||||
if (view.affix) {
|
||||
visitedViews.value.unshift(view);
|
||||
} else {
|
||||
// 如果视图不是固定的,则在已访问的视图列表的末尾添加
|
||||
visitedViews.value.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缓存视图到缓存视图列表中
|
||||
*/
|
||||
function addCachedView(view: TagView) {
|
||||
const viewName = view.name;
|
||||
// 如果缓存视图名称已经存在于缓存视图列表中,则不再添加
|
||||
if (cachedViews.value.includes(viewName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果视图需要缓存(keepAlive),则将其路由名称添加到缓存视图列表中
|
||||
if (view.keepAlive) {
|
||||
cachedViews.value.push(viewName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已访问视图列表中删除指定的视图
|
||||
*/
|
||||
function delVisitedView(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
for (const [i, v] of visitedViews.value.entries()) {
|
||||
// 找到与指定视图路径匹配的视图,在已访问视图列表中删除该视图
|
||||
if (v.path === view.path) {
|
||||
visitedViews.value.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
resolve([...visitedViews.value]);
|
||||
});
|
||||
}
|
||||
|
||||
function delCachedView(view: TagView) {
|
||||
const viewName = view.name;
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
if (index > -1) {
|
||||
cachedViews.value.splice(index, 1);
|
||||
}
|
||||
resolve([...cachedViews.value]);
|
||||
});
|
||||
}
|
||||
function delOtherVisitedViews(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
visitedViews.value = visitedViews.value.filter((v) => {
|
||||
return v?.affix || v.path === view.path;
|
||||
});
|
||||
resolve([...visitedViews.value]);
|
||||
});
|
||||
}
|
||||
|
||||
function delOtherCachedViews(view: TagView) {
|
||||
const viewName = view.name as string;
|
||||
return new Promise((resolve) => {
|
||||
const index = cachedViews.value.indexOf(viewName);
|
||||
if (index > -1) {
|
||||
cachedViews.value = cachedViews.value.slice(index, index + 1);
|
||||
} else {
|
||||
// if index = -1, there is no cached tags
|
||||
cachedViews.value = [];
|
||||
}
|
||||
resolve([...cachedViews.value]);
|
||||
});
|
||||
}
|
||||
|
||||
function updateVisitedView(view: TagView) {
|
||||
for (let v of visitedViews.value) {
|
||||
if (v.path === view.path) {
|
||||
v = Object.assign(v, view);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addView(view: TagView) {
|
||||
addVisitedView(view);
|
||||
addCachedView(view);
|
||||
}
|
||||
|
||||
function delView(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
delVisitedView(view);
|
||||
delCachedView(view);
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delOtherViews(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
delOtherVisitedViews(view);
|
||||
delOtherCachedViews(view);
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delLeftViews(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
|
||||
if (currIndex === -1) {
|
||||
return;
|
||||
}
|
||||
visitedViews.value = visitedViews.value.filter((item, index) => {
|
||||
if (index >= currIndex || item?.affix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cacheIndex = cachedViews.value.indexOf(item.name);
|
||||
if (cacheIndex > -1) {
|
||||
cachedViews.value.splice(cacheIndex, 1);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delRightViews(view: TagView) {
|
||||
return new Promise((resolve) => {
|
||||
const currIndex = visitedViews.value.findIndex((v) => v.path === view.path);
|
||||
if (currIndex === -1) {
|
||||
return;
|
||||
}
|
||||
visitedViews.value = visitedViews.value.filter((item, index) => {
|
||||
if (index <= currIndex || item?.affix) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delAllViews() {
|
||||
return new Promise((resolve) => {
|
||||
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
|
||||
visitedViews.value = affixTags;
|
||||
cachedViews.value = [];
|
||||
resolve({
|
||||
visitedViews: [...visitedViews.value],
|
||||
cachedViews: [...cachedViews.value],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delAllVisitedViews() {
|
||||
return new Promise((resolve) => {
|
||||
const affixTags = visitedViews.value.filter((tag) => tag?.affix);
|
||||
visitedViews.value = affixTags;
|
||||
resolve([...visitedViews.value]);
|
||||
});
|
||||
}
|
||||
|
||||
function delAllCachedViews() {
|
||||
return new Promise((resolve) => {
|
||||
cachedViews.value = [];
|
||||
resolve([...cachedViews.value]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭当前tagView
|
||||
*/
|
||||
function closeCurrentView() {
|
||||
const tags: TagView = {
|
||||
name: route.name as string,
|
||||
title: route.meta.title as string,
|
||||
path: route.path,
|
||||
fullPath: route.fullPath,
|
||||
affix: route.meta?.affix,
|
||||
keepAlive: route.meta?.keepAlive,
|
||||
query: route.query,
|
||||
};
|
||||
delView(tags).then((res: any) => {
|
||||
if (isActive(tags)) {
|
||||
toLastView(res.visitedViews, tags);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isActive(tag: TagView) {
|
||||
return tag.path === route.path;
|
||||
}
|
||||
|
||||
function toLastView(visitedViews: TagView[], view?: TagView) {
|
||||
const latestView = visitedViews.slice(-1)[0];
|
||||
if (latestView && latestView.fullPath) {
|
||||
router.push(latestView.fullPath);
|
||||
} else {
|
||||
// now the default is to redirect to the home page if there is no tags-view,
|
||||
// you can adjust it according to your needs.
|
||||
const vn = view?.name != null ? String(view.name) : "";
|
||||
if (
|
||||
vn === "Home" ||
|
||||
vn === "Workplace" ||
|
||||
vn === "Dashboard" ||
|
||||
vn.startsWith("Dashboard")
|
||||
) {
|
||||
// to reload home page
|
||||
if (view?.fullPath) {
|
||||
router.replace("/redirect" + view.fullPath);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visitedViews,
|
||||
cachedViews,
|
||||
addVisitedView,
|
||||
addCachedView,
|
||||
delVisitedView,
|
||||
delCachedView,
|
||||
delOtherVisitedViews,
|
||||
delOtherCachedViews,
|
||||
updateVisitedView,
|
||||
addView,
|
||||
delView,
|
||||
delOtherViews,
|
||||
delLeftViews,
|
||||
delRightViews,
|
||||
delAllViews,
|
||||
delAllVisitedViews,
|
||||
delAllCachedViews,
|
||||
closeCurrentView,
|
||||
isActive,
|
||||
toLastView,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export function useTagsViewStoreHook() {
|
||||
return useTagsViewStore(store);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { MenuTable } from "@/api/module_system/menu";
|
||||
import { Auth } from "@utils/auth";
|
||||
import { ResultEnum } from "@/enums/api/result.enum";
|
||||
import { ElNotification } from "element-plus";
|
||||
import { store, useTagsViewStore, useDictStore } from "@stores";
|
||||
import { store, useDictStore } from "@stores";
|
||||
import type { UserInfo } from "@/api/module_system/user";
|
||||
|
||||
/** {@link useUserStore} 的 `logout` 可选参数 */
|
||||
@@ -154,11 +154,9 @@ export const useUserStore = defineStore(
|
||||
return;
|
||||
}
|
||||
|
||||
// 不同用户登录,清空工作台标签页
|
||||
if (String(currentUserId) !== lastUserId) {
|
||||
const worktabStore = useWorktabStore();
|
||||
worktabStore.opened = [];
|
||||
worktabStore.keepAliveExclude = [];
|
||||
// 不同用户登录:清空工作台标签(含 current,避免与路由脱节;并与持久化一致)
|
||||
if (String(currentUserId) !== String(lastUserId)) {
|
||||
useWorktabStore().clearAll();
|
||||
}
|
||||
|
||||
// 清除临时存储
|
||||
@@ -311,6 +309,8 @@ export const useUserStore = defineStore(
|
||||
accessToken.value = "";
|
||||
refreshToken.value = "";
|
||||
prems.value = [];
|
||||
/** 登出 / 认证失效:会话结束,工作栏与 KeepAlive exclude 一并清空(pinia 持久化随之写入) */
|
||||
useWorktabStore().clearAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,8 +355,7 @@ export const useUserStore = defineStore(
|
||||
Auth.clearAuth();
|
||||
// 重置用户信息
|
||||
clearUserInfo();
|
||||
// 清除标签视图
|
||||
useTagsViewStore().delAllViews();
|
||||
useWorktabStore().clearAll();
|
||||
// 重置字典
|
||||
useDictStore(store).clearDictData();
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* ## 核心特性
|
||||
*
|
||||
* - 智能标签页复用(同路由名称复用)
|
||||
* - 标签以 path 标识;同一路由组件可对不同 path 多开
|
||||
* - 固定标签页保护(不可关闭)
|
||||
* - KeepAlive 缓存排除管理
|
||||
* - 路由有效性验证
|
||||
@@ -43,6 +43,7 @@ import { router } from "@/router";
|
||||
import { LocationQueryRaw, Router } from "vue-router";
|
||||
import { WorkTab } from "@/types";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { ROUTE_PATH_LOGIN_ALT } from "@/router/staticRoutes";
|
||||
|
||||
interface WorktabState {
|
||||
current: Partial<WorkTab>;
|
||||
@@ -108,6 +109,19 @@ export const useWorktabStore = defineStore(
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量关标签后:若当前 URL 已不在 opened 中,则跳到 `current` 对应标签(避免路由与工作栏脱节)。
|
||||
*/
|
||||
const ensureRouterMatchesOpenedTab = (): void => {
|
||||
if (!opened.value.length) return;
|
||||
const locPath = router.currentRoute.value.path;
|
||||
if (opened.value.some((t) => t.path === locPath)) return;
|
||||
const tab = current.value as Partial<WorkTab>;
|
||||
if (tab.path) {
|
||||
safeRouterPush(tab as WorkTab);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开或激活一个选项卡
|
||||
*/
|
||||
@@ -122,14 +136,8 @@ export const useWorktabStore = defineStore(
|
||||
removeKeepAliveExclude(tab.name);
|
||||
}
|
||||
|
||||
// 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找
|
||||
let existingIndex = -1;
|
||||
if (tab.name) {
|
||||
existingIndex = opened.value.findIndex((t) => t.name === tab.name);
|
||||
}
|
||||
if (existingIndex === -1) {
|
||||
existingIndex = findTabIndex(tab.path);
|
||||
}
|
||||
// 仅以 path 识别标签:同一 route_name(同一组件)可对不同 path 同时保留多条标签
|
||||
const existingIndex = findTabIndex(tab.path);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// 新增标签页
|
||||
@@ -255,6 +263,7 @@ export const useWorktabStore = defineStore(
|
||||
if (targetTab) {
|
||||
current.value = targetTab;
|
||||
}
|
||||
ensureRouterMatchesOpenedTab();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -290,6 +299,7 @@ export const useWorktabStore = defineStore(
|
||||
if (targetTab) {
|
||||
current.value = targetTab;
|
||||
}
|
||||
ensureRouterMatchesOpenedTab();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -320,6 +330,7 @@ export const useWorktabStore = defineStore(
|
||||
|
||||
// 确保当前标签是激活状态
|
||||
current.value = targetTab;
|
||||
ensureRouterMatchesOpenedTab();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -365,14 +376,28 @@ export const useWorktabStore = defineStore(
|
||||
};
|
||||
|
||||
/**
|
||||
* 将指定选项卡添加到 keepAlive 排除列表中
|
||||
* KeepAlive 的 exclude 按「组件名」匹配,会一次性清掉所有同名实例。
|
||||
* 仅当 remaining 中已无任何同名标签时,才把该组件名加入 exclude(支持同 route_name 多 path 并存)。
|
||||
*/
|
||||
const pushExcludeIfLastSiblingOfName = (
|
||||
name: string | undefined,
|
||||
remaining: WorkTab[],
|
||||
tab: Pick<WorkTab, "keepAlive">
|
||||
): void => {
|
||||
if (!name || tab.keepAlive === false) return;
|
||||
if (remaining.some((t) => t.name === name)) return;
|
||||
if (!keepAliveExclude.value.includes(name)) {
|
||||
keepAliveExclude.value.push(name);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将指定选项卡添加到 keepAlive 排除列表中。
|
||||
* 须与布局「keepAlive !== false 即缓存」一致:仅用 `=== false` 跳过(undefined/true 均应排除),
|
||||
* 否则关标签时无法剔除原先 meta 未显式写 keepAlive 的页的缓存。
|
||||
*/
|
||||
const addKeepAliveExclude = (tab: WorkTab): void => {
|
||||
if (!tab.keepAlive || !tab.name) return;
|
||||
|
||||
if (!keepAliveExclude.value.includes(tab.name)) {
|
||||
keepAliveExclude.value.push(tab.name);
|
||||
}
|
||||
pushExcludeIfLastSiblingOfName(tab.name, opened.value, tab);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -388,11 +413,13 @@ export const useWorktabStore = defineStore(
|
||||
* 将传入的一组选项卡的组件名称标记为排除缓存
|
||||
*/
|
||||
const markTabsToRemove = (tabs: WorkTab[]): void => {
|
||||
tabs.forEach((tab) => {
|
||||
const removedPaths = new Set(tabs.map((t) => t.path));
|
||||
const futureOpened = opened.value.filter((t) => !removedPaths.has(t.path));
|
||||
for (const tab of tabs) {
|
||||
if (tab.name) {
|
||||
addKeepAliveExclude(tab);
|
||||
pushExcludeIfLastSiblingOfName(tab.name, futureOpened, tab);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -454,16 +481,30 @@ export const useWorktabStore = defineStore(
|
||||
}
|
||||
};
|
||||
|
||||
/** 登录页不应出现在工作台标签(历史持久化可能残留) */
|
||||
const isLoginWorktab = (tab: Partial<WorkTab>): boolean =>
|
||||
tab.name === "Login" || tab.path === "/login" || tab.path === ROUTE_PATH_LOGIN_ALT;
|
||||
|
||||
// 过滤出有效的标签页
|
||||
const validTabs = opened.value.filter((tab) => isTabRouteValid(tab));
|
||||
const validTabs = opened.value.filter(
|
||||
(tab) => isTabRouteValid(tab) && !isLoginWorktab(tab)
|
||||
);
|
||||
|
||||
if (validTabs.length !== opened.value.length) {
|
||||
console.warn("发现无效的标签页路由,已自动清理");
|
||||
const validPaths = new Set(validTabs.map((t) => t.path));
|
||||
// 未走 removeTab 批量剔除的标签:须同步 exclude;同名多标签时仅最后一条删尽才 exclude
|
||||
for (const tab of opened.value) {
|
||||
if (!validPaths.has(tab.path) && tab.name && tab.keepAlive !== false) {
|
||||
pushExcludeIfLastSiblingOfName(tab.name, validTabs, tab);
|
||||
}
|
||||
}
|
||||
opened.value = validTabs;
|
||||
}
|
||||
|
||||
// 验证当前激活标签的有效性
|
||||
const isCurrentValid = current.value && isTabRouteValid(current.value);
|
||||
// 验证当前激活标签的有效性(登录页不应作为当前工作台标签)
|
||||
const isCurrentValid =
|
||||
current.value && isTabRouteValid(current.value) && !isLoginWorktab(current.value);
|
||||
|
||||
if (!isCurrentValid && validTabs.length > 0) {
|
||||
console.warn("当前激活标签无效,已自动切换");
|
||||
@@ -524,6 +565,30 @@ export const useWorktabStore = defineStore(
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭「多标签栏」时:visited 不写入 `opened`(与 KeepAlive include 策略一致),
|
||||
* 但路由每次完成导航后应更新 `current`,避免持久化/内部状态与 `router` 真值脱节。
|
||||
*/
|
||||
const syncCurrentFromRoute = (tab: {
|
||||
path: string;
|
||||
name: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
keepAlive: boolean;
|
||||
params?: object;
|
||||
query?: LocationQueryRaw;
|
||||
}): void => {
|
||||
current.value = {
|
||||
path: tab.path,
|
||||
name: tab.name,
|
||||
title: tab.title,
|
||||
icon: tab.icon,
|
||||
keepAlive: tab.keepAlive,
|
||||
params: tab.params,
|
||||
query: tab.query,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
current,
|
||||
@@ -557,6 +622,7 @@ export const useWorktabStore = defineStore(
|
||||
getTabTitle,
|
||||
updateTabTitle,
|
||||
resetTabTitle,
|
||||
syncCurrentFromRoute,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
// 处理移动端组件兼容性
|
||||
@media screen and (max-width: 640px) {
|
||||
@media screen and (width <= 640px) {
|
||||
* {
|
||||
cursor: default !important;
|
||||
}
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
// 色弱模式
|
||||
.color-weak {
|
||||
filter: invert(80%);
|
||||
-webkit-filter: invert(80%);
|
||||
filter: invert(80%);
|
||||
}
|
||||
|
||||
#noop {
|
||||
@@ -52,14 +52,14 @@
|
||||
.langDropDownStyle {
|
||||
// 选中项背景颜色
|
||||
.is-selected {
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
background-color: var(--fa-el-active-color) !important;
|
||||
}
|
||||
|
||||
// 语言切换按钮菜单样式优化
|
||||
.lang-btn-item {
|
||||
.el-dropdown-menu__item {
|
||||
padding-left: 13px !important;
|
||||
padding-right: 6px !important;
|
||||
padding-left: 13px !important;
|
||||
margin-bottom: 3px !important;
|
||||
}
|
||||
|
||||
@@ -70,23 +70,23 @@
|
||||
}
|
||||
|
||||
.menu-txt {
|
||||
min-width: 60px;
|
||||
display: block;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 盒子默认边框
|
||||
.page-content {
|
||||
border: 1px solid var(--art-card-border) !important;
|
||||
border: 1px solid var(--fa-card-border) !important;
|
||||
}
|
||||
|
||||
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
|
||||
@mixin fa-card-base($border-color, $shadow: none, $radius-diff: 4px) {
|
||||
background: var(--default-box-color);
|
||||
border: 1px solid #{$border-color} !important;
|
||||
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
|
||||
@@ -95,63 +95,63 @@
|
||||
--el-card-border-color: var(--default-border) !important;
|
||||
}
|
||||
|
||||
.art-card,
|
||||
.art-card-sm,
|
||||
.art-card-xs {
|
||||
border: 1px solid var(--art-card-border);
|
||||
.fa-card,
|
||||
.fa-card-sm,
|
||||
.fa-card-xs {
|
||||
border: 1px solid var(--fa-card-border);
|
||||
}
|
||||
|
||||
// 盒子边框
|
||||
[data-box-mode='border-mode'] {
|
||||
[data-box-mode="border-mode"] {
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
border: 1px solid var(--art-card-border) !important;
|
||||
.fa-table-card {
|
||||
border: 1px solid var(--fa-card-border) !important;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@include art-card-base(var(--art-card-border), none, 4px);
|
||||
.fa-card {
|
||||
@include fa-card-base(var(--fa-card-border), none, 4px);
|
||||
}
|
||||
|
||||
.art-card-sm {
|
||||
@include art-card-base(var(--art-card-border), none, 0px);
|
||||
.fa-card-sm {
|
||||
@include fa-card-base(var(--fa-card-border), none, 0);
|
||||
}
|
||||
|
||||
.art-card-xs {
|
||||
@include art-card-base(var(--art-card-border), none, -4px);
|
||||
.fa-card-xs {
|
||||
@include fa-card-base(var(--fa-card-border), none, -4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 盒子阴影
|
||||
[data-box-mode='shadow-mode'] {
|
||||
[data-box-mode="shadow-mode"] {
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid var(--art-gray-200) !important;
|
||||
.fa-table-card {
|
||||
border: 1px solid var(--fa-gray-200) !important;
|
||||
box-shadow: 0 0 4px 0 rgb(0 0 0 / 4%) !important;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
border-right: 1px solid var(--fa-card-border) !important;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
.fa-card {
|
||||
@include fa-card-base(
|
||||
var(--fa-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
4px
|
||||
);
|
||||
}
|
||||
|
||||
.art-card-sm {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
.fa-card-sm {
|
||||
@include fa-card-base(
|
||||
var(--fa-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
2px
|
||||
);
|
||||
}
|
||||
|
||||
.art-card-xs {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
.fa-card-xs {
|
||||
@include fa-card-base(
|
||||
var(--fa-gray-200),
|
||||
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
|
||||
-4px
|
||||
);
|
||||
@@ -162,58 +162,62 @@
|
||||
.el-full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100vw !important;
|
||||
height: 100% !important;
|
||||
left: 0;
|
||||
z-index: 2300;
|
||||
margin-top: 0;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--default-box-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw !important;
|
||||
height: 100% !important;
|
||||
padding: 15px;
|
||||
margin-top: 0;
|
||||
background-color: var(--default-box-color);
|
||||
}
|
||||
|
||||
// 表格卡片:与 FaTableHeader + FaTable 组成纵向 flex,表格区 flex 填满、分页在表格正下方
|
||||
.art-table-card {
|
||||
flex: 1;
|
||||
.fa-table-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin-top: 12px;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.el-card__body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
/** 工具栏区不参与压缩,表格区 FaTable 使用 flex:1 占满剩余高度 */
|
||||
#art-table-header {
|
||||
#fa-table-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 容器全高:沿布局 flex 链填满 `#app-scroll-main` 内剩余高度,底部自然对齐 #app-content 的 padding(勿再用 100vh 估算)
|
||||
.art-full-height {
|
||||
.fa-full-height {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (width <= 640px) {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 徽章样式
|
||||
.art-badge {
|
||||
.fa-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
@@ -225,33 +229,33 @@
|
||||
border-radius: 50%;
|
||||
animation: breathe 1.5s ease-in-out infinite;
|
||||
|
||||
&.art-badge-horizontal {
|
||||
&.fa-badge-horizontal {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.art-badge-mixed {
|
||||
&.fa-badge-mixed {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.art-badge-dual {
|
||||
right: 5px;
|
||||
&.fa-badge-dual {
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 文字徽章样式
|
||||
.art-text-badge {
|
||||
.fa-text-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
line-height: 17px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
font-size: 10px;
|
||||
line-height: 17px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #fd4e4e;
|
||||
@@ -276,20 +280,17 @@
|
||||
}
|
||||
|
||||
// 修复老机型 loading 定位问题
|
||||
.art-loading-fix {
|
||||
.fa-loading-fix {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
inset: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.art-loading-fix .el-loading-spinner {
|
||||
.fa-loading-fix .el-loading-spinner {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
@@ -297,7 +298,7 @@
|
||||
}
|
||||
|
||||
// 去除移动端点击背景色
|
||||
@media screen and (max-width: 1180px) {
|
||||
@media screen and (width <= 1180px) {
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
|
||||
*/
|
||||
|
||||
$font-color: rgba(#ffffff, 0.85);
|
||||
$font-color: rgba(#fff, 0.85);
|
||||
|
||||
/* 覆盖element-plus默认深色背景色 */
|
||||
html.dark {
|
||||
@@ -17,7 +17,7 @@ html.dark {
|
||||
// 输入区域背景颜色
|
||||
--w-e-textarea-bg-color: #090909;
|
||||
// 工具栏文字颜色
|
||||
--w-e-toolbar-color: var(--art-gray-600);
|
||||
--w-e-toolbar-color: var(--fa-gray-600);
|
||||
// 选中菜单颜色
|
||||
--w-e-toolbar-active-bg-color: #25262b;
|
||||
// 弹窗边框颜色
|
||||
@@ -31,7 +31,7 @@ html.dark {
|
||||
// 按钮背景颜色
|
||||
--w-e-modal-button-bg-color: #090909;
|
||||
// hover toolbar 背景颜色
|
||||
--w-e-toolbar-active-color: var(--art-gray-800);
|
||||
--w-e-toolbar-active-color: var(--fa-gray-800);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -44,10 +44,18 @@ html.dark {
|
||||
*:not(pre code *) {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid var(--default-border-dashed) !important;
|
||||
}
|
||||
|
||||
.w-e-modal {
|
||||
background-color: var(--fa-color);
|
||||
}
|
||||
}
|
||||
// 分隔线
|
||||
.w-e-bar-divider {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
background-color: var(--fa-gray-300) !important;
|
||||
}
|
||||
|
||||
.w-e-select-list,
|
||||
@@ -71,23 +79,13 @@ html.dark {
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
background-color: #25262b !important;
|
||||
text-shadow: none !important;
|
||||
background-color: #25262b !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: var(--fa-color);
|
||||
border-left: 4px solid var(--default-border-dashed) !important;
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid var(--default-border-dashed) !important;
|
||||
}
|
||||
|
||||
.w-e-modal {
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// 导入暗黑主题
|
||||
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
|
||||
@use "element-plus/theme-chalk/src/dark/css-vars.scss" as *;
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
|
||||
// 自定义Element 亮色主题
|
||||
|
||||
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||
@forward "element-plus/theme-chalk/src/common/var.scss" with (
|
||||
$colors: (
|
||||
'white': #ffffff,
|
||||
'black': #000000,
|
||||
'success': (
|
||||
'base': #13deb9
|
||||
"white": #ffffff,
|
||||
"black": #000000,
|
||||
"success": (
|
||||
"base": #13deb9,
|
||||
),
|
||||
'warning': (
|
||||
'base': #ffae1f
|
||||
"warning": (
|
||||
"base": #ffae1f,
|
||||
),
|
||||
'danger': (
|
||||
'base': #ff4d4f
|
||||
"danger": (
|
||||
"base": #ff4d4f,
|
||||
),
|
||||
"error": (
|
||||
"base": #fa896b,
|
||||
),
|
||||
'error': (
|
||||
'base': #fa896b
|
||||
)
|
||||
),
|
||||
$button: (
|
||||
'hover-bg-color': var(--el-color-primary-light-9),
|
||||
'hover-border-color': var(--el-color-primary),
|
||||
'border-color': var(--el-color-primary),
|
||||
'text-color': var(--el-color-primary)
|
||||
"hover-bg-color": var(--el-color-primary-light-9),
|
||||
"hover-border-color": var(--el-color-primary),
|
||||
"border-color": var(--el-color-primary),
|
||||
"text-color": var(--el-color-primary),
|
||||
),
|
||||
$messagebox: (
|
||||
'border-radius': '12px'
|
||||
"border-radius": "12px",
|
||||
),
|
||||
$popover: (
|
||||
'padding': '14px',
|
||||
'border-radius': '10px'
|
||||
"padding": "14px",
|
||||
"border-radius": "10px",
|
||||
)
|
||||
);
|
||||
|
||||
@@ -9,14 +9,11 @@
|
||||
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
||||
// 按钮粗度
|
||||
--el-font-weight-primary: 400 !important;
|
||||
|
||||
--el-component-custom-height: 36px !important;
|
||||
|
||||
--el-component-size: var(--el-component-custom-height) !important;
|
||||
|
||||
// 边框、按钮圆角...
|
||||
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
|
||||
|
||||
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
@@ -34,7 +31,7 @@
|
||||
|
||||
// 日期选择器
|
||||
.el-date-range-picker {
|
||||
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
|
||||
--el-datepicker-inrange-bg-color: var(--fa-gray-200) !important;
|
||||
}
|
||||
|
||||
// el-card 背景色跟系统背景色保持一致
|
||||
@@ -44,15 +41,11 @@ html.dark .el-card {
|
||||
|
||||
// 修改 el-pagination 大小
|
||||
.el-pagination--default {
|
||||
& {
|
||||
--el-pagination-button-width: 32px !important;
|
||||
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
--el-pagination-button-width: 32px !important;
|
||||
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
& {
|
||||
--el-pagination-button-width: 28px !important;
|
||||
}
|
||||
@media (width <= 1180px) {
|
||||
--el-pagination-button-width: 28px !important;
|
||||
}
|
||||
|
||||
.el-select--default .el-select__wrapper {
|
||||
@@ -118,9 +111,9 @@ html.dark .el-card {
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
overflow: hidden;
|
||||
border-radius: 100px !important;
|
||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
@@ -130,8 +123,8 @@ html.dark .el-card {
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 25px 0 !important;
|
||||
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
|
||||
padding: 25px 0 !important;
|
||||
}
|
||||
|
||||
.el-dialog.el-dialog-border {
|
||||
@@ -140,12 +133,12 @@ html.dark .el-card {
|
||||
&::before,
|
||||
// 下边框
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
width: calc(100% + 32px);
|
||||
height: 1px;
|
||||
background-color: var(--art-gray-300);
|
||||
content: "";
|
||||
background-color: var(--fa-gray-300);
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -163,9 +156,9 @@ html.dark .el-card {
|
||||
background-color: var(--default-box-color) !important;
|
||||
border: 0 !important;
|
||||
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) !important;
|
||||
0 6px 16px 0 rgb(0 0 0 / 8%),
|
||||
0 3px 6px -4px rgb(0 0 0 / 12%),
|
||||
0 9px 28px 8px rgb(0 0 0 / 5%) !important;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
@@ -175,21 +168,21 @@ html.dark .el-card {
|
||||
// 修改 el-dropdown 样式
|
||||
.el-dropdown-menu {
|
||||
padding: 6px !important;
|
||||
border-radius: 10px !important;
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
padding: 6px 16px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
color: var(--fa-gray-900) !important;
|
||||
background-color: var(--fa-el-active-color) !important;
|
||||
}
|
||||
|
||||
&:focus:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
color: var(--fa-gray-900) !important;
|
||||
background-color: var(--fa-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,23 +202,23 @@ html.dark .el-card {
|
||||
}
|
||||
|
||||
// 处理移动端组件兼容性
|
||||
@media screen and (max-width: 640px) {
|
||||
@media screen and (width <= 640px) {
|
||||
.el-message-box,
|
||||
.el-dialog {
|
||||
width: calc(100% - 24px) !important;
|
||||
}
|
||||
|
||||
.el-date-picker.has-sidebar.has-time {
|
||||
width: calc(100% - 24px);
|
||||
left: 12px !important;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.el-picker-panel *[slot='sidebar'],
|
||||
.el-picker-panel *[slot="sidebar"],
|
||||
.el-picker-panel__sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
|
||||
.el-picker-panel *[slot="sidebar"] + .el-picker-panel__body,
|
||||
.el-picker-panel__sidebar + .el-picker-panel__body {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -234,8 +227,8 @@ html.dark .el-card {
|
||||
// 修改el-button样式
|
||||
.el-button {
|
||||
&.el-button--text {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
background-color: transparent !important;
|
||||
|
||||
span {
|
||||
margin-left: 0 !important;
|
||||
@@ -292,9 +285,9 @@ html.dark .el-card {
|
||||
border-radius: 4px !important;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 4px !important;
|
||||
top: 5px !important;
|
||||
height: 4px !important;
|
||||
content: "";
|
||||
background-color: #fff !important;
|
||||
transform: scale(0.6) !important;
|
||||
}
|
||||
@@ -307,8 +300,8 @@ html.dark .el-card {
|
||||
height: 8px;
|
||||
margin: auto;
|
||||
border: 2px solid var(--el-checkbox-checked-icon-color);
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -331,8 +324,8 @@ html.dark .el-card {
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
color: var(--art-gray-900) !important;
|
||||
color: var(--fa-gray-900) !important;
|
||||
background-color: var(--fa-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,8 +338,8 @@ html.dark .el-card {
|
||||
}
|
||||
|
||||
.el-table__column-filter-trigger i {
|
||||
color: var(--theme-color) !important;
|
||||
margin: -3px 0 0 2px;
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
|
||||
// 去除 el-dropdown 鼠标放上去出现的边框
|
||||
@@ -355,7 +348,7 @@ html.dark .el-card {
|
||||
}
|
||||
|
||||
// ipad 表单右侧按钮优化
|
||||
@media screen and (max-width: 1180px) {
|
||||
@media screen and (width <= 1180px) {
|
||||
.el-table-fixed-column--right {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
@@ -432,14 +425,14 @@ html.dark .el-card {
|
||||
border-radius: 6px !important;
|
||||
|
||||
&.is-selected {
|
||||
color: var(--art-gray-900) !important;
|
||||
font-weight: 400 !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
margin-bottom: 4px !important;
|
||||
font-weight: 400 !important;
|
||||
color: var(--fa-gray-900) !important;
|
||||
background-color: var(--fa-el-active-color) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
background-color: var(--fa-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +454,7 @@ html.dark .el-card {
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200) !important;
|
||||
background-color: var(--fa-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,26 +481,26 @@ html.dark .el-card {
|
||||
|
||||
// 优化树型菜单样式
|
||||
.el-tree-node__content {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 0;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
background-color: var(--fa-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
background-color: var(--fa-gray-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏折叠菜单弹窗 hover 出现的边框
|
||||
.menu-left-popper:focus-within,
|
||||
.horizontal-menu-popper:focus-within {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// 数字输入组件右侧按钮高度跟随自定义组件高度
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* 文章标题设置(h1-h6)*/
|
||||
/* 文章标题设置(h1-h6) */
|
||||
|
||||
/* ------------------------------------------------ */
|
||||
$font-color: #24292e;
|
||||
|
||||
@@ -8,16 +9,16 @@ $font-color: #24292e;
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
color: var(--art-gray-800) !important;
|
||||
margin: 30px 0 10px 0;
|
||||
margin: 30px 0 10px;
|
||||
font-weight: 600;
|
||||
color: var(--fa-gray-800) !important;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 550px) {
|
||||
@media only screen and (width <= 550px) {
|
||||
.markdown-body h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
@@ -32,96 +33,98 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
/* 块引用 */
|
||||
|
||||
/* ------------------------------------------------ */
|
||||
.markdown-body blockquote {
|
||||
color: rgba(60, 60, 67, 0.7);
|
||||
font-size: 15px !important;
|
||||
border-left: 0.18em solid #e7e7e8;
|
||||
background: #f8f8f8;
|
||||
padding: 15px 1em;
|
||||
font-size: 15px !important;
|
||||
font-weight: 400 !important;
|
||||
color: rgb(60 60 67 / 70%);
|
||||
background: #f8f8f8;
|
||||
border-left: 0.18em solid #e7e7e8;
|
||||
}
|
||||
|
||||
/* 详情页文章字体颜色 */
|
||||
|
||||
/* ------------------------------------------------ */
|
||||
.markdown-body p {
|
||||
line-height: 28px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.markdown-body li,
|
||||
.markdown-body p {
|
||||
color: var(--art-gray-800) !important;
|
||||
font-size: 16px !important;
|
||||
color: var(--fa-gray-800) !important;
|
||||
}
|
||||
|
||||
.dark .markdown-body li span {
|
||||
color: var(--art-gray-800) !important;
|
||||
color: var(--fa-gray-800) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.dark .markdown-body p span {
|
||||
color: var(--art-gray-800) !important;
|
||||
color: var(--fa-gray-800) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.line-numbers-mode {
|
||||
background-color: var(--art-code-bg);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
box-sizing: border-box;
|
||||
padding-left: 32px;
|
||||
background-color: var(--fa-code-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.line-numbers-mode pre {
|
||||
flex: 1;
|
||||
background-color: var(--fa-code-bg);
|
||||
border-radius: 0 8px 8px 0;
|
||||
background-color: var(--art-code-bg);
|
||||
}
|
||||
|
||||
.line-numbers-mode .line-numbers-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #000000;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
text-align: center;
|
||||
border-right: 1px solid #000;
|
||||
}
|
||||
|
||||
.line-numbers-mode .line-numbers-wrapper span {
|
||||
height: 23.6px;
|
||||
line-height: 23.6px;
|
||||
display: block;
|
||||
color: #72747b;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
height: 23.6px;
|
||||
font-size: 13px;
|
||||
line-height: 23.6px;
|
||||
color: #72747b;
|
||||
}
|
||||
|
||||
.line-numbers-mode .copy-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
color: rgb(255 255 255 / 60%);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
background-color: #000;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.line-numbers-mode .copy-btn div {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.line-numbers-mode:hover .copy-btn {
|
||||
@@ -129,16 +132,16 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.line-numbers-mode .copy-btn span {
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
display: none;
|
||||
height: 34px;
|
||||
padding-left: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 34px;
|
||||
}
|
||||
|
||||
.line-numbers-mode .copy-btn .show-copy {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.line-numbers-mode ::-webkit-scrollbar-track {
|
||||
@@ -147,9 +150,9 @@ $font-color: #24292e;
|
||||
|
||||
.markdown-body .anchor {
|
||||
float: left;
|
||||
line-height: 1;
|
||||
margin-left: -20px;
|
||||
padding-right: 4px;
|
||||
margin-left: -20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.markdown-body .anchor:focus {
|
||||
@@ -162,9 +165,9 @@ $font-color: #24292e;
|
||||
.markdown-body h4 .octicon-link,
|
||||
.markdown-body h5 .octicon-link,
|
||||
.markdown-body h6 .octicon-link {
|
||||
color: #1b1f23;
|
||||
vertical-align: middle;
|
||||
visibility: hidden;
|
||||
vertical-align: middle;
|
||||
color: #1b1f23;
|
||||
}
|
||||
|
||||
.markdown-body h1:hover .anchor,
|
||||
@@ -185,26 +188,25 @@ $font-color: #24292e;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.markdown-body h1:hover .anchor .octicon-link:before,
|
||||
.markdown-body h2:hover .anchor .octicon-link:before,
|
||||
.markdown-body h3:hover .anchor .octicon-link:before,
|
||||
.markdown-body h4:hover .anchor .octicon-link:before,
|
||||
.markdown-body h5:hover .anchor .octicon-link:before,
|
||||
.markdown-body h6:hover .anchor .octicon-link:before {
|
||||
.markdown-body h1:hover .anchor .octicon-link::before,
|
||||
.markdown-body h2:hover .anchor .octicon-link::before,
|
||||
.markdown-body h3:hover .anchor .octicon-link::before,
|
||||
.markdown-body h4:hover .anchor .octicon-link::before,
|
||||
.markdown-body h5:hover .anchor .octicon-link::before,
|
||||
.markdown-body h6:hover .anchor .octicon-link::before {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
content: " ";
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
line-height: 1.5;
|
||||
color: $font-color;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: $font-color;
|
||||
word-wrap: break-word;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.markdown-body details {
|
||||
@@ -245,15 +247,15 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markdown-body [type='checkbox'] {
|
||||
.markdown-body [type="checkbox"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -289,13 +291,13 @@ $font-color: #24292e;
|
||||
border-bottom: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-body hr:after,
|
||||
.markdown-body hr:before {
|
||||
.markdown-body hr::after,
|
||||
.markdown-body hr::before {
|
||||
display: table;
|
||||
content: '';
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdown-body hr:after {
|
||||
.markdown-body hr::after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
@@ -319,12 +321,12 @@ $font-color: #24292e;
|
||||
font:
|
||||
11px SFMono-Regular,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
"Liberation Mono",
|
||||
Menlo,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
color: #444d56;
|
||||
vertical-align: middle;
|
||||
color: #444d56;
|
||||
background-color: #fafbfc;
|
||||
border: 1px solid #d1d5da;
|
||||
border-radius: 3px;
|
||||
@@ -362,8 +364,8 @@ $font-color: #24292e;
|
||||
.markdown-body pre,
|
||||
.markdown-body .line-number {
|
||||
font-size: 14px !important;
|
||||
border-radius: 8px;
|
||||
background-color: #282c34;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -540,8 +542,8 @@ $font-color: #24292e;
|
||||
background-color: #d73a49;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c2:before {
|
||||
content: '^M';
|
||||
.markdown-body .pl-c2::before {
|
||||
content: "^M";
|
||||
}
|
||||
|
||||
.markdown-body .pl-sr .pl-cce {
|
||||
@@ -604,8 +606,8 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body .pl-corl {
|
||||
text-decoration: underline;
|
||||
color: #032f62;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body .mb-0 {
|
||||
@@ -692,25 +694,25 @@ $font-color: #24292e;
|
||||
font:
|
||||
11px SFMono-Regular,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
"Liberation Mono",
|
||||
Menlo,
|
||||
monospace;
|
||||
line-height: 10px;
|
||||
color: #444d56;
|
||||
vertical-align: middle;
|
||||
color: #444d56;
|
||||
background-color: #fafbfc;
|
||||
border: 1px solid #d1d5da;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -1px 0 #d1d5da;
|
||||
}
|
||||
|
||||
.markdown-body:after,
|
||||
.markdown-body:before {
|
||||
.markdown-body::after,
|
||||
.markdown-body::before {
|
||||
display: table;
|
||||
content: '';
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdown-body:after {
|
||||
.markdown-body::after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
@@ -768,11 +770,11 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
line-height: 28px;
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
word-wrap: break-all;
|
||||
list-style: disc;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.markdown-body li > p {
|
||||
@@ -826,19 +828,19 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
box-sizing: initial;
|
||||
max-width: 100%;
|
||||
cursor: zoom-in;
|
||||
background-color: #fff;
|
||||
border: 1px solid #eee;
|
||||
border: 1px solid var(--art-c-border-2);
|
||||
cursor: zoom-in;
|
||||
border: 1px solid var(--fa-c-border-2);
|
||||
}
|
||||
|
||||
.markdown-body img[align='right'] {
|
||||
.markdown-body img[align="right"] {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body img[align='left'] {
|
||||
.markdown-body img[align="left"] {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
@@ -846,7 +848,7 @@ $font-color: #24292e;
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
background-color: rgb(27 31 35 / 5%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -905,8 +907,7 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body .blob-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
.markdown-body .blob-wrapper-embedded {
|
||||
@@ -921,10 +922,10 @@ $font-color: #24292e;
|
||||
padding-left: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(27, 31, 35, 0.3);
|
||||
vertical-align: top;
|
||||
color: rgb(27 31 35 / 30%);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
@@ -933,10 +934,10 @@ $font-color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body .blob-num:hover {
|
||||
color: rgba(27, 31, 35, 0.6);
|
||||
color: rgb(27 31 35 / 60%);
|
||||
}
|
||||
|
||||
.markdown-body .blob-num:before {
|
||||
.markdown-body .blob-num::before {
|
||||
content: attr(data-line-number);
|
||||
}
|
||||
|
||||
@@ -962,62 +963,62 @@ $font-color: #24292e;
|
||||
background: #ffea7f;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='1'] {
|
||||
.markdown-body .tab-size[data-tab-size="1"] {
|
||||
-moz-tab-size: 1;
|
||||
tab-size: 1;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='2'] {
|
||||
.markdown-body .tab-size[data-tab-size="2"] {
|
||||
-moz-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='3'] {
|
||||
.markdown-body .tab-size[data-tab-size="3"] {
|
||||
-moz-tab-size: 3;
|
||||
tab-size: 3;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='4'] {
|
||||
.markdown-body .tab-size[data-tab-size="4"] {
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='5'] {
|
||||
.markdown-body .tab-size[data-tab-size="5"] {
|
||||
-moz-tab-size: 5;
|
||||
tab-size: 5;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='6'] {
|
||||
.markdown-body .tab-size[data-tab-size="6"] {
|
||||
-moz-tab-size: 6;
|
||||
tab-size: 6;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='7'] {
|
||||
.markdown-body .tab-size[data-tab-size="7"] {
|
||||
-moz-tab-size: 7;
|
||||
tab-size: 7;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='8'] {
|
||||
.markdown-body .tab-size[data-tab-size="8"] {
|
||||
-moz-tab-size: 8;
|
||||
tab-size: 8;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='9'] {
|
||||
.markdown-body .tab-size[data-tab-size="9"] {
|
||||
-moz-tab-size: 9;
|
||||
tab-size: 9;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='10'] {
|
||||
.markdown-body .tab-size[data-tab-size="10"] {
|
||||
-moz-tab-size: 10;
|
||||
tab-size: 10;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='11'] {
|
||||
.markdown-body .tab-size[data-tab-size="11"] {
|
||||
-moz-tab-size: 11;
|
||||
tab-size: 11;
|
||||
}
|
||||
|
||||
.markdown-body .tab-size[data-tab-size='12'] {
|
||||
.markdown-body .tab-size[data-tab-size="12"] {
|
||||
-moz-tab-size: 12;
|
||||
tab-size: 12;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
* @param {Number} 行数
|
||||
*/
|
||||
@mixin ellipsis($rowCount: 1) {
|
||||
@if $rowCount <=1 {
|
||||
@if $rowCount <= 1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
} @else {
|
||||
display: -webkit-box;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: $rowCount;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
@@ -24,19 +24,16 @@
|
||||
* @param {String} 类型
|
||||
*/
|
||||
@mixin userSelect($value: none) {
|
||||
user-select: $value;
|
||||
-moz-user-select: $value;
|
||||
-ms-user-select: $value;
|
||||
-webkit-user-select: $value;
|
||||
user-select: $value;
|
||||
}
|
||||
|
||||
// 绝对定位居中
|
||||
@mixin absoluteCenter() {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@@ -46,10 +43,10 @@
|
||||
*/
|
||||
@mixin animation(
|
||||
$from: (
|
||||
width: 0px
|
||||
width: 0,
|
||||
),
|
||||
$to: (
|
||||
width: 100px
|
||||
width: 100px,
|
||||
),
|
||||
$name: mymove,
|
||||
$animate: mymove 2s 1 linear infinite
|
||||
@@ -58,6 +55,7 @@
|
||||
-o-animation: $animate;
|
||||
animation: $animate;
|
||||
|
||||
/* stylelint-disable-next-line at-rule-prelude-no-invalid */
|
||||
@keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
@@ -71,46 +69,34 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 圆形盒子
|
||||
@mixin circle($size: 11px, $bg: #fff) {
|
||||
border-radius: 50%;
|
||||
width: $size;
|
||||
height: $size;
|
||||
line-height: $size;
|
||||
text-align: center;
|
||||
background: $bg;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// placeholder
|
||||
@mixin placeholder($color: #bbb) {
|
||||
// Firefox
|
||||
&::-moz-placeholder {
|
||||
&::placeholder {
|
||||
color: $color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Internet Explorer 10+
|
||||
// Internet Explorer 10–11
|
||||
/* stylelint-disable-next-line selector-no-vendor-prefix */
|
||||
&:-ms-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
|
||||
// Safari and Chrome
|
||||
// Safari and Chrome (legacy)
|
||||
/* stylelint-disable-next-line selector-no-vendor-prefix */
|
||||
&::-webkit-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
@@ -124,17 +110,18 @@
|
||||
@mixin betterTransparentize($color, $alpha) {
|
||||
$c: rgba($color, $alpha);
|
||||
$ie_c: ie_hex_str($c);
|
||||
|
||||
zoom: 1;
|
||||
background: rgba($color, 1);
|
||||
background: $c;
|
||||
background: transparent \9;
|
||||
zoom: 1;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})";
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
|
||||
}
|
||||
|
||||
//添加浏览器前缀
|
||||
@mixin browserPrefix($propertyName, $value) {
|
||||
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
|
||||
@each $prefix in -webkit-, -moz-, -ms-, -o-, "" {
|
||||
#{$prefix}#{$propertyName}: $value;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +134,7 @@
|
||||
// 背景滤镜
|
||||
@mixin backdropBlur() {
|
||||
--tw-backdrop-blur: blur(30px);
|
||||
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
|
||||
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
|
||||
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/*滚动条*/
|
||||
/*滚动条整体部分,必须要设置*/
|
||||
/* 滚动条 */
|
||||
|
||||
/* 滚动条整体部分,必须要设置 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
/*滚动条的轨道*/
|
||||
/* 滚动条的轨道 */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--art-gray-200);
|
||||
background-color: var(--fa-gray-200);
|
||||
}
|
||||
|
||||
/*滚动条的滑块按钮*/
|
||||
/* 滚动条的滑块按钮 */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc !important;
|
||||
border-radius: 5px;
|
||||
background-color: #cccccc !important;
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #b0abab !important;
|
||||
}
|
||||
|
||||
/*滚动条的上下两端的按钮*/
|
||||
/* 滚动条的上下两端的按钮 */
|
||||
::-webkit-scrollbar-button {
|
||||
height: 0px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -36,6 +37,6 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
background-color: var(--fa-gray-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use 'sass:map';
|
||||
@use "sass:map";
|
||||
|
||||
// === 变量区域 ===
|
||||
$transition: (
|
||||
@@ -18,10 +18,10 @@ $transition: (
|
||||
}
|
||||
|
||||
// 变量简写
|
||||
$duration: transition-config('duration');
|
||||
$distance: transition-config('distance');
|
||||
$easing: transition-config('easing');
|
||||
$fade-easing: transition-config('fade-easing');
|
||||
$duration: transition-config("duration");
|
||||
$distance: transition-config("distance");
|
||||
$easing: transition-config("easing");
|
||||
$fade-easing: transition-config("fade-easing");
|
||||
|
||||
// === 动画类 ===
|
||||
|
||||
@@ -49,13 +49,13 @@ $fade-easing: transition-config('fade-easing');
|
||||
$distance-x: 0;
|
||||
$distance-y: 0;
|
||||
|
||||
@if $direction == 'left' {
|
||||
@if $direction == "left" {
|
||||
$distance-x: -$distance;
|
||||
} @else if $direction == 'right' {
|
||||
} @else if $direction == "right" {
|
||||
$distance-x: $distance;
|
||||
} @else if $direction == 'top' {
|
||||
} @else if $direction == "top" {
|
||||
$distance-y: -$distance;
|
||||
} @else if $direction == 'bottom' {
|
||||
} @else if $direction == "bottom" {
|
||||
$distance-y: $distance;
|
||||
}
|
||||
|
||||
@@ -91,14 +91,17 @@ $fade-easing: transition-config('fade-easing');
|
||||
|
||||
// 滑动动画方向类
|
||||
.slide-left {
|
||||
@include slide-transition('left');
|
||||
@include slide-transition("left");
|
||||
}
|
||||
|
||||
.slide-right {
|
||||
@include slide-transition('right');
|
||||
@include slide-transition("right");
|
||||
}
|
||||
|
||||
.slide-top {
|
||||
@include slide-transition('top');
|
||||
@include slide-transition("top");
|
||||
}
|
||||
|
||||
.slide-bottom {
|
||||
@include slide-transition('bottom');
|
||||
@include slide-transition("bottom");
|
||||
}
|
||||
|
||||
@@ -1,72 +1,71 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ==================== Light Mode Variables ==================== */
|
||||
:root {
|
||||
/* Base Colors */
|
||||
--art-color: #ffffff;
|
||||
--fa-color: #fff;
|
||||
--theme-color: var(--main-color);
|
||||
|
||||
/* Theme Colors - OKLCH Format */
|
||||
--art-primary: oklch(0.7 0.23 260);
|
||||
--art-secondary: oklch(0.72 0.19 231.6);
|
||||
--art-error: oklch(0.73 0.15 25.3);
|
||||
--art-info: oklch(0.58 0.03 254.1);
|
||||
--art-success: oklch(0.78 0.17 166.1);
|
||||
--art-warning: oklch(0.78 0.14 75.5);
|
||||
--art-danger: oklch(0.68 0.22 25.3);
|
||||
--fa-primary: oklch(70% 0.23 260deg);
|
||||
--fa-secondary: oklch(72% 0.19 231.6deg);
|
||||
--fa-error: oklch(73% 0.15 25.3deg);
|
||||
--fa-info: oklch(58% 0.03 254.1deg);
|
||||
--fa-success: oklch(78% 0.17 166.1deg);
|
||||
--fa-warning: oklch(78% 0.14 75.5deg);
|
||||
--fa-danger: oklch(68% 0.22 25.3deg);
|
||||
|
||||
/* Gray Scale - Light Mode */
|
||||
--art-gray-100: #f9fafb;
|
||||
--art-gray-200: #f2f4f5;
|
||||
--art-gray-300: #e6eaeb;
|
||||
--art-gray-400: #dbdfe1;
|
||||
--art-gray-500: #949eb7;
|
||||
--art-gray-600: #7987a1;
|
||||
--art-gray-700: #4d5875;
|
||||
--art-gray-800: #383853;
|
||||
--art-gray-900: #323251;
|
||||
--fa-gray-100: #f9fafb;
|
||||
--fa-gray-200: #f2f4f5;
|
||||
--fa-gray-300: #e6eaeb;
|
||||
--fa-gray-400: #dbdfe1;
|
||||
--fa-gray-500: #949eb7;
|
||||
--fa-gray-600: #7987a1;
|
||||
--fa-gray-700: #4d5875;
|
||||
--fa-gray-800: #383853;
|
||||
--fa-gray-900: #323251;
|
||||
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--fa-card-border: rgb(0 0 0 / 8%);
|
||||
--default-border: #e2e8ee;
|
||||
--default-border-dashed: #dbdfe9;
|
||||
|
||||
/* Background Colors */
|
||||
--default-bg-color: #fafbfc;
|
||||
--default-box-color: #ffffff;
|
||||
--default-box-color: #fff;
|
||||
|
||||
/* Hover Color */
|
||||
--art-hover-color: #edeff0;
|
||||
--fa-hover-color: #edeff0;
|
||||
|
||||
/* Active Color */
|
||||
--art-active-color: #f2f4f5;
|
||||
--fa-active-color: #f2f4f5;
|
||||
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #f2f4f5;
|
||||
--fa-el-active-color: #f2f4f5;
|
||||
}
|
||||
|
||||
/* ==================== Dark Mode Variables ==================== */
|
||||
.dark {
|
||||
/* Base Colors */
|
||||
--art-color: #000000;
|
||||
--fa-color: #000;
|
||||
|
||||
/* Gray Scale - Dark Mode */
|
||||
--art-gray-100: #110f0f;
|
||||
--art-gray-200: #17171c;
|
||||
--art-gray-300: #393946;
|
||||
--art-gray-400: #505062;
|
||||
--art-gray-500: #73738c;
|
||||
--art-gray-600: #8f8fa3;
|
||||
--art-gray-700: #ababba;
|
||||
--art-gray-800: #c7c7d1;
|
||||
--art-gray-900: #e3e3e8;
|
||||
--fa-gray-100: #110f0f;
|
||||
--fa-gray-200: #17171c;
|
||||
--fa-gray-300: #393946;
|
||||
--fa-gray-400: #505062;
|
||||
--fa-gray-500: #73738c;
|
||||
--fa-gray-600: #8f8fa3;
|
||||
--fa-gray-700: #ababba;
|
||||
--fa-gray-800: #c7c7d1;
|
||||
--fa-gray-900: #e3e3e8;
|
||||
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--default-border: rgba(255, 255, 255, 0.1);
|
||||
--fa-card-border: rgb(255 255 255 / 8%);
|
||||
--default-border: rgb(255 255 255 / 10%);
|
||||
--default-border-dashed: #363843;
|
||||
|
||||
/* Background Colors */
|
||||
@@ -74,13 +73,13 @@
|
||||
--default-box-color: #161618;
|
||||
|
||||
/* Hover Color */
|
||||
--art-hover-color: #252530;
|
||||
--fa-hover-color: #252530;
|
||||
|
||||
/* Active Color */
|
||||
--art-active-color: #202226;
|
||||
--fa-active-color: #202226;
|
||||
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #2e2e38;
|
||||
--fa-el-active-color: #2e2e38;
|
||||
}
|
||||
|
||||
/* ==================== Tailwind Theme Configuration ==================== */
|
||||
@@ -92,33 +91,33 @@
|
||||
--color-theme: var(--theme-color);
|
||||
|
||||
/* Hover Color */
|
||||
--color-hover-color: var(--art-hover-color);
|
||||
--color-hover-color: var(--fa-hover-color);
|
||||
|
||||
/* Active Color */
|
||||
--color-active-color: var(--art-active-color);
|
||||
--color-active-color: var(--fa-active-color);
|
||||
|
||||
/* Active Color */
|
||||
--color-el-active-color: var(--art-active-color);
|
||||
--color-el-active-color: var(--fa-active-color);
|
||||
|
||||
/* ElementPlus Theme Colors */
|
||||
--color-primary: var(--art-primary);
|
||||
--color-secondary: var(--art-secondary);
|
||||
--color-error: var(--art-error);
|
||||
--color-info: var(--art-info);
|
||||
--color-success: var(--art-success);
|
||||
--color-warning: var(--art-warning);
|
||||
--color-danger: var(--art-danger);
|
||||
--color-primary: var(--fa-primary);
|
||||
--color-secondary: var(--fa-secondary);
|
||||
--color-error: var(--fa-error);
|
||||
--color-info: var(--fa-info);
|
||||
--color-success: var(--fa-success);
|
||||
--color-warning: var(--fa-warning);
|
||||
--color-danger: var(--fa-danger);
|
||||
|
||||
/* Gray Scale Colors (Auto-adapts to dark mode) */
|
||||
--color-g-100: var(--art-gray-100);
|
||||
--color-g-200: var(--art-gray-200);
|
||||
--color-g-300: var(--art-gray-300);
|
||||
--color-g-400: var(--art-gray-400);
|
||||
--color-g-500: var(--art-gray-500);
|
||||
--color-g-600: var(--art-gray-600);
|
||||
--color-g-700: var(--art-gray-700);
|
||||
--color-g-800: var(--art-gray-800);
|
||||
--color-g-900: var(--art-gray-900);
|
||||
--color-g-100: var(--fa-gray-100);
|
||||
--color-g-200: var(--fa-gray-200);
|
||||
--color-g-300: var(--fa-gray-300);
|
||||
--color-g-400: var(--fa-gray-400);
|
||||
--color-g-500: var(--fa-gray-500);
|
||||
--color-g-600: var(--fa-gray-600);
|
||||
--color-g-700: var(--fa-gray-700);
|
||||
--color-g-800: var(--fa-gray-800);
|
||||
--color-g-900: var(--fa-gray-900);
|
||||
}
|
||||
|
||||
/* ==================== Custom Border Radius Utilities ==================== */
|
||||
@@ -187,8 +186,8 @@
|
||||
|
||||
/* ==================== Custom Component Classes ==================== */
|
||||
@layer components {
|
||||
/* Art Card Header Component */
|
||||
.art-card-header {
|
||||
/* Fa Card Header Component */
|
||||
.fa-card-header {
|
||||
@apply flex justify-between pr-6 pb-1;
|
||||
|
||||
.title {
|
||||
|
||||
@@ -6,10 +6,6 @@ $bg-animation-duration: 0.5s;
|
||||
html {
|
||||
--bg-animation-color: $bg-animation-color-light;
|
||||
|
||||
&.dark {
|
||||
--bg-animation-color: $bg-animation-color-dark;
|
||||
}
|
||||
|
||||
// View transition styles
|
||||
&::view-transition-old(*) {
|
||||
animation: none;
|
||||
@@ -28,6 +24,8 @@ html {
|
||||
}
|
||||
|
||||
&.dark {
|
||||
--bg-animation-color: $bg-animation-color-dark;
|
||||
|
||||
&::view-transition-old(*) {
|
||||
animation: clip $bg-animation-duration ease-in reverse both;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
|
||||
overflow-x: auto;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
// 重置默认样式
|
||||
@use './core/reset.scss';
|
||||
@use "./core/reset";
|
||||
|
||||
// 应用全局样式
|
||||
@use './core/app.scss';
|
||||
@use "./core/app";
|
||||
|
||||
// Element Plus 样式优化
|
||||
@use './core/el-ui.scss';
|
||||
@use "./core/el-ui";
|
||||
|
||||
// Element Plus 暗黑主题
|
||||
@use './core/el-dark.scss';
|
||||
@use "./core/el-dark";
|
||||
|
||||
// 暗黑主题样式优化
|
||||
@use './core/dark.scss';
|
||||
@use "./core/dark";
|
||||
|
||||
// 路由切换动画
|
||||
@use './core/router-transition';
|
||||
@use "./core/router-transition";
|
||||
|
||||
// 主题切换过渡优化
|
||||
@use './core/theme-change.scss';
|
||||
@use "./core/theme-change";
|
||||
|
||||
// 主题切换圆形扩散动画
|
||||
@use './core/theme-animation.scss';
|
||||
@use "./core/theme-animation";
|
||||
|
||||
Vendored
+2
-23
@@ -1,26 +1,4 @@
|
||||
declare global {
|
||||
/**
|
||||
* 页签对象
|
||||
*/
|
||||
interface TagView {
|
||||
/** 页签名称 */
|
||||
name: string;
|
||||
/** 页签标题 */
|
||||
title: string;
|
||||
/** 页签路由路径 */
|
||||
path: string;
|
||||
/** 页签路由完整路径 */
|
||||
fullPath: string;
|
||||
/** 页签图标 */
|
||||
icon?: string;
|
||||
/** 是否固定页签 */
|
||||
affix?: boolean;
|
||||
/** 是否开启缓存 */
|
||||
keepAlive?: boolean;
|
||||
/** 路由查询参数 */
|
||||
query?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统设置
|
||||
*/
|
||||
@@ -133,7 +111,8 @@ declare global {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应对象
|
||||
* 分页响应对象(列表接口 `data` 统一为该结构)
|
||||
* 前端 `useTable` 仅通过 `@utils/table` 的 `defaultResponseAdapter` 解析该形状(及 ApiResponse 包装)
|
||||
*/
|
||||
interface PageResult<T = any> {
|
||||
items: T[];
|
||||
|
||||
@@ -67,6 +67,12 @@ declare module "vue-router" {
|
||||
*/
|
||||
keepAlive?: boolean;
|
||||
|
||||
/**
|
||||
* 为 true 时 KeepAlive 子组件 `:key` 使用 `fullPath`(query/hash 变化会整页重挂载)。
|
||||
* 默认用 `name + params`,减轻 query 微调导致的重复 onMounted / useTable immediate。
|
||||
*/
|
||||
remountOnFullPath?: boolean;
|
||||
|
||||
/**
|
||||
* 静态壳层路由(路由已在 router 注册,菜单项仅用于跳转,无 component 字段)
|
||||
*/
|
||||
@@ -125,6 +131,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
|
||||
parentPath?: string;
|
||||
/** 静态壳层菜单(侧边栏可点,组件由静态路由提供) */
|
||||
shellRoute?: boolean;
|
||||
/** @see RouteMeta(vue-router 模块扩展) */
|
||||
remountOnFullPath?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -549,7 +549,7 @@ export interface QuickLink {
|
||||
*/
|
||||
class QuickStartManager {
|
||||
/** 本地存储键名 */
|
||||
private storageKey = "quick-stfa-links";
|
||||
private storageKey = "quick-start-links";
|
||||
|
||||
/** 监听器列表 */
|
||||
private listeners: Array<(links: QuickLink[]) => void> = [];
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Axios 实例与请求 / 响应拦截器
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type AxiosError,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Auth, redirectToLogin } from "@utils/auth";
|
||||
import { ResultEnum } from "@/enums/api/result.enum";
|
||||
import { $t } from "@/locales";
|
||||
import { defaultConfig, NO_AUTH_FLAG } from "./config";
|
||||
import { ApiStatus, HttpError } from "./error";
|
||||
|
||||
const handleRequest = (config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = Auth.getAccessToken();
|
||||
const auth = config.headers.Authorization;
|
||||
|
||||
if (auth === NO_AUTH_FLAG) {
|
||||
delete config.headers.Authorization;
|
||||
return config;
|
||||
}
|
||||
|
||||
if (!auth && accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
const handleResponse = (response: AxiosResponse<ApiResponse>) => {
|
||||
if (response.config.responseType === "blob") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
if (data.code !== ResultEnum.SUCCESS) {
|
||||
ElMessage.error(data.msg);
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
if (
|
||||
response.config.method?.toUpperCase() !== "GET" &&
|
||||
!response.config.url?.includes("login") &&
|
||||
!response.config.url?.includes("logout")
|
||||
) {
|
||||
ElMessage.success(data.msg);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const handleResponseError = async (error: AxiosError<ApiResponse>) => {
|
||||
if (!error.response) {
|
||||
let errorMessage = $t("httpMsg.networkError");
|
||||
if (error.message?.includes("ECONNREFUSED")) {
|
||||
errorMessage = "服务器连接失败,请检查后端服务是否正常运行";
|
||||
} else if (error.message?.includes("timeout")) {
|
||||
errorMessage = "请求超时,请稍后重试";
|
||||
} else if (error.message?.includes("Network Error")) {
|
||||
errorMessage = "网络连接错误,请检查您的网络设置";
|
||||
}
|
||||
console.error("网络请求失败:", error);
|
||||
ElMessage.error(errorMessage);
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
|
||||
const data = error.response?.data;
|
||||
|
||||
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
|
||||
try {
|
||||
const text = await new Response(error.response.data).text();
|
||||
const jsonData: ApiResponse = JSON.parse(text);
|
||||
|
||||
if (jsonData.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(jsonData.msg || "请求错误");
|
||||
return Promise.reject(new Error(jsonData.msg || "请求错误"));
|
||||
}
|
||||
if (jsonData.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(jsonData.msg || "服务异常");
|
||||
return Promise.reject(new Error(jsonData.msg || "服务异常"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("请求异常:", e);
|
||||
ElMessage.error("数据解析失败");
|
||||
return Promise.reject(new Error("数据解析失败"));
|
||||
}
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
const hasApiCode =
|
||||
data !== undefined &&
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"code" in data &&
|
||||
typeof (data as ApiResponse).code === "number";
|
||||
|
||||
if (status === 401 && !hasApiCode) {
|
||||
await redirectToLogin("登录已失效,请重新登录");
|
||||
return Promise.reject(new HttpError("Unauthorized", ApiStatus.unauthorized));
|
||||
}
|
||||
|
||||
if (data?.code === ResultEnum.TOKEN_EXPIRED) {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
const msg = data.msg || "登录已过期,请重新登录";
|
||||
return Promise.reject(new HttpError(msg, ApiStatus.unauthorized));
|
||||
}
|
||||
if (data?.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(data.msg || "请求错误");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
}
|
||||
if (data?.code === ResultEnum.UNAUTHORIZED) {
|
||||
ElMessage.error(data.msg || "暂无权限");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
}
|
||||
if (data?.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(data.msg || "服务异常");
|
||||
return Promise.reject(new Error(data.msg || "服务异常"));
|
||||
}
|
||||
|
||||
ElMessage.error("请求处理失败,请稍后重试");
|
||||
return Promise.reject(new Error("请求处理失败"));
|
||||
};
|
||||
|
||||
const httpRequest: AxiosInstance = axios.create(defaultConfig);
|
||||
|
||||
httpRequest.interceptors.request.use(handleRequest, handleRequestError);
|
||||
httpRequest.interceptors.response.use(handleResponse, handleResponseError);
|
||||
|
||||
export default httpRequest;
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Axios 默认配置与请求相关类型
|
||||
*/
|
||||
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import * as qs from "qs";
|
||||
|
||||
export const defaultConfig: AxiosRequestConfig = {
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: Number(import.meta.env.VITE_TIMEOUT) || 15000,
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
|
||||
};
|
||||
|
||||
/** 跳过鉴权:与单接口 `headers.Authorization` 约定一致 */
|
||||
export const NO_AUTH_FLAG = "no-auth";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
|
||||
|
||||
export interface ExtendedRequestConfig extends AxiosRequestConfig {
|
||||
skipAuth?: boolean;
|
||||
showSuccessMessage?: boolean;
|
||||
showErrorMessage?: boolean;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* HTTP 语义状态码、HttpError 与错误辅助函数
|
||||
*/
|
||||
|
||||
import type { AxiosError } from "axios";
|
||||
import { $t } from "@/locales";
|
||||
|
||||
export enum ApiStatus {
|
||||
success = 200,
|
||||
error = 400,
|
||||
unauthorized = 401,
|
||||
forbidden = 403,
|
||||
notFound = 404,
|
||||
methodNotAllowed = 405,
|
||||
requestTimeout = 408,
|
||||
internalServerError = 500,
|
||||
notImplemented = 501,
|
||||
badGateway = 502,
|
||||
serviceUnavailable = 503,
|
||||
gatewayTimeout = 504,
|
||||
httpVersionNotSupported = 505,
|
||||
}
|
||||
|
||||
export interface ErrorLogData {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
timestamp: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public readonly code: number;
|
||||
public readonly data?: unknown;
|
||||
public readonly timestamp: string;
|
||||
public readonly url?: string;
|
||||
public readonly method?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: number,
|
||||
options?: {
|
||||
data?: unknown;
|
||||
url?: string;
|
||||
method?: string;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.code = code;
|
||||
this.data = options?.data;
|
||||
this.timestamp = new Date().toISOString();
|
||||
this.url = options?.url;
|
||||
this.method = options?.method;
|
||||
}
|
||||
|
||||
public toLogData(): ErrorLogData {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
timestamp: this.timestamp,
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorMessage = (status: number): string => {
|
||||
const errorMap: Record<number, string> = {
|
||||
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
|
||||
[ApiStatus.forbidden]: "httpMsg.forbidden",
|
||||
[ApiStatus.notFound]: "httpMsg.notFound",
|
||||
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
|
||||
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
|
||||
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
|
||||
[ApiStatus.badGateway]: "httpMsg.badGateway",
|
||||
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
|
||||
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
|
||||
};
|
||||
|
||||
return $t(errorMap[status] || "httpMsg.internalServerError");
|
||||
};
|
||||
|
||||
export function handleError(error: AxiosError<ApiResponse>): never {
|
||||
if (error.code === "ERR_CANCELED") {
|
||||
console.warn("Request cancelled:", error.message);
|
||||
throw new HttpError($t("httpMsg.requestCancelled"), ApiStatus.error);
|
||||
}
|
||||
|
||||
const statusCode = error.response?.status;
|
||||
const errorMessage = error.response?.data?.msg || error.message;
|
||||
const requestConfig = error.config;
|
||||
|
||||
if (!error.response) {
|
||||
throw new HttpError($t("httpMsg.networkError"), ApiStatus.error, {
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase(),
|
||||
});
|
||||
}
|
||||
|
||||
const message = statusCode
|
||||
? getErrorMessage(statusCode)
|
||||
: errorMessage || $t("httpMsg.requestFailed");
|
||||
throw new HttpError(message, statusCode || ApiStatus.error, {
|
||||
data: error.response.data,
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase(),
|
||||
});
|
||||
}
|
||||
|
||||
export function showError(error: HttpError, showMessage: boolean = true): void {
|
||||
import("element-plus").then(({ ElMessage: EM }) => {
|
||||
if (showMessage) {
|
||||
EM.error(error.message);
|
||||
}
|
||||
});
|
||||
console.error("[HTTP Error]", error.toLogData());
|
||||
}
|
||||
|
||||
export function showSuccess(message: string, showMessage: boolean = true): void {
|
||||
import("element-plus").then(({ ElMessage: EM }) => {
|
||||
if (showMessage) {
|
||||
EM.success(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const isHttpError = (error: unknown): error is HttpError => {
|
||||
return error instanceof HttpError;
|
||||
};
|
||||
@@ -1,10 +1,285 @@
|
||||
/**
|
||||
* HTTP 客户端(配置 / 错误类型 / Axios 实例)
|
||||
*
|
||||
* @module utils/http
|
||||
* HTTP:Axios 实例、约定常量、错误类型与拦截器(单文件聚合)
|
||||
*/
|
||||
|
||||
export * from "./config";
|
||||
export * from "./error";
|
||||
export { default } from "./client";
|
||||
import axios, {
|
||||
type AxiosError,
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import qs from "qs";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ResultEnum } from "@/enums/api/result.enum";
|
||||
import { Auth } from "@/utils/auth";
|
||||
import { redirectToLogin } from "@/utils/auth";
|
||||
import { $t } from "@/locales";
|
||||
|
||||
// --- 配置常量 -----------------------------------------------------------------
|
||||
|
||||
/** 跳过鉴权:与单接口 `headers.Authorization` 约定一致 */
|
||||
export const NO_AUTH_FLAG = "no-auth";
|
||||
|
||||
export interface ExtendedRequestConfig extends AxiosRequestConfig {
|
||||
skipAuth?: boolean;
|
||||
showSuccessMessage?: boolean;
|
||||
showErrorMessage?: boolean;
|
||||
}
|
||||
|
||||
// --- 语义状态码与 HttpError ----------------------------------------------------
|
||||
|
||||
export enum ApiStatus {
|
||||
success = 200,
|
||||
error = 400,
|
||||
unauthorized = 401,
|
||||
forbidden = 403,
|
||||
notFound = 404,
|
||||
methodNotAllowed = 405,
|
||||
requestTimeout = 408,
|
||||
internalServerError = 500,
|
||||
notImplemented = 501,
|
||||
badGateway = 502,
|
||||
serviceUnavailable = 503,
|
||||
gatewayTimeout = 504,
|
||||
httpVersionNotSupported = 505,
|
||||
}
|
||||
|
||||
export interface ErrorLogData {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
timestamp: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public readonly code: number;
|
||||
public readonly data?: unknown;
|
||||
public readonly timestamp: string;
|
||||
public readonly url?: string;
|
||||
public readonly method?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: number,
|
||||
options?: {
|
||||
data?: unknown;
|
||||
url?: string;
|
||||
method?: string;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.code = code;
|
||||
this.data = options?.data;
|
||||
this.timestamp = new Date().toISOString();
|
||||
this.url = options?.url;
|
||||
this.method = options?.method;
|
||||
}
|
||||
|
||||
public toLogData(): ErrorLogData {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
timestamp: this.timestamp,
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorMessage = (status: number): string => {
|
||||
const errorMap: Record<number, string> = {
|
||||
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
|
||||
[ApiStatus.forbidden]: "httpMsg.forbidden",
|
||||
[ApiStatus.notFound]: "httpMsg.notFound",
|
||||
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
|
||||
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
|
||||
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
|
||||
[ApiStatus.badGateway]: "httpMsg.badGateway",
|
||||
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
|
||||
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
|
||||
};
|
||||
|
||||
return $t(errorMap[status] || "httpMsg.internalServerError");
|
||||
};
|
||||
|
||||
export function handleError(error: AxiosError<ApiResponse>): never {
|
||||
if (error.code === "ERR_CANCELED") {
|
||||
console.warn("Request cancelled:", error.message);
|
||||
throw new HttpError($t("httpMsg.requestCancelled"), ApiStatus.error);
|
||||
}
|
||||
|
||||
const statusCode = error.response?.status;
|
||||
const errorMessage = error.response?.data?.msg || error.message;
|
||||
const requestConfig = error.config;
|
||||
|
||||
if (!error.response) {
|
||||
throw new HttpError($t("httpMsg.networkError"), ApiStatus.error, {
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase(),
|
||||
});
|
||||
}
|
||||
|
||||
const message = statusCode
|
||||
? getErrorMessage(statusCode)
|
||||
: errorMessage || $t("httpMsg.requestFailed");
|
||||
throw new HttpError(message, statusCode || ApiStatus.error, {
|
||||
data: error.response.data,
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase(),
|
||||
});
|
||||
}
|
||||
|
||||
export function showError(error: HttpError, showMessage: boolean = true): void {
|
||||
if (showMessage) {
|
||||
ElMessage.error(error.message);
|
||||
}
|
||||
console.error("[HTTP Error]", error.toLogData());
|
||||
}
|
||||
|
||||
export function showSuccess(message: string, showMessage: boolean = true): void {
|
||||
if (showMessage) {
|
||||
ElMessage.success(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const isHttpError = (error: unknown): error is HttpError => {
|
||||
return error instanceof HttpError;
|
||||
};
|
||||
|
||||
// --- Axios 实例 ---------------------------------------------------------------
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: Number(import.meta.env.VITE_TIMEOUT) || 15000,
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
|
||||
});
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = Auth.getAccessToken();
|
||||
const auth = config.headers.Authorization;
|
||||
|
||||
if (auth === NO_AUTH_FLAG) {
|
||||
delete config.headers.Authorization;
|
||||
return config;
|
||||
}
|
||||
|
||||
if (!auth && accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
if (response.config.responseType === "blob") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (data.code !== ResultEnum.SUCCESS) {
|
||||
ElMessage.error(data.msg);
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
if (
|
||||
response.config.method?.toUpperCase() !== "GET" &&
|
||||
!response.config.url?.includes("login") &&
|
||||
!response.config.url?.includes("logout")
|
||||
) {
|
||||
ElMessage.success(data.msg);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError<ApiResponse>) => {
|
||||
if (!error.response) {
|
||||
let errorMessage = "网络连接异常";
|
||||
|
||||
if (error.message?.includes("ECONNREFUSED")) {
|
||||
errorMessage = "服务器连接失败,请检查后端服务是否正常运行";
|
||||
} else if (error.message?.includes("timeout")) {
|
||||
errorMessage = "请求超时,请稍后重试";
|
||||
} else if (error.message?.includes("Network Error")) {
|
||||
errorMessage = "网络连接错误,请检查您的网络设置";
|
||||
}
|
||||
|
||||
console.error("网络请求失败:", error);
|
||||
ElMessage.error(errorMessage);
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
|
||||
const data = error.response?.data;
|
||||
|
||||
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
|
||||
try {
|
||||
const text = await new Response(error.response.data).text();
|
||||
const jsonData: ApiResponse = JSON.parse(text);
|
||||
|
||||
if (jsonData.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(jsonData.msg || "请求错误");
|
||||
return Promise.reject(new Error(jsonData.msg || "请求错误"));
|
||||
} else if (jsonData.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(jsonData.msg || "服务异常");
|
||||
return Promise.reject(new Error(jsonData.msg || "服务异常"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("请求异常:", e);
|
||||
ElMessage.error("数据解析失败");
|
||||
return Promise.reject(new Error("数据解析失败"));
|
||||
}
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
const hasApiCode =
|
||||
data !== undefined &&
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"code" in data &&
|
||||
typeof (data as ApiResponse).code === "number";
|
||||
|
||||
if (status === 401 && !hasApiCode) {
|
||||
await redirectToLogin("登录已失效,请重新登录");
|
||||
return Promise.reject(new HttpError("Unauthorized", ApiStatus.unauthorized));
|
||||
}
|
||||
|
||||
if (data?.code === ResultEnum.TOKEN_EXPIRED) {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
const msg = data.msg || "登录已过期,请重新登录";
|
||||
return Promise.reject(new HttpError(msg, ApiStatus.unauthorized));
|
||||
} else if (data?.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(data.msg || "请求错误");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
} else if (data?.code === ResultEnum.UNAUTHORIZED) {
|
||||
ElMessage.error(data.msg || "暂无权限");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
} else if (data?.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(data.msg || "服务异常");
|
||||
return Promise.reject(new Error(data.msg || "服务异常"));
|
||||
} else {
|
||||
ElMessage.error("请求处理失败,请稍后重试");
|
||||
return Promise.reject(new Error("请求处理失败"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
|
||||
export type { AxiosInstance } from "axios";
|
||||
|
||||
@@ -13,7 +13,7 @@ export function isElementPlusStoredIcon(icon?: string | null): boolean {
|
||||
return !!s && s.startsWith("el-icon");
|
||||
}
|
||||
|
||||
/** 与历史 `MenuSearch` 中 `resolveEpMenuIcon` 一致:`pie-chart` → `PieChart` */
|
||||
/** `el-icon-*` 主体转 PascalCase,与侧栏 / 搜索里 Element Plus 图标解析一致:`pie-chart` → `PieChart` */
|
||||
function kebabSnakeBodyToPascalKey(body: string): string {
|
||||
return body
|
||||
.split(/[-_]/)
|
||||
@@ -25,7 +25,7 @@ function kebabSnakeBodyToPascalKey(body: string): string {
|
||||
/**
|
||||
* 解析为 Element Plus 图标组件;否则 null(再走 Iconify / Remix 映射)。
|
||||
* 对齐旧版 `layouts/old/components/Menu/components/MenuItemContent.vue`(el-icon / 自定义文件名)
|
||||
* 及 `MenuSearch` 里对 `el-icon-*` 主体的 Pascal 推导。
|
||||
* 及对 `el-icon-*` 主体的 Pascal 推导。
|
||||
*/
|
||||
export function resolveElementPlusIconComponent(icon?: string | null): Component | null {
|
||||
const ic = icon?.trim();
|
||||
@@ -171,7 +171,7 @@ const REMIX_BY_NAME: Record<string, string> = {
|
||||
upload_folder: "ri:folder-upload-line",
|
||||
"upload-folder": "ri:folder-upload-line",
|
||||
user: "ri:user-line",
|
||||
visitor: "ri:user-hefa-line",
|
||||
visitor: "ri:user-heart-line",
|
||||
vite: "ri:rocket-line",
|
||||
vue: "ri:vuejs-line",
|
||||
wechat: "ri:wechat-fill",
|
||||
@@ -179,7 +179,7 @@ const REMIX_BY_NAME: Record<string, string> = {
|
||||
people: "ri:team-line",
|
||||
|
||||
"menu-about": "ri:information-line",
|
||||
"menu-analyse": "ri:line-chfa-line",
|
||||
"menu-analyse": "ri:line-chart-line",
|
||||
"menu-crud": "ri:database-2-line",
|
||||
"menu-detail": "ri:file-list-line",
|
||||
"menu-document": "ri:file-text-line",
|
||||
@@ -191,7 +191,7 @@ const REMIX_BY_NAME: Record<string, string> = {
|
||||
"menu-home": "ri:home-4-line",
|
||||
"menu-layout": "ri:layout-line",
|
||||
"menu-multi": "ri:layout-grid-line",
|
||||
"menu-result": "ri:bar-chfa-box-line",
|
||||
"menu-result": "ri:bar-chart-box-line",
|
||||
"menu-system": "ri:settings-3-line",
|
||||
"menu-table": "ri:table-line",
|
||||
"menu-test": "ri:test-tube-line",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** Navigation helpers (flattened). */
|
||||
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from "vue-router";
|
||||
import { router } from "@/router";
|
||||
import type { AppRouteRecord, AppRouteRecord as AppRouteRecordFromTypes } from "@/types";
|
||||
@@ -10,8 +8,12 @@ import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { IframeRouteManager } from "@/router";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
|
||||
/**
|
||||
* 导航辅助:菜单标题、外链与路由跳转、工作标签(`setWorktab`)、文档标题(`setPageTitle`)。
|
||||
*/
|
||||
export type AppRouteRecordRaw = RouteRecordRaw & { hidden?: boolean };
|
||||
|
||||
/** 浏览器标题:meta.title 经 i18n 键或原文 + 站点名 */
|
||||
export const setPageTitle = (to: RouteLocationNormalized): void => {
|
||||
const { title } = to.meta;
|
||||
if (!title) return;
|
||||
@@ -83,11 +85,18 @@ export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = fals
|
||||
return router.push(firstChild.path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 与路由同步工作栏:受 `meta.isHideTab`、`settings.showWorkTab`、首页路径影响。
|
||||
* - 开多标签或停留在首页:写入 `opened`(openTab)。
|
||||
* - 关多标签且非首页:仅 `syncCurrentFromRoute`,不把每一页压入 `opened`,但保证 `current` 与本次导航一致。
|
||||
*/
|
||||
export const setWorktab = (to: RouteLocationNormalized): void => {
|
||||
const worktabStore = useWorktabStore();
|
||||
const { meta, path, name, params, query } = to;
|
||||
if (meta.isHideTab) return;
|
||||
|
||||
const routeNameStr = name != null ? String(name) : "";
|
||||
|
||||
if (isIframe(path)) {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path);
|
||||
if (!iframeRoute?.meta) return;
|
||||
@@ -96,8 +105,8 @@ export const setWorktab = (to: RouteLocationNormalized): void => {
|
||||
title: iframeRoute.meta.title,
|
||||
icon: meta.icon as string,
|
||||
path,
|
||||
name: name as string,
|
||||
keepAlive: meta.keepAlive as boolean,
|
||||
name: routeNameStr,
|
||||
keepAlive: meta.keepAlive !== false,
|
||||
params,
|
||||
query,
|
||||
});
|
||||
@@ -105,16 +114,28 @@ export const setWorktab = (to: RouteLocationNormalized): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabPayload = {
|
||||
title: (meta.title as string) || "",
|
||||
icon: meta.icon as string,
|
||||
path,
|
||||
name: routeNameStr,
|
||||
keepAlive: meta.keepAlive !== false,
|
||||
params,
|
||||
query,
|
||||
fixedTab: meta.fixedTab as boolean,
|
||||
};
|
||||
|
||||
if (useSettingsStore().showWorkTab || path === useCommon().homePath.value) {
|
||||
worktabStore.openTab({
|
||||
title: meta.title as string,
|
||||
icon: meta.icon as string,
|
||||
path,
|
||||
name: name as string,
|
||||
keepAlive: meta.keepAlive as boolean,
|
||||
params,
|
||||
query,
|
||||
fixedTab: meta.fixedTab as boolean,
|
||||
worktabStore.openTab(tabPayload);
|
||||
} else {
|
||||
worktabStore.syncCurrentFromRoute({
|
||||
path: tabPayload.path,
|
||||
name: tabPayload.name,
|
||||
title: tabPayload.title,
|
||||
icon: tabPayload.icon,
|
||||
keepAlive: tabPayload.keepAlive,
|
||||
params: tabPayload.params,
|
||||
query: tabPayload.query,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,31 +1,90 @@
|
||||
/** Table helpers (flattened). */
|
||||
/**
|
||||
* 表格列表工具:`useTable`、缓存与分页响应适配。
|
||||
*
|
||||
* - 接口约定:分页列表返回体须符合全局 `PageResult`(或包在标准 `data` 内),见 `defaultResponseAdapter`。
|
||||
* - 缓存:`TableCache` 按参数 hash + 标签失效;策略枚举供 `useTable.clearCache` 使用。
|
||||
*/
|
||||
|
||||
import { h } from "vue";
|
||||
import type { VNode } from "vue";
|
||||
import { ElTooltip } from "element-plus";
|
||||
import { hash } from "ohash";
|
||||
import FaButtonMore from "@/components/forms/fa-button-more/index.vue";
|
||||
import ArtButtonMore from "@/components/forms/fa-button-more/index.vue";
|
||||
import type { ButtonMoreItem } from "@/components/forms/fa-button-more/index.vue";
|
||||
import FaButtonTable from "@/components/forms/fa-button-table/index.vue";
|
||||
import ArtButtonTable from "@/components/forms/fa-button-table/index.vue";
|
||||
|
||||
// -----------------------------
|
||||
// Config
|
||||
// -----------------------------
|
||||
// --- 全局分页字段名(与 PageQuery 对齐) ---
|
||||
|
||||
/** 仅保留与全局 `PageQuery` / `PageResult` 对齐的分页参数字段名(供 useTable 合并请求参数) */
|
||||
export const tableConfig = {
|
||||
recordFields: ["list", "data", "records", "items", "result", "rows"],
|
||||
totalFields: ["total", "count"],
|
||||
currentFields: ["current", "page", "pageNum", "page_no"],
|
||||
sizeFields: ["size", "pageSize", "limit", "page_size"],
|
||||
paginationKey: {
|
||||
current: "current",
|
||||
size: "size",
|
||||
current: "page_no",
|
||||
size: "page_size",
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
// -----------------------------
|
||||
// Cache
|
||||
// -----------------------------
|
||||
/** 与 `global.d.ts` 中 `PageResult` 字段一致;分页列表接口必须返回该结构(或包在 ApiResponse.data 内) */
|
||||
function isPageResultPayload(o: unknown): o is PageResult<unknown> {
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) return false;
|
||||
const r = o as Record<string, unknown>;
|
||||
return (
|
||||
Array.isArray(r.items) &&
|
||||
typeof r.total === "number" &&
|
||||
typeof r.page_no === "number" &&
|
||||
typeof r.page_size === "number" &&
|
||||
typeof r.has_next === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
/** 将常见分页形状(含 MyBatis-Plus / 旧字段名)规范为全局 `PageResult` */
|
||||
function normalizePageResultLike(raw: unknown): PageResult<unknown> | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
const items =
|
||||
(Array.isArray(o.items) && o.items) ||
|
||||
(Array.isArray(o.records) && o.records) ||
|
||||
(Array.isArray(o.list) && o.list) ||
|
||||
(Array.isArray(o.rows) && o.rows) ||
|
||||
null;
|
||||
if (!items) return null;
|
||||
|
||||
const totalRaw = o.total;
|
||||
const total =
|
||||
typeof totalRaw === "number" ? totalRaw : typeof totalRaw === "string" ? Number(totalRaw) : NaN;
|
||||
if (!Number.isFinite(total)) return null;
|
||||
|
||||
const pageNoRaw = o.page_no ?? o.current ?? o.page;
|
||||
const page_no =
|
||||
typeof pageNoRaw === "number"
|
||||
? pageNoRaw
|
||||
: typeof pageNoRaw === "string"
|
||||
? Number(pageNoRaw)
|
||||
: NaN;
|
||||
if (!Number.isFinite(page_no)) return null;
|
||||
|
||||
const pageSizeRaw = o.page_size ?? o.size ?? o.limit ?? o.pageSize;
|
||||
const page_size =
|
||||
typeof pageSizeRaw === "number"
|
||||
? pageSizeRaw
|
||||
: typeof pageSizeRaw === "string"
|
||||
? Number(pageSizeRaw)
|
||||
: NaN;
|
||||
if (!Number.isFinite(page_size)) return null;
|
||||
|
||||
let has_next: boolean;
|
||||
if (typeof o.has_next === "boolean") {
|
||||
has_next = o.has_next;
|
||||
} else if (typeof o.hasNext === "boolean") {
|
||||
has_next = o.hasNext;
|
||||
} else {
|
||||
has_next = page_no * page_size < total;
|
||||
}
|
||||
|
||||
return { items, total, page_no, page_size, has_next };
|
||||
}
|
||||
|
||||
// --- 缓存 ---
|
||||
|
||||
export enum CacheInvalidationStrategy {
|
||||
CLEAR_ALL = "clear_all",
|
||||
@@ -34,12 +93,13 @@ export enum CacheInvalidationStrategy {
|
||||
KEEP_ALL = "keep_all",
|
||||
}
|
||||
|
||||
/** useTable 内部使用的规范化分页响应(由 PageResult 映射而来) */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total?: number;
|
||||
records: T[];
|
||||
total: number;
|
||||
current?: number;
|
||||
size?: number;
|
||||
has_next?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -78,7 +138,7 @@ export class TableCache<T> {
|
||||
|
||||
const searchKeys = Object.keys(params).filter(
|
||||
(key) =>
|
||||
!["current", "size", "total"].includes(key) &&
|
||||
!["current", "size", "total", "page_no", "page_size"].includes(key) &&
|
||||
params[key] !== undefined &&
|
||||
params[key] !== "" &&
|
||||
params[key] !== null
|
||||
@@ -91,7 +151,9 @@ export class TableCache<T> {
|
||||
tags.add("search:default");
|
||||
}
|
||||
|
||||
tags.add(`pagination:${params.size || 10}`);
|
||||
const ps = params as Record<string, unknown>;
|
||||
const pageSizeVal = ps.page_size ?? ps.size;
|
||||
tags.add(`pagination:${typeof pageSizeVal === "number" ? pageSizeVal : 10}`);
|
||||
tags.add("pagination");
|
||||
return tags;
|
||||
}
|
||||
@@ -215,9 +277,7 @@ export class TableCache<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Utils
|
||||
// -----------------------------
|
||||
// --- 响应解析与防抖 ---
|
||||
|
||||
function unwrapAxiosResponseBody(response: unknown): unknown {
|
||||
if (response === null || typeof response !== "object") return response;
|
||||
@@ -236,6 +296,41 @@ function unwrapAxiosResponseBody(response: unknown): unknown {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 axios 原始响应或已解包 body 中取出唯一合法的 `PageResult`。
|
||||
* 支持:① 严格 `PageResult`;② `ApiResponse.data` 嵌套;③ `records/current/size` 等常见变体。
|
||||
*/
|
||||
function extractPageResultPayload(response: unknown): PageResult<unknown> | null {
|
||||
const candidates: unknown[] = [];
|
||||
|
||||
const push = (x: unknown) => {
|
||||
if (x !== undefined && x !== null) candidates.push(x);
|
||||
};
|
||||
|
||||
push(response);
|
||||
push(unwrapAxiosResponseBody(response));
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const x = candidates[i];
|
||||
if (isPageResultPayload(x)) {
|
||||
return x;
|
||||
}
|
||||
const normalized = normalizePageResultLike(x);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
if (x && typeof x === "object" && !Array.isArray(x) && "data" in x) {
|
||||
const inner = (x as Record<string, unknown>).data;
|
||||
push(inner);
|
||||
if (inner && typeof inner === "object" && !Array.isArray(inner) && "data" in inner) {
|
||||
push((inner as Record<string, unknown>).data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BaseRequestParams extends PageQuery {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -246,104 +341,28 @@ export interface TableError {
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
function extractRecords<T>(obj: Record<string, unknown>, fields: string[]): T[] {
|
||||
for (const field of fields) {
|
||||
if (field in obj && Array.isArray(obj[field])) return obj[field] as T[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractTotal(obj: Record<string, unknown>, records: unknown[], fields: string[]): number {
|
||||
for (const field of fields) {
|
||||
if (field in obj && typeof obj[field] === "number") return obj[field] as number;
|
||||
}
|
||||
return records.length;
|
||||
}
|
||||
|
||||
function extractPagination(
|
||||
obj: Record<string, unknown>,
|
||||
data?: Record<string, unknown>
|
||||
): Pick<ApiResponse<unknown>, "current" | "size"> | undefined {
|
||||
const result: Partial<Pick<ApiResponse<unknown>, "current" | "size">> = {};
|
||||
const sources = [obj, data ?? {}];
|
||||
|
||||
for (const src of sources) {
|
||||
for (const field of tableConfig.currentFields) {
|
||||
if (field in src && typeof src[field] === "number") {
|
||||
result.current = src[field] as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result.current !== undefined) break;
|
||||
}
|
||||
|
||||
for (const src of sources) {
|
||||
for (const field of tableConfig.sizeFields) {
|
||||
if (field in src && typeof src[field] === "number") {
|
||||
result.size = src[field] as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result.size !== undefined) break;
|
||||
}
|
||||
|
||||
if (result.current === undefined && result.size === undefined) return undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> => {
|
||||
const recordFields = tableConfig.recordFields;
|
||||
|
||||
response = unwrapAxiosResponseBody(response);
|
||||
|
||||
if (!response) return { records: [], total: 0 };
|
||||
if (Array.isArray(response)) return { records: response, total: response.length };
|
||||
|
||||
if (typeof response !== "object") {
|
||||
console.warn(
|
||||
"[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含" +
|
||||
recordFields.join("/") +
|
||||
"字段的对象、嵌套data对象。当前格式:",
|
||||
const pr = extractPageResultPayload(response);
|
||||
if (!pr) {
|
||||
console.error(
|
||||
"[tableUtils] 分页列表响应必须符合全局 PageResult<T>(items、total、page_no、page_size、has_next);或为 ApiResponse 且 data 为该结构。收到:",
|
||||
response
|
||||
);
|
||||
return { records: [], total: 0 };
|
||||
return { records: [], total: 0, current: 1, size: 10 };
|
||||
}
|
||||
|
||||
const res = response as Record<string, unknown>;
|
||||
let records: T[] = [];
|
||||
let total = 0;
|
||||
let pagination: Pick<ApiResponse<unknown>, "current" | "size"> | undefined;
|
||||
|
||||
records = extractRecords(res, recordFields);
|
||||
total = extractTotal(res, records, tableConfig.totalFields);
|
||||
pagination = extractPagination(res);
|
||||
|
||||
if (records.length === 0 && "data" in res && typeof res.data === "object") {
|
||||
const data = res.data as Record<string, unknown>;
|
||||
records = extractRecords(data, ["list", "records", "items"]);
|
||||
total = extractTotal(data, records, tableConfig.totalFields);
|
||||
pagination = extractPagination(res, data);
|
||||
|
||||
if (Array.isArray(res.data)) {
|
||||
records = res.data as T[];
|
||||
total = records.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordFields.some((field) => field in res) && records.length === 0) {
|
||||
console.warn("[tableUtils] 无法识别的响应格式");
|
||||
console.warn("支持的字段包括: " + recordFields.join("、"), response);
|
||||
console.warn("扩展字段请到 utils/table/tableConfig 文件配置");
|
||||
}
|
||||
|
||||
const result: ApiResponse<T> = { records, total };
|
||||
if (pagination) Object.assign(result, pagination);
|
||||
return result;
|
||||
return {
|
||||
records: pr.items as T[],
|
||||
total: pr.total,
|
||||
current: pr.page_no,
|
||||
size: pr.page_size,
|
||||
has_next: pr.has_next,
|
||||
};
|
||||
};
|
||||
|
||||
export const extractTableData = <T>(response: ApiResponse<T>): T[] => {
|
||||
const data = response.records || response.data || [];
|
||||
return Array.isArray(data) ? data : [];
|
||||
const rows = response.records;
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
};
|
||||
|
||||
export const updatePaginationFromResponse = <T>(
|
||||
@@ -438,9 +457,7 @@ export const createErrorHandler = (
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// Operation cell renderer
|
||||
// -----------------------------
|
||||
// --- 操作列渲染(表格单元格内按钮 + 溢出「更多」) ---
|
||||
|
||||
export const DEFAULT_MAX_INLINE_TABLE_OPERATIONS = 3;
|
||||
|
||||
@@ -512,7 +529,7 @@ export function renderTableOperationCell(
|
||||
"span",
|
||||
{ class: a.disabled ? "inline-flex opacity-40 pointer-events-none" : "inline-flex" },
|
||||
[
|
||||
h(FaButtonTable, {
|
||||
h(ArtButtonTable, {
|
||||
type: a.artType,
|
||||
icon: iconForOperation(a),
|
||||
iconColor: iconColorForOperation(a),
|
||||
@@ -525,7 +542,7 @@ export function renderTableOperationCell(
|
||||
|
||||
if (overflow.length === 0) return h("div", { class: wrapperClass }, inlineNodes);
|
||||
|
||||
const moreDropdown = h(FaButtonMore, {
|
||||
const moreDropdown = h(ArtButtonMore, {
|
||||
list: overflow.map((a) => ({
|
||||
key: a.key,
|
||||
label: a.label,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div
|
||||
v-for="item in upgradeLogList"
|
||||
:key="item.version"
|
||||
class="art-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
|
||||
class="fa-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
|
||||
>
|
||||
<div class="flex-cb gap-3 mb-4 flex-wrap">
|
||||
<span class="px-3 py-1 bg-theme/10 text-theme text-sm font-medium rounded-full">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
|
||||
<!-- 左侧卡片 -->
|
||||
<div class="w-112 mr-5 max-md:w-full max-md:mr-0">
|
||||
<div class="art-card-sm relative p-9 pb-6 overflow-hidden text-center">
|
||||
<div class="fa-card-sm relative p-9 pb-6 overflow-hidden text-center">
|
||||
<img
|
||||
class="absolute top-0 left-0 w-full h-50 object-cover"
|
||||
src="@imgs/user/bg.webp"
|
||||
@@ -58,19 +58,19 @@
|
||||
|
||||
<div class="relative z-10 w-75 mx-auto mt-7.5 text-left">
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<ArtSvgIcon icon="ri:mail-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<FaSvgIcon icon="ri:mail-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm break-all">{{ infoFormState.email || "—" }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<ArtSvgIcon icon="ri:user-3-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<FaSvgIcon icon="ri:user-3-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm">{{ infoFormState.username || "—" }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<ArtSvgIcon icon="ri:map-pin-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<FaSvgIcon icon="ri:map-pin-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm">{{ infoFormState.dept?.name || "—" }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-start">
|
||||
<ArtSvgIcon icon="ri:briefcase-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<FaSvgIcon icon="ri:briefcase-line" class="text-g-700 shrink-0 mt-0.5" />
|
||||
<span class="ml-2 text-sm">
|
||||
{{ infoFormState.positions?.map((p) => p.name).join("、") || "—" }}
|
||||
</span>
|
||||
@@ -100,7 +100,7 @@
|
||||
destroy-on-close
|
||||
@closed="onAvatarCropDialogClosed"
|
||||
>
|
||||
<ArtCutterImg
|
||||
<FaCutterImg
|
||||
v-if="avatarCropVisible && avatarCropSrc"
|
||||
:key="avatarCropSrc"
|
||||
:img-url="avatarCropSrc"
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
|
||||
<div class="art-card-sm">
|
||||
<div class="fa-card-sm">
|
||||
<h1 class="p-4 text-xl font-normal border-b border-g-300">基本设置</h1>
|
||||
|
||||
<ElForm
|
||||
@@ -198,7 +198,7 @@
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<div class="art-card-sm my-5">
|
||||
<div class="fa-card-sm my-5">
|
||||
<h1 class="p-4 text-xl font-normal border-b border-g-300">更改密码</h1>
|
||||
|
||||
<ElForm
|
||||
@@ -263,8 +263,8 @@ import { Camera } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { redirectToLogin } from "@utils/auth";
|
||||
import ArtSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
import ArtCutterImg from "@/components/media/fa-cutter-img/index.vue";
|
||||
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
|
||||
import FaCutterImg from "@/components/media/fa-cutter-img/index.vue";
|
||||
import { dataURLToFile } from "@utils/file/dataUrl";
|
||||
|
||||
defineOptions({ name: "UserProfile" });
|
||||
@@ -609,7 +609,7 @@ onMounted(async () => {
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
|
||||
:deep(.el-upload) {
|
||||
::deep(.el-upload) {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
<h2 class="page-title">图表</h2>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(单数据)</span>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
<FaBarChart
|
||||
:data="singleBarData"
|
||||
:xAxisData="xAxisData"
|
||||
:showLegend="true"
|
||||
@@ -53,11 +53,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(多组数据)</span>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
<FaBarChart
|
||||
:data="multiBarData"
|
||||
:xAxisData="xAxisData"
|
||||
:showLegend="true"
|
||||
@@ -66,11 +66,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(堆叠)</span>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
<FaBarChart
|
||||
:data="stackBarData"
|
||||
:xAxisData="stackXAxisData"
|
||||
:showLegend="true"
|
||||
@@ -80,11 +80,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">折线图</span>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
<FaLineChart
|
||||
:data="[58, 15, 82, 35, 120, 62, 45]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
|
||||
symbol="none"
|
||||
@@ -93,11 +93,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">折线图(渐变背景)</span>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
<FaLineChart
|
||||
:data="[28, 45, 82, 35, 100, 32, 55]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
|
||||
:showAreaColor="true"
|
||||
@@ -105,11 +105,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">折线图(多组数据)</span>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
<FaLineChart
|
||||
:data="multiLineData"
|
||||
:xAxisData="['1月', '2月', '3月', '4月', '5月', '6月']"
|
||||
:showLegend="true"
|
||||
@@ -118,23 +118,23 @@
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(水平)</span>
|
||||
</div>
|
||||
<ArtHBarChart
|
||||
<FaHBarChart
|
||||
:data="[50, 80, 120, 90, 60]"
|
||||
:xAxisData="['产品A', '产品B', '产品C', '产品D', '产品E']"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(水平)</span>
|
||||
</div>
|
||||
|
||||
<ArtHBarChart
|
||||
<FaHBarChart
|
||||
:data="[
|
||||
{ name: '系列1', data: [10, 20, 30] },
|
||||
{ name: '系列2', data: [15, 25, 35] },
|
||||
@@ -146,11 +146,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(水平堆叠)</span>
|
||||
</div>
|
||||
<ArtHBarChart
|
||||
<FaHBarChart
|
||||
:data="[
|
||||
{ name: '系列1', data: [10, 20, 30] },
|
||||
{ name: '系列2', data: [15, 25, 35] },
|
||||
@@ -164,11 +164,11 @@
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">环形图</span>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
<FaRingChart
|
||||
:data="[
|
||||
{ value: 35, name: '分类A' },
|
||||
{ value: 28, name: '分类B' },
|
||||
@@ -181,11 +181,11 @@
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">环形图</span>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
<FaRingChart
|
||||
:data="[
|
||||
{ value: 35, name: '分类A' },
|
||||
{ value: 28, name: '分类B' },
|
||||
@@ -203,11 +203,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">饼图</span>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
<FaRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '分类A' },
|
||||
{ value: 25, name: '分类B' },
|
||||
@@ -220,11 +220,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">散点图</span>
|
||||
</div>
|
||||
<ArtScatterChart
|
||||
<FaScatterChart
|
||||
:data="[
|
||||
{ value: [1, 3] },
|
||||
{ value: [2, 4] },
|
||||
@@ -244,11 +244,11 @@
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">雷达图</span>
|
||||
</div>
|
||||
<ArtRadarChart
|
||||
<FaRadarChart
|
||||
:indicator="[
|
||||
{ name: '销售', max: 100 },
|
||||
{ name: '管理', max: 100 },
|
||||
@@ -270,11 +270,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">k线图</span>
|
||||
</div>
|
||||
<ArtKLineChart
|
||||
<FaKLineChart
|
||||
:data="[
|
||||
{ time: '2024-01-01', open: 20, close: 23, high: 25, low: 18 },
|
||||
{ time: '2024-01-02', open: 23, close: 21, high: 24, low: 20 },
|
||||
@@ -287,11 +287,11 @@
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="fa-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">双向堆叠柱状图</span>
|
||||
</div>
|
||||
<ArtDualBarCompareChart
|
||||
<FaDualBarCompareChart
|
||||
:positiveData="[50, 28, 80, 65, 68, 70, 60, 55]"
|
||||
:negativeData="[50, 28, 40, 45, 38, 50, 42, 48]"
|
||||
:xAxisData="[
|
||||
@@ -373,7 +373,7 @@ const stackXAxisData = ref(["产品A", "产品B", "产品C", "产品D", "产品E
|
||||
@apply my-5 text-xl font-medium first:mt-0;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
.fa-card {
|
||||
@apply p-5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>客户满意度</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
<FaLineChart
|
||||
height="calc(100% - 30px)"
|
||||
:data="chartData"
|
||||
:xAxisData="xAxisData"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>全国销售分布</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtRingChart
|
||||
<FaRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '北京' },
|
||||
{ value: 25, name: '上海' },
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>目标与实际</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
<FaBarChart
|
||||
class="p-5"
|
||||
height="12rem"
|
||||
:data="revenueData"
|
||||
@@ -19,7 +19,7 @@
|
||||
<div v-for="item in totalItems" :key="item.label" class="flex-c mb-5 last:mb-0">
|
||||
<div class="flex-c justify-start w-3/5 text-sm">
|
||||
<div class="w-10 h-10 mr-3 text-lg rounded-md flex-cc" :class="item.iconClass">
|
||||
<ArtSvgIcon :icon="item.icon" />
|
||||
<FaSvgIcon :icon="item.icon" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 overflow-hidden max-lg:h-auto max-sm:mb-4">
|
||||
<div class="art-card-header pr-0">
|
||||
<div class="fa-card h-82 p-5 mb-5 overflow-hidden max-lg:h-auto max-sm:mb-4">
|
||||
<div class="fa-card-header pr-0">
|
||||
<div class="title">
|
||||
<h4>今日销售</h4>
|
||||
<p>销售总结</p>
|
||||
</div>
|
||||
<div class="flex-cc h-7.5 min-w-17 border border-g-300 rounded-lg text-g-500 c-p">
|
||||
<ArtSvgIcon icon="ri:arrow-up-line" class="text-base mr-1.5" />
|
||||
<FaSvgIcon icon="ri:arrow-up-line" class="text-base mr-1.5" />
|
||||
<span class="text-xs">导出</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,11 +18,11 @@
|
||||
class="flex px-5 flex-col justify-center h-55 border border-g-300/85 rounded-xl max-lg:mb-4 max-sm:flex-row max-sm:justify-between max-sm:items-center max-sm:h-40"
|
||||
>
|
||||
<div class="size-12 rounded-lg flex-cc bg-theme/10">
|
||||
<ArtSvgIcon :icon="item.icon" class="text-xl text-theme" />
|
||||
<FaSvgIcon :icon="item.icon" class="text-xl text-theme" />
|
||||
</div>
|
||||
|
||||
<div class="max-sm:ml-4 mt-3.5 max-sm:mt-0 max-sm:text-end">
|
||||
<ArtCountTo class="text-2xl font-medium" :target="item.value" :duration="1500" />
|
||||
<FaCountTo class="text-2xl font-medium" :target="item.value" :duration="1500" />
|
||||
<p class="mt-2 text-base text-g-600 max-sm:mt-1">{{ item.label }}</p>
|
||||
<small class="text-g-500 mt-1 max-sm:mt-0.5">
|
||||
较昨天
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 overflow-hidden max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-82 p-5 mb-5 overflow-hidden max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>热门产品</h4>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>总收入</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
<FaBarChart
|
||||
height="calc(100% - 30px)"
|
||||
:data="revenueData"
|
||||
:xAxisData="weekDays"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>访客洞察</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
<FaLineChart
|
||||
height="calc(100% - 30px)"
|
||||
:data="chartData"
|
||||
:xAxisData="xAxisData"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="fa-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="fa-card-header">
|
||||
<div class="title">
|
||||
<h4>业务量与服务水平</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
<FaBarChart
|
||||
class="mt-6"
|
||||
height="14.3rem"
|
||||
:data="volumeServiceData"
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
destroy-on-close
|
||||
class="article-comment-wall-drawer"
|
||||
>
|
||||
<p class="mt-0 mb-8 text-g-600">
|
||||
每一份留言都记录了您的想法,也为我们提供了珍贵的回忆
|
||||
</p>
|
||||
<p class="mt-0 mb-8 text-g-600">每一份留言都记录了您的想法,也为我们提供了珍贵的回忆</p>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1 mb-5 list-none p-0 m-0"
|
||||
@@ -28,11 +26,11 @@
|
||||
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
|
||||
<div class="flex-c">
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<span>{{ item.collection }}</span>
|
||||
</div>
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<span>{{ item.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,17 +53,20 @@
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="drawer-default">
|
||||
<div class="relative p-4 aspect-16/12 rounded-md" :style="{ background: clickItem.color }">
|
||||
<div
|
||||
class="relative p-4 aspect-16/12 rounded-md"
|
||||
:style="{ background: clickItem.color }"
|
||||
>
|
||||
<p class="text-g-500 text-sm">{{ clickItem.date }}</p>
|
||||
<p class="mt-4 text-sm text-gray-800">{{ clickItem.content }}</p>
|
||||
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
|
||||
<div class="flex-c">
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<FaSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<span>{{ clickItem.collection }}</span>
|
||||
</div>
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<FaSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<span>{{ clickItem.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user