This commit is contained in:
oneao committed 2026-10-11 22:14:13 +08:00
1 parent 32d423b759
commit 78eb811ff7
102 files changed
+12067 -209

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+313 -1
View File
@@ -4,9 +4,321 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>G3Soft ERP</title>
<script>
// 首帧前同步应用的暗色偏好,避免暗色用户先看到浅色闪屏。
// 偏好持久化 key = 'g3erp:preferences'(见 src/preferences/store.ts),
// 结构 { theme: { mode: 'light' | 'dark' | 'auto' } }。
// 注意:此刻全局样式还没加载,加载屏只能用字面量颜色、不能依赖 --g3-* 变量。
try {
var raw = localStorage.getItem('g3erp:preferences')
var mode = raw ? (JSON.parse(raw) || {}).theme.mode : null
var dark = mode === 'dark'
if (mode === 'auto' || mode === null) {
dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
}
if (dark) document.documentElement.classList.add('boot-dark')
} catch (e) {
/* 无持久化数据 / 解析失败时按浅色默认渲染 */
}
</script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
/* ===== 开屏加载屏 =====
配色对齐本应用 token(浅色):背景 #f5f6f8 / 文字 #1f2329 /
次级 #4e5969 / 三级 #86909c / 主色 #165dff / 边框 #e5e6eb;
深色(html.boot-dark,由上方脚本按持久化偏好写入)对齐 .dark 覆盖值。
品牌区版式对齐登录页 .login__logo-mark(主色圆角方块 + 白字)。 */
.app-boot {
display: flex;
background-color: #f5f6f8;
position: fixed;
left: 0;
top: 0;
height: 100%;
width: 100%;
z-index: 9999;
transition: opacity 0.3s ease-in;
opacity: 1;
pointer-events: none;
/* 锁定字体,避免 Vue 注入的 web 字体就绪后替换导致文字重定位抖动 */
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei',
sans-serif;
}
.boot-dark .app-boot {
background-color: #17171a;
}
.app-boot--hide {
opacity: 0;
}
/* 加载失败态:浅红渐变背景 + 错误提示;错误时需要可交互(重试按钮可点击) */
.app-boot--error {
background: radial-gradient(circle at 50% 50%, #fef2f2 0%, #fee2e2 100%);
color: #b91c1c;
pointer-events: auto;
}
.boot-dark .app-boot--error {
background: radial-gradient(circle at 50% 50%, #2a1215 0%, #1c0e10 100%);
color: #f87171;
}
.app-boot__container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
flex-grow: 1;
user-select: none;
-webkit-user-select: none;
}
/* 加载效果:字母逐个从下方弹起的文字 loader(字形 SVG + translateY 动画)。
原实现用 .loader / .letter / .i 这类通用类名,直接放进 index.html 会污染
整个应用(例如所有 class="i" 的元素),因此统一收敛到 .app-boot__loading 下。
字形 fill 改走 currentColor,跟随主题色。 */
.app-boot__loading {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
/* 容器刻意比字母高:字母静止时居中,上下各留出 10px 渐隐带供出入场过渡 */
height: 60px;
overflow: hidden; /* 关键:裁掉位于容器外的字母,动画升上来才可见 */
color: #1f2329;
opacity: 0;
animation: app-boot-fade 0.45s ease-out 0.08s forwards;
/* 上下各 10px 渐隐:字母升入/降出时淡进淡出,避免被容器硬边切成
「从切面冒出来」的生硬边缘。字母静止区间正好落在中间的清晰区。 */
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
#000 10px,
#000 calc(100% - 10px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0,
#000 10px,
#000 calc(100% - 10px),
transparent 100%
);
}
.boot-dark .app-boot__loading {
color: #f7f8fa;
}
.app-boot__loading span {
display: flex;
transform: translateY(50px);
animation: app-boot-letters 1s alternate infinite cubic-bezier(0.86, 0, 0.07, 1);
}
.app-boot__loading svg {
width: auto;
height: 40px;
fill: currentColor;
}
/* 字母间留一点呼吸,同时窄字形 i 额外加边距,避免和 d / n 贴太近 */
.app-boot__loading span + span {
margin-left: 2px;
}
.app-boot__loading span:nth-child(5) {
margin-inline: 6px;
}
/* 逐个错开 70ms,形成波浪式弹起 */
.app-boot__loading span:nth-child(1) {
animation-delay: 0ms;
}
.app-boot__loading span:nth-child(2) {
animation-delay: 70ms;
}
.app-boot__loading span:nth-child(3) {
animation-delay: 140ms;
}
.app-boot__loading span:nth-child(4) {
animation-delay: 210ms;
}
.app-boot__loading span:nth-child(5) {
animation-delay: 280ms;
}
.app-boot__loading span:nth-child(6) {
animation-delay: 350ms;
}
.app-boot__loading span:nth-child(7) {
animation-delay: 420ms;
}
@keyframes app-boot-letters {
from {
transform: translateY(50px);
}
to {
transform: translateY(0);
}
}
@keyframes app-boot-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 错误态:隐藏正常加载内容,展示提示与重试按钮 */
.app-boot--error .app-boot__loading {
display: none;
}
.app-boot__message {
font-size: 14px;
font-weight: 600;
}
.app-boot__retry {
margin-top: 16px;
padding: 6px 18px;
border: 1px solid currentColor;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 14px;
font-family: inherit;
cursor: pointer;
transition: background 150ms ease;
}
.app-boot__retry:hover {
background: rgba(185, 28, 28, 0.08);
}
.boot-dark .app-boot__retry:hover {
background: rgba(248, 113, 113, 0.1);
}
@media (prefers-reduced-motion: reduce) {
/* 关掉弹跳动画后,字母静止排成一行,仍可读地表示「加载中」 */
.app-boot__loading {
opacity: 1;
animation: none;
}
.app-boot__loading span {
transform: none;
animation: none;
}
}
</style>
</head>
<body>
<div id="app"></div>
<div id="app">
<script>
// 模块加载失败时(如直接 file:// 打开)展示错误,避免永远卡在加载屏。
// 忽略良性噪音错误(ResizeObserver 循环等浏览器警告也会走 error 事件),
// 否则 DevTools 展开/收起等触发 resize 时会把整个页面误替换成错误页。
window.addEventListener('error', (e) => {
const msg = e && e.message ? String(e.message) : ''
if (/ResizeObserver loop/i.test(msg)) return
const app = document.getElementById('app')
if (app) {
app.innerHTML =
'<div class="app-boot app-boot--error" style="position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;pointer-events:auto;font-family:sans-serif">' +
'<div style="font-size:20px;font-weight:bold">加载失败</div>' +
'<div style="font-size:14px">请通过本地开发服务器访问(如 pnpm dev),不要直接以 file:// 方式打开本文件。</div>' +
(msg ? '<div style="font-size:12px;opacity:.7">' + msg + '</div>' : '') +
'</div>'
}
})
</script>
</div>
<div id="app-boot" class="app-boot" role="status" aria-busy="true">
<div class="app-boot__container">
<div class="app-boot__loading" aria-hidden="true">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 11 18" height="18" width="11">
<path
d="M0.28 16.14V0.94L3.7 0.64L5.7 1.64V12.3L8.5 12.06L10.5 13.06V16.44L2.28 17.14L0.28 16.14ZM3.5 12.7V0.859999L0.48 1.12V15.94L8.3 15.26V12.28L3.5 12.7Z"
></path>
</svg>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 18" height="18" width="16">
<path
d="M8.94 17.24C8.84667 17.2533 8.74667 17.26 8.64 17.26C8.54667 17.26 8.45333 17.26 8.36 17.26C7.66667 17.26 7.02667 17.16 6.44 16.96C5.86667 16.7733 5.30667 16.5533 4.76 16.3C3.33333 15.5933 2.28667 14.6 1.62 13.32C0.966667 12.0267 0.64 10.4933 0.64 8.72C0.64 7.68 0.766667 6.67333 1.02 5.7C1.28667 4.71333 1.68 3.82667 2.2 3.04C2.72 2.24 3.36667 1.58667 4.14 1.08C4.92667 0.573332 5.84667 0.273333 6.9 0.18C7.00667 0.166666 7.10667 0.159999 7.2 0.159999C7.29333 0.159999 7.38667 0.159999 7.48 0.159999C8.14667 0.159999 8.74 0.246666 9.26 0.419999C9.78 0.579999 10.3067 0.766666 10.84 0.979999C11.8 1.36667 12.6 1.94 13.24 2.7C13.88 3.46 14.36 4.35333 14.68 5.38C15 6.39333 15.16 7.48 15.16 8.64C15.16 9.72 15.0333 10.7533 14.78 11.74C14.5267 12.7267 14.14 13.62 13.62 14.42C13.1133 15.2067 12.4667 15.8467 11.68 16.34C10.9067 16.8467 9.99333 17.1467 8.94 17.24ZM6.92 16.04C7.94667 15.96 8.84 15.68 9.6 15.2C10.36 14.7067 10.9867 14.0733 11.48 13.3C11.9733 12.5133 12.34 11.64 12.58 10.68C12.8333 9.70667 12.96 8.69333 12.96 7.64C12.96 6.68 12.8467 5.76667 12.62 4.9C12.4067 4.02 12.0733 3.24 11.62 2.56C11.1667 1.88 10.5933 1.34667 9.9 0.959999C9.22 0.559999 8.41333 0.359999 7.48 0.359999C7.38667 0.359999 7.29333 0.359999 7.2 0.359999C7.12 0.359999 7.02667 0.366666 6.92 0.38C5.89333 0.473333 5 0.766666 4.24 1.26C3.48 1.74 2.84667 2.37333 2.34 3.16C1.83333 3.93333 1.45333 4.8 1.2 5.76C0.96 6.70667 0.84 7.69333 0.84 8.72C0.84 9.72 0.953333 10.6667 1.18 11.56C1.40667 12.44 1.74667 13.22 2.2 13.9C2.65333 14.5667 3.22667 15.0933 3.92 15.48C4.61333 15.8667 5.42 16.06 6.34 16.06C6.44667 16.06 6.54667 16.06 6.64 16.06C6.73333 16.06 6.82667 16.0533 6.92 16.04ZM6.92 12.94C6.86667 12.94 6.81333 12.9467 6.76 12.96C6.72 12.96 6.67333 12.96 6.62 12.96C5.82 12.96 5.18667 12.6133 4.72 11.92C4.26667 11.2267 4.04 10.0667 4.04 8.44C4.04 7.28 4.16667 6.34667 4.42 5.64C4.67333 4.93333 5.02 4.41333 5.46 4.08C5.9 3.73333 6.38667 3.54 6.92 3.5C6.97333 3.5 7.02667 3.5 7.08 3.5C7.13333 3.48667 7.18667 3.48 7.24 3.48C8.02667 3.48 8.64 3.82 9.08 4.5C9.52 5.16667 9.74 6.31333 9.74 7.94C9.74 9.67333 9.47333 10.9267 8.94 11.7C8.42 12.46 7.74667 12.8733 6.92 12.94ZM6.86 12.74C7.64667 12.6733 8.28667 12.2733 8.78 11.54C9.28667 10.8067 9.54 9.60667 9.54 7.94C9.54 7.18 9.49333 6.53333 9.4 6C9.30667 5.46667 9.16667 5.03333 8.98 4.7C8.91333 4.68667 8.84667 4.68 8.78 4.68C8.71333 4.66667 8.64667 4.66 8.58 4.66C7.79333 4.66 7.20667 5.07333 6.82 5.9C6.43333 6.71333 6.24 7.89333 6.24 9.44C6.24 10.2133 6.29333 10.8733 6.4 11.42C6.50667 11.9533 6.66 12.3933 6.86 12.74Z"
></path>
</svg>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 15 18" height="18" width="15">
<path
d="M9.28 15.76L8.54 13.38L6.96 13.52L5.98 17.02L2.58 17.32L0.58 16.32L4.96 0.699999L8.26 0.419999L10.26 1.42L14.72 16.48L11.28 16.76L9.28 15.76ZM5.12 0.899999L0.88 16.08L3.8 15.84L4.8 12.34L8.36 12.02L9.42 15.56L12.44 15.3L8.1 0.64L5.12 0.899999ZM5.5 9.42C5.75333 8.59333 5.96 7.80667 6.12 7.06C6.29333 6.31333 6.44 5.56667 6.56 4.82H6.64C6.74667 5.55333 6.88 6.27333 7.04 6.98C7.21333 7.67333 7.42 8.42 7.66 9.22L5.5 9.42Z"
></path>
</svg>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 14 18" height="18" width="14">
<path
d="M0.28 16.24V1.04L4.44 0.679999C4.61333 0.666666 4.78 0.66 4.94 0.66C5.1 0.646666 5.24667 0.64 5.38 0.64C6.11333 0.64 6.76667 0.726666 7.34 0.899999C7.92667 1.07333 8.56667 1.32667 9.26 1.66C10.1933 2.08667 10.9533 2.61333 11.54 3.24C12.1267 3.85333 12.56 4.61333 12.84 5.52C13.12 6.41333 13.26 7.50667 13.26 8.8C13.26 10.4933 12.9733 11.92 12.4 13.08C11.84 14.24 11.0667 15.1333 10.08 15.76C9.09333 16.3733 7.95333 16.74 6.66 16.86L2.28 17.24L0.28 16.24ZM4.64 15.68C5.89333 15.5733 7 15.2133 7.96 14.6C8.93333 13.9867 9.69333 13.1133 10.24 11.98C10.7867 10.8467 11.06 9.45333 11.06 7.8C11.06 5.53333 10.5733 3.80667 9.6 2.62C8.64 1.43333 7.21333 0.84 5.32 0.84C5.18667 0.84 5.04667 0.846666 4.9 0.859999C4.75333 0.859999 4.60667 0.866666 4.46 0.879999L0.48 1.22V16.02L4.64 15.68ZM3.5 3.9L4.08 3.86C4.22667 3.84667 4.36 3.84 4.48 3.84C4.61333 3.82667 4.74667 3.82 4.88 3.82C5.57333 3.82 6.14 3.94667 6.58 4.2C7.03333 4.45333 7.36667 4.88667 7.58 5.5C7.80667 6.11333 7.92 6.97333 7.92 8.08C7.92 9.65333 7.59333 10.8067 6.94 11.54C6.28667 12.26 5.4 12.6667 4.28 12.76L3.5 12.82V3.9ZM5.7 12.2C6.38 11.9067 6.88667 11.4333 7.22 10.78C7.55333 10.1133 7.72 9.21333 7.72 8.08C7.72 6.66667 7.52 5.65333 7.12 5.04C7.06667 5.02667 7.01333 5.02 6.96 5.02C6.90667 5.02 6.85333 5.02 6.8 5.02C6.68 5.02 6.56 5.02667 6.44 5.04C6.33333 5.04 6.22 5.04667 6.1 5.06L5.7 5.08V12.2Z"
></path>
</svg>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 6 17" height="18" width="6">
<path
d="M0.38 15.96V0.76L3.86 0.439999L5.86 1.44V16.64L2.38 16.94L0.38 15.96ZM0.58 0.94V15.74L3.66 15.46V0.66L0.58 0.94Z"
></path>
</svg>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 13 18" height="18" width="13">
<path
d="M7.22 15.82L5.72 12.44V16.92L2.28 17.22L0.28 16.22V1.02L3.52 0.74L5.52 1.74L7 4.94V0.64L10.48 0.319999L12.48 1.32V16.54L9.22 16.82L7.22 15.82ZM7.2 0.819999V6.42C7.2 6.56667 7.20667 6.80667 7.22 7.14C7.23333 7.46 7.24667 7.8 7.26 8.16C7.28667 8.50667 7.30667 8.80667 7.32 9.06C7.33333 9.3 7.34 9.42 7.34 9.42L7.28 9.46C7.28 9.46 7.26 9.38667 7.22 9.24C7.19333 9.09333 7.14667 8.92 7.08 8.72C7.01333 8.50667 6.94 8.31333 6.86 8.14L3.4 0.959999L0.48 1.2V16L3.52 15.76V10.52C3.52 10.36 3.51333 10.0867 3.5 9.7C3.48667 9.31333 3.47333 8.90667 3.46 8.48C3.46 8.05333 3.45333 7.69333 3.44 7.4C3.42667 7.09333 3.42 6.94 3.42 6.94L3.48 6.92C3.48 6.92 3.51333 7.05333 3.58 7.32C3.66 7.57333 3.76667 7.84 3.9 8.12L7.4 15.62L10.28 15.36V0.539999L7.2 0.819999Z"
></path>
</svg>
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 15 18" height="18" width="15">
<path
d="M14.04 13.72C13.64 14.6533 12.9933 15.44 12.1 16.08C11.22 16.72 10.1333 17.0933 8.84 17.2C8.72 17.2133 8.6 17.22 8.48 17.22C8.36 17.22 8.24 17.22 8.12 17.22C7.12 17.22 6.16667 17.0467 5.26 16.7C4.36667 16.3533 3.57333 15.8333 2.88 15.14C2.18667 14.4333 1.64 13.54 1.24 12.46C0.84 11.38 0.64 10.1 0.64 8.62C0.64 7.48667 0.78 6.42667 1.06 5.44C1.34 4.44 1.74667 3.55333 2.28 2.78C2.82667 2.00667 3.48667 1.38667 4.26 0.92C5.03333 0.453333 5.90667 0.179999 6.88 0.0999997C6.96 0.0866657 7.04 0.0799987 7.12 0.0799987C7.2 0.0799987 7.28 0.0799987 7.36 0.0799987C8.33333 0.0799987 9.28 0.299999 10.2 0.74C11.1333 1.18 11.9467 1.78 12.64 2.54C13.3467 3.3 13.8467 4.16 14.14 5.12L11.76 6.46L12.04 6.44L14.04 7.44V13.72ZM5.9 7.16V10L8.98 9.74V11.46C8.80667 11.8067 8.52667 12.1067 8.14 12.36C7.76667 12.6 7.37333 12.7333 6.96 12.76C6.90667 12.7733 6.84667 12.78 6.78 12.78C6.72667 12.78 6.66667 12.78 6.6 12.78C5.73333 12.78 5.08 12.4333 4.64 11.74C4.2 11.0467 3.98 9.92 3.98 8.36C3.98 6.94667 4.20667 5.82 4.66 4.98C5.11333 4.14 5.84 3.68 6.84 3.6H7.06C7.60667 3.6 8.07333 3.76 8.46 4.08C8.86 4.4 9.14667 4.86667 9.32 5.48L11.9 4.02C11.6733 3.38 11.36 2.78 10.96 2.22C10.5733 1.64667 10.0867 1.18 9.5 0.819999C8.91333 0.459999 8.2 0.28 7.36 0.28C7.29333 0.28 7.22 0.28 7.14 0.28C7.06 0.28 6.98 0.286666 6.9 0.299999C5.63333 0.406666 4.54667 0.846666 3.64 1.62C2.73333 2.38 2.04 3.37333 1.56 4.6C1.08 5.81333 0.84 7.15333 0.84 8.62C0.84 10.14 1.06 11.4533 1.5 12.56C1.94 13.6667 2.56667 14.52 3.38 15.12C4.19333 15.72 5.16 16.02 6.28 16.02C6.37333 16.02 6.46 16.02 6.54 16.02C6.63333 16.02 6.72667 16.0133 6.82 16C8.07333 15.8933 9.12667 15.54 9.98 14.94C10.8467 14.3267 11.4733 13.5733 11.86 12.68V6.66L5.9 7.16ZM9.2 5.78C9.14667 5.59333 9.08667 5.42 9.02 5.26C8.95333 5.08667 8.88 4.93333 8.8 4.8C8.2 4.85333 7.70667 5.06667 7.32 5.44C6.94667 5.8 6.66667 6.29333 6.48 6.92L10.8 6.56L9.2 5.78ZM7.8 11.26L6.24 10.46C6.26667 10.9933 6.32 11.4133 6.4 11.72C6.49333 12.0133 6.62667 12.3 6.8 12.58C6.84 12.5667 6.88667 12.56 6.94 12.56C7.28667 12.5333 7.63333 12.4267 7.98 12.24C8.32667 12.04 8.59333 11.8067 8.78 11.54V11.14L7.8 11.26Z"
></path>
</svg>
</span>
</div>
<div class="app-boot__message" id="app-boot-message" hidden></div>
<button class="app-boot__retry" id="app-boot-retry" type="button" hidden>重新加载</button>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+42 -1
View File
@@ -1,7 +1,48 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import ErrorBoundary from '@/views/fallback/ErrorBoundary.vue'
import FallbackPage from '@/views/fallback/FallbackPage.vue'
import { useOnline } from '@/composables/useOnline'
/**
* 应用根:渲染当前路由页面 + 离线兜底层。
*
* ErrorBoundary 包住整个 RouterView(含 DefaultLayout 外壳):
* 页面或布局的渲染错误就地兜成 500 缺省页,而不是整屏白。
* 对照 fms-vue 的 App.vue(`<ErrorBoundary><router-view /></ErrorBoundary>`)。
*
* 注意 onErrorCaptured 只捕获【后代组件】的错误;根组件自身与
* 事件回调 / 异步链里的错误不走这里 —— 那些由 main.ts 的
* app.config.errorHandler 与接口层的统一提示兜底。
*/
const { isOffline } = useOnline()
/**
* 离线兜底:断网时在最上层【盖一页】离线缺省页,恢复后自动移除。
*
* ⚠️ 用覆盖层,而不是 v-if 把 RouterView 换掉:
* 原页面组件始终挂载,断网前后的表单输入、滚动位置、页签缓存全部保留;
* 若用 v-else 替换,断网瞬间页面被卸载,恢复后回到初始状态,用户填了一半的表单就没了。
* 这正是 fms-vue 的做法(它注释里明确写了这个取舍)。
*
* z-index 取 5000:高于弹窗(G3UI 的弹层走 z-index 管理器,基数 200 左右),
* 断网时全域可见、不被任何浮层遮挡。
*/
</script>
<template>
<RouterView />
<ErrorBoundary>
<RouterView />
</ErrorBoundary>
<FallbackPage v-if="isOffline" status="offline" class="app-offline" />
</template>
<style lang="scss" scoped>
.app-offline {
position: fixed;
inset: 0;
z-index: 5000;
}
</style>
@@ -0,0 +1,63 @@
import { computed, onBeforeUnmount, readonly, ref, type ComputedRef, type Ref } from 'vue'
/**
* 网络在线状态。
*
* 对照 fms-vue 的 App.vue —— 它在应用根监听 window 的 online/offline,
* 断网时盖一层离线缺省页(FallbackPage status="offline"),恢复后自动移除。
*
* 为什么用 navigator.onLine + 事件监听,而不是轮询:
* 轮询会持续发请求,白耗流量与电量;这两个 API 是浏览器原生事件驱动,
* 零轮询、零定时器,断网/恢复的响应是即时的。性能开销可忽略。
*
* ⚠️ 已知局限(fms-vue 也记了同一条):navigator.onLine 只反映【本机是否连上网】,
* 连在局域网但服务端不可达时它仍报在线。那种场景由接口层兜底 ——
* request.ts 对「无 status(服务端不可达)」统一弹「网络异常」提示。
* 所以本 composable 不需要、也不应该去做健康检查。
*/
/** 模块级单例:多处读取共用一份状态与一套监听 */
const online = ref(true)
let listenerCount = 0
function sync(): void {
online.value = navigator.onLine
}
/** 首个使用者挂监听,最后一个使用者解绑 —— 避免重复注册 */
function bind(): void {
if (listenerCount === 0) {
sync()
window.addEventListener('online', sync)
window.addEventListener('offline', sync)
}
listenerCount += 1
}
function unbind(): void {
listenerCount -= 1
if (listenerCount > 0) return
window.removeEventListener('online', sync)
window.removeEventListener('offline', sync)
}
/** 只读的在线态(对外只读,写入只能由浏览器的 online/offline 事件驱动) */
const isOnline = readonly(online)
/** 离线态:取反,供模板直接 v-if */
const isOffline: ComputedRef<boolean> = computed(() => !online.value)
export interface UseOnlineReturn {
/** 当前是否在线 */
isOnline: Readonly<Ref<boolean>>
/** 当前是否离线 */
isOffline: ComputedRef<boolean>
}
export function useOnline(): UseOnlineReturn {
bind()
onBeforeUnmount(unbind)
return { isOnline, isOffline }
}
@@ -3,31 +3,59 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { House } from '@lucide/vue'
import { usePreferencesStore } from '@/preferences'
import { findMenuItemByPath, findParentKeyByPath } from '@/router'
import { menuItems } from '@/constants/menu'
import { findMenuItemByKey, findMenuItemByPath, findParentKeysByPath } from '@/router'
/** 面包屑单项:有 path 的是可点的父级,没有的是当前页。 */
interface Crumb {
title: string
path?: string
}
/**
* 面包屑。
* 数据来自菜单常量:父级分组 + 当前页,与侧栏同源,不额外维护。
* 数据来自菜单常量:祖先分组链 + 当前页,与侧栏同源,不额外维护。
*/
const route = useRoute()
const prefStore = usePreferencesStore()
const pref = computed(() => prefStore.preferences)
/** 当前路径对应的祖先链:[分组, 当前页] */
const crumbs = computed(() => {
const list: Array<{ title: string; path?: string }> = []
/**
* 当前路径对应的祖先链:[...分组, 当前页]。
*
* 父级用 findParentKeysByPath(返回【完整】祖先 key 链)而不是
* findParentKeyByPath(只取 [0] = 顶级)—— 后者在多级菜单下会漏掉中间层:
* 「海运 → 订舱业务 → 订舱单」只会显示成「海运 / 订舱单」。
*/
const crumbs = computed<Crumb[]>(() => {
const list: Crumb[] = []
if (pref.value.breadcrumb.showHome) {
list.push({ title: '首页', path: '/' })
}
const current = findMenuItemByPath(route.path)
const parentKey = findParentKeyByPath(route.path)
const parent = parentKey ? menuItems.find((item) => item.key === parentKey) : undefined
// 1) 菜单里的完整祖先链(顶级 → 最近一级分组)
for (const key of findParentKeysByPath(route.path)) {
const node = findMenuItemByKey(key)
if (node) list.push({ title: node.title, path: node.path })
}
if (parent) list.push({ title: parent.title, path: parent.path })
if (current && current.key !== parent?.key) list.push({ title: current.title })
const current = findMenuItemByPath(route.path)
// 2) 菜单里查不到祖先(页面不在菜单树 / 菜单与路由不同步)时,
// 用路由层级兜底:route.matched 上带 title 的祖先记录即父级。
// 当前页不在这里补(由下面统一追加),避免自链接。
if (!current && list.length === 0) {
for (const record of route.matched) {
if (record.path === route.path) break
const title = record.meta.title as string | undefined
if (title) list.push({ title, path: record.path })
}
}
// 3) 当前页:菜单里查得到就用菜单名(业务页);
// 查不到(403/404/500 等缺省页、详情页)回退到 route.meta.title。
const currentTitle = current?.title ?? (route.meta.title as string | undefined)
if (currentTitle) list.push({ title: currentTitle })
return list
})
@@ -50,11 +78,16 @@ const visible = computed(() => {
<span v-if="index > 0" class="layout-breadcrumb__sep">/</span>
<RouterLink v-if="crumb.path" class="layout-breadcrumb__item" :to="crumb.path">
<House v-if="index === 0 && pref.breadcrumb.showHome" :size="14" />
<span>{{ crumb.title }}</span>
<!-- __content 是 background 风格里做「内容整体左移」的包裹层(照 vben 的 li a > span) -->
<span class="layout-breadcrumb__content">
<House v-if="index === 0 && pref.breadcrumb.showHome" :size="14" />
{{ crumb.title }}
</span>
</RouterLink>
<span v-else class="layout-breadcrumb__item is-current">{{ crumb.title }}</span>
<span v-else class="layout-breadcrumb__item is-current">
<span class="layout-breadcrumb__content">{{ crumb.title }}</span>
</span>
</template>
</nav>
</template>
@@ -65,13 +98,8 @@ const visible = computed(() => {
align-items: center;
gap: 6px;
min-width: 0;
font-size: 13px;
&.is-background {
padding: 4px 10px;
background: var(--g3-color-fill-hover, #f2f3f5);
border-radius: 6px;
}
/* 字号:15px —— 比顶栏 18px 的图标按钮小一档,层次清楚又不至于太弱 */
font-size: 15px;
&__sep {
color: var(--g3-color-text-tertiary, #86909c);
@@ -80,7 +108,6 @@ const visible = computed(() => {
&__item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--g3-color-text-secondary, #4e5969);
text-decoration: none;
@@ -92,5 +119,113 @@ const visible = computed(() => {
color: var(--g3-color-text, #1f2329);
}
}
&__content {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* ---- background 风格 ----
* 照搬 vben 的 breadcrumb-background.vue:整条不是「灰底圆角块」,而是
* 一段段 accent 底色的「>」形(面包屑步骤条)。段与段之间不再用 / 分隔符,
* 靠两块 14px 边框三角拼接:
* ::before 放在段左侧 28px 处,左三角透明 → 形成向左的凹口;
* ::after 放在段右侧,只留左三角着色 → 形成指向右的尖角。
* 14px 边框 × 上下两半 = 28px,正好等于段高 h-7(28px)。 */
&.is-background {
gap: 0;
font-size: 13px;
/* 有箭头就没有分隔符了 */
.layout-breadcrumb__sep {
display: none;
}
.layout-breadcrumb__item {
position: relative;
display: flex;
align-items: center;
height: 28px;
/* mr-9(36px):给右箭头占的 28px 留位,另留 8px 视觉间隙 */
margin-right: 36px;
padding: 0 8px 0 5px;
color: var(--g3-color-text-secondary, #4e5969);
background: var(--g3-color-accent, #f2f3f5);
transition: background-color 0.2s ease;
&::before,
&::after {
content: '';
position: absolute;
top: 0;
width: 0;
height: 0;
border: 14px solid var(--g3-color-accent, #f2f3f5);
}
&::before {
left: -28px;
z-index: 10;
border-left-color: transparent;
}
&::after {
left: 100%;
border-color: transparent;
border-left-color: var(--g3-color-accent, #f2f3f5);
}
/* 首段:左端不开口,左侧留白加大并做左圆角 */
&:first-child {
padding-left: 15px;
border-radius: 2px 0 0 2px;
&::before {
border: none;
}
}
/* 末段(当前页):不做右箭头,右侧留白加大并做右圆角 */
&:last-child {
padding-right: 15px;
border-radius: 0 2px 2px 0;
&::after {
border: none;
}
}
&.is-current {
color: var(--g3-color-foreground, #373c43);
}
/* 悬停只换底色,不改文字色(对齐 vben:当前页不可点,不参与 hover) */
&:not(:last-child):hover {
color: var(--g3-color-text-secondary, #4e5969);
background: var(--g3-color-accent-hover, #e3e5e8);
&::before {
border-color: var(--g3-color-accent-hover, #e3e5e8);
border-left-color: transparent;
}
&::after {
border-left-color: var(--g3-color-accent-hover, #e3e5e8);
}
}
}
/* 内容整体左移,视觉上落进「>」的口子里(照 vben 的 -ml-3 / 首段 -ml-1) */
.layout-breadcrumb__content {
position: relative;
z-index: 10;
margin-left: -12px;
}
.layout-breadcrumb__item:first-child .layout-breadcrumb__content {
margin-left: -4px;
}
}
}
</style>
+119 -21
View File
@@ -1,22 +1,27 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import {
Bell,
Languages,
LogOut,
Maximize,
Minimize,
Moon,
PanelLeftClose,
PanelLeftOpen,
PanelLeft,
PanelRight,
Settings,
Sun,
User,
} from '@lucide/vue'
import { G3Message } from '@g3soft/ui'
import { useUserStore } from '@/stores'
import { usePreferencesStore } from '@/preferences'
import { useFullscreen } from '@/composables/useFullscreen'
import PreferencesDrawer from '@/preferences/components/PreferencesDrawer.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const prefStore = usePreferencesStore()
const pref = computed(() => prefStore.preferences)
@@ -51,6 +56,20 @@ function toggleSidebar(): void {
prefStore.update({ sidebar: { collapsed: !pref.value.sidebar.collapsed } })
}
/**
* 退出登录。
*
* 对照 vben:登出走 authStore.logout() → resetAllStores(),
* 把用户/权限等 store 全部 $reset()。本仓库用户模块尚未落地,
* 所以这里做当前能做的事:清会话(userStore.clear:清 userInfo + localStorage 的 token)。
*
* TODO 用户模块落地后补:调登出接口、清权限码、销毁动态路由。
*/
function logout(): void {
userStore.clear()
router.replace('/login')
}
/** 当前是否深色(auto 跟随系统时也能正确反映) */
const isDark = computed(() => prefStore.isDark)
@@ -69,6 +88,21 @@ function toggleTheme(): void {
* 与页签栏的「局部全屏」是两件事 —— 那个只隐藏应用外壳,浏览器窗口本身不变。
*/
const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen } = useFullscreen()
/* ---------------- 语言 / 提醒(功能未实现,先给占位反馈) ---------------- */
/**
* 语言切换:项目尚未接入 i18n(无 vue-i18n 依赖),
* 与登录页 Login.vue 的 toggleLang 保持同一占位口径,接入后替换为真实切换。
*/
function handleLanguage(): void {
G3Message.info('语言切换待接入 i18n 后生效')
}
/** 消息提醒:功能未实现,先给占位反馈 */
function handleNotification(): void {
G3Message.info('消息提醒功能尚未实现')
}
</script>
<template>
@@ -95,9 +129,11 @@ const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen
:title="pref.sidebar.collapsed ? '展开侧栏' : '折叠侧栏'"
@click="toggleSidebar"
>
<!-- 折叠/展开语义图标(对照 vben 的 ep:fold / ep:expand):折叠态显示「展开」、展开态显示「折叠」 -->
<PanelLeftOpen v-if="pref.sidebar.collapsed" :size="18" />
<PanelLeftClose v-else :size="18" />
<!-- 折叠/展开语义图标(对照 vben 的 ep:fold / ep:expand):
折叠态显示 PanelRight(点击展开)、展开态显示 PanelLeft(点击折叠)。
两者同为 18×18 圆角矩形轮廓,仅分隔线在左/右,视觉重量与切换动效一致,不会因换图标跳动。 -->
<PanelRight v-if="pref.sidebar.collapsed" :size="18" />
<PanelLeft v-else :size="18" />
</button>
<!-- 顶部菜单布局:菜单直接放进顶栏 -->
@@ -110,18 +146,15 @@ const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen
<slot v-else name="breadcrumb" />
</div>
<!-- 右侧工具区。顺序照 vben 的 preferences.widget.order
(packages/@core/preferences/src/config.ts:157-168):
['globalSearch','preferences','themeToggle','languageToggle','timezone',
'fullscreen','refresh','notification','lockScreenBtn','logoutBtn']
本仓库没有 globalSearch / timezone,refresh 在页签栏而非顶栏,
lockScreen 已移除,故实际为:
preferences → themeToggle → languageToggle → fullscreen → notification
→ logoutBtn(用户区整体在最后) -->
<div class="layout-header__right">
<button
type="button"
class="layout-header__icon-btn"
:title="isDark ? '切换为浅色' : '切换为深色'"
@click="toggleTheme"
>
<!-- 深色时显示太阳(点击切回浅色),浅色时显示月亮(点击切深色) -->
<Sun v-if="isDark" :size="17" />
<Moon v-else :size="17" />
</button>
<button
v-if="pref.app.enablePreferences"
type="button"
@@ -129,7 +162,29 @@ const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen
title="偏好设置"
@click="preferencesOpen = true"
>
<Settings :size="17" />
<Settings :size="18" />
</button>
<button
type="button"
class="layout-header__icon-btn"
:title="isDark ? '切换为浅色' : '切换为深色'"
@click="toggleTheme"
>
<!-- 深色时显示太阳(点击切回浅色),浅色时显示月亮(点击切深色) -->
<Sun v-if="isDark" :size="18" />
<Moon v-else :size="18" />
</button>
<!-- 语言切换:i18n 未接入,点击给占位提示(与登录页同一口径) -->
<button
v-if="pref.header.showLanguage"
type="button"
class="layout-header__icon-btn"
title="语言切换"
@click="handleLanguage"
>
<Languages :size="18" />
</button>
<!-- 页面全屏:原生 Fullscreen API,整页铺满屏幕;浏览器不支持时不渲染 -->
@@ -140,13 +195,34 @@ const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen
:title="isFullscreen ? '退出全屏' : '页面全屏'"
@click="toggleFullscreen()"
>
<Minimize v-if="isFullscreen" :size="17" />
<Maximize v-else :size="17" />
<Minimize v-if="isFullscreen" :size="18" />
<Maximize v-else :size="18" />
</button>
<!-- 消息提醒:功能未实现,点击给占位提示 -->
<button
v-if="pref.header.showNotification"
type="button"
class="layout-header__icon-btn"
title="消息提醒"
@click="handleNotification"
>
<Bell :size="18" />
</button>
<div class="layout-header__user">
<User :size="16" />
<span>{{ displayName }}</span>
<!-- 退出登录:用户模块落地前只有「回登录页」这一个动作 -->
<button
class="layout-header__logout"
type="button"
title="退出登录"
aria-label="退出登录"
@click="logout"
>
<LogOut :size="15" />
</button>
</div>
</div>
@@ -170,7 +246,7 @@ const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen
&__right {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
min-width: 0;
}
@@ -261,5 +337,27 @@ const { isFullscreen, isSupported: fullscreenSupported, toggle: toggleFullscreen
font-size: 13px;
color: var(--g3-color-foreground, #1f2329);
}
/* 退出登录:比工具条按钮小一号,不抢视觉;色值走 inherit,半深色顶栏也不脱色 */
&__logout {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
padding: 0;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 6px;
opacity: 0.7;
&:hover {
background: var(--g3-color-accent, #f2f3f5);
opacity: 1;
}
}
}
</style>
+226 -36
View File
@@ -79,7 +79,7 @@ const overflowed = ref(false)
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
/** 滚动区末尾的样式留白(与 .layout-tabbar__spacer 的 24px 一致)。
/** 滚动区末尾的样式留白(与 .layout-tabbar__track 的 padding-right: 24px 一致)。
* 它是纯占位、不计真实内容,但会计入 scrollWidth;判断"是否需要滚动"时必须把它排除,
* 否则页签刚好接近铺满时会被误判为溢出 —— 左侧冒出一个变灰、不可点的滚动按钮 */
const TRACK_END_SPACER = 24
@@ -150,9 +150,27 @@ function revealActiveTab(): void {
if (selfFullyVisible && !hasHiddenLeft && !hasHiddenRight) return
/** 让激活项居中所需要的滚动增量 */
const offset = activeRect.left - trackRect.left - (trackRect.width - activeRect.width) / 2
/**
* 计算「让激活项居中」的滚动量,并钳制到合法区间。
*
* 为什么要显式钳上限(不只是 Math.max(0, ...) 的钳下限):
* 当激活项靠近【末尾】时,「居中」所需的目标 scrollLeft 会超过
* track.scrollWidth - track.clientWidth(右侧没有足够内容撑出滚动量),
* 浏览器会把 scrollTo 静默截断 —— 结果停在「居中所需但被截断」的中间值,
* 激活项仍有一截露在可视区外(实测最后一项右边界超出 54px)。
*
* 正确行为:目标超过上限时,滚到【上限】(贴右),让激活项至少完整可见;
* 而不是停在那个被截断的中间位置。
*/
const targetScrollLeft = track.scrollLeft + offset
const maxScrollLeft = track.scrollWidth - track.clientWidth
const clamped = Math.min(Math.max(0, targetScrollLeft), Math.max(0, maxScrollLeft))
try {
track.scrollTo({ left: Math.max(0, track.scrollLeft + offset), behavior: 'smooth' })
track.scrollTo({ left: clamped, behavior: 'smooth' })
} catch {
/* 环境不支持平滑滚动时忽略 */
}
@@ -171,12 +189,48 @@ function handleWheel(event: WheelEvent): void {
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
resizeObserver = new ResizeObserver(updateScrollState)
onMounted(async () => {
/* ResizeObserver 回调里既刷箭头状态、又重定位激活页签。
*
* 为什么 resize 也要 reveal(这是第二个坑):
* 挂载那一刻 revealActiveTab 已经跑过、滚到了正确位置(贴右或居中)。
* 但之后侧栏展开动画 / 滚动条出现 / 局部全屏切换会让轨道【变窄】,
* maxScrollLeft 变大而 scrollLeft 停在旧值 —— 激活项右缘又被裁出去。
* 实测:挂载时 clientWidth=661、滚到 maxScroll=722;稳定后 clientWidth 缩到 595,
* maxScroll 涨到 788,但 scrollLeft 仍停在 722 → 右缘露出去 66px。
*
* revealActiveTab 有完整守卫(完整可见且左右无隐藏就 return),
* 所以 resize 里再调一次是安全的:没被裁就不滚,被裁了才补滚。
*/
resizeObserver = new ResizeObserver(() => {
updateScrollState()
revealActiveTab()
})
if (trackRef.value) {
resizeObserver.observe(trackRef.value)
trackRef.value.addEventListener('wheel', handleWheel, { passive: false })
}
/* 首次挂载也要定位激活页签 —— 修复「刷新后页签不居中」。
*
* 为什么不能只靠下面的 watch:
* 页签是持久化的(g3erp:app:v2),刷新时 pinia 在组件挂载【之前】就把 tabs 还原好了,
* 而 route.fullPath 在路由解析完成时也已确定。
* 于是 watch 的两个依赖在挂载这一刻【already 是初始值】,
* 没有 immediate 的 watch 根本不会触发 → revealActiveTab 一次都没跑
* → 被恢复的页签栏停在 scrollLeft = 0,激活页签若在后面就看不见。
*
* 为什么不用 watch 的 immediate: true:
* immediate 的回调在 onMounted【之前】执行,那时 trackRef 还是 null,
* revealActiveTab 第 126 行的守卫会直接 return —— 等于没修。
*
* 为什么加 await nextTick():
* 挂载时页签虽是同步渲染的,但激活项的高亮/宽度可能受样式影响;
* 等一帧确保布局稳定后再量 getBoundingClientRect,避免量到中间态。
*/
await nextTick()
revealActiveTab()
updateScrollState()
})
onBeforeUnmount(() => {
@@ -366,9 +420,6 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
</div>
</div>
</G3Dropdown>
<!-- 末尾留白(照 vben 的 pr-6) -->
<span class="layout-tabbar__spacer" />
</div>
<!-- 右侧滚动箭头(页签溢出时显示) -->
@@ -471,8 +522,10 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
flex: 1;
align-items: stretch;
min-width: 0;
/* 照 vben:标签区顶部留 3px(pt-0.75),chrome 标签从留白下沿延伸到底边线 */
padding-top: 3px;
/* 注意:这里【不能】写死 padding-top: 3px。
* 那 3px 是 chrome 专属的 —— 见下方 .is-chrome .layout-tabbar__track。
* 写死会让 plain / card 的页签整体下移 3px,文字与满高竖线都不居中。 */
padding-right: 24px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
@@ -482,11 +535,13 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
}
}
/* 末尾留白:滚动到最右时最后一个标签不贴边 */
.layout-tabbar__spacer {
flex: none;
width: 24px;
}
/* 末尾留白 24px(照 vben 容器的 pr-6):滚动到最右时最后一个标签不贴边。
*
* ⚠️ 必须写成【轨道自身的 padding-right】,不能用一个末尾的 <span> 兄弟节点占位。
* plain 的「最后一个页签右边那条竖线」靠 .layout-tabbar__item:last-child 实现,
* 一旦在 item 之后插入 spacer,最后一个 item 就不再是 :last-child,
* last:border-r 永远不生效 —— 最后一个页签会缺一条右边框。
* vben 正是用容器的 pr-6 而非兄弟节点,就是这个原因。 */
/* 页签槽位:撑满轨道高度 + -12px 负右边距(相邻标签重叠,实现「标签块连排」) */
.layout-tabbar__item {
@@ -503,7 +558,11 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
z-index: 2;
}
/* 内层容器:承载绝对定位的背景层与文字区(对应 vben 的 px-1 容器) */
/* 内层容器:承载绝对定位的背景层与文字区。
* 宽度/高度撑满即可 —— 内边距是【分风格】的,见下:
* chrome 对应 vben tabs-chrome 的 'px-1'(4px),写在 .is-chrome 里;
* plain 对应 vben tabs 的 'size-full',【无内边距】。
* 所以这里不能写死 padding,否则 plain 会凭空多出 8px 宽。 */
.layout-tabbar__inner {
position: relative;
z-index: 0;
@@ -511,7 +570,6 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
align-items: center;
width: 100%;
height: 100%;
padding: 0 4px;
}
/* 分隔线:非首个、非激活的页签左侧一条 16px 竖线 */
@@ -600,7 +658,20 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
}
}
/* 主内容:决定页签宽度(照 FMS / vben:左右 14px 外边距 + 右侧 16px 内边距给关闭按钮留位) */
/* 主内容:决定页签宽度。
*
* 【布局意图】文字靠左、关闭按钮贴右 —— 一个页签的两端。
* 这是 vben 的结构(文字 flex-1 撑满并靠左,X 绝对定位在右侧),也是页签的通用范式:
* 文字从固定的一侧起排,扫视一排页签时视线不用来回找起点。
*
* ⚠️ 不要把这里改成 justify-content: center(曾这么改过,观感是错的):
* 关闭按钮(__extra)是 position:absolute、不占文档流宽度,
* 文字居中后视觉重心会偏右 —— 右边看着「文字和 X 挤在一起」,左边却空着。
*
* 内外边距因此是不对称的,右边多出来的部分就是给关闭按钮让位:
* 左 = inner 4 + margin 14 + padding 8 = 26px
* 右 = inner 4 + margin 14 + padding 16 = 34px
* (与 vben chrome 的「左 26 / 右 34」完全一致:px-1 + mx-3.5 + pl-2/pr-4) */
.layout-tabbar__main {
position: relative;
z-index: 2;
@@ -619,23 +690,40 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
transition: color 150ms ease;
}
/* 文字:flex-1 撑满剩余宽度 —— 这是「文字靠左」的关键。
* 去掉 flex-1 后文字盒会收缩成内容宽度,再配合父级的 justify-content 就会居中;
* 保留 flex-1 则文字盒始终占满左侧空间,文字自然靠左,右侧把关闭按钮的位置空出来。
* 超长标题靠 overflow/ellipsis 截断,不会挤压关闭按钮。 */
.layout-tabbar__label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 不可关闭的页签(工作台):没有关闭按钮,右侧不必预留 16px,
* 左右对称 8px,文字在标签内居中 */
.layout-tabbar__item.is-unclosable .layout-tabbar__main {
padding: 0 8px;
}
/* 照 FMS:flex-1 + 溢出隐藏,不设宽度上限 */
.layout-tabbar__label {
flex: 1;
overflow: hidden;
white-space: nowrap;
}
/* ---- 状态:激活 / 悬停 ---- */
/* 激活:浅主色标签块 + 主色文字,底部外弧与标签块同色 */
.layout-tabbar.is-chrome {
/* 顶部留 3px(vben 的 pt-0.75,见 tabs-view.vue:'pt-0.75': styleType === 'chrome')。
* chrome 标签从留白下沿延伸到底边线,所以这 3px 是 chrome 形态的一部分;
* plain / card 是满高连排,不能吃这 3px,否则内容整体下移、不居中。 */
.layout-tabbar__track {
padding-top: 3px;
}
/* 内层 4px 内边距:vben tabs-chrome 的 'px-1'(tabs-chrome/tabs.vue L110)。
* chrome 专属 —— plain 对应 tabs.vue 的 'size-full',没有内边距。 */
.layout-tabbar__inner {
padding: 0 4px;
}
.layout-tabbar__item.is-active {
.layout-tabbar__background-content {
background: var(--g3-color-primary-soft, hsl(212 100% 45% / 15%));
@@ -678,14 +766,59 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
}
}
/* ---- card 风格 ---- */
/* ---- card 风格(照 vben 的 tabs/tabs.vue 的 card 类名) ----
*
* 来源:packages/@core/ui-kit/tabs-ui/src/components/tabs/tabs.vue
* card 类(L37-40):
* 'h-[calc(100%-6px)] rounded-md ml-2 border border-border transition-all'
*
* ⚠️ 关键差异 —— vben 与本项目的「卡片边界」落在不同元素上,不能照抄几何参数:
*
* vben:item 自带 'rounded-md border border-border'(卡片 = 整个 item)。
* 所以它【继续沿用 plain 的 __main 参数】(mx-3 mr-4 pr-3)没问题 ——
* 那 12/16px 的外边距是在卡片【内部】给文字让位的,卡片本身仍撑满 item。
*
* 本项目:卡片外形(border + radius)画在 __main 上(见下方),item 只负责
* margin-left 8px 的相邻间隔与缩高。此时若再套 plain 的 mx-3 mr-4,
* 就会变成「带边框的卡片外面再留 12/16px」—— 卡片被挤小、文字紧贴左边框,
* 视觉上就是错乱的。所以 card 的 __main 必须:
* margin: 0(卡片直接占满 item,左右间隔由 item 的 margin-left 负责)
* padding 左右对称(文字在卡片内居中,而不是靠左)
*
* 另:vben 容器是 items-center,缩高 6px 的卡片会垂直居中;
* 本项目 __track 用 align-items: stretch,所以显式加 align-self: center。 */
.layout-tabbar.is-card .layout-tabbar__item {
align-self: center;
height: calc(100% - 6px);
margin: 0 0 0 8px;
/* 卡片本体:边框 + 圆角画在这里。
*
* 【为什么 margin-right 必须是 0 —— 它决定卡片之间的间距】
* 边框画在 __main 上,所以 __main 的右 margin 会跑到【卡片外面】,
* 与下一个 item 的 margin-left 叠加成「卡片间距」:
* 曾经 margin-right: 16 → 间距 = 16 + 8(item margin-left) = 24px,观感过疏
* vben 的卡片间距只有 8px(card 类名的 ml-2,边框在 item 上、__main 的 mr-4 在卡内)
* 故这里取 0,让卡片边框直接贴到 item 右缘 → 间距回到 8px。
*
* 【给 X 让位的空间改由 padding-right 承担】
* 记 D(x) = x 距 __inner 右缘的距离(__extra 的包含块是 __inner;【D 越小越靠右】):
* D(X 右边界) = extra.right
* D(X 左边界) = extra.right + 16 (X 宽 16)
* D(卡片右缘) = margin-right = 0
* D(文字右边界) = margin-right + padding-right + 1(border)
*
* 取 inset = 6(X 距卡片右缘,与 plain 的 right-1.5 同观感)、gap = 6(与 plain 实测一致):
* extra.right = 0 + 6 = 6 (于是与 plain 的 6px 一致了)
* padding-right = 6 + 16 + 1 - 1 → 27
*
* CDP 实测走过的弯路(别再试这些组合):
* margin:0 + padding:16 → 文字压住 X(gap = -5)
* margin:16 + right:6 → X 露在卡片外
* margin:16 + right:16/22 → X 在卡内了,但卡片间距 24px(本次要修的问题) */
.layout-tabbar__main {
margin: 0;
padding: 0 8px;
padding: 0 27px 0 8px;
border: 1px solid var(--g3-color-border, #e5e6eb);
border-radius: 6px;
}
@@ -694,21 +827,78 @@ const activeTabContextItems = computed<DropdownItemData[]>(() => {
background: var(--g3-color-primary-soft, hsl(212 100% 45% / 15%));
border-color: var(--g3-color-primary-soft, hsl(212 100% 45% / 15%));
}
/* 关闭按钮:6px = inset(X 距卡片右缘),与 plain 同值。
* 与 __main 的 padding-right 是【联立关系】,单独改任一个都会让 X 露到卡外、或压住文字。 */
.layout-tabbar__extra {
right: 6px;
}
}
/* ---- plain 风格 ---- */
.layout-tabbar.is-plain .layout-tabbar__item {
margin-right: 0;
/* ---- plain 风格(照 vben 的 tabs/tabs.vue 逐条移植) ----
*
* 来源:packages/@core/ui-kit/tabs-ui/src/components/tabs/tabs.vue
*
* item 的类(typeWithClass.plain,L43):
* 'h-full [&:not(:first-child)]:border-l last:border-r border-border'
* item 自身的类(L101 / L94):
* '[&:not(.is-active)]:hover:bg-accent' + 'is-active bg-primary/15'
* 内层容器(L112):'relative flex size-full items-center' ← 注意:无 px-1
* 主内容(L132): 'mx-3 mr-4 ... pr-3' ← margin L12/R16,padding R12
* 关闭按钮(L115):'absolute ... right-1.5' ← 6px
*
* 与 chrome(tabs-chrome/tabs.vue)的关键差异:
* - chrome 的内层有 px-1(4px)、__extra 在 right-(--gap)=7px、__main 是 pl-2 pr-4;
* plain 内层【无内边距】、__extra 在 6px、__main 是 pr-3。两者数值并不通用。
* - chrome 的 item 有 -mr-3(-12px)让标签块重叠;plain 无负边距 ——
* 竖分割线一旦重叠就会被盖住。
* - 边框挂在 item 上而不是 __main:竖线要撑满整条页签栏高度,
* 挂 __main 会被它的 margin 截断。 */
.layout-tabbar.is-plain {
.layout-tabbar__item {
/* 1. 无重叠:每个页签独立占位(chrome 是 -12px) */
margin-right: 0;
.layout-tabbar__main {
margin: 0;
padding: 0 12px;
border-left: 1px solid var(--g3-color-border, #e5e6eb);
border-radius: 0;
/* 2. 满高竖分割线:[&:not(:first-child)]:border-l + last:border-r
* last:border-r 就是「最后一个页签右边缘那条竖线」——两个伪类缺一不可。 */
&:not(:first-child) {
border-left: 1px solid var(--g3-color-border, #e5e6eb);
}
&:last-child {
border-right: 1px solid var(--g3-color-border, #e5e6eb);
}
/* 3. 悬停(非激活):整格浅灰底 */
&:hover:not(.is-active) {
background: var(--g3-color-accent, #f2f3f5);
}
/* 4. 激活:整格主色 15% 底 */
&.is-active {
background: var(--g3-color-primary-soft, hsl(212 100% 45% / 15%));
.layout-tabbar__close {
color: var(--g3-color-primary, #165dff);
}
}
/* 主内容:照 vben 的 'mx-3 mr-4 ... pr-3'
* margin: 0 16px 0 12px ← mx-3(12) 被 mr-4(16) 覆盖右侧,所以右是 16
* padding: 0 12px 0 0 ← pr-3,只有右侧内边距
* 底色铺在 item 上,这里保持透明。 */
.layout-tabbar__main {
margin: 0 16px 0 12px;
padding: 0 12px 0 0;
border-radius: 5px 5px 0 0;
background: transparent;
}
}
&.is-active .layout-tabbar__main {
background: var(--g3-color-primary-soft, hsl(212 100% 45% / 15%));
/* 关闭按钮:照 vben 的 right-1.5(6px)。
* plain 的右留白比 chrome 小(16+12=28 vs 14+16=30),X 也相应更靠右。 */
.layout-tabbar__extra {
right: 6px;
}
}
+92 -2
View File
@@ -1,5 +1,5 @@
import { createApp } from 'vue'
import { G3UI, zhCN } from '@g3soft/ui'
import { G3Message, G3UI, zhCN } from '@g3soft/ui'
import App from './App.vue'
import router from './router'
@@ -16,6 +16,27 @@ G3UI.config({
locale: zhCN,
})
/**
* 全局错误兜底。
*
* App.vue 的 ErrorBoundary 只捕获【后代组件的渲染错误】;这里补上它够不到的两类:
* 1. 事件回调(@click 里抛错)—— Vue 会走到 errorHandler,不会触发 onErrorCaptured
* 2. 异步链(未被 catch 的 Promise / 生命周期钩子里的抛错)
*
* 这两类不处理的话,用户点了按钮没反应、控制台有报错但界面毫无提示。
*
* 用 G3Message 而非渲染错误页:这类错误通常【不破坏页面结构】,
* 给一条提示、让用户能继续操作,比整屏换掉更合适。
* 真正该换页的渲染崩溃已由 ErrorBoundary 处理。
*/
app.config.errorHandler = (error, _instance, info) => {
// 控制台保留完整现场(含 Vue 给的 info:是哪个钩子/回调抛的),便于排查
console.error('[global errorHandler]', info, error)
const message = error instanceof Error ? error.message : String(error)
G3Message.error(message || '应用发生未知错误')
}
app.use(pinia)
// 提前初始化偏好设置:store 内部的 watch 是 immediate 的,
@@ -23,4 +44,73 @@ app.use(pinia)
usePreferencesStore()
app.use(router)
app.mount('#app')
/**
* 启动失败时把开屏加载屏切换成错误态(不再无限转圈)。
*
* 与 index.html 里那段 window.onerror 分工不同:那段兜的是「模块都没加载进来」
* (file:// 直接打开、网络断了拿不到 chunk);这里兜的是「模块进来了,但
* router.isReady() 或 mount 抛错」,比如路由守卫里的异常。
*/
function showBootstrapError(error: unknown): void {
console.error('Application bootstrap failed', error)
const boot = document.getElementById('app-boot')
const message = document.getElementById('app-boot-message')
const retry = document.getElementById('app-boot-retry')
if (!boot || !message || !retry) return
boot.classList.add('app-boot--error')
boot.setAttribute('aria-busy', 'false')
boot.setAttribute('aria-label', '应用加载失败')
message.textContent = '应用加载失败,请检查网络后重试'
message.hidden = false
retry.hidden = false
retry.addEventListener('click', () => window.location.reload(), { once: true })
}
/**
* 应用启动流程(对照 fms-vue 的 src/main.js bootstrap)。
*
* 关键取舍:先把首屏数据准备好,再挂载、再淡出加载屏 ——
* 顺序反了会出现「加载屏淡出后菜单才跳出来」的闪动。
* 1. await router.isReady():等首个路由解析完(权限/菜单在守卫里同步加载完,
* 首屏 beforeEach 在 mount 前 return,菜单一次性到位)
* 2. 把 #app-boot 移出 #app 之外(它是 #app 的兄弟节点,这里再确认一次),
* 避免 mount 时 #app 被整体替换导致加载屏瞬间消失(那样没有过渡、还卡顿)
* 3. mount 首屏:布局含完整菜单一次性渲染出来
* 4. 下一帧加 .app-boot--hide:opacity 淡出由合成器驱动,不被渲染阻塞;
* 此时完整页面已在底下就位,淡出过程不会「跳出」
* 5. transitionend 移除节点并清掉加载屏用的临时主题类 boot-dark
* (真正的主题由 applyPreferencesToCss 写入的 .dark / data-g3-theme 管理)
*/
async function bootstrap(): Promise<void> {
try {
await router.isReady()
const boot = document.getElementById('app-boot')
if (boot) document.body.appendChild(boot)
app.mount('#app')
requestAnimationFrame(() => {
boot?.classList.add('app-boot--hide')
})
if (boot) {
const cleanupBoot = () => {
boot.remove()
document.documentElement.classList.remove('boot-dark')
}
boot.addEventListener('transitionend', cleanupBoot, { once: true })
// 兜底:transitionend 未触发(如被 prefers-reduced-motion 关掉动画)时也要移除节点
setTimeout(() => {
if (boot.isConnected) cleanupBoot()
}, 600)
}
} catch (error) {
showBootstrapError(error)
}
}
void bootstrap()
@@ -60,6 +60,8 @@ export const defaultPreferences: Preferences = {
mode: 'fixed',
menuAlign: 'start',
showFullscreen: true,
showLanguage: true,
showNotification: true,
theme: 'light',
},
@@ -145,6 +145,10 @@ export interface HeaderPreferences {
menuAlign: HeaderMenuAlignType
/** 显示页面全屏按钮(整页走原生 Fullscreen API) */
showFullscreen: boolean
/** 显示语言切换按钮(i18n 未接入,当前为提示占位) */
showLanguage: boolean
/** 显示消息提醒按钮(功能未实现,当前为提示占位) */
showNotification: boolean
/** 顶栏主题 */
theme: 'dark' | 'light'
}
@@ -39,7 +39,15 @@ export function applyPreferencesToCss(preferences: Preferences): void {
const { theme, app, sidebar, header } = preferences
// ---- 深色模式:给 html 加 .dark 类,样式里用 .dark { ... } 覆盖变量 ----
root.classList.toggle('dark', isDarkTheme(theme.mode))
const dark = isDarkTheme(theme.mode)
root.classList.toggle('dark', dark)
// 业务 token(--g3-color-*)用上面的 .dark 类;@g3soft/ui 自己的令牌
// (--g3-text-1 / --g3-bg-container / --g3-border-color ...)走
// html[data-g3-theme='dark'](见 @g3soft/tokens/scss/theme.scss)。
// 两条线必须一起切,否则深色下 G3 组件仍吃浅色令牌 —— 表现为表单 label、
// 输入框文字发黑看不见,下拉/弹窗底色依旧雪白。
root.dataset.g3Theme = dark ? 'dark' : 'light'
// ---- 主题色 ----
const colors: Array<[string, string]> = [
@@ -56,11 +64,13 @@ export function applyPreferencesToCss(preferences: Preferences): void {
// 跟随主题色动态派生:选中背景 = 当前主色 + 15%/20% 透明度,
// 这样在偏好里换主题色时,选中背景会一起变,而不是停在写死的蓝色。
// 透明度对齐 vben:浅色 15%,深色 20%。
const isDark = isDarkTheme(theme.mode)
root.style.setProperty(
'--g3-color-primary-soft',
toColor(theme.colorPrimary, isDark ? 0.2 : 0.15),
)
root.style.setProperty('--g3-color-primary-soft', toColor(theme.colorPrimary, dark ? 0.2 : 0.15))
// ---- 主色的悬停 / 按下态(@g3soft/ui 的按钮、链接 hover/active 读这两个变量)----
// 组件库在 @g3soft/tokens 里给的是「种子色 + 90% / 80% 透明度」,而种子色是 #18181b(近黑):
// 只覆盖 --g3-color-primary 的话,蓝色按钮 hover 会直接变成黑色。这里按同一套算法跟随主题色。
root.style.setProperty('--g3-color-primary-hover', toColor(theme.colorPrimary, 0.9))
root.style.setProperty('--g3-color-primary-active', toColor(theme.colorPrimary, 0.8))
// ---- 圆角与字号 ----
root.style.setProperty('--g3-radius-base', `${theme.radius}rem`)
@@ -78,6 +88,6 @@ export function applyPreferencesToCss(preferences: Preferences): void {
root.style.setProperty('--g3-content-padding', `${app.contentPadding}px`)
// ---- 半深色:仅浅色模式下生效,用 class 表达 ----
root.classList.toggle('semi-dark-header', theme.semiDarkHeader && !isDarkTheme(theme.mode))
root.classList.toggle('semi-dark-sidebar', theme.semiDarkSidebar && !isDarkTheme(theme.mode))
root.classList.toggle('semi-dark-header', theme.semiDarkHeader && !dark)
root.classList.toggle('semi-dark-sidebar', theme.semiDarkSidebar && !dark)
}
+43 -1
View File
@@ -3,6 +3,7 @@ import 'nprogress/nprogress.css'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import airRoutes from './modules/air'
import authRoutes from './modules/auth'
import containerRoutes from './modules/container'
import crmRoutes from './modules/crm'
import financeRoutes from './modules/finance'
@@ -19,6 +20,8 @@ NProgress.configure({ showSpinner: false })
const firstMenuPath = menuItems[0]?.path ?? '/ocean'
const routes: RouteRecordRaw[] = [
// 认证域(登录等):顶层路由,与主框架平级、不套 DefaultLayout
...authRoutes,
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
@@ -30,12 +33,31 @@ const routes: RouteRecordRaw[] = [
...financeRoutes,
...crmRoutes,
...systemRoutes,
// 403 / 500:作为主框架的子路由(保留侧栏/顶栏/页签,用户可直接切走),
// 与 fms-vue 一致(routes.js 里 /403 /500 挂在布局下)。
// 渲染崩溃的兜底不经过这里 —— 那由 App 层的 ErrorBoundary 全屏渲染。
//
// 页签 / 面包屑 / 文档标题统一用「403 / 500」状态码(与 URL 一一对应),
// 页面内的标题仍由 G3Result 按 locale 显示「无权访问 / 页面出错了」。
{
path: '/403',
name: 'forbidden',
component: () => import('@/views/fallback/Forbidden.vue'),
meta: { title: '403' },
},
{
path: '/500',
name: 'server-error',
component: () => import('@/views/fallback/ServerError.vue'),
meta: { title: '500' },
},
],
},
// 通配 404:独立布局(无外壳),放最后
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/error/NotFound.vue'),
component: () => import('@/views/fallback/NotFound.vue'),
meta: { title: '页面不存在' },
},
]
@@ -80,6 +102,26 @@ export function findMenuItemByPath(path: string): MenuItem | undefined {
return walk(menuItems)
}
/**
* 在菜单树中按 key 查找菜单项(含子级)。
* 面包屑用它把祖先 key 链还原成「分组名称」。
*/
export function findMenuItemByKey(key: string): MenuItem | undefined {
const walk = (items: MenuItem[]): MenuItem | undefined => {
for (const item of items) {
if (item.key === key) return item
if (item.children?.length) {
const found = walk(item.children)
if (found) return found
}
}
return undefined
}
return walk(menuItems)
}
/**
* 由当前路径反查它的所有祖先分组 key(从顶级到最近一级)。
* 支持多级嵌套与详情页:/ocean/booking/123 会命中「海运」「订舱业务」。
@@ -0,0 +1,19 @@
import type { RouteRecordRaw } from 'vue-router'
/**
* 认证域路由(登录 / 忘记密码 / 重置密码 / SSO 回调…)。
*
* ⚠️ 与其它 modules/*.ts 的展开位置不同:这些是**顶层**路由,与主框架平级、
* 不套 DefaultLayout —— 登录时还没有侧栏 / 顶栏 / 页签。
* 所以它们在 router/index.ts 里于顶层展开,而不是塞进 DefaultLayout 的 children。
*/
const authRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/auth/Login.vue'),
meta: { title: '登录' },
},
]
export default authRoutes
+108 -19
View File
@@ -1,16 +1,46 @@
import axios, { type AxiosInstance, type AxiosResponse } from 'axios'
import axios, { type AxiosInstance, type AxiosResponse, type AxiosRequestConfig } from 'axios'
import { G3Message } from '@g3soft/ui'
import { API_BASE } from '@/constants/common'
/** 后端统一响应结构。骨架阶段的最小契约,引擎设计确定后再扩展。 */
/**
* HTTP 层:统一拆包、鉴权注入、错误提示。
*
* 设计对照 fms-vue 的 src/services/http.js,四处关键取舍一并沿用:
*
* 1. 【只有鉴权类错误才跳登录】—— 网络异常、5xx 不能把用户登出。
* 早先的实现(本文件上一版)在 401 时只 removeItem('token'),
* 既没跳转、也没清 store,用户会停在「页面看着正常但所有请求都失败」的状态。
*
* 2. 【全局性问题才自动弹提示】—— 服务端不可达 / 5xx 属全局性问题,统一弹;
* 4xx 业务错误交给调用方决定(不同的业务错误该有不同的展示位置与文案)。
*
* 3. 【toasted 标记去重】—— 已经自动弹过的错误打上 toasted,
* 调用方若自己也要提示,据此判断避免弹两次。这是 fms-vue 踩过的坑。
*
* 4. 【跳登录去重】—— 并发请求同时 401 时,只跳一次。
*/
/** 业务成功的 code 约定。 */
const SUCCESS_CODE = 0
/** 鉴权失败:HTTP 401 与业务码 401 都算。 */
const AUTH_ERROR_CODES = new Set([401])
/** 业务错误对象:带上 code / data / toasted,供调用方分支处理。 */
export interface RequestError extends Error {
code: number
data?: unknown
/** 已由拦截器统一提示过,调用方不要重复弹 */
toasted?: boolean
}
/** 后端统一响应结构。 */
export interface ApiResult<T = unknown> {
code: number
message: string
data: T
}
/** 业务成功的 code 约定。 */
const SUCCESS_CODE = 0
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE || API_BASE,
timeout: 30_000,
@@ -19,7 +49,38 @@ const request: AxiosInstance = axios.create({
},
})
// 请求拦截:附加鉴权信息
/* ---------------- 鉴权失败处理 ---------------- */
/** 跳转去重:并发请求同时 401 时只跳一次 */
let redirectingToLogin = false
/**
* 跳登录页,带上 redirect 参数回到原地址。
*
* 用 window.location.assign 而非 router.push:这是【会话已失效】的硬跳转,
* 整页重载能顺带清掉内存里所有旧状态(store、keep-alive 缓存、定时器),
* 比在 SPA 内软跳更干净。
*/
function redirectToLogin(): void {
const { pathname, search, hash } = window.location
const current = pathname.replace(/\/+$/, '') || '/'
if (current === '/login' || redirectingToLogin) return
redirectingToLogin = true
const redirect = `${pathname}${search}${hash}`
const query = new URLSearchParams({ redirect, reason: 'expired' })
window.location.assign(`/login?${query.toString()}`)
}
/** 清会话并跳登录页 */
function handleUnauthorized(): void {
// 清 token(user store 尚未落地;接入后改为 userStore.clear())
localStorage.removeItem('g3erp:token')
redirectToLogin()
}
/* ---------------- 请求拦截 ---------------- */
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('g3erp:token')
@@ -31,34 +92,62 @@ request.interceptors.request.use(
(error) => Promise.reject(error),
)
// 响应拦截:拆包 + 统一错误提示
/* ---------------- 响应拦截 ---------------- */
request.interceptors.response.use(
(response: AxiosResponse<ApiResult>) => {
const body = response.data
// 非标准响应(如文件流)直接透传
// 非标准响应直接透传:
// - 文件流(blob):没有 code 字段,调用方需要读 headers(Content-Disposition 里的文件名)
// - 其它无 code 的响应:视为成功
if (body == null || typeof body !== 'object' || !('code' in body)) {
return response
}
if (body.code !== SUCCESS_CODE) {
return Promise.reject(new Error(body.message || '请求失败'))
}
if (body.code === SUCCESS_CODE) return response
return response
if (AUTH_ERROR_CODES.has(body.code)) handleUnauthorized()
const err = new Error(body.message || '请求失败') as RequestError
err.code = body.code
err.data = body.data
throw err
},
(error) => {
const status = error?.response?.status
if (status === 401) {
// 骨架阶段先清 token,登录页与跳转逻辑待用户模块落地后接入
localStorage.removeItem('g3erp:token')
const status: number | undefined = error?.response?.status
const body = error?.response?.data as ApiResult | undefined
const code = body?.code ?? status ?? -1
// 仅鉴权类错误清会话跳登录;网络异常 / 5xx 不能把用户登出
if (AUTH_ERROR_CODES.has(code)) handleUnauthorized()
const message =
body?.message ||
(error?.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络异常,请检查服务是否正常')
const requestError = new Error(message) as RequestError
requestError.code = code
requestError.data = body?.data
// 服务端不可达(无 status)/ 5xx 属全局性问题,统一提示;
// 4xx 业务错误交由调用方决定展示方式。
if (!status || status >= 500) {
G3Message.error(message)
requestError.toasted = true
}
return Promise.reject(error)
throw requestError
},
)
/** 发起请求并直接拿到 data,避免每个调用点都写 res.data.data。 */
export async function http<T = unknown>(config: Parameters<AxiosInstance['request']>[0]) {
/**
* 发起请求并直接拿到 data,避免每个调用点都写 res.data.data。
*
* ⚠️ 失败时 reject 的是 RequestError(带 code / data / toasted)。
* 调用方若需自行提示,先判断 `err.toasted` —— 已自动提示过的不要重复弹。
*/
export async function http<T = unknown>(config: AxiosRequestConfig): Promise<T> {
const response = await request.request<ApiResult<T>>(config)
return response.data.data
}
+66 -6
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { usePreferencesStore } from '@/preferences'
/**
* 应用壳层状态:主题、侧栏折叠、页签、内容区局部全屏。
@@ -127,6 +128,54 @@ export const useAppStore = defineStore(
routeRefreshKeys.value[key] = (routeRefreshKeys.value[key] ?? 0) + 1
}
/* ---------------- 页签持久化(受 tabbar.persist 控制) ---------------- */
/**
* 手写读写,替代 pinia-plugin-persistedstate 的静态 persist。
*
* 时机说明:store setup 在 pinia 实例化时执行,此时可以安全地调
* usePreferencesStore()(preferences store 自身没有对 app store 的依赖,
* 无循环;且 pinia 在 main.ts 里 app.use(pinia) 之后、首个 store 使用前已就绪)。
*
* 但为了稳妥,这里用「首次 watch 时惰性读取偏好」的方式 ——
* watch 的回调第一次触发是在 store 挂到 pinia 之后,那时偏好 store 一定可用。
*/
const prefStore = usePreferencesStore()
/** 从 localStorage 恢复页签(仅在 persist 开启时) */
function hydrateTabs(): void {
if (!prefStore.preferences.tabbar.persist) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as { tabs?: TabItem[] }
if (Array.isArray(parsed.tabs)) {
tabs.value = parsed.tabs
}
} catch {
/* 持久化数据损坏时静默忽略,回到空页签 */
}
}
/** 把页签写回 localStorage;persist 关闭时清掉旧数据 */
function persistTabs(): void {
if (prefStore.preferences.tabbar.persist) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ tabs: tabs.value }))
} else {
localStorage.removeItem(STORAGE_KEY)
}
}
// 首次进入:恢复页签(若 persist 开启)
hydrateTabs()
// 页签变化 → 写回;偏好 persist 变化 → 立即写/清一次
watch(() => tabs.value, persistTabs, { deep: true })
watch(
() => prefStore.preferences.tabbar.persist,
() => persistTabs(),
)
return {
tabs,
cachedKeys,
@@ -144,10 +193,21 @@ export const useAppStore = defineStore(
}
},
{
// 页签是否持久化由偏好设置 tabbar.persist 决定,这里不做无条件持久化。
persist: {
key: STORAGE_KEY,
pick: ['tabs'],
},
// 页签是否持久化由偏好设置 tabbar.persist 决定。
//
// 为什么【不用】 pinia-plugin-persistedstate 的 persist 选项:
// 它的 persist 是【静态】的(boolean | PersistenceOptions | 数组),
// 无法在运行时根据偏好开关切换。若写死 persist: true,
// 用户在偏好里关掉「页签/刷新后保留」也不会生效(正是本次要修的 bug)。
//
// 所以这里【不做插件持久化】,改为在 store 内部手写读写 localStorage,
// 由 tabbar.persist 控制。做法:
// 1. 初始化时若 tabbar.persist 为 true,从 localStorage 恢复 tabs;
// 2. watch tabs,persist 为 true 时写回,false 时【清除】已存的旧数据,
// 避免「关了开关,旧页签下次还冒出来」。
//
// 读取偏好用 usePreferencesStore:它自身也是 pinia store、且不依赖 app store,
// 无循环依赖。但 store setup 里不能直接调 usePreferencesStore(会在 pinia 激活前
// 实例化),所以持久化的读写逻辑放到一个懒初始化的 watch 里。
},
)
@@ -0,0 +1,600 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Languages, Lock, Moon, Sun, User } from '@lucide/vue'
import {
G3Button,
G3Checkbox,
G3Form,
G3FormItem,
G3Input,
G3Message,
G3SliderCaptcha,
} from '@g3soft/ui'
import { APP_TITLE } from '@/constants/common'
import { usePreferencesStore } from '@/preferences'
/**
* 登录页。
*
* 结构照抄 vue-vben-admin 的认证壳(packages/effects/layouts/src/authentication):
* - 左侧 slogan 面板:bg-background-deep + 模糊光晕 + 浮动插画 + 标题/描述,lg(1024px)以下隐藏
* - 右侧表单面板:px-6 py-10(lg:px-8)、内容 md:max-w-md、垂直水平居中,底部绝对定位版权
* - 左上角 logo + 应用名、右上角 bg-accent 圆角药丸工具条
* 表单卡片细节(标题/副标题/纵向表单/底部版权)参考 fms-vue 的 src/views/login/index.vue。
*
* ⚠️ 登录接口与用户模块尚未落地:提交只做前端校验后进系统,鉴权 / 路由守卫待后续接入。
*/
defineOptions({ name: 'Login' })
const router = useRouter()
const route = useRoute()
const prefStore = usePreferencesStore()
/** 表单数据:接入接口后直接作为请求参数 */
const formModel = reactive({
username: '',
password: '',
captcha: false,
})
/** 记住我:默认勾选,对照 vben 的 rememberMe(是否落本地存储待接口接入后定) */
const rememberMe = ref(true)
/** 主题跟随偏好设置:mode 为 auto 时由 store 按系统偏好解析 */
const isDark = computed(() => prefStore.isDark)
/**
* 切换主题:与顶栏 LayoutHeader 同一套逻辑 ——
* mode 是 'auto' 时按当前实际明暗收敛到 'dark' / 'light',
* 避免「auto → 点一下变 light(却看不到变化)」的困惑。
*/
function toggleTheme(): void {
prefStore.update({ theme: { mode: isDark.value ? 'light' : 'dark' } })
}
/** 语言:项目尚未引入 i18n,先给提示占位(与 fms-vue 登录页的做法一致) */
function toggleLang(): void {
G3Message.info('语言切换待接入 i18n 后生效')
}
/** 必填规则:G3Form 的声明式配置,纯前端校验(校验失败时按钮不会触发 submit) */
const formRules = {
username: [{ required: true, message: '请输入账号' }],
password: [{ required: true, message: '请输入密码' }],
captcha: [{ validator: (value: unknown) => value === true, message: '请完成滑块验证' }],
}
/**
* 提交:G3Form 校验通过后先直接进系统。
*
* TODO 用户模块落地后替换:调登录接口 → 写用户 store → 拉起权限 → 再跳转;
* 失败时提示并清空密码;未登录访问内页由 router 守卫重定向回本页
* (redirect 参数就是守卫带过来的原目标地址)。
*/
function onSubmit(): void {
const redirect = route.query.redirect
router.replace(typeof redirect === 'string' && redirect ? redirect : '/')
}
</script>
<template>
<div class="login">
<!-- 装饰光晕:照搬 vben authentication.vue 的 .login-background(154deg 渐变 + blur(100px)) -->
<div class="login__glow" aria-hidden="true"></div>
<!-- 左上角 logo + 应用名(vben 认证壳的 logo 槽位:absolute top-0 left-0 + mt-4 ml-4) -->
<div class="login__logo">
<span class="login__logo-mark">G3</span>
<span class="login__logo-text">{{ APP_TITLE }}</span>
</div>
<!-- 右上角工具条(vben Toolbar:bg-accent 药丸容器 + 语言 / 主题按钮) -->
<div class="login__toolbar">
<button class="login__toolbar-btn" type="button" title="切换语言" @click="toggleLang">
<Languages :size="16" />
</button>
<button
class="login__toolbar-btn"
type="button"
:title="isDark ? '切换为浅色' : '切换为深色'"
@click="toggleTheme"
>
<Sun v-if="isDark" :size="16" />
<Moon v-else :size="16" />
</button>
</div>
<!-- 左侧 slogan 面板:lg 以下整块隐藏,窄屏只留表单 -->
<section class="login__slogan">
<svg class="login__art" viewBox="0 0 320 240" fill="none" aria-hidden="true">
<!-- 主窗口 -->
<rect
x="24"
y="28"
width="272"
height="184"
rx="14"
fill="var(--g3-color-bg-container, #fff)"
stroke="var(--g3-color-border, #e5e6eb)"
/>
<!-- 顶部栏 -->
<path
d="M24 42a14 14 0 0 1 14-14h244a14 14 0 0 1 14 14v14H24V42Z"
fill="var(--g3-color-accent, #f2f3f5)"
/>
<circle cx="44" cy="49" r="4" fill="var(--g3-color-primary, #165dff)" opacity="0.9" />
<rect
x="56"
y="46"
width="52"
height="6"
rx="3"
fill="var(--g3-color-text-tertiary, #86909c)"
opacity="0.5"
/>
<!-- 左侧菜单列 -->
<rect
x="24"
y="56"
width="76"
height="156"
fill="var(--g3-color-accent, #f2f3f5)"
opacity="0.6"
/>
<rect
x="38"
y="74"
width="48"
height="8"
rx="4"
fill="var(--g3-color-primary, #165dff)"
opacity="0.55"
/>
<rect
x="38"
y="94"
width="40"
height="8"
rx="4"
fill="var(--g3-color-text-tertiary, #86909c)"
opacity="0.35"
/>
<rect
x="38"
y="114"
width="44"
height="8"
rx="4"
fill="var(--g3-color-text-tertiary, #86909c)"
opacity="0.35"
/>
<rect
x="38"
y="134"
width="36"
height="8"
rx="4"
fill="var(--g3-color-text-tertiary, #86909c)"
opacity="0.35"
/>
<!-- 内容区:标题 + 数据卡片 -->
<rect
x="116"
y="74"
width="84"
height="10"
rx="5"
fill="var(--g3-color-text, #1f2329)"
opacity="0.25"
/>
<rect
x="116"
y="92"
width="56"
height="8"
rx="4"
fill="var(--g3-color-text-tertiary, #86909c)"
opacity="0.35"
/>
<rect
x="116"
y="116"
width="76"
height="48"
rx="8"
fill="var(--g3-color-primary, #165dff)"
opacity="0.14"
/>
<rect
x="200"
y="116"
width="76"
height="48"
rx="8"
fill="var(--g3-color-primary, #165dff)"
opacity="0.22"
/>
<rect
x="116"
y="176"
width="160"
height="20"
rx="6"
fill="var(--g3-color-accent, #f2f3f5)"
/>
<!-- 悬浮指标卡 -->
<rect
x="228"
y="20"
width="84"
height="56"
rx="10"
fill="var(--g3-color-bg-container, #fff)"
stroke="var(--g3-color-border, #e5e6eb)"
/>
<rect
x="242"
y="34"
width="40"
height="7"
rx="3.5"
fill="var(--g3-color-text-tertiary, #86909c)"
opacity="0.45"
/>
<rect
x="242"
y="50"
width="56"
height="10"
rx="5"
fill="var(--g3-color-primary, #165dff)"
opacity="0.7"
/>
</svg>
<p class="login__slogan-title">{{ APP_TITLE }} 企业管理平台</p>
<p class="login__slogan-desc">海运 / 空运 / 集装箱 / 财务 / CRM 一体化协同</p>
</section>
<!-- 右侧表单面板 -->
<section class="login__panel">
<div class="login__form">
<!-- 标题块:对照 vben auth-title.vue(h2 30px/36px 粗体 → lg 36px,描述 14px → lg 16px,下间距 28px) -->
<header class="login__heading">
<h2 class="login__heading-title">欢迎回来 👋🏻</h2>
<p class="login__heading-desc">请输入您的账号和密码登录系统</p>
</header>
<!-- 纵向表单:label 在上、控件在下,与 fms-vben 登录页一致。
requiredMark 保持默认(开),账号 / 密码这类「规则里写了 required」的字段会带红色星号;
滑块项没有 label 行,不会出现星号。 -->
<G3Form
class="login__fields"
:model="formModel"
:rules="formRules"
layout="vertical"
label-align="left"
:gap="20"
@submit="onSubmit"
>
<G3FormItem name="username" label="账号">
<G3Input
v-model="formModel.username"
placeholder="请输入账号"
autocomplete="username"
:maxlength="32"
>
<template #prefix><User :size="16" /></template>
</G3Input>
</G3FormItem>
<G3FormItem name="password" label="密码">
<!-- type="password" 时 G3Input 自带「显示 / 隐藏密码」按钮,无需自绘 -->
<G3Input
v-model="formModel.password"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
:maxlength="32"
>
<template #prefix><Lock :size="16" /></template>
</G3Input>
</G3FormItem>
<!-- 滑块验证:@g3soft/ui 的 G3SliderCaptcha(v-model 布尔值,拖到位即通过)。
容器高 40px,这里用 size="large" 与输入框对齐;文案取语言包,不用手写。 -->
<G3FormItem name="captcha">
<G3SliderCaptcha v-model="formModel.captcha" size="large" />
</G3FormItem>
<!-- 记住我:对照 vben login.vue 的 rememberMe 行 -->
<div class="login__options">
<G3Checkbox v-model="rememberMe" name="rememberMe">记住我</G3Checkbox>
</div>
<!-- html-type="submit":点击与回车都走 G3Form 的 submit,校验通过后交给 onSubmit -->
<G3Button class="login__submit" type="primary" size="large" block html-type="submit">
登录
</G3Button>
</G3Form>
</div>
<!-- 版权:对照 vben form.vue 的 absolute bottom-3 right-0 left-0 居中 -->
<p class="login__copyright">Copyright © 2026 G3Soft</p>
</section>
</div>
</template>
<style lang="scss" scoped>
/* ===========================================================================
* 登录页样式
*
* 尺寸换算自 vue-vben-admin 的 tailwind 类(1 单位 = 4px):
* slogan 面板 hidden lg:block / flex-1 → 1024px 断点
* 表单面板 px-6 py-10 lg:px-8 → 24px/40px → 32px
* 表单容器 md:max-w-md → 448px
* h2 text-3xl/9 lg:text-4xl → 30px/36px → 36px
* toolbar top-4 right-2 rounded-3xl px-3 py-1 → 16px/8px, 24px, 12px/4px
* copyright absolute bottom-3 → 12px
* 配色一律用本项目 token(--g3-*),浅色 / 深色随 :root / .dark 自动切换。
* =========================================================================== */
.login {
position: relative;
display: flex;
min-height: 100svh;
overflow: hidden;
background: var(--g3-color-bg, #f5f6f8);
color: var(--g3-color-text, #1f2329);
}
/* ---- 装饰光晕:只在左侧 slogan 区出现,向右自然淡出 ---- */
.login__glow {
position: absolute;
inset: 0 50% 0 0;
background: linear-gradient(
154deg,
rgb(7 7 9 / 8%) 30%,
color-mix(in srgb, var(--g3-color-primary, #165dff) 30%, transparent) 48%,
rgb(7 7 9 / 8%) 64%
);
filter: blur(100px);
pointer-events: none;
}
/* ---- 左上角品牌区 ---- */
.login__logo {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
user-select: none;
}
.login__logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
height: 36px;
font-size: 14px;
font-weight: 700;
color: #fff;
background: var(--g3-color-primary, #165dff);
border-radius: 8px;
}
.login__logo-text {
margin: 0;
font-size: 20px;
font-weight: 500;
color: var(--g3-color-text, #1f2329);
white-space: nowrap;
}
/* ---- 右上角工具条 ---- */
.login__toolbar {
position: absolute;
top: 16px;
right: 8px;
z-index: 10;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: var(--g3-color-accent, #f2f3f5);
border-radius: 24px;
}
.login__toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 28px;
padding: 0 6px;
font-size: 14px;
color: var(--g3-color-text-secondary, #4e5969);
cursor: pointer;
background: none;
border: 0;
border-radius: 6px;
&:hover {
color: var(--g3-color-text, #1f2329);
background: var(--g3-color-fill-hover, rgb(0 0 0 / 4%));
}
}
/* ---- 左侧 slogan ---- */
.login__slogan {
display: none;
flex: 1 1 0;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
padding: 48px;
user-select: none;
}
.login__art {
width: 40%;
max-width: 320px;
height: auto;
animation: login-float 6s ease-in-out infinite;
}
.login__slogan-title {
margin: 24px 0 0;
font-size: 24px;
font-weight: 600;
color: var(--g3-color-text, #1f2329);
text-align: center;
}
.login__slogan-desc {
margin: 8px 0 0;
font-size: 14px;
color: var(--g3-color-text-secondary, #4e5969);
text-align: center;
}
/* ---- 右侧表单面板 ---- */
.login__panel {
position: relative;
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
padding: 40px 24px;
background: var(--g3-color-bg-container, #fff);
}
.login__form {
width: 100%;
max-width: 448px;
/* 输入框与按钮统一 40px 高(G3 组件的控件高度令牌),避免一大一小 */
--g3-control-height: 40px;
}
/* ---- 标题块 ---- */
.login__heading {
margin-bottom: 28px;
}
.login__heading-title {
margin: 0 0 12px;
font-size: 30px;
font-weight: 700;
line-height: 36px;
letter-spacing: -0.025em;
color: var(--g3-color-text, #1f2329);
}
.login__heading-desc {
margin: 0;
font-size: 14px;
color: var(--g3-color-text-secondary, #4e5969);
}
/* ---- 表单 ---- */
.login__fields {
width: 100%;
}
/* 输入框前缀图标:次要色,和控件文字拉开层次 */
.login__fields :deep(.g3-input__prefix) {
color: var(--g3-color-text-tertiary, #86909c);
}
/* 纵向表单项的 label ↔ 控件间距:组件库默认 4px(--g3-space-1)是为 32px 的紧凑控件配的,
登录页控件高 40px,这里提到 8px(--g3-space-2,与横向布局同档),
顺带把「label 与错误提示都离控件 4px」的层级感拉开。 */
.login__fields :deep(.g3-form-item--vertical) {
gap: var(--g3-space-2, 8px);
}
/* ---- 记住我 ---- */
.login__options {
display: flex;
align-items: center;
}
.login__submit {
width: 100%;
font-size: 15px;
}
/* ---- 版权 ---- */
.login__copyright {
position: absolute;
right: 0;
bottom: 12px;
left: 0;
margin: 0;
font-size: 12px;
color: var(--g3-color-text-secondary, #4e5969);
text-align: center;
}
/* 插画轻微上下浮动(对照 vben 的 animate-float) */
@keyframes login-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
/* 窄屏:只留表单,左右内边距收紧 */
@media (max-width: 767px) {
.login__panel {
padding: 40px 20px;
}
.login__logo-text {
display: none;
}
}
/* 大屏:slogan 面板登场并加宽左右内边距(lg:px-8) */
@media (min-width: 1024px) {
.login__slogan {
display: flex;
}
/* 表单面板固定占 2/5(对照 vben 表单面板的 w-2/5),剩下 3/5 全给左侧 slogan ——
两边都用 flex:1 会平分 50/50,左侧就不够宽了 */
.login__panel {
flex: 0 0 40%;
padding: 40px 32px;
}
.login__heading-title {
font-size: 36px;
}
.login__heading-desc {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.login__art {
animation: none;
}
}
</style>
@@ -1,42 +0,0 @@
<script setup lang="ts">
import { FileQuestionMark } from '@lucide/vue'
</script>
<template>
<div class="not-found">
<FileQuestionMark :size="40" />
<h2 class="not-found__title">404</h2>
<p class="not-found__desc">页面不存在或已被移除</p>
<RouterLink class="not-found__link" to="/">返回首页</RouterLink>
</div>
</template>
<style lang="scss" scoped>
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
color: var(--g3-color-text-secondary, #4e5969);
&__title {
margin: 0;
font-size: 28px;
font-weight: 600;
color: var(--g3-color-text, #1f2329);
}
&__desc {
margin: 0;
font-size: 14px;
}
&__link {
margin-top: 8px;
font-size: 14px;
color: var(--g3-color-primary, #165dff);
}
}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import FallbackPage from './FallbackPage.vue'
/**
* 渲染错误边界:捕获子树(布局 + 页面)的未处理错误,就地渲染 500 缺省页,替代白屏。
*
* 移植自 fms-vue 的 src/views/fallback/ErrorBoundary.vue。
* 500 页的「重新加载」按钮即恢复手段(整页刷新重置一切状态)。
*
* onErrorCaptured 返回 false 的语义:
* 标记该错误「已处理」,阻止它继续向上冒泡到 app.config.errorHandler。
* 但这里仍保留 console.error —— 兜底页只是让用户不白屏,排查线索不能丢。
*/
const hasError = ref(false)
onErrorCaptured((error) => {
hasError.value = true
console.error('[ErrorBoundary]', error)
return false
})
</script>
<template>
<FallbackPage v-if="hasError" status="500" />
<slot v-else />
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, RefreshCw } from '@lucide/vue'
import { G3Button, G3Result, type ResultStatus } from '@g3soft/ui'
/**
* 缺省页(403 / 404 / 500 / 离线)—— 应用层的薄包装。
*
* 分工(这是本次拆分的核心):
* @g3soft/ui 的 G3Result —— 只管「插图 + 标题 + 描述 + 操作区」的呈现,
* 文案走 locale,不碰路由。
* 本组件 —— 只管【应用约定】:哪个状态配哪个按钮、
* 按钮点了跳到哪 / 要不要刷新。
*
* 之前插图与文案都写在这里,现在它们下沉到了组件库;
* 本文件剩下的都是「只有本应用才知道」的东西。
*
* 按钮分工照 fms-vue / vben:
* 403 / 404 → 「返回首页」(用户走错路了,需要回到正轨)
* 500 / 离线 → 「重新加载」(整页刷新就是恢复手段)
*/
const props = withDefaults(
defineProps<{
/** 缺省页类型 */
status?: Extract<ResultStatus, '403' | '404' | '500' | 'offline'>
/** 403 / 404 的返回目标 */
homePath?: string
}>(),
{
status: '404',
homePath: '/',
},
)
const router = useRouter()
/** 哪些状态用「返回首页」,其余用「重新加载」—— 应用级约定 */
const isHome = computed(() => props.status === '403' || props.status === '404')
const actionText = computed(() => (isHome.value ? '返回首页' : '重新加载'))
function onAction(): void {
if (isHome.value) {
router.push(props.homePath)
} else {
// 整页刷新即恢复手段:重置所有内存状态(store、keep-alive 缓存、定时器)
window.location.reload()
}
}
</script>
<template>
<!--
高度说明:G3Result 内部用 min-height: 100%(不是 100vh),
因为本组件同时挂在两种容器里 ——
独立路由(404):外层是 #app(height:100%),100% 即视口高;
布局内(403/500):外层是 .layout__content,100vh 会撑出滚动条,100% 正好吃满。
一种写法覆盖两种场景,这里不必再补样式。
-->
<G3Result :status="props.status">
<template #extra>
<G3Button type="primary" @click="onAction">
<template #icon>
<component :is="isHome ? ArrowLeft : RefreshCw" :size="16" />
</template>
{{ actionText }}
</G3Button>
</template>
</G3Result>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import FallbackPage from './FallbackPage.vue'
/**
* 403 缺省页,挂在 DefaultLayout 内(保留侧栏 / 顶栏 / 页签,用户可直接切走)。
*
* ⚠️ status 在组件内写死,不走路由 props:
* DefaultLayout 的 keep-alive 包装(formatComponentInstance)只把 fullPath 透给组件,
* 路由的 props 配置不会到达页面组件 —— 与 fms-vue 同样的约束。
*/
</script>
<template>
<FallbackPage status="403" />
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import FallbackPage from './FallbackPage.vue'
/**
* 404 缺省页,独立路由(不套 DefaultLayout)。
* 走通配路由 `/:pathMatch(.*)*`,所以能覆盖任意未匹配路径。
*/
</script>
<template>
<FallbackPage status="404" />
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import FallbackPage from './FallbackPage.vue'
/**
* 500 缺省页,挂在 DefaultLayout 内供直接访问排查。
* 渲染崩溃的兜底由 App 层的 ErrorBoundary 全屏渲染,不经过本路由。
*/
</script>
<template>
<FallbackPage status="500" />
</template>