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

@@ -0,0 +1,74 @@
---
name: layouts-dark-light-adaptation
overview: 修复 layouts 下未做 dark/light 主题适配的硬编码样式,统一改用 --fms-* token。
todos:
- id: fix-apptabs-active
content: 修改 AppTabs.vue 激活态与关闭按钮的硬编码白色为 var(--fms-primary-text) 与 color-mix 派生
status: completed
- id: fix-apptopbar-check
content: 修改 AppTopbar.vue .check 对勾 color:#fff 为 var(--fms-primary-text)
status: completed
- id: lint-check
content: 运行 pnpm lint 静态检查确认无新增告警
status: completed
dependencies:
- fix-apptabs-active
- fix-apptopbar-check
---
## 用户需求
检查 `src/layouts` 下所有文件并结合 `src/theme/tokens.css`,定位未做 dark/light 模式适配的硬编码样式,并用 `--fms-*` token 替换修复,使其跟随主题切换。
## 产品概述
FMS Vue 前端布局壳(顶栏 / 侧栏 / 页签 / 导航菜单)基于 `tokens.css` 的 CSS 变量做深浅色适配。本次仅修复布局层残留的硬编码颜色,保证切换深色模式时视觉一致。
## 核心功能
- 修复 `AppTabs.vue` 激活页签的硬编码白色文字与关闭按钮白色(含 hover 态),改用 `var(--fms-primary-text)` 及基于主色文字色的 `color-mix` 派生。
- 修复 `AppTopbar.vue` 主题色板选中对勾的硬编码 `color: #fff`,改用 `var(--fms-primary-text)`。
- 保持其余已正确使用 token 的样式不变(DefaultLayout / AppSidebar / NavMain / NavMenuItem 及 AppTopbar 其他部分均无需改动)。
## 技术栈
- Vue 3 `<script setup>` + SCSS(scoped)+ CSS 变量(`:root` 浅色 / `.dark` 深色,主色 `--fms-primary` 由 JS 写入,`--fms-primary-text` 语义色)。
- 适配方式:所有颜色引用 `--fms-*` token;半透明白使用 `color-mix(in srgb, var(--fms-primary-text) x%, transparent)` 派生,不裸写 `rgba(255,255,255,...)`。
## 实现方案
逐项替换 layouts 下两处文件的硬编码颜色,全部复用现有 token,不新增 token、不改 JS 逻辑:
1. **AppTabs.vue 激活态**
- `.fms-tab.is-active`:`color: #fff` → `color: var(--fms-primary-text)`;`background/border-color` 已用 `var(--fms-primary)`,保持。
- `.fms-tab.is-active .fms-tab-close`:`color: rgba(255,255,255,0.85)` → `color: color-mix(in srgb, var(--fms-primary-text) 85%, transparent)`;hover 背景 `rgba(255,255,255,0.22)` → `color-mix(in srgb, var(--fms-primary-text) 22%, transparent)`;`color: #fff` → `var(--fms-primary-text)`。
- 理由:`#fff` 在浅色主色(墨蓝 `#0f172b`)下成立,但用户可切换主色且深色模式下主色文字语义由 `--fms-primary-text` 统一表达,裸白会破坏换肤一致性。
2. **AppTopbar.vue 色板对勾**
- `.check { color: #fff }` → `color: var(--fms-primary-text)`。彩色块源于主色/预设色,对勾需高对比,`primary-text` 在浅色为白、深色主色深时仍为白,视觉等价且符合规范。
## 实现要点
- 仅改 scoped `<style>`,不动 `<script>`/`<template>`。
- 沿用项目现有 `color-mix` 派生写法(参考 `AppTopbar.vue` 搜索框 `color-mix(in srgb, var(--fms-secondary-hover) 50%, var(--fms-card))`),保持一致。
- 不引入过渡/动画之外的额外复杂度;`prefers-reduced-motion` 已处理,无需改动。
- 按开发规范:修改后只做静态检查(oxlint),不主动 `pnpm build` / 启动服务,除非用户要求。
## 架构与目录
仅修改以下两个文件(均属 `src/layouts`):
```
src/layouts/components/AppTabs.vue # [MODIFY] 激活态与关闭按钮颜色改用 primary-text / color-mix
src/layouts/components/AppTopbar.vue # [MODIFY] .check 对勾颜色改用 primary-text
```
其余 `DefaultLayout.vue` / `AppSidebar.vue` / `NavMain.vue` / `NavMenuItem.vue` 经核查已正确使用 token,不在本次改动范围。
## 验证
- 浅色模式:激活页签文字/对勾为白(主色墨蓝,primary-text=白),与现状一致。
- 切换深色模式(`.dark`):`--fms-primary-text` 维持白,激活态与对勾仍清晰;若未来主色改为浅色,`primary-text` 会反转,样式自动跟随。
- 运行 `pnpm lint`(oxlint)确认无新增告警(不主动 build)。
File diff suppressed because it is too large. Load diff
+706 -94
View File
@@ -38,8 +38,9 @@
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
display: flex;
align-items: center;
justify-content: center;
color: var(--boot-fg);
font-family:
Inter,
@@ -50,8 +51,7 @@
'Segoe UI',
sans-serif;
background:
radial-gradient(120% 120% at 50% 0%, var(--boot-glow), transparent 55%),
var(--boot-bg);
radial-gradient(120% 120% at 50% 0%, var(--boot-glow), transparent 55%), var(--boot-bg);
transition: opacity 0.4s ease;
}
@@ -59,70 +59,91 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
.app-boot__mark {
position: relative;
display: grid;
width: 104px;
height: 104px;
place-items: center;
.app-boot__svg {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.app-boot__ring {
position: absolute;
inset: 0;
box-sizing: border-box;
border-radius: 50%;
.app-boot__svg svg {
display: block;
height: auto;
}
.app-boot__ring--1 {
border: 2px solid color-mix(in srgb, var(--boot-accent) 22%, transparent);
border-top-color: var(--boot-accent);
animation: app-boot-spin 1.1s linear infinite;
/* 开屏 SVG 动画(原样移植自 demo.html 的 3D 服务器插画) */
#svg_svg {
zoom: 0.6;
}
.app-boot__ring--2 {
inset: 11px;
border: 2px solid transparent;
border-bottom-color: var(--boot-accent-2);
animation: app-boot-spin 1.7s linear infinite reverse;
.estrobo_animation {
animation:
floatAndBounce 4s infinite ease-in-out,
strobe 0.8s infinite;
}
.app-boot__ring--3 {
inset: 22px;
border: 2px dashed color-mix(in srgb, var(--boot-accent) 30%, transparent);
animation: app-boot-spin 2.6s linear infinite;
.estrobo_animationV2 {
animation:
floatAndBounce 4s infinite ease-in-out,
strobev2 0.8s infinite;
}
.app-boot__logo {
width: 46px;
height: 46px;
font-size: 20px;
font-weight: 800;
letter-spacing: 0.5px;
line-height: 1;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--boot-accent), var(--boot-accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: app-boot-pulse 1.9s ease-in-out infinite;
#float_server {
animation: floatAndBounce 4s infinite ease-in-out;
}
.app-boot__title {
margin: 0;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.5px;
background: linear-gradient(90deg, var(--boot-accent), var(--boot-accent-2), var(--boot-accent));
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: app-boot-flow 2.4s linear infinite;
@keyframes floatAndBounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
@keyframes strobe {
0%,
50%,
100% {
fill: #17e300;
}
25%,
75% {
fill: #17e300b4;
}
}
@keyframes strobev2 {
0%,
50%,
100% {
fill: rgb(255, 95, 74);
}
25%,
75% {
fill: rgb(16, 53, 115);
}
}
@keyframes animateGradient {
0% {
stop-color: #313f8773;
}
50% {
stop-color: #040d3a;
}
100% {
stop-color: #313f8773;
}
}
#paint13_linear_163_1030 stop {
animation: animateGradient 4s infinite alternate;
}
.app-boot__hint {
@@ -133,8 +154,7 @@
}
.app-boot__message {
max-width: 320px;
margin: 0;
margin: 4px 0 0;
color: var(--boot-error);
font-size: 13px;
line-height: 1.5;
@@ -143,6 +163,7 @@
.app-boot__retry {
height: 32px;
margin-top: 16px;
padding: 0 14px;
color: var(--boot-fg);
font: inherit;
@@ -158,35 +179,10 @@
background: color-mix(in srgb, var(--boot-fg) 8%, transparent);
}
.app-boot--error .app-boot__ring,
.app-boot--error .app-boot__logo,
.app-boot--error .app-boot__title,
.app-boot--error .app-boot__hint {
display: none;
}
[hidden] {
display: none !important;
}
@keyframes app-boot-spin {
to {
transform: rotate(360deg);
}
}
@keyframes app-boot-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.12);
opacity: 0.82;
}
}
@keyframes app-boot-flow {
to {
background-position: 200% center;
@@ -194,9 +190,9 @@
}
@media (prefers-reduced-motion: reduce) {
.app-boot__ring,
.app-boot__logo,
.app-boot__title {
#float_server,
.estrobo_animation,
.estrobo_animationV2 {
animation-duration: 2.4s;
}
}
@@ -226,7 +222,7 @@
function () {
window.location.reload()
},
{ once: true }
{ once: true },
)
}
window.__appBoot = {
@@ -243,7 +239,7 @@
message.hidden = false
}
bindRetry(retry)
}
},
}
})()
</script>
@@ -259,16 +255,632 @@
aria-busy="true"
>
<div class="app-boot__content">
<div class="app-boot__mark" aria-hidden="true">
<span class="app-boot__ring app-boot__ring--1"></span>
<span class="app-boot__ring app-boot__ring--2"></span>
<span class="app-boot__ring app-boot__ring--3"></span>
<span class="app-boot__logo">FMS</span>
<div class="app-boot__svg" aria-hidden="true">
<div>
<svg
id="svg_svg"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 477 578"
height="578"
width="477"
>
<g filter="url(#filter0_i_163_1030)">
<path
fill="#E9E9E9"
d="M235.036 304.223C236.949 303.118 240.051 303.118 241.964 304.223L470.072 435.921C473.898 438.13 473.898 441.712 470.072 443.921L247.16 572.619C242.377 575.38 234.623 575.38 229.84 572.619L6.92817 443.921C3.10183 441.712 3.10184 438.13 6.92817 435.921L235.036 304.223Z"
></path>
</g>
<path
stroke="white"
d="M235.469 304.473C237.143 303.506 239.857 303.506 241.531 304.473L469.639 436.171C473.226 438.242 473.226 441.6 469.639 443.671L246.727 572.369C242.183 574.992 234.817 574.992 230.273 572.369L7.36118 443.671C3.77399 441.6 3.774 438.242 7.36119 436.171L235.469 304.473Z"
></path>
<path
stroke="white"
fill="#C3CADC"
d="M234.722 321.071C236.396 320.105 239.111 320.105 240.785 321.071L439.477 435.786C443.064 437.857 443.064 441.215 439.477 443.286L240.785 558.001C239.111 558.967 236.396 558.967 234.722 558.001L36.0304 443.286C32.4432 441.215 32.4432 437.857 36.0304 435.786L234.722 321.071Z"
></path>
<path
fill="#4054B2"
d="M234.521 366.089C236.434 364.985 239.536 364.985 241.449 366.089L406.439 461.346L241.247 556.72C239.333 557.825 236.231 557.825 234.318 556.72L69.3281 461.463L234.521 366.089Z"
></path>
<path
fill="#30439B"
d="M237.985 364.089L237.984 556.972C236.144 556.941 235.082 556.717 233.13 556.043L69.3283 461.463L237.985 364.089Z"
></path>
<path
fill="url(#paint0_linear_163_1030)"
d="M36.2146 117.174L237.658 0.435217V368.615C236.541 368.598 235.686 368.977 233.885 370.124L73.1836 463.678L39.2096 444.075C37.0838 442.229 36.285 440.981 36.2146 438.027V117.174Z"
id="layer_pared"
></path>
<path
fill="url(#paint1_linear_163_1030)"
d="M439.1 116.303L237.657 0.435568V368.616C238.971 368.585 239.822 369.013 241.43 370.135L403.64 462.925L436.128 444.089C437.832 442.715 438.975 441.147 439.1 439.536V116.303Z"
id="layer_pared"
></path>
<path
fill="#27C6FD"
d="M64.5447 181.554H67.5626V186.835L64.5447 188.344V181.554Z"
id="float_server"
></path>
<path
fill="#138EB9"
d="M88.3522 374.347L232.415 457.522C234.202 458.405 234.866 458.629 236.335 458.71V468.291C235.356 468.291 234.086 468.212 232.415 467.275L88.3522 384.1C86.3339 382.882 85.496 382.098 85.4707 380.198V370.428L88.3522 374.347Z"
id="float_server"
></path>
<path
fill="#138EB9"
d="M384.318 374.445L240.254 457.62C238.914 458.385 238.295 458.629 236.335 458.71V468.291C237.315 468.291 238.704 468.211 240.236 467.274L384.318 384.198C386.457 383.091 387.151 382.244 387.258 380.228V370.917C386.768 372.387 386.21 373.295 384.318 374.445Z"
id="float_server"
></path>
<path
stroke="url(#paint3_linear_163_1030)"
fill="url(#paint2_linear_163_1030)"
d="M240.452 226.082L408.617 323.172C412.703 325.531 412.703 329.355 408.617 331.713L240.452 428.803C238.545 429.904 235.455 429.904 233.548 428.803L65.3832 331.713C61.298 329.355 61.298 325.531 65.3832 323.172L233.548 226.082C235.455 224.982 238.545 224.982 240.452 226.082Z"
id="float_server"
></path>
<path
fill="#5B6CA2"
d="M408.896 332.123L241.489 428.775C240.013 429.68 238.557 430.033 236.934 430.033V464.518C238.904 464.518 239.366 464.169 241.489 463.233L408.896 366.58C411.372 365.292 412.125 363.262 412.312 361.317C412.312 361.317 412.312 326.583 412.312 327.722C412.312 328.86 411.42 330.514 408.896 332.123Z"
id="float_server"
></path>
<path
fill="#6879AF"
d="M240.92 429.077L255.155 420.857V432.434L251.511 439.064V457.432L241.489 463.242C240.116 463.858 239.141 464.518 236.934 464.518V430.024C238.695 430.024 239.862 429.701 240.92 429.077Z"
id="float_server"
></path>
<path
fill="url(#paint4_linear_163_1030)"
d="M65.084 331.984L232.379 428.571C233.882 429.619 235.101 430.005 236.934 430.005V464.523C234.656 464.523 234.285 464.215 232.379 463.214L65.084 366.442C62.4898 365 61.6417 362.992 61.6699 361.29V327.125C61.6899 329.24 62.4474 330.307 65.084 331.984Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M400.199 361.032C403.195 359.302 405.623 355.096 405.623 351.637C405.623 348.177 403.195 346.775 400.199 348.505C397.203 350.235 394.775 354.441 394.775 357.9C394.775 361.359 397.203 362.762 400.199 361.032Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M221.404 446.444C224.4 448.174 226.828 446.771 226.828 443.312C226.828 439.853 224.4 435.646 221.404 433.917C218.408 432.187 215.979 433.589 215.979 437.049C215.979 440.508 218.408 444.714 221.404 446.444Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M102.895 359.589L97.9976 356.762V380.07L102.895 382.897V359.589Z"
id="float_server"
></path>
<path
fill="#313654"
d="M102.895 359.619L98.3394 356.989V379.854L102.895 382.484V359.619Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M78.9793 345.923L74.0823 343.096V366.37L78.9793 369.198V345.923Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M86.9512 350.478L82.0542 347.651V370.959L86.9512 373.787V350.478Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M94.9229 355.034L90.0259 352.206V375.515L94.9229 378.342V355.034Z"
id="float_server"
></path>
<path
fill="#313654"
d="M86.951 350.509L82.3958 347.879V370.743L86.951 373.373V350.509Z"
id="float_server"
></path>
<path
fill="#313654"
d="M94.9227 355.064L90.3674 352.434V375.299L94.9227 377.929V355.064Z"
class="estrobo_animation"
></path>
<path
fill="#313654"
d="M78.9794 345.954L74.4241 343.324V366.188L78.9794 368.818V345.954Z"
class="estrobo_animation"
></path>
<path
fill="#333B5F"
d="M221.859 446.444C224.855 448.174 227.284 446.771 227.284 443.312C227.284 439.853 224.855 435.646 221.859 433.917C218.863 432.187 216.435 433.589 216.435 437.049C216.435 440.508 218.863 444.714 221.859 446.444Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M399.516 361.032C402.511 359.302 404.94 355.096 404.94 351.637C404.94 348.177 402.511 346.775 399.516 348.505C396.52 350.235 394.091 354.441 394.091 357.9C394.091 361.359 396.52 362.762 399.516 361.032Z"
id="float_server"
></path>
<path
fill="#27C6FD"
d="M88.3522 317.406L232.415 400.581C234.202 401.464 234.866 401.688 236.335 401.769V411.35C235.356 411.35 234.086 411.271 232.415 410.334L88.3522 327.159C86.3339 325.941 85.496 325.157 85.4707 323.256V313.486L88.3522 317.406Z"
id="float_server"
></path>
<path
fill="#27C6FD"
d="M384.318 317.504L240.254 400.679C238.914 401.444 238.295 401.688 236.335 401.769V411.35C237.315 411.35 238.704 411.27 240.236 410.333L384.318 327.257C386.457 326.15 387.151 325.303 387.258 323.287V313.976C386.768 315.446 386.21 316.354 384.318 317.504Z"
id="float_server"
></path>
<path
stroke="url(#paint6_linear_163_1030)"
fill="url(#paint5_linear_163_1030)"
d="M240.452 169.141L408.617 266.231C412.703 268.59 412.703 272.414 408.617 274.772L240.452 371.862C238.545 372.962 235.455 372.962 233.548 371.862L65.3832 274.772C61.298 272.414 61.298 268.59 65.3832 266.231L233.548 169.141C235.455 168.04 238.545 168.04 240.452 169.141Z"
id="float_server"
></path>
<path
fill="#5B6CA2"
d="M408.896 275.182L241.489 371.834C240.013 372.739 238.557 373.092 236.934 373.092V407.577C238.904 407.577 239.366 407.229 241.489 406.292L408.896 309.64C411.372 308.352 412.125 306.321 412.312 304.376C412.312 304.376 412.312 269.642 412.312 270.781C412.312 271.92 411.42 273.573 408.896 275.182Z"
id="float_server"
></path>
<path
fill="#6879AF"
d="M240.92 372.135L255.155 363.915V375.493L251.511 382.123V400.491L241.489 406.3C240.116 406.916 239.141 407.577 236.934 407.577V373.083C238.695 373.083 239.862 372.759 240.92 372.135Z"
id="float_server"
></path>
<path
fill="url(#paint7_linear_163_1030)"
d="M65.084 275.043L232.379 371.63C233.882 372.678 235.101 373.064 236.934 373.064V407.582C234.656 407.582 234.285 407.274 232.379 406.273L65.084 309.501C62.4898 308.059 61.6417 306.051 61.6699 304.349V270.184C61.6899 272.299 62.4474 273.366 65.084 275.043Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M400.199 304.091C403.195 302.362 405.623 298.155 405.623 294.696C405.623 291.237 403.195 289.835 400.199 291.564C397.203 293.294 394.775 297.5 394.775 300.959C394.775 304.419 397.203 305.821 400.199 304.091Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M221.404 389.503C224.4 391.232 226.828 389.83 226.828 386.371C226.828 382.912 224.4 378.705 221.404 376.976C218.408 375.246 215.979 376.648 215.979 380.107C215.979 383.567 218.408 387.773 221.404 389.503Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M102.553 301.281L97.656 298.454V321.762L102.553 324.59V301.281Z"
id="float_server"
></path>
<path
fill="#313654"
d="M102.553 301.312L97.9976 298.682V321.546L102.553 324.176V301.312Z"
class="estrobo_animation"
></path>
<path
fill="#494F76"
d="M78.6377 287.615L73.7407 284.788V308.063L78.6377 310.89V287.615Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M86.6094 292.171L81.7124 289.343V312.652L86.6094 315.479V292.171Z"
id="float_server"
></path>
<path
fill="#494F76"
d="M94.5811 296.726L89.6841 293.899V317.207L94.5811 320.034V296.726Z"
id="float_server"
></path>
<path
fill="#313654"
d="M86.6095 292.201L82.0542 289.571V312.436L86.6095 315.066V292.201Z"
id="float_server"
></path>
<path
fill="#313654"
d="M94.5812 296.756L90.0259 294.126V316.991L94.5812 319.621V296.756Z"
class="estrobo_animationV2"
></path>
<path
fill="#313654"
d="M78.6376 287.646L74.0823 285.016V307.88L78.6376 310.51V287.646Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M221.859 389.503C224.855 391.232 227.284 389.83 227.284 386.371C227.284 382.912 224.855 378.705 221.859 376.976C218.863 375.246 216.435 376.648 216.435 380.107C216.435 383.567 218.863 387.773 221.859 389.503Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M399.516 304.091C402.511 302.362 404.94 298.155 404.94 294.696C404.94 291.237 402.511 289.835 399.516 291.564C396.52 293.294 394.091 297.5 394.091 300.959C394.091 304.419 396.52 305.821 399.516 304.091Z"
id="float_server"
></path>
<path
fill="#27C6FD"
d="M89.4907 214.912L233.554 298.087C235.341 298.97 236.003 299.194 237.474 299.275V308.856C236.494 308.856 235.223 308.777 233.554 307.84L89.4907 224.665C87.4726 223.447 86.6347 222.663 86.6094 220.762V210.993L89.4907 214.912Z"
id="float_server"
></path>
<path
fill="#27C6FD"
d="M385.457 215.01L241.393 298.185C240.053 298.951 239.434 299.194 237.474 299.275V308.856C238.454 308.856 239.844 308.776 241.375 307.839L385.457 224.763C387.597 223.656 388.29 222.809 388.397 220.793V211.482C387.907 212.953 387.349 213.86 385.457 215.01Z"
id="float_server"
></path>
<path
fill="url(#paint8_linear_163_1030)"
d="M66.1102 196.477L233.517 293.129C235.593 294.154 236.364 294.416 238.073 294.509V305.642C236.934 305.642 235.458 305.551 233.517 304.463L66.1102 207.81C63.7651 206.394 62.7914 205.483 62.762 203.275V191.922L66.1102 196.477Z"
id="float_server"
></path>
<path
fill="#5B6CA2"
d="M410.101 196.591L242.694 293.243C241.135 294.132 240.35 294.375 238.073 294.468V305.643C239.211 305.643 240.892 305.55 242.671 304.46L410.101 207.923C412.587 206.638 413.392 205.653 413.517 203.31V192.491C412.948 194.199 412.3 195.254 410.101 196.591Z"
id="float_server"
></path>
<path
stroke="url(#paint10_linear_163_1030)"
fill="url(#paint9_linear_163_1030)"
d="M241.59 90.5623L409.756 187.652C413.842 190.011 413.842 193.835 409.756 196.194L241.59 293.284C239.684 294.384 236.593 294.384 234.687 293.284L66.5219 196.194C62.4367 193.835 62.4367 190.011 66.5219 187.652L234.687 90.5623C236.593 89.4616 239.684 89.4616 241.59 90.5623Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M89.0427 195.334C92.0385 197.063 96.8956 197.063 99.8914 195.334C102.887 193.604 102.887 190.8 99.8914 189.07C96.8956 187.341 92.0385 187.341 89.0427 189.07C86.0469 190.8 86.0469 193.604 89.0427 195.334Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M231.396 111.061C234.391 112.791 239.249 112.791 242.244 111.061C245.24 109.331 245.24 106.527 242.244 104.798C239.249 103.068 234.391 103.068 231.396 104.798C228.4 106.527 228.4 109.331 231.396 111.061Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M374.887 194.195C377.883 195.925 382.74 195.925 385.736 194.195C388.732 192.465 388.732 189.661 385.736 187.932C382.74 186.202 377.883 186.202 374.887 187.932C371.891 189.661 371.891 192.465 374.887 194.195Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M231.396 279.607C234.391 281.336 239.249 281.336 242.244 279.607C245.24 277.877 245.24 275.073 242.244 273.343C239.249 271.613 234.391 271.613 231.396 273.343C228.4 275.073 228.4 277.877 231.396 279.607Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M232.109 279.607C235.104 281.336 239.962 281.336 242.957 279.607C245.953 277.877 245.953 275.073 242.957 273.343C239.962 271.613 235.104 271.613 232.109 273.343C229.113 275.073 229.113 277.877 232.109 279.607Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M89.7563 195.334C92.7521 197.063 97.6092 197.063 100.605 195.334C103.601 193.604 103.601 190.8 100.605 189.07C97.6092 187.341 92.7521 187.341 89.7563 189.07C86.7605 190.8 86.7605 193.604 89.7563 195.334Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M232.109 111.061C235.104 112.791 239.962 112.791 242.957 111.061C245.953 109.331 245.953 106.527 242.957 104.798C239.962 103.068 235.104 103.068 232.109 104.798C229.113 106.527 229.113 109.331 232.109 111.061Z"
id="float_server"
></path>
<path
fill="#333B5F"
d="M375.6 194.195C378.595 195.925 383.453 195.925 386.448 194.195C389.444 192.465 389.444 189.661 386.448 187.932C383.453 186.202 378.595 186.202 375.6 187.932C372.604 189.661 372.604 192.465 375.6 194.195Z"
id="float_server"
></path>
<path
stroke="#313654"
d="M371.315 166.009L354.094 176.748C351.92 178.337 350.677 179.595 350.677 181.872L351.247 196.108C351.412 198.824 350.734 200.095 347.83 201.802L251.03 257.603C248.955 258.968 247.598 259.356 244.767 259.312L215.727 258.743C212.711 258.605 211.233 259.005 208.894 260.45L193.659 269.072"
id="float_server"
></path>
<path
stroke="#313654"
d="M345.691 151.204L328.328 161.374C326.154 162.963 324.911 164.221 324.911 166.498L325.481 180.734C325.646 183.45 324.968 184.721 322.064 186.428L225.264 242.229C223.19 243.594 221.832 243.982 219.001 243.938L189.961 243.369C186.946 243.231 185.468 243.631 183.128 245.076L167.124 253.698"
id="float_server"
></path>
<path
stroke="#313654"
d="M105.482 218.098L122.697 207.363C124.87 205.773 126.111 204.516 126.111 202.24L125.537 188.007C125.371 185.291 126.048 184.02 128.951 182.314L225.715 126.533C227.788 125.17 229.146 124.782 231.976 124.825L261.012 125.398C264.026 125.535 265.503 125.136 267.842 123.691L283.072 115.072"
id="float_server"
></path>
<path
stroke="#313654"
d="M131.121 232.893L148.482 222.725C150.656 221.136 151.898 219.879 151.898 217.601L151.327 203.367C151.162 200.65 151.839 199.379 154.743 197.673L251.531 141.878C253.605 140.514 254.962 140.126 257.794 140.17L286.832 140.74C289.847 140.878 291.325 140.478 293.664 139.032L309.667 130.412"
id="float_server"
></path>
<path
fill="#313654"
d="M327.961 242.79L301.907 227.748L300.673 228.46L326.727 243.503L327.961 242.79Z"
id="float_server"
></path>
<path
fill="#313654"
d="M354.625 227.426L328.56 212.377L327.326 213.09L353.392 228.139L354.625 227.426Z"
id="float_server"
></path>
<path
fill="#313654"
d="M300.864 258.519L274.707 243.417L273.474 244.129L299.631 259.231L300.864 258.519Z"
id="float_server"
></path>
<path
fill="#313654"
d="M176.498 155.101L150.21 139.924L148.977 140.636L175.264 155.813L176.498 155.101Z"
id="float_server"
></path>
<path
fill="#313654"
d="M193.703 145.191L167.388 129.998L166.154 130.711L192.469 145.903L193.703 145.191Z"
id="float_server"
></path>
<path
fill="#313654"
d="M158.333 165.69L131.974 150.472L130.74 151.184L157.099 166.402L158.333 165.69Z"
id="float_server"
></path>
<path
fill="#20273A"
d="M232.079 135.83C234.258 134.573 237.79 134.573 239.969 135.83L329.717 187.647C334.074 190.163 334.074 194.242 329.717 196.757L239.969 248.574C237.79 249.832 234.258 249.832 232.079 248.574L142.33 196.757C137.972 194.242 137.972 190.163 142.33 187.647L232.079 135.83Z"
id="float_server"
></path>
<path
fill="url(#paint11_linear_163_1030)"
d="M234.357 135.83C236.535 134.573 240.068 134.573 242.246 135.83L331.995 187.647C336.352 190.163 336.352 194.242 331.995 196.757L242.246 248.574C240.068 249.832 236.535 249.832 234.357 248.574L144.608 196.757C140.25 194.242 140.25 190.163 144.608 187.647L234.357 135.83Z"
id="float_server"
></path>
<path
stroke-width="3"
stroke="#27C6FD"
d="M380.667 192.117V181.97C380.667 179.719 383.055 178.27 385.052 179.309L409.985 192.282C410.978 192.799 411.601 193.825 411.601 194.943V301.113C411.601 302.642 409.953 303.606 408.62 302.856L399.529 297.742"
class="after_animation"
id="float_server"
></path>
<path
stroke-width="3"
stroke="#27C6FD"
d="M94.7234 192.117V180.306C94.7234 179.214 94.1301 178.208 93.1744 177.68L70.5046 165.152C68.5052 164.047 66.0536 165.493 66.0536 167.778V185.326"
id="float_server"
></path>
<ellipse
fill="#27C6FD"
ry="1.50894"
rx="1.50894"
cy="192.117"
cx="380.667"
id="float_server"
></ellipse>
<ellipse
fill="#27C6FD"
ry="1.50894"
rx="1.50894"
cy="192.117"
cx="94.7235"
id="float_server"
></ellipse>
<ellipse
fill="#27C6FD"
ry="1.50894"
rx="1.50894"
cy="297.742"
cx="399.529"
id="float_server"
></ellipse>
<ellipse
fill="#27C6FD"
ry="1.50894"
rx="1.50894"
cy="383.751"
cx="221.474"
id="float_server"
></ellipse>
<ellipse
fill="#27C6FD"
ry="1.50894"
rx="1.50894"
cy="439.583"
cx="221.474"
id="float_server"
></ellipse>
<path
stroke-width="3"
stroke="#27C6FD"
d="M221.474 383.752L211.746 388.941C210.768 389.462 210.157 390.48 210.157 391.588V444.34C210.157 445.108 210.988 445.589 211.654 445.208L221.474 439.583"
id="float_server"
></path>
<path
fill="url(#paint13_linear_163_1030)"
d="M237.376 236.074L36 119.684V439.512C36.0957 441.966 36.7214 443.179 39.0056 445.021L200.082 538.547L231.362 556.441C233.801 557.806 235.868 558.222 237.376 558.328V236.074Z"
id="layer_pared"
></path>
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="556.454"
x2="438.984"
y1="235.918"
x1="237.376"
id="paint13_linear_163_1030"
>
<stop style="stop-color: #4457b3; stop-opacity: 0" offset="10%"></stop>
<stop style="stop-color: #4457b3; stop-opacity: 1" offset="100%"></stop>
</linearGradient>
</defs>
<path
fill="url(#paint13_linear_163_1030)"
d="M237.376 235.918L438.984 119.576V439.398C439.118 441.699 438.452 442.938 435.975 444.906L274.712 538.539L243.397 556.454C240.955 557.821 238.886 558.23 237.376 558.336V235.918Z"
class="animatedStop"
id="layer_pared"
></path>
<defs>
<filter
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
height="275.295"
width="468.883"
y="303.394"
x="4.05835"
id="filter0_i_163_1030"
>
<feFlood result="BackgroundImageFix" flood-opacity="0"></feFlood>
<feBlend
result="shape"
in2="BackgroundImageFix"
in="SourceGraphic"
mode="normal"
></feBlend>
<feColorMatrix
result="hardAlpha"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
type="matrix"
in="SourceAlpha"
></feColorMatrix>
<feOffset dy="4"></feOffset>
<feGaussianBlur stdDeviation="2"></feGaussianBlur>
<feComposite k3="1" k2="-1" operator="arithmetic" in2="hardAlpha"></feComposite>
<feColorMatrix
values="0 0 0 0 0.220833 0 0 0 0 0.220833 0 0 0 0 0.220833 0 0 0 1 0"
type="matrix"
></feColorMatrix>
<feBlend
result="effect1_innerShadow_163_1030"
in2="shape"
mode="normal"
></feBlend>
</filter>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="336.055"
x2="294.366"
y1="60.1113"
x1="135.05"
id="paint0_linear_163_1030"
>
<stop stop-opacity="0.01" stop-color="white" offset="0.305"></stop>
<stop stop-color="#4054B2" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="335.208"
x2="180.935"
y1="59.2405"
x1="340.265"
id="paint1_linear_163_1030"
>
<stop stop-opacity="0.01" stop-color="white" offset="0.305"></stop>
<stop stop-color="#4054B2" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="420.619"
x2="88.5367"
y1="327.152"
x1="412.313"
id="paint2_linear_163_1030"
>
<stop stop-color="#313654"></stop>
<stop stop-color="#313654" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="211.092"
x2="168.239"
y1="426.799"
x1="236.934"
id="paint3_linear_163_1030"
>
<stop stop-color="#7281B8"></stop>
<stop stop-color="#333952" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="349.241"
x2="232.379"
y1="349.241"
x1="65.0839"
id="paint4_linear_163_1030"
>
<stop stop-color="#7281B8"></stop>
<stop stop-color="#5D6EA4" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="363.678"
x2="88.5367"
y1="270.211"
x1="412.313"
id="paint5_linear_163_1030"
>
<stop stop-color="#313654"></stop>
<stop stop-color="#313654" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="154.15"
x2="168.239"
y1="369.858"
x1="236.934"
id="paint6_linear_163_1030"
>
<stop stop-color="#7281B8"></stop>
<stop stop-color="#333952" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="292.3"
x2="232.379"
y1="292.3"
x1="65.0839"
id="paint7_linear_163_1030"
>
<stop stop-color="#7281B8"></stop>
<stop stop-color="#5D6EA4" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="198.899"
x2="238.073"
y1="198.899"
x1="62.762"
id="paint8_linear_163_1030"
>
<stop stop-color="#7382B9"></stop>
<stop stop-color="#5D6EA4" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="191.599"
x2="67.1602"
y1="191.633"
x1="413.451"
id="paint9_linear_163_1030"
>
<stop stop-color="#5F6E99"></stop>
<stop stop-color="#465282" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="191.599"
x2="63.6601"
y1="191.599"
x1="417.16"
id="paint10_linear_163_1030"
>
<stop stop-color="#7281B8"></stop>
<stop stop-color="#333952" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="243.221"
x2="156.734"
y1="191.633"
x1="335.442"
id="paint11_linear_163_1030"
>
<stop stop-color="#313654"></stop>
<stop stop-color="#313654" offset="1"></stop>
</linearGradient>
<linearGradient
gradientUnits="userSpaceOnUse"
y2="421.983"
x2="-1.9283"
y1="179.292"
x1="138.189"
id="paint12_linear_163_1030"
>
<stop stop-opacity="0.01" stop-color="white" offset="0.305"></stop>
<stop stop-color="#4054B2" offset="1"></stop>
</linearGradient>
</defs>
</svg>
</div>
<p id="app-boot-message" class="app-boot__message" hidden></p>
<button id="app-boot-retry" class="app-boot__retry" type="button" hidden>
重新加载
</button>
</div>
<h1 class="app-boot__title">FMS 企业管理系统</h1>
<p class="app-boot__hint" id="app-boot-hint">正在加载应用,请稍候…</p>
<p id="app-boot-message" class="app-boot__message" hidden></p>
<button id="app-boot-retry" class="app-boot__retry" type="button" hidden>重新加载</button>
</div>
</div>
</div>
@@ -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">