20260817225227
This commit is contained in:
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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 后自动走权限校验
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user