20260817225227

This commit is contained in:
oneao committed 2026-08-17 22:52:27 +08:00
1 parent d3acc7d28f
commit b90ae89b0e
39 files changed
+3861 -353

No files matched your search

@@ -1,98 +0,0 @@
<script setup>
import { computed } from 'vue'
import { PRESETS } from '@/theme/presets'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const activeColor = computed(() => appStore.primaryColor)
function pick(color) {
appStore.setPrimary(color)
}
</script>
<template>
<div class="color-picker" role="group" aria-label="主题色">
<button
v-for="preset in PRESETS"
:key="preset.color"
class="swatch"
type="button"
:style="{ background: preset.color }"
:aria-label="preset.name"
:title="preset.name"
:aria-pressed="activeColor === preset.color"
@click="pick(preset.color)"
>
<svg
v-if="activeColor === preset.color"
class="check"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="#fff"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
</div>
</template>
<style scoped>
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.swatch {
position: relative;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: inset 0 0 0 2px transparent;
transition:
transform 150ms ease,
box-shadow 150ms ease;
}
.swatch:hover {
transform: scale(1.15);
box-shadow:
inset 0 0 0 2px var(--fms-border),
var(--fms-shadow);
}
.swatch[aria-pressed='true'] {
box-shadow:
inset 0 0 0 2px var(--fms-card),
0 0 0 2px var(--fms-text);
}
.check {
position: absolute;
inset: 0;
margin: auto;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35));
}
.swatch:focus-visible {
outline: 2px solid var(--fms-primary);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.swatch {
transition: none;
}
}
</style>
@@ -1,61 +0,0 @@
<script setup>
import { Moon, Sun } from '@lucide/vue'
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
const isDark = computed(() => appStore.darkMode)
function toggle() {
appStore.toggleDark()
}
</script>
<template>
<button
class="theme-toggle"
type="button"
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
:title="isDark ? '浅色模式' : '深色模式'"
@click="toggle"
>
<Sun v-if="!isDark" class="theme-toggle-icon" aria-hidden="true" />
<Moon v-else class="theme-toggle-icon" aria-hidden="true" />
</button>
</template>
<style scoped>
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text);
cursor: pointer;
transition: background 150ms ease;
}
.theme-toggle:hover {
background: var(--fms-secondary-hover);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--fms-primary);
outline-offset: 2px;
}
.theme-toggle-icon {
width: 16px;
height: 16px;
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle {
transition: none;
}
}
</style>
@@ -52,7 +52,12 @@ const classes = computed(() => [
</script>
<template>
<button :type="htmlType" :class="classes" :disabled="isDisabled" :aria-busy="loading || undefined">
<button
:type="htmlType"
:class="classes"
:disabled="isDisabled"
:aria-busy="loading || undefined"
>
<Loader v-if="loading" class="button-spinner" aria-hidden="true" />
<slot />
</button>
@@ -22,8 +22,8 @@ const multiFormat = ['YYYY年MM月DD日', 'YYYY-MM-DD']
<p class="eyebrow">Components</p>
<h2 id="date-title">DatePicker 日期选择</h2>
<p>
format 同时决定选择面板、选择精度和展示格式;valueFormat 仅控制绑定值格式,
未设置时自动跟随 format。
format 同时决定选择面板、选择精度和展示格式;valueFormat 仅控制绑定值格式, 未设置时自动跟随
format。
</p>
</div>
@@ -46,7 +46,10 @@ function openDrawer(placement) {
:placement="drawerPlacement"
:width="360"
>
<p>抽屉从<strong>{{ drawerPlacement }}</strong>侧滑入,拖动边缘可调整尺寸;点击遮罩、关闭按钮或按 Esc 均可关闭。</p>
<p>
抽屉从<strong>{{ drawerPlacement }}</strong
>侧滑入,拖动边缘可调整尺寸;点击遮罩、关闭按钮或按 Esc 均可关闭。
</p>
<p style="margin-top: 12px">适用于表单填写、详情查看等需要更大空间的场景。</p>
</Drawer>
@@ -54,7 +57,10 @@ function openDrawer(placement) {
<template #extra>
<Button type="link">帮助</Button>
</template>
<p>头部左侧通过 <code>#extra</code> 插槽放入附加操作,底部通过 <code>#footer</code> 插槽放置按钮。</p>
<p>
头部左侧通过 <code>#extra</code> 插槽放入附加操作,底部通过
<code>#footer</code> 插槽放置按钮。
</p>
<template #footer>
<Button type="outline" @click="drawerCustomOpen = false">取消</Button>
<Button type="default" @click="drawerCustomOpen = false">确定</Button>
@@ -122,7 +122,12 @@ const contextItems = [
<p class="demo-label">右键触发</p>
<div class="demo-panel">
<Dropdown :items="contextItems" trigger="contextmenu" placement="bottom-start" @click="onSelect">
<Dropdown
:items="contextItems"
trigger="contextmenu"
placement="bottom-start"
@click="onSelect"
>
<template #trigger>
<div class="dropdown-demo-context">在此区域右键</div>
</template>
@@ -155,7 +155,12 @@ function onPickerReset() {
@reset="onCustomReset"
>
<FormItem name="phone" label="手机号">
<Input v-model="customModel.phone" placeholder="请输入 11 位手机号" maxlength="11" allow-clear />
<Input
v-model="customModel.phone"
placeholder="请输入 11 位手机号"
maxlength="11"
allow-clear
/>
</FormItem>
<FormItem name="code" label="验证码" help="输入短信中的 6 位数字">
<Input v-model="customModel.code" placeholder="6 位数字" maxlength="6" allow-clear />
@@ -63,7 +63,10 @@ function saveWithFeedback() {
</div>
<Modal v-model:open="modalBasicOpen" title="基础模态框" :width="440">
<p>这是一个基础模态框。拖动标题栏可移动位置,拖动右下角可调整尺寸;右上角可切换全屏;点击遮罩、关闭按钮或按 Esc 均可关闭。</p>
<p>
这是一个基础模态框。拖动标题栏可移动位置,拖动右下角可调整尺寸;右上角可切换全屏;点击遮罩、关闭按钮或按
Esc 均可关闭。
</p>
</Modal>
<Modal v-model:open="modalCenteredOpen" title="垂直居中" :width="440" centered>
@@ -134,12 +134,7 @@ onBeforeUnmount(() => {
<p class="demo-label">自定义字段名(labelKey / valueKey)</p>
<div class="demo-panel demo-panel-grid">
<Select
v-model="regionValue"
:options="regionOptions"
label-key="name"
value-key="code"
/>
<Select v-model="regionValue" :options="regionOptions" label-key="name" value-key="code" />
</div>
<p class="demo-label">插槽自定义选项</p>
@@ -13,7 +13,6 @@ const tableRows = [
{ name: '组件库', version: '1.2.0', status: '运行中' },
{ name: '主题系统', version: '2.0.0', status: '已停用' },
]
</script>
<template>
@@ -111,7 +110,9 @@ const tableRows = [
</Spin>
</div>
<p class="spin-hint">同一卡片在未包裹与包裹 Spin(含加载中)两种状态下尺寸一致,内部元素位置不偏移。</p>
<p class="spin-hint">
同一卡片在未包裹与包裹 Spin(含加载中)两种状态下尺寸一致,内部元素位置不偏移。
</p>
</div>
</section>
</template>
@@ -15,7 +15,9 @@ function onSplitterResize(sizes) {
<div class="section-heading">
<p class="eyebrow">Components</p>
<h2 id="splitter-title">Splitter 分割面板</h2>
<p>水平或垂直分割容器,拖动分隔条调整各面板尺寸,支持固定尺寸、最小/最大约束与键盘方向键操作。</p>
<p>
水平或垂直分割容器,拖动分隔条调整各面板尺寸,支持固定尺寸、最小/最大约束与键盘方向键操作。
</p>
</div>
<p class="demo-label">水平分割(首尾固定,中间自适应)</p>
@@ -19,7 +19,9 @@ const tabsItems = [
<div class="section-heading">
<p class="eyebrow">Components</p>
<h2 id="tabs-title">Tabs 标签页</h2>
<p>line 线型标签页,支持 TabPane 插槽与 items 数组两种数据源、左右侧边布局与键盘方向键导航。</p>
<p>
line 线型标签页,支持 TabPane 插槽与 items 数组两种数据源、左右侧边布局与键盘方向键导航。
</p>
</div>
<p class="demo-label">基础用法(TabPane 插槽)</p>
@@ -157,7 +157,12 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<Transition name="fms-drawer-mask">
<div v-if="isOpen && mask" class="fms-drawer-mask" aria-hidden="true" @click="onMaskClick"></div>
<div
v-if="isOpen && mask"
class="fms-drawer-mask"
aria-hidden="true"
@click="onMaskClick"
></div>
</Transition>
<Transition name="fms-drawer">
<div v-if="isOpen" class="fms-drawer-wrap" :class="`fms-drawer-${placement}`">
@@ -6,7 +6,7 @@
* 从而复用现有的键盘导航 / 高亮 / 子菜单体系。
*
* 支持的属性与 items 数组项一一对应:
* key 标识,点击时回传 @click 的 { key, item }
* itemKey 标识,点击时回传 @click 的 { key, item }
* icon 图标组件(Lucide)
* label 菜单文本(也可直接用默认插槽写富内容,由父级读取 children)
* danger 危险项(红色)
@@ -15,7 +15,7 @@
* onClick 项自带点击回调,命中时优先执行
*/
defineProps({
key: { type: [String, Number], default: undefined },
itemKey: { type: [String, Number], default: undefined },
icon: { type: [Object, Function], default: undefined },
label: { type: String, default: '' },
danger: { type: Boolean, default: false },
@@ -50,7 +50,7 @@ function collectDropdownItems() {
continue
}
const item = {}
if (p.key != null) item.key = p.key
if (p.itemKey != null) item.key = p.itemKey
if (p.icon != null) item.icon = p.icon
if (p.label != null) item.label = p.label
if (p.danger) item.danger = true
@@ -188,13 +188,7 @@ provide('dropdownActiveIndex', activeIndex)
</template>
<template #content>
<div
ref="menuEl"
class="dropdown-menu"
role="menu"
tabindex="-1"
@keydown="onMenuKeydown"
>
<div ref="menuEl" class="dropdown-menu" role="menu" tabindex="-1" @keydown="onMenuKeydown">
<!-- 富头部插槽:菜单项列表上方的自定义区域(头像+名字+组织等),有内容时才渲染 -->
<div v-if="$slots.header" class="dropdown-header">
<slot name="header" />
@@ -4,9 +4,15 @@ import { messageState } from './message-store.js'
/* 9 个方位:分组容器始终存在,仅消息项进出,保证 enter/leave 动画稳定触发 */
const PLACEMENTS = [
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right',
'top-left',
'top-center',
'top-right',
'center-left',
'center',
'center-right',
'bottom-left',
'bottom-center',
'bottom-right',
]
/* ---------- 关闭某条 ---------- */
@@ -258,10 +258,26 @@ onBeforeUnmount(() => {
</div>
<!-- 四边缩放手柄:拖动调整面板宽高,全屏时不可用 -->
<template v-if="resizable && !isFullscreen">
<div ref="resizeERef" class="fms-modal-resizer fms-modal-resizer-e" aria-hidden="true"></div>
<div ref="resizeWRef" class="fms-modal-resizer fms-modal-resizer-w" aria-hidden="true"></div>
<div ref="resizeSRef" class="fms-modal-resizer fms-modal-resizer-s" aria-hidden="true"></div>
<div ref="resizeNRef" class="fms-modal-resizer fms-modal-resizer-n" aria-hidden="true"></div>
<div
ref="resizeERef"
class="fms-modal-resizer fms-modal-resizer-e"
aria-hidden="true"
></div>
<div
ref="resizeWRef"
class="fms-modal-resizer fms-modal-resizer-w"
aria-hidden="true"
></div>
<div
ref="resizeSRef"
class="fms-modal-resizer fms-modal-resizer-s"
aria-hidden="true"
></div>
<div
ref="resizeNRef"
class="fms-modal-resizer fms-modal-resizer-n"
aria-hidden="true"
></div>
</template>
</div>
</div>
@@ -5,9 +5,15 @@ import { notificationState } from './notification-store.js'
/* 9 个方位:分组容器始终存在,仅通知项进出,保证 enter/leave 动画稳定触发 */
const PLACEMENTS = [
'top-left', 'top-center', 'top-right',
'center-left', 'center', 'center-right',
'bottom-left', 'bottom-center', 'bottom-right',
'top-left',
'top-center',
'top-right',
'center-left',
'center',
'center-right',
'bottom-left',
'bottom-center',
'bottom-right',
]
/* ---------- 关闭某条 ---------- */
@@ -48,9 +48,7 @@ const pageSizeValue = computed(() =>
// 每页条数选项统一转为数字,保证与 pageSize 值类型匹配
const sizeOptions = computed(() => props.pageSizeOptions.map((item) => Number(item)))
const totalPages = computed(() =>
Math.max(1, Math.ceil((props.total || 0) / pageSizeValue.value)),
)
const totalPages = computed(() => Math.max(1, Math.ceil((props.total || 0) / pageSizeValue.value)))
// 显示层当前页:受控取 props,非受控取内部值;越界时收敛到 [1, totalPages],
// 受控模式不写回 props,保持受控语义由调用方同步
@@ -157,7 +157,14 @@ function onContextMenu(event) {
event.preventDefault()
const { clientX, clientY } = event
// 右键在鼠标位置弹出:构造零尺寸锚点矩形,computePosition 的 flip/shift 会兜底防溢出
pointAnchor = { left: clientX, top: clientY, width: 0, height: 0, right: clientX, bottom: clientY }
pointAnchor = {
left: clientX,
top: clientY,
width: 0,
height: 0,
right: clientX,
bottom: clientY,
}
if (isOpen.value) {
// 已打开时右键到新位置,仅重新定位不重置状态
updatePosition()
@@ -48,9 +48,7 @@ function hasDefaultSlot() {
return !!(slots.default && slots.default().length > 0)
}
const hasDescription = computed(
() => !!props.description || !!slots.description,
)
const hasDescription = computed(() => !!props.description || !!slots.description)
</script>
<template>
@@ -45,8 +45,8 @@
.switch-checked .switch-thumb {
transform: translateX(
calc(
var(--fms-switch-width) - var(--fms-switch-thumb-size) -
var(--fms-switch-thumb-offset) - var(--fms-switch-thumb-offset)
var(--fms-switch-width) - var(--fms-switch-thumb-size) - var(--fms-switch-thumb-offset) -
var(--fms-switch-thumb-offset)
)
);
}
@@ -81,8 +81,7 @@ const slotPanes = computed(() => {
const paneProps = node.props ?? {}
const { content, label } = extractChildren(node.children)
// key 解析顺序:tabKey prop > vnode.key > 出现顺序索引
const rawKey =
paneProps.tabKey != null ? paneProps.tabKey : node.key != null ? node.key : index
const rawKey = paneProps.tabKey != null ? paneProps.tabKey : node.key != null ? node.key : index
panes.push({
key: String(rawKey),
rawKey,
@@ -14,8 +14,7 @@ const props = defineProps({
/** @type {TagVariant} */
type: String,
default: 'default',
validator: (value) =>
['default', 'secondary', 'outline', 'danger', 'ghost'].includes(value),
validator: (value) => ['default', 'secondary', 'outline', 'danger', 'ghost'].includes(value),
},
/** 是否可关闭:显示关闭按钮,点击后触发 close 并隐藏自身 */
closable: { type: Boolean, default: false },
+58 -6
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue'
import { computed, defineComponent, h, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import Splitter from '@/components/ui/splitter/splitter.vue'
@@ -7,13 +7,54 @@ import SplitterPanel from '@/components/ui/splitter/panel.vue'
import AppSidebar from './components/AppSidebar.vue'
import AppTabs from './components/AppTabs.vue'
import AppTopbar from './components/AppTopbar.vue'
import DetailPage from '@/views/detail/DetailPage.vue'
import { cacheKeyOf } from '@/utils/cacheKey'
const route = useRoute()
const appStore = useAppStore()
// 路由实例 key:fullPath + 刷新计数,双击页签刷新时强制重渲染
const routeInstanceKey = computed(
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
/* 页签缓存核心(统一处理所有页签,含详情页):
* - keep-alive 的 cache key = 外层 <component> 的 :key = route.fullPath(稳定)。
* 因此切来切去、刷新都不会生成新的 keep-alive 缓存项 —— 消除「刷新泄漏」。
* - 刷新(双击页签)仅改变内层 <component> 的 :key(fullPath:refreshKey),
* 使页面组件自身重新挂载、状态归零,而外层缓存槽位不变。
* - 每个页签一个命名包装组件(include 名:普通页 = componentName,详情页 =
* cacheKeyOf(fullPath)),使 keep-alive 的 include 能按页签精确销毁实例;
* 不同参数(/detail/1、/detail/2)因外层 key = fullPath 不同而天然多个独立缓存。 */
const pageWrapperCache = {}
function getPageWrapper(name, fullPath, innerComponent) {
if (!pageWrapperCache[name]) {
pageWrapperCache[name] = {
fullPath,
comp: defineComponent({
name,
setup() {
const r = useRoute()
const app = useAppStore()
return () => {
const inner = r.meta?.cacheByKey ? h(DetailPage, { id: r.params.id }) : innerComponent
const innerKey = `${r.fullPath}:${app.getRouteRefreshKey(r.fullPath)}`
return h(inner, { key: innerKey })
}
},
}),
}
}
return pageWrapperCache[name].comp
}
/* 回收已关闭详情页(page_ 前缀)的包装组件定义,避免 pageWrapperCache 随访问过的
* fullPath 无限增长(普通页的 componentName 数量有限、实例本身由 keep-alive :max 限制)。 */
watch(
() => appStore.visitedTabs.map((t) => t.key),
(keys) => {
const keep = new Set(keys)
for (const name of Object.keys(pageWrapperCache)) {
if (name.startsWith('page_') && !keep.has(pageWrapperCache[name].fullPath)) {
delete pageWrapperCache[name]
}
}
},
)
/* ---------- 侧栏宽度(展开态可拖拽调宽,由 Splitter 接管) ---------- */
@@ -70,8 +111,19 @@ function onSplitterResize(sizes) {
<div class="fms-content">
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<keep-alive :max="8">
<component :is="Component" :key="routeInstanceKey" />
<keep-alive :include="appStore.cachedViews" :max="8">
<component
:is="
getPageWrapper(
route.meta?.cacheByKey
? cacheKeyOf(route.fullPath)
: route.meta?.componentName,
route.fullPath,
Component,
)
"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
@@ -1,6 +1,14 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { AudioWaveform, Check, ChevronsUpDown, Command, Component, GalleryVerticalEnd, LayoutGrid } from '@lucide/vue'
import {
AudioWaveform,
Check,
ChevronsUpDown,
Command,
Component,
GalleryVerticalEnd,
LayoutGrid,
} from '@lucide/vue'
import { useAppStore } from '@/stores/app'
import { usePermissionStore } from '@/stores/permissions'
import { getIcon } from '@/utils/lucideIcons'
@@ -41,7 +49,6 @@ const navMain = computed(() => {
],
})
const menuModules = permissionStore.modules.filter(
(m) => m.b_canmenu === 1 && m.b_canuse === 1 && ['directory', 'page'].includes(m.b_module_type),
)
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import Button from '@/components/ui/button/button.vue'
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
import { cacheKeyOf } from '@/utils/cacheKey'
const route = useRoute()
const router = useRouter()
@@ -23,10 +24,14 @@ watch(
async () => {
const title = route.meta?.title
if (title) {
// cacheByKey 路由(如 /detail/:id):每个 fullPath 一个独立组件名,
// 使 keep-alive 的 include 能按参数精确销毁其实例,且不同参数天然多个独立缓存。
const name = route.meta?.cacheByKey ? cacheKeyOf(route.fullPath) : route.meta?.componentName
appStore.addVisitedTab({
key: route.fullPath,
label: title,
closable: route.fullPath !== '/dashboard',
name,
})
}
await nextTick()
@@ -325,21 +330,21 @@ function refreshTab(key) {
&.is-active {
background: var(--fms-primary);
border-color: var(--fms-primary);
color: #fff;
color: var(--fms-primary-text);
}
&.is-active:hover {
background: var(--fms-primary);
border-color: var(--fms-primary);
color: #fff;
color: var(--fms-primary-text);
}
&.is-active .fms-tab-close {
color: rgba(255, 255, 255, 0.85);
color: color-mix(in srgb, var(--fms-primary-text) 85%, transparent);
&:hover {
background: rgba(255, 255, 255, 0.22);
color: #fff;
background: color-mix(in srgb, var(--fms-primary-text) 22%, transparent);
color: var(--fms-primary-text);
}
}
}
@@ -3,29 +3,43 @@ import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import {
BadgeCheck,
Bell,
Check,
CreditCard,
Languages,
LogOut,
Maximize,
Minimize,
Moon,
Palette,
PanelLeft,
Search,
Sun,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { Message } from '@/components/ui/message/message-manager'
import { PRESETS } from '@/theme/presets'
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
import Button from '@/components/ui/button/button.vue'
import Input from '@/components/ui/input/input.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import ColorThemePicker from '@/components/ui/ColorThemePicker.vue'
const router = useRouter()
const appStore = useAppStore()
const authStore = useAuthStore()
/* 深浅色切换:原 ThemeToggle 组件逻辑内联,按钮样式复用 .fms-icon-btn */
const isDark = computed(() => appStore.darkMode)
function toggleDark() {
appStore.toggleDark()
}
/* 主色选择:原 ColorThemePicker 组件逻辑内联,直接复用 .fms-icon-btn 风格 */
const activeColor = computed(() => appStore.primaryColor)
function pickColor(color) {
appStore.setPrimary(color)
}
const user = computed(() => ({
name: authStore.user?.name ?? '用户',
account: authStore.user?.account ?? '',
@@ -128,13 +142,35 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
</template>
<template #menu>
<div class="fms-topbar-scheme">
<ColorThemePicker />
<div class="color-picker" role="group" aria-label="主题色">
<button
v-for="preset in PRESETS"
:key="preset.color"
class="swatch"
type="button"
:style="{ background: preset.color }"
:aria-label="preset.name"
:title="preset.name"
:aria-pressed="activeColor === preset.color"
@click="pickColor(preset.color)"
>
<Check v-if="activeColor === preset.color" class="check" />
</button>
</div>
</div>
</template>
</Dropdown>
<!-- 深浅色切换 -->
<ThemeToggle />
<Button
type="ghost"
class="fms-icon-btn"
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
:title="isDark ? '浅色模式' : '深色模式'"
@click="toggleDark"
>
<component :is="isDark ? Moon : Sun" />
</Button>
<!-- 全屏切换 -->
<Button
@@ -153,12 +189,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<span class="fms-separator" aria-hidden="true" />
<!-- 用户下拉:菜单项走组件库标准 items,不手写;富头部渲染头像+名字+组织 -->
<Dropdown
placement="bottom-end"
:offset="8"
:items="userMenuItems"
@click="onUserMenuClick"
>
<Dropdown placement="bottom-end" :offset="8" :items="userMenuItems" @click="onUserMenuClick">
<template #trigger>
<button class="fms-user-trigger" type="button" :title="user.name">
<span class="fms-avatar">{{ user.name.slice(0, 1) }}</span>
@@ -303,6 +334,46 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
padding: 4px 8px 8px;
}
/* 色板(原 ColorThemePicker 组件内联) */
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.swatch {
position: relative;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
padding: 0;
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 1px;
transition:
outline-color 120ms ease,
transform 120ms ease;
&:hover {
transform: scale(1.1);
}
&:focus-visible {
outline-color: var(--fms-primary);
}
}
.check {
position: absolute;
inset: 0;
margin: auto;
width: 14px;
height: 14px;
color: var(--fms-primary-text);
}
/* 用户触发按钮 */
.fms-user-trigger {
display: flex;
+16 -8
View File
@@ -26,51 +26,59 @@ const router = createRouter({
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '工作台' },
meta: { title: '工作台', componentName: 'Dashboard' },
},
// 测试页:用于演练 Tabs 导航(无需权限,纯布局验证)
{
path: '/test/list',
name: 'test-list',
component: () => import('@/views/test/TestPage.vue'),
meta: { title: '测试-列表' },
meta: { title: '测试-列表', componentName: 'TestPage' },
},
{
path: '/test/form',
name: 'test-form',
component: () => import('@/views/test/TestPage.vue'),
meta: { title: '测试-表单' },
meta: { title: '测试-表单', componentName: 'TestPage' },
},
{
path: '/test/detail',
name: 'test-detail',
component: () => import('@/views/test/TestPage.vue'),
meta: { title: '测试-详情' },
meta: { title: '测试-详情', componentName: 'TestPage' },
},
{
path: '/test/chart',
name: 'test-chart',
component: () => import('@/views/test/TestPage.vue'),
meta: { title: '测试-图表' },
meta: { title: '测试-图表', componentName: 'TestPage' },
},
{
path: '/test/table',
name: 'test-table',
component: () => import('@/views/test/TestPage.vue'),
meta: { title: '测试-表格' },
meta: { title: '测试-表格', componentName: 'TestPage' },
},
{
path: '/test/settings',
name: 'test-settings',
component: () => import('@/views/test/TestPage.vue'),
meta: { title: '测试-设置' },
meta: { title: '测试-设置', componentName: 'TestPage' },
},
// 组件库演练场:纳入业务布局(带侧栏 / 顶栏 / Tabs)
{
path: '/demo',
name: 'demo',
component: () => import('@/views/demo/index.vue'),
meta: { title: '组件库' },
meta: { title: '组件库', componentName: 'Demo' },
},
// 详情页:同一组件按参数复用,但每个 fullPath 独立缓存、关闭即销毁
// (per-param 的命名组件由 DefaultLayout 的 getPageWrapper 按路由动态生成)
{
path: '/detail/:id',
name: 'detail',
component: () => import('@/views/detail/DetailPage.vue'),
meta: { title: '详情', cacheByKey: true },
},
// 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验
],
+26 -7
View File
@@ -12,11 +12,21 @@ export const useAppStore = defineStore('app', {
/** 界面语言(占位:项目暂无 i18n 文案,仅保留切换状态) */
locale: 'zh-CN',
/** 已访问页签列表;工作台固定不可关闭 */
visitedTabs: [{ key: '/dashboard', label: '工作台', closable: false }],
visitedTabs: [{ key: '/dashboard', label: '工作台', closable: false, name: 'Dashboard' }],
/** 路由刷新计数表:key 为路由 fullPath,值变化即强制重渲染该路由 */
routeRefreshKeys: {},
}),
getters: {
/** keep-alive 的 include 列表:当前打开页签对应的组件名(去重)。
* 页签关闭时该集合随之缩小,keep-alive 据此销毁对应缓存实例,
* 实现"关闭页签即销毁缓存" */
cachedViews: (state) => {
const names = state.visitedTabs.map((t) => t.name).filter(Boolean)
return [...new Set(names)]
},
},
actions: {
setDark(value) {
this.darkMode = !!value
@@ -43,30 +53,39 @@ export const useAppStore = defineStore('app', {
removeVisitedTab(key) {
const index = this.visitedTabs.findIndex((t) => t.key === key)
if (index >= 0) this.visitedTabs.splice(index, 1)
// 同步清理该页签的刷新计数(对齐 old1:关闭即释放,避免 key 无限累积)
if (key in this.routeRefreshKeys) delete this.routeRefreshKeys[key]
},
/** 关闭其他:保留工作台(不可关)与指定 key */
closeOthers(key) {
this.visitedTabs = this.visitedTabs.filter(
(t) => t.key === '/dashboard' || t.key === key,
)
this.visitedTabs = this.visitedTabs.filter((t) => t.key === '/dashboard' || t.key === key)
this.pruneOrphanRefreshKeys()
},
/** 关闭左侧:保留工作台与指定 key 及其右侧 */
closeLeft(key) {
const idx = this.visitedTabs.findIndex((t) => t.key === key)
if (idx <= 0) return
this.visitedTabs = this.visitedTabs.filter(
(t, i) => i >= idx || t.key === '/dashboard',
)
this.visitedTabs = this.visitedTabs.filter((t, i) => i >= idx || t.key === '/dashboard')
this.pruneOrphanRefreshKeys()
},
/** 关闭右侧:保留指定 key 及其左侧(含工作台) */
closeRight(key) {
const idx = this.visitedTabs.findIndex((t) => t.key === key)
if (idx < 0) return
this.visitedTabs = this.visitedTabs.filter((t, i) => i <= idx)
this.pruneOrphanRefreshKeys()
},
/** 关闭全部:仅保留工作台 */
closeAll() {
this.visitedTabs = this.visitedTabs.filter((t) => t.key === '/dashboard')
this.pruneOrphanRefreshKeys()
},
/** 清理已关闭页签残留的刷新计数,避免 routeRefreshKeys 无限增长 */
pruneOrphanRefreshKeys() {
const keep = new Set(this.visitedTabs.map((t) => t.key))
for (const key of Object.keys(this.routeRefreshKeys)) {
if (!keep.has(key)) delete this.routeRefreshKeys[key]
}
},
getRouteRefreshKey(key) {
return this.routeRefreshKeys[key] ?? 0
+11
View File
@@ -0,0 +1,11 @@
/**
* 页签缓存标识:把路由 fullPath 映射为 keep-alive include 可匹配的唯一组件名。
*
* 背景:keep-alive 的 include/exclude 只能按「组件名」匹配,无法按 :key(fullPath)精确销毁。
* 对于「同一组件被不同参数复用」(如 /detail/1、/detail/2 共用 DetailPage)的场景,
* 需要让每个 fullPath 拥有独立的组件类型与组件名,include 才能按参数精确销毁其实例,
* 同时不同参数天然成为多个独立缓存。
*/
export function cacheKeyOf(fullPath) {
return `page_${encodeURIComponent(fullPath)}`
}
+25 -1
View File
@@ -1,5 +1,16 @@
<script setup>
import { LayoutDashboard } from '@lucide/vue'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/button.vue'
defineOptions({ name: 'Dashboard' })
const router = useRouter()
// 跳转到详情页(不同 id = 不同路由缓存),用于验证「多参数独立缓存」
function goDetail(id) {
router.push(`/detail/${id}`)
}
</script>
<template>
@@ -8,7 +19,13 @@ import { LayoutDashboard } from '@lucide/vue'
<LayoutDashboard />
</div>
<h1 class="dashboard-title">工作台</h1>
<p class="dashboard-desc">布局壳与业务页面正在搭建中,敬请期待。</p>
<p class="dashboard-desc">布局壳与业务页面已就绪,点击下方按钮进入详情页验证多参数缓存。</p>
<div class="dashboard-actions">
<Button type="primary" @click="goDetail(1)">打开详情 #1</Button>
<Button type="primary" @click="goDetail(2)">打开详情 #2</Button>
<Button type="primary" @click="goDetail(3)">打开详情 #3</Button>
</div>
</div>
</template>
@@ -53,4 +70,11 @@ import { LayoutDashboard } from '@lucide/vue'
font-size: 14px;
color: var(--fms-text-secondary);
}
.dashboard-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
}
</style>
+111 -5
View File
@@ -1,7 +1,11 @@
<script setup>
import { ref } from 'vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import ColorThemePicker from '@/components/ui/ColorThemePicker.vue'
import { ref, computed } from 'vue'
defineOptions({ name: 'Demo' })
import { Check, Moon, Sun } from '@lucide/vue'
import { useAppStore } from '@/stores/app'
import { PRESETS } from '@/theme/presets'
import Button from '@/components/ui/button/button.vue'
import ButtonDemo from '@/components/ui/demo/button.vue'
import ButtonGroupDemo from '@/components/ui/demo/button-group.vue'
import TabsDemo from '@/components/ui/demo/tabs.vue'
@@ -27,6 +31,18 @@ import TagDemo from '@/components/ui/demo/tag.vue'
import TreeDemo from '@/components/ui/demo/tree.vue'
/* ---------- 导航分类 ---------- */
const appStore = useAppStore()
const isDark = computed(() => appStore.darkMode)
function toggleDark() {
appStore.toggleDark()
}
/* 主色选择:原 ColorThemePicker 组件逻辑内联 */
const activeColor = computed(() => appStore.primaryColor)
function pickColor(color) {
appStore.setPrimary(color)
}
const activeKey = ref('button')
const categories = [
@@ -112,8 +128,29 @@ const demoMap = {
</div>
<div class="theme-controls">
<ColorThemePicker />
<ThemeToggle />
<div class="color-picker" role="group" aria-label="主题色">
<button
v-for="preset in PRESETS"
:key="preset.color"
class="swatch"
type="button"
:style="{ background: preset.color }"
:aria-label="preset.name"
:title="preset.name"
:aria-pressed="activeColor === preset.color"
@click="pickColor(preset.color)"
>
<Check v-if="activeColor === preset.color" class="check" />
</button>
</div>
<Button
type="ghost"
class="theme-toggle-btn"
:title="isDark ? '浅色模式' : '深色模式'"
@click="toggleDark"
>
<component :is="isDark ? Moon : Sun" />
</Button>
</div>
</header>
@@ -179,6 +216,75 @@ const demoMap = {
gap: 12px;
}
.theme-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text);
cursor: pointer;
transition: background-color 150ms ease;
}
.theme-toggle-btn:hover {
background: var(--fms-secondary-hover);
}
.theme-toggle-btn:focus-visible {
outline: 2px solid var(--fms-primary);
outline-offset: 2px;
}
.theme-toggle-btn svg {
width: 16px;
height: 16px;
}
/* 色板(原 ColorThemePicker 组件内联) */
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.swatch {
position: relative;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
padding: 0;
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 1px;
transition:
outline-color 120ms ease,
transform 120ms ease;
&:hover {
transform: scale(1.1);
}
&:focus-visible {
outline-color: var(--fms-primary);
}
}
.check {
position: absolute;
inset: 0;
margin: auto;
width: 14px;
height: 14px;
color: #fff;
}
.app-body {
display: flex;
align-items: flex-start;
@@ -0,0 +1,129 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
/** 详情 id(由路由参数经工厂注入,保证每个 fullPath 拿到自己的 id) */
id: { type: [String, Number], default: '' },
})
// 下面这个输入框用于验证「缓存按 fullPath 隔离」:
// 在同一参数页输入内容后切换/关闭再回来,值应保留;不同参数之间互不影响。
const draft = ref('')
const counter = ref(0)
</script>
<template>
<div class="detail-page">
<h1 class="detail-title">详情页 #{{ id }}</h1>
<p class="detail-sub">
当前路由:<code>{{ $route.fullPath }}</code>
</p>
<div class="detail-card">
<p class="detail-hint">
本页每个 <code>id</code> 是<strong>独立缓存</strong>:切换 / 关闭后再回到相同
<code>id</code>,下方输入内容与计数会保留;不同 <code>id</code> 之间互不影响。
</p>
<label class="detail-field">
<span>测试输入(验证缓存隔离)</span>
<input v-model="draft" class="detail-input" placeholder="输入点什么,再切换页签回来看看" />
</label>
<div class="detail-row">
<Button type="primary" @click="counter += 1">计数 +1(当前 {{ counter }})</Button>
<span class="detail-tip">关闭本页签再重开,计数归零说明缓存已销毁;保留说明命中缓存。</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.detail-page {
display: flex;
flex-direction: column;
gap: 12px;
color: var(--fms-text);
}
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.detail-sub {
margin: 0;
font-size: 13px;
color: var(--fms-text-secondary);
code {
padding: 1px 6px;
border-radius: 4px;
background: var(--fms-secondary-hover);
font-size: 12px;
}
}
.detail-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
}
.detail-hint {
margin: 0;
font-size: 13px;
line-height: 20px;
color: var(--fms-text-secondary);
code {
padding: 1px 6px;
border-radius: 4px;
background: var(--fms-secondary-hover);
font-size: 12px;
}
}
.detail-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--fms-text-secondary);
}
.detail-input {
width: 320px;
max-width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-background);
color: var(--fms-text);
font-size: 14px;
font-family: inherit;
&:focus {
outline: 2px solid var(--fms-primary);
outline-offset: -2px;
}
}
.detail-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.detail-tip {
font-size: 12px;
color: var(--fms-text-secondary);
}
</style>
+12 -2
View File
@@ -94,13 +94,23 @@ function onFailed() {
@submit-failed="onFailed"
>
<FormItem name="orgId" label="机构码">
<Input id="orgId" v-model="formModel.orgId" placeholder="请输入机构码" :disabled="submitting">
<Input
id="orgId"
v-model="formModel.orgId"
placeholder="请输入机构码"
:disabled="submitting"
>
<template #prefix><Building2 /></template>
</Input>
</FormItem>
<FormItem name="userId" label="账号">
<Input id="userId" v-model="formModel.userId" placeholder="请输入账号" :disabled="submitting">
<Input
id="userId"
v-model="formModel.userId"
placeholder="请输入账号"
:disabled="submitting"
>
<template #prefix><User /></template>
</Input>
</FormItem>
+8 -2
View File
@@ -1,6 +1,8 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
defineOptions({ name: 'TestPage' })
import Button from '@/components/ui/button/button.vue'
const route = useRoute()
@@ -19,8 +21,12 @@ const index = computed(() => Number(route.params.id ?? 0))
<div class="fms-test-page__body">
<div class="fms-test-page__card">
<p>这是用于测试 Tabs 导航的占位页面。</p>
<p>当前路由:<code>{{ route.fullPath }}</code></p>
<p>页签索引:<strong>{{ index }}</strong></p>
<p>
当前路由:<code>{{ route.fullPath }}</code>
</p>
<p>
页签索引:<strong>{{ index }}</strong>
</p>
</div>
<div class="fms-test-page__actions">