4 Commits

Author SHA1 Message Date
c6e1d94510 fix: 修复 CI 时区测试失败 + 依赖安全升级 + 新增依赖检查 workflow
All checks were successful
CI / check (pull_request) Successful in 4m11s
Dependency Check / check (pull_request) Successful in 2m30s
- fix(server): getCourseClock 固定 UTC+8(Asia/Shanghai),与 attendance-settlement 保持一致,
  修复 CI(UTC 容器)下 attendance.lesson-session.spec.ts 失败
- chore(deps): @ant-design/x 2.9.0 / @ant-design/x-markdown 2.9.0 / mermaid 11.16.1;
  直接依赖 dompurify 3.4.13(修复生产依赖 audit 漏洞,mermaid/x-markdown 复用同一份)
- ci: 新增 dependency-check workflow(生产依赖 moderate+ / 全局 critical 失败,outdated 报告)
2026-08-10 10:05:05 +08:00
8c437cd082 Merge pull request 'fix: 迁移读取根 .env + 部署脚本本地直跑 + 健康检查读 PORT' (#66) from fix/datasource-root-env into main 2026-08-10 01:45:19 +00:00
4c95de2e56 Merge pull request 'fix(admin): 修复首次进入系统后点击任意导航被拉回 dashboard' (#67) from fix/route-redirect-keepalive into main 2026-08-10 01:44:02 +00:00
ce9bde35eb fix(admin): 修复首次进入系统后点击任意导航被拉回 dashboard
Some checks failed
CI / check (pull_request) Failing after 2m14s
根因:RouteKeeper 保活缓存了首页 index 节点,DefaultRoute 中的声明式
<Navigate> 会随缓存节点在后续路由变化时反复触发 replace,导致所有导航
被强制拉回落地页(dashboard),刷新后因不再经过 / 而恢复正常。

修复:DefaultRoute 改为 effect 导航,仅在确实处于 / 且已算出落地页时
跳转一次;navigate 通过 ref 持有避免 effect 空转。新增集成测试覆盖
「首次进入 / → 跳转一次 → 后续导航不被劫持 → 回到 / 仍能重定向」。
2026-08-10 09:42:46 +08:00
7 changed files with 283 additions and 125 deletions

View File

@@ -0,0 +1,44 @@
name: Dependency Check
# 依赖安全检查:生产依赖 moderate+ 或全局 critical 漏洞即失败;
# npm outdated 作为信息输出,不阻断。
# 注意npmmirror 未实现 audit 接口audit 必须显式指定官方 registry。
env:
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_REPLACE_REGISTRY_HOST: always
CI: "true"
on:
pull_request:
branches: [main]
schedule:
- cron: '30 2 * * 1' # 每周一 02:30 复查已发布的新漏洞
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
container: node:22.22.0-bookworm
timeout-minutes: 20
steps:
- name: Checkout
uses: https://gitee.com/mirrors_actions/checkout@v4
with:
github-server-url: https://git.gongxue100.com
fetch-depth: 1
- name: Install dependencies
run: npm ci
- name: Audit production dependencies (moderate+)
run: npm audit --registry=https://registry.npmjs.org --omit=dev --audit-level=moderate
- name: Audit all dependencies (critical only)
run: npm audit --registry=https://registry.npmjs.org --audit-level=critical
- name: Outdated report (informational)
run: npm outdated --registry=https://registry.npmjs.org || true

View File

@@ -14,9 +14,9 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/x": "^2.8.0",
"@ant-design/x": "^2.9.0",
"@ant-design/x-card": "^2.9.0",
"@ant-design/x-markdown": "^2.8.0",
"@ant-design/x-markdown": "^2.9.0",
"@ant-design/x-sdk": "^2.8.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -26,10 +26,11 @@
"antd": "^6.3.6",
"axios": "^1.15.1",
"dayjs": "^1.11.20",
"dompurify": "^3.4.13",
"echarts": "^6.0.0",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"mermaid": "^11.16.0",
"mermaid": "^11.16.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router": "^8.3.0",

View File

@@ -0,0 +1,105 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import DefaultRoute from './DefaultRoute';
import { RouteKeeper } from './RouteKeeper';
import { usePermissionStore } from '../store/permission/permissionStore';
import { useUserStore } from '../store/user/userStore';
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
beforeAll(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
beforeEach(() => {
// 清掉可能由其他测试文件遗留的持久化数据,保证会话状态可控
Object.keys(localStorage).forEach((key) => localStorage.removeItem(key));
useUserStore.setState({ token: null, user: null });
usePermissionStore.setState({ permissions: [], status: 'unknown' });
useUserStore.getState().setSession('test-token', {
id: 1,
username: 'admin',
roles: ['超级管理员'],
});
usePermissionStore.getState().writePermissions(['dashboard:view', 'student:view']);
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
});
function PageDashboard() {
const navigate = useNavigate();
return (
<div>
<div data-testid="page-dashboard"></div>
<button data-testid="go-students" onClick={() => navigate('/students')}>
</button>
</div>
);
}
function PageStudents() {
const navigate = useNavigate();
return (
<div>
<div data-testid="page-students"></div>
<button data-testid="go-home" onClick={() => navigate('/')}>
</button>
</div>
);
}
function Harness() {
return (
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<RouteKeeper />}>
<Route index element={<DefaultRoute />} />
<Route path="dashboard" element={<PageDashboard />} />
<Route path="students" element={<PageStudents />} />
</Route>
</Routes>
</MemoryRouter>
);
}
async function renderHarness() {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<Harness />);
});
}
describe('DefaultRoute keep-alive regression', () => {
it('redirects to landing page once and does not hijack later navigations', async () => {
await renderHarness();
// 首次进入 '/' 应跳转到落地页 /dashboard且只跳一次
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
// 再导航到 /students不应被保活的首页节点拉回 /dashboard
await act(async () => {
(document.querySelector('[data-testid="go-students"]') as HTMLButtonElement).click();
});
expect(document.querySelector('[data-testid="page-students"]')).not.toBeNull();
// 回到 '/' 时仍应再次跳转到落地页
await act(async () => {
(document.querySelector('[data-testid="go-home"]') as HTMLButtonElement).click();
});
expect(document.querySelector('[data-testid="page-dashboard"]')).not.toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Navigate } from 'react-router';
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { Result, Spin } from 'antd';
import { usePermission } from '../hooks/usePermission';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
@@ -8,14 +8,31 @@ import { useUserStore } from '../store/user/userStore';
const DefaultRoute: React.FC = () => {
const { permissions, permissionsReady } = usePermission();
const roles = useUserStore((state) => state.user?.roles ?? []);
const navigate = useNavigate();
const { pathname } = useLocation();
const firstPath = permissionsReady ? findRoleAwareLandingPath(roles, permissions) : null;
// 用 effect 导航替代声明式 <Navigate>RouteKeeper 会把首页index节点保活缓存
// 声明式 <Navigate> 在缓存节点随路由变化重渲染时会反复触发,导致首次进入系统后
// 点击任何按钮都被拉回 dashboard必须刷新页面才能恢复。这里仅在确实处于首页
// 且已计算出落点时跳转navigate 通过 ref 持有,避免其每次渲染变化导致 effect 空转。
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
useEffect(() => {
if (pathname === '/' && firstPath) {
navigateRef.current(firstPath, { replace: true });
}
}, [pathname, firstPath]);
if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
const firstPath = findRoleAwareLandingPath(roles, permissions);
if (firstPath) return <Navigate to={firstPath} replace />;
return (
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
);
if (!firstPath) {
return (
<Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />
);
}
return null;
};
export default DefaultRoute;

View File

@@ -6,7 +6,9 @@ export function toMinutes(time: string): number {
}
export function getCourseClock(date: Date): { date: string; minutes: number } {
const d = dayjs(date);
// Asia/Shanghai 无夏令时,固定 UTC+8与 attendance-settlement 保持一致),
// 避免依赖服务器进程时区导致「课程是否已开始」判断漂移。
const d = dayjs(date).utcOffset(8);
return {
date: d.format('YYYY-MM-DD'),
minutes: d.hour() * 60 + d.minute(),

214
package-lock.json generated
View File

@@ -21,9 +21,9 @@
"version": "0.0.0",
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/x": "^2.8.0",
"@ant-design/x": "^2.9.0",
"@ant-design/x-card": "^2.9.0",
"@ant-design/x-markdown": "^2.8.0",
"@ant-design/x-markdown": "^2.9.0",
"@ant-design/x-sdk": "^2.8.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -33,10 +33,11 @@
"antd": "^6.3.6",
"axios": "^1.15.1",
"dayjs": "^1.11.20",
"dompurify": "^3.4.13",
"echarts": "^6.0.0",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"mermaid": "^11.16.0",
"mermaid": "^11.16.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router": "^8.3.0",
@@ -65,6 +66,103 @@
"vitest": "^4.1.10"
}
},
"apps/admin/node_modules/@ant-design/x": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/@ant-design/x/-/x-2.9.0.tgz",
"integrity": "sha512-m3N2nprrPG/D0n93B/fBFhjpvSaJAb2IDF6w0rX0ycYXSGxtgrJQvqSI33Jhx6gxUHj495zdIzqA1NMFld9iQA==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.0",
"@ant-design/cssinjs": "^2.0.1",
"@ant-design/cssinjs-utils": "^2.0.2",
"@ant-design/fast-color": "^3.0.0",
"@ant-design/icons": "^6.0.0",
"@babel/runtime": "^7.25.6",
"@rc-component/motion": "^1.1.6",
"@rc-component/resize-observer": "^1.0.1",
"@rc-component/util": "^1.4.0",
"clsx": "^2.1.1",
"lodash.throttle": "^4.1.1",
"mermaid": "^11.12.1",
"react-syntax-highlighter": "^16.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ant-design"
},
"peerDependencies": {
"antd": "^6.1.1",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"apps/admin/node_modules/@ant-design/x-markdown": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/@ant-design/x-markdown/-/x-markdown-2.9.0.tgz",
"integrity": "sha512-IwYUky9Fi3y86wouFQ3rjx/DyKFUm/eAidiU/leVD0u6hAfN4INDWltWuZqe1FRRwOE7DJdSxweDyD6+/B6oQg==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"dompurify": "^3.2.6",
"html-react-parser": "^5.2.13",
"katex": "^0.16.22",
"marked": "^15.0.12"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"apps/admin/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"apps/admin/node_modules/mermaid": {
"version": "11.16.1",
"resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.1.tgz",
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.2.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.3",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"es-toolkit": "^1.45.1",
"katex": "^0.16.45",
"khroma": "^2.1.0",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"apps/admin/node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"apps/admin/node_modules/react-router": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-8.3.0.tgz",
@@ -553,36 +651,6 @@
"react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@ant-design/x": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@ant-design/x/-/x-2.8.0.tgz",
"integrity": "sha512-z0ncy8i3sy8fnFS1iETTQfP2Os1kzOl5OPdljT5ZFSCDsZp+VYFt9jHHMacBnkEftKNzkmG1BLSwvBTS1aWxiw==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.0",
"@ant-design/cssinjs": "^2.0.1",
"@ant-design/cssinjs-utils": "^2.0.2",
"@ant-design/fast-color": "^3.0.0",
"@ant-design/icons": "^6.0.0",
"@babel/runtime": "^7.25.6",
"@rc-component/motion": "^1.1.6",
"@rc-component/resize-observer": "^1.0.1",
"@rc-component/util": "^1.4.0",
"clsx": "^2.1.1",
"lodash.throttle": "^4.1.1",
"mermaid": "^11.12.1",
"react-syntax-highlighter": "^16.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ant-design"
},
"peerDependencies": {
"antd": "^6.1.1",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@ant-design/x-card": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/@ant-design/x-card/-/x-card-2.9.0.tgz",
@@ -599,23 +667,6 @@
"react-dom": ">=18.0.0"
}
},
"node_modules/@ant-design/x-markdown": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@ant-design/x-markdown/-/x-markdown-2.8.0.tgz",
"integrity": "sha512-QRS0s81ykVt8RLqf9nGHJmP/y8hNQgQZdu7+7x+EMS5VuPBeQw74IpIUNchVLQy/P60VoNpkw+YTalyJtyLPPw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"dompurify": "^3.2.6",
"html-react-parser": "^5.2.13",
"katex": "^0.16.22",
"marked": "^15.0.12"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@ant-design/x-sdk": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@ant-design/x-sdk/-/x-sdk-2.8.0.tgz",
@@ -10085,15 +10136,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -14394,60 +14436,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/mermaid": {
"version": "11.16.0",
"resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz",
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.2.0",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.3",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.20",
"dompurify": "^3.3.3",
"es-toolkit": "^1.45.1",
"katex": "^0.16.45",
"khroma": "^2.1.0",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mermaid/node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",

View File

@@ -36,6 +36,7 @@
},
"minimatch@10.2.5": {
"brace-expansion": "^5.0.9"
}
},
"dompurify": "^3.4.13"
}
}