Compare commits
70 Commits
d1ad7a1f75
...
5d9981fd7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9981fd7d | ||
|
|
7387d70289 | ||
|
|
fc150e1e14 | ||
|
|
ebaf03107d | ||
|
|
783b8f5484 | ||
|
|
524ecade19 | ||
|
|
2adf61faa8 | ||
|
|
d7017f0e30 | ||
|
|
b090a815ae | ||
|
|
224740ad98 | ||
|
|
b333cda8c5 | ||
|
|
4e6d397d8e | ||
|
|
7c1b764b59 | ||
|
|
4122175915 | ||
|
|
811ad11f9f | ||
|
|
12a766d3ee | ||
|
|
0a034945d4 | ||
|
|
fefc65702d | ||
|
|
a75fdcd60d | ||
|
|
2236eb36e7 | ||
|
|
c44f19aefd | ||
|
|
dce2561751 | ||
|
|
a208fcc601 | ||
|
|
3bd3ebc12d | ||
|
|
c2575960eb | ||
|
|
94f098b0f2 | ||
|
|
6104e6a685 | ||
|
|
e76c626779 | ||
|
|
ee10380462 | ||
|
|
fa68ec0b34 | ||
|
|
025d4de50d | ||
|
|
19a05091d3 | ||
|
|
11ddc8ccbe | ||
|
|
80d98e13e4 | ||
|
|
ef2040edf4 | ||
|
|
1a34d1f14e | ||
|
|
9ce8d6d3fd | ||
|
|
22c2e6459d | ||
|
|
8fff820e1d | ||
|
|
7cfd85d0f5 | ||
|
|
05e68a3dad | ||
|
|
0f33484bd2 | ||
|
|
841b130f4c | ||
|
|
27374d1b2c | ||
|
|
6dee6b6299 | ||
|
|
cb5b92160c | ||
|
|
99f15ee37a | ||
|
|
a3dc22cb9e | ||
|
|
9eb02807a4 | ||
|
|
80dd67780c | ||
|
|
220d702b44 | ||
|
|
dd7a49504f | ||
|
|
0f9d8825e7 | ||
|
|
d6227d6e6c | ||
|
|
92913a728f | ||
|
|
e510f191c9 | ||
|
|
2d49f1bad8 | ||
|
|
6baa60b2a1 | ||
|
|
13409e55f1 | ||
|
|
1756ac21a8 | ||
|
|
0058b0b311 | ||
|
|
4174cd61d7 | ||
|
|
44f997bad7 | ||
|
|
fcf89dfb2c | ||
|
|
bc03275262 | ||
|
|
16ffd44161 | ||
|
|
be31da6223 | ||
|
|
8afd7af6dc | ||
|
|
9ec1be1528 | ||
|
|
214ebec976 |
14
.env.example
@@ -13,6 +13,16 @@ AI_API_KEY=""
|
|||||||
AI_BASE_URL=""
|
AI_BASE_URL=""
|
||||||
AI_MODEL=""
|
AI_MODEL=""
|
||||||
|
|
||||||
|
# ===== Redis / 缓存配置(可选) =====
|
||||||
|
# 缓存驱动: memory(默认,单实例 LRU) | redis(分布式,多实例共享)
|
||||||
|
CACHE_DRIVER=memory
|
||||||
|
# 速率限制驱动: memory(默认,单实例) | redis(分布式,多实例共享)
|
||||||
|
RATE_LIMIT_DRIVER=memory
|
||||||
|
# Upstash Redis REST 凭据(仅 CACHE_DRIVER=redis 或 RATE_LIMIT_DRIVER=redis 时必填)
|
||||||
|
# 获取方式: 注册 https://upstash.com → 创建数据库 → 复制 REST URL 和 TOKEN
|
||||||
|
UPSTASH_REDIS_REST_URL=
|
||||||
|
UPSTASH_REDIS_REST_TOKEN=
|
||||||
|
|
||||||
# ===== 灾备配置 =====
|
# ===== 灾备配置 =====
|
||||||
# 异地备份后端类型: s3|oss|nfs|none
|
# 异地备份后端类型: s3|oss|nfs|none
|
||||||
BACKUP_OFFSITE_BACKEND=none
|
BACKUP_OFFSITE_BACKEND=none
|
||||||
@@ -65,3 +75,7 @@ BACKUP_DIR=./backups
|
|||||||
RETENTION_DAYS=30
|
RETENTION_DAYS=30
|
||||||
# 备份校验最小文件大小(字节,默认 1024)
|
# 备份校验最小文件大小(字节,默认 1024)
|
||||||
BACKUP_VERIFY_MIN_SIZE=1024
|
BACKUP_VERIFY_MIN_SIZE=1024
|
||||||
|
|
||||||
|
# ===== 日志配置 =====
|
||||||
|
# 日志级别(debug/info/warn/error),默认 info
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|||||||
71
.gitea/workflows/lighthouse.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: Lighthouse CI
|
||||||
|
|
||||||
|
# 性能预算回归门槛:每次 PR 与每日凌晨 3 点对关键路由采样断言。
|
||||||
|
# 失败时阻断合并,触发审计报告 docs/architecture/audit/performance-budget-audit-report.md 中基线复核。
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *" # 每天凌晨 3 点性能采样
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: CDCD
|
||||||
|
container: dockerreg.eazygame.cn/node-with-docker:22
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: "1"
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Cache npm dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
- name: Configure npm proxy
|
||||||
|
run: |
|
||||||
|
GATEWAY_IP=$(ip route show | grep default | awk '{print $3}')
|
||||||
|
if [ -z "$GATEWAY_IP" ]; then
|
||||||
|
GATEWAY_IP="172.17.0.1"
|
||||||
|
fi
|
||||||
|
PROXY_URL="http://$GATEWAY_IP:7890"
|
||||||
|
npm config set proxy "$PROXY_URL"
|
||||||
|
npm config set https-proxy "$PROXY_URL"
|
||||||
|
echo "http_proxy=$PROXY_URL" >> $GITHUB_ENV
|
||||||
|
echo "https_proxy=$PROXY_URL" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Start production server
|
||||||
|
run: npm run start &
|
||||||
|
env:
|
||||||
|
PORT: "3000"
|
||||||
|
|
||||||
|
- name: Wait for server
|
||||||
|
run: |
|
||||||
|
for i in {1..30}; do
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 | grep -q "200\|307\|308" && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Install Lighthouse CI
|
||||||
|
run: npm install -g @lhci/cli@0.13.x
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
run: lhci autorun --config=./lighthouserc.json --collect.url=http://localhost:3000/login || true
|
||||||
|
|
||||||
|
- name: Assert performance budgets
|
||||||
|
run: lhci assert --config=./lighthouserc.json
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<h2>课文锚点时间线布局</h2>
|
||||||
|
<p class="subtitle">课文作为主轴,节点锚定到课文位置,形成教学流程时间线</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">备课编辑器 — 课文锚点时间线</div>
|
||||||
|
<div class="mockup-body" style="padding:0;">
|
||||||
|
<div style="font-family:monospace;font-size:12px;line-height:1.6;">
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span>📖</span>
|
||||||
|
<span style="font-weight:bold;">秋天(第一课时)</span>
|
||||||
|
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册 · 第一单元</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:10px;color:#94a3b8;">💾 已保存 · 2 分钟前</span>
|
||||||
|
<span style="background:#334155;padding:4px 8px;border-radius:3px;font-size:10px;">📋 版本历史</span>
|
||||||
|
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主体:左课文 + 右节点时间线 -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 320px;gap:0;background:#fff;min-height:480px;">
|
||||||
|
|
||||||
|
<!-- 左侧:课文正文区(带锚点 gutter) -->
|
||||||
|
<div style="display:grid;grid-template-columns:32px 1fr;background:#fffbeb;border-right:1px solid #e2e8f0;">
|
||||||
|
|
||||||
|
<!-- 锚点 gutter(显示锚点标记) -->
|
||||||
|
<div style="background:#fef3c7;border-right:1px solid #fde68a;position:relative;">
|
||||||
|
<!-- 锚点标记 -->
|
||||||
|
<div style="position:absolute;top:60px;left:4px;background:#3b82f6;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">1</div>
|
||||||
|
<div style="position:absolute;top:140px;left:4px;background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">2</div>
|
||||||
|
<div style="position:absolute;top:200px;left:4px;background:#0ea5e9;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">3</div>
|
||||||
|
<div style="position:absolute;top:280px;left:4px;background:#ec4899;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">4</div>
|
||||||
|
<div style="position:absolute;top:360px;left:4px;background:#22c55e;color:#fff;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:bold;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,0.2);">5</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课文内容 -->
|
||||||
|
<div style="padding:16px 20px;font-size:13px;color:#78350f;line-height:2;position:relative;">
|
||||||
|
<div style="font-size:16px;font-weight:bold;color:#92400e;text-align:center;margin-bottom:16px;">秋天</div>
|
||||||
|
|
||||||
|
<p style="margin:0 0 12px 0;">
|
||||||
|
<span style="background:#dbeafe;border-bottom:2px solid #3b82f6;padding:1px 2px;">天气凉了,树叶黄了,</span>
|
||||||
|
一片片叶子从树上落下来。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 12px 0;">
|
||||||
|
<span style="background:#fef3c7;border-bottom:2px solid #f59e0b;padding:1px 2px;">天空那么蓝,那么高。</span>
|
||||||
|
一群大雁往南飞,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 12px 0;">
|
||||||
|
<span style="background:#e0f2fe;border-bottom:2px solid #0ea5e9;padding:1px 2px;">一会儿排成个"人"字,</span>
|
||||||
|
<span style="background:#fce7f3;border-bottom:2px solid #ec4899;padding:1px 2px;">一会儿排成个"一"字。</span>
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 12px 0;">
|
||||||
|
<span style="background:#dcfce7;border-bottom:2px solid #22c55e;padding:1px 2px;">啊!秋天来了!</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 拖放提示 -->
|
||||||
|
<div style="margin-top:24px;padding:8px;border:1px dashed #cbd5e1;border-radius:4px;text-align:center;font-size:10px;color:#94a3b8;">
|
||||||
|
💡 选中文字可"关联节点",或从右侧拖动节点到课文某字前
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:节点时间线 -->
|
||||||
|
<div style="background:#f8fafc;padding:12px;overflow-y:auto;">
|
||||||
|
<div style="font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">教学流程时间线</div>
|
||||||
|
|
||||||
|
<!-- 已锚定节点(按课文位置排序) -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
|
||||||
|
<!-- 节点 1:导入(锚定到"天气凉了") -->
|
||||||
|
<div style="background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:4px;padding:8px;cursor:pointer;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">1</span>
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#64748b;background:#eff6ff;padding:4px 6px;border-radius:3px;">
|
||||||
|
"天气凉了,树叶黄了" → 提问:你见过秋天的树叶吗?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点 2:文本研习(锚定到"天空那么蓝") -->
|
||||||
|
<div style="background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:4px;padding:8px;cursor:pointer;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">2</span>
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#92400e;">📝 文本研习</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#64748b;background:#fffbeb;padding:4px 6px;border-radius:3px;">
|
||||||
|
"天空那么蓝,那么高" → 赏析:叠词的运用
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点 3:新授(锚定到"人字") -->
|
||||||
|
<div style="background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:4px;padding:8px;cursor:pointer;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">3</span>
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#075985;">📚 新授</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#64748b;background:#f0f9ff;padding:4px 6px;border-radius:3px;">
|
||||||
|
"一会儿排成个'人'字" → 讲解:大雁南飞
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点 4:练习(锚定到"一字") -->
|
||||||
|
<div style="background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:4px;padding:8px;cursor:pointer;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||||
|
<span style="background:#ec4899;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">4</span>
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#64748b;background:#fdf2f8;padding:4px 6px;border-radius:3px;">
|
||||||
|
"一会儿排成个'一'字" → 3 道题
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点 5:小结(锚定到"秋天来了") -->
|
||||||
|
<div style="background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:4px;padding:8px;cursor:pointer;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||||
|
<span style="background:#22c55e;color:#fff;border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:9px;font-weight:bold;">5</span>
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#166534;">📌 小结</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">📍 锚定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#64748b;background:#f0fdf4;padding:4px 6px;border-radius:3px;">
|
||||||
|
"啊!秋天来了!" → 总结全文
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div style="border-top:1px dashed #cbd5e1;margin:8px 0;padding-top:8px;">
|
||||||
|
<div style="font-size:9px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">未锚定节点</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未锚定节点 -->
|
||||||
|
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#1e3a8a;">🎯 教学目标</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#92400e;">⭐ 重难点</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#a855f7;">🏠 作业</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">课后</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#6366f1;">📋 板书设计</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">全局</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px dashed #cbd5e1;border-radius:4px;padding:8px;cursor:pointer;opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#64748b;">💭 教学反思</span>
|
||||||
|
<span style="margin-left:auto;font-size:9px;color:#94a3b8;">课后</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加节点按钮 -->
|
||||||
|
<div style="border:1px dashed #94a3b8;border-radius:4px;padding:8px;text-align:center;font-size:10px;color:#64748b;cursor:pointer;margin-top:4px;">
|
||||||
|
+ 添加节点
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:24px;">
|
||||||
|
<h3>核心交互:两种锚定方式</h3>
|
||||||
|
<div class="split">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">方式 1:拖动节点到课文某字前</div>
|
||||||
|
<div class="mockup-body" style="padding:16px;font-family:monospace;font-size:12px;">
|
||||||
|
<div style="display:flex;gap:12px;">
|
||||||
|
<div style="background:#f8fafc;padding:8px;border-radius:4px;">
|
||||||
|
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">右侧节点</div>
|
||||||
|
<div style="background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;padding:6px;border-radius:3px;cursor:grab;font-size:10px;">💡 导入</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:18px;color:#94a3b8;align-self:center;">→</div>
|
||||||
|
<div style="background:#fffbeb;padding:8px;border-radius:4px;flex:1;">
|
||||||
|
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">课文</div>
|
||||||
|
<div style="font-size:11px;color:#78350f;line-height:1.8;">
|
||||||
|
天气凉了,<span style="background:#dbeafe;border:2px dashed #3b82f6;padding:1px 2px;border-radius:2px;">|</span>树叶黄了,<br>
|
||||||
|
一片片叶子从树上落下来。
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#3b82f6;margin-top:4px;">💡 节点锚定到此位置</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">方式 2:选中文字 → 关联节点</div>
|
||||||
|
<div class="mockup-body" style="padding:16px;font-family:monospace;font-size:12px;">
|
||||||
|
<div style="background:#fffbeb;padding:8px;border-radius:4px;margin-bottom:8px;">
|
||||||
|
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">1. 选中文字</div>
|
||||||
|
<div style="font-size:11px;color:#78350f;line-height:1.8;">
|
||||||
|
<span style="background:#fef08a;">天空那么蓝,那么高</span>。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:18px;color:#94a3b8;text-align:center;">↓</div>
|
||||||
|
<div style="background:#f8fafc;padding:8px;border-radius:4px;margin-top:8px;">
|
||||||
|
<div style="font-size:9px;color:#94a3b8;margin-bottom:4px;">2. 弹出菜单选择节点</div>
|
||||||
|
<div style="display:flex;gap:4px;flex-wrap:wrap;">
|
||||||
|
<span style="background:#fff;border:1px solid #3b82f6;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">💡 导入</span>
|
||||||
|
<span style="background:#fff;border:1px solid #f59e0b;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📝 文本研习</span>
|
||||||
|
<span style="background:#fff;border:1px solid #0ea5e9;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📚 新授</span>
|
||||||
|
<span style="background:#fff;border:1px solid #ec4899;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">✏️ 练习</span>
|
||||||
|
<span style="background:#fff;border:1px solid #22c55e;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">📌 小结</span>
|
||||||
|
<span style="background:#fff;border:1px dashed #94a3b8;padding:3px 6px;border-radius:3px;font-size:9px;cursor:pointer;">+ 新建节点</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:24px;">
|
||||||
|
<h3>数据模型:锚点(Anchor)</h3>
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
|
||||||
|
<div style="color:#94a3b8;">// 节点锚点 — 记录节点与课文位置的关联</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">NodeAnchor</span> {</div>
|
||||||
|
<div> nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
|
||||||
|
<div> type: <span style="color:#10b981;">"point"</span> | <span style="color:#10b981;">"range"</span>; <span style="color:#64748b;">// 点锚点 or 范围锚点</span></div>
|
||||||
|
<div> start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 课文纯文本偏移量(字符)</span></div>
|
||||||
|
<div> end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚点的结束偏移</span></div>
|
||||||
|
<div> textPreview?: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 锚定文字预览(便于回显)</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// LessonPlanDocument 扩展</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">LessonPlanDocument</span> {</div>
|
||||||
|
<div> version: <span style="color:#10b981;">3</span>; <span style="color:#64748b;">// 升级到 v3</span></div>
|
||||||
|
<div> nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[];</div>
|
||||||
|
<div> edges: <span style="color:#3b82f6;">LessonPlanEdge</span>[]; <span style="color:#64748b;">// 保留:节点间连线</span></div>
|
||||||
|
<div> anchors: <span style="color:#3b82f6;">NodeAnchor</span>[]; <span style="color:#64748b;">// 新增:节点与课文的锚点</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>这个设计的优势</h3>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros">
|
||||||
|
<h4>优势</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>教学流程可视化</strong>:节点按课文位置排序,天然形成时间线</li>
|
||||||
|
<li><strong>节点与课文强关联</strong>:每个节点对应课文的哪部分一目了然</li>
|
||||||
|
<li><strong>双模式锚定</strong>:拖动(点锚点)+ 选文字(范围锚点)</li>
|
||||||
|
<li><strong>保留连线能力</strong>:节点间仍可连线(如"导入→新授"流程线)</li>
|
||||||
|
<li><strong>未锚定节点</strong>:目标/重难点/作业/板书/反思等全局节点不强制锚定</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="cons">
|
||||||
|
<h4>需要注意</h4>
|
||||||
|
<ul>
|
||||||
|
<li>课文偏移量需基于纯文本(Markdown 渲染后需映射)</li>
|
||||||
|
<li>课文内容变更后锚点可能失效(需重新定位或提示)</li>
|
||||||
|
<li>数据结构升级到 v3,需迁移现有 v2 数据</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<h2>画布式锚点布局 — 正文固定 + 节点散布 + 连线关联</h2>
|
||||||
|
<p class="subtitle">保留 React Flow 画布交互,正文为不可移动但可缩放的中央容器,节点通过连线关联正文锚点</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">备课编辑器 — 画布视图(默认状态)</div>
|
||||||
|
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
|
||||||
|
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:560px;">
|
||||||
|
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;z-index:10;position:relative;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span>📖</span>
|
||||||
|
<span style="font-weight:bold;">秋天(第一课时)</span>
|
||||||
|
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:10px;color:#94a3b8;">💾 已保存</span>
|
||||||
|
<span style="background:#334155;padding:4px 8px;border-radius:3px;font-size:10px;">📋 版本</span>
|
||||||
|
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 画布区域 -->
|
||||||
|
<div style="position:relative;width:100%;height:520px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- SVG 连线层(默认 10% 透明度) -->
|
||||||
|
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 520">
|
||||||
|
<!-- 节点1(导入) → 正文锚点1 -->
|
||||||
|
<path d="M 130 120 Q 200 140 280 180" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<circle cx="280" cy="180" r="4" fill="#3b82f6"/>
|
||||||
|
<!-- 节点2(文本研习) → 正文锚点2 -->
|
||||||
|
<path d="M 130 220 Q 200 230 280 240" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<circle cx="280" cy="240" r="4" fill="#f59e0b"/>
|
||||||
|
<!-- 节点3(新授) → 正文锚点3 -->
|
||||||
|
<path d="M 670 120 Q 600 150 520 200" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<circle cx="520" cy="200" r="4" fill="#0ea5e9"/>
|
||||||
|
<!-- 节点4(练习) → 正文锚点4 -->
|
||||||
|
<path d="M 670 220 Q 600 240 520 260" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<circle cx="520" cy="260" r="4" fill="#ec4899"/>
|
||||||
|
<!-- 节点5(小结) → 正文锚点5 -->
|
||||||
|
<path d="M 670 340 Q 600 320 520 300" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<circle cx="520" cy="300" r="4" fill="#22c55e"/>
|
||||||
|
<!-- 节点间连线(教学流程) -->
|
||||||
|
<path d="M 130 140 L 130 200" stroke="#64748b" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 670 140 L 670 200" stroke="#64748b" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 670 240 L 670 320" stroke="#64748b" stroke-width="1.5" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 中央:正文容器(不可移动,可缩放) -->
|
||||||
|
<div style="position:absolute;left:280px;top:80px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
|
||||||
|
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:#78350f;line-height:1.8;">
|
||||||
|
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
|
||||||
|
<p style="margin:0 0 4px 0;">
|
||||||
|
<span style="background:#dbeafe;border-bottom:2px solid #3b82f6;">天气凉了</span>,树叶黄了,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 4px 0;">
|
||||||
|
<span style="background:#fef3c7;border-bottom:2px solid #f59e0b;">天空那么蓝</span>,那么高。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 4px 0;">
|
||||||
|
一群大雁往南飞,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 4px 0;">
|
||||||
|
一会儿排成个<span style="background:#e0f2fe;border-bottom:2px solid #0ea5e9;">"人"字</span>,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 4px 0;">
|
||||||
|
一会儿排成个<span style="background:#fce7f3;border-bottom:2px solid #ec4899;">"一"字</span>。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
<span style="background:#dcfce7;border-bottom:2px solid #22c55e;">啊!秋天来了!</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- 缩放控件 -->
|
||||||
|
<div style="position:absolute;bottom:-12px;right:-12px;background:#fff;border:1px solid #f59e0b;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:12px;cursor:pointer;box-shadow:0 2px 4px rgba(0,0,0,0.1);">🔍</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左侧节点 -->
|
||||||
|
<!-- 节点1:导入 -->
|
||||||
|
<div style="position:absolute;left:30px;top:90px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
|
||||||
|
<!-- React Flow Handle 标记 -->
|
||||||
|
<div style="position:absolute;right:-4px;top:50%;width:8px;height:8px;background:#3b82f6;border-radius:50%;border:1px solid #fff;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点2:文本研习 -->
|
||||||
|
<div style="position:absolute;left:30px;top:200px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
|
||||||
|
<div style="position:absolute;right:-4px;top:50%;width:8px;height:8px;background:#f59e0b;border-radius:50%;border:1px solid #fff;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧节点 -->
|
||||||
|
<!-- 节点3:新授 -->
|
||||||
|
<div style="position:absolute;left:630px;top:90px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
|
||||||
|
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#0ea5e9;border-radius:50%;border:1px solid #fff;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点4:练习 -->
|
||||||
|
<div style="position:absolute;left:630px;top:200px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">3 道题</div>
|
||||||
|
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#ec4899;border-radius:50%;border:1px solid #fff;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点5:小结 -->
|
||||||
|
<div style="position:absolute;left:630px;top:320px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);cursor:move;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">总结全文</div>
|
||||||
|
<div style="position:absolute;left:-4px;top:50%;width:8px;height:8px;background:#22c55e;border-radius:50%;border:1px solid #fff;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部全局节点(未锚定) -->
|
||||||
|
<div style="position:absolute;left:30px;top:10px;width:100px;background:#fff;border:1px dashed #3b82f6;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
|
||||||
|
<div style="font-size:9px;font-weight:bold;color:#1e3a8a;">🎯 教学目标</div>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:140px;top:10px;width:100px;background:#fff;border:1px dashed #f59e0b;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
|
||||||
|
<div style="font-size:9px;font-weight:bold;color:#92400e;">⭐ 重难点</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部全局节点(未锚定) -->
|
||||||
|
<div style="position:absolute;left:30px;top:440px;width:100px;background:#fff;border:1px dashed #a855f7;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
|
||||||
|
<div style="font-size:9px;font-weight:bold;color:#9333ea;">🏠 作业</div>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:140px;top:440px;width:100px;background:#fff;border:1px dashed #6366f1;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
|
||||||
|
<div style="font-size:9px;font-weight:bold;color:#4f46e5;">📋 板书设计</div>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:250px;top:440px;width:100px;background:#fff;border:1px dashed #64748b;border-radius:6px;padding:6px;box-shadow:0 2px 4px rgba(0,0,0,0.05);opacity:0.8;">
|
||||||
|
<div style="font-size:9px;font-weight:bold;color:#475569;">💭 教学反思</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- React Flow Controls(右下角) -->
|
||||||
|
<div style="position:absolute;bottom:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px;display:flex;flex-direction:column;gap:2px;box-shadow:0 2px 4px rgba(0,0,0,0.05);">
|
||||||
|
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;border-radius:2px;">+</div>
|
||||||
|
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:14px;border-radius:2px;">−</div>
|
||||||
|
<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:10px;border-radius:2px;">⌖</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加节点按钮(左下角) -->
|
||||||
|
<div style="position:absolute;bottom:12px;left:12px;background:#3b82f6;color:#fff;padding:6px 12px;border-radius:4px;font-size:10px;cursor:pointer;box-shadow:0 2px 4px rgba(59,130,246,0.3);">
|
||||||
|
+ 添加节点
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 透明度提示 -->
|
||||||
|
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px 8px;font-size:9px;color:#64748b;">
|
||||||
|
连线默认 10% 透明度 · 选中节点时完整显示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中状态对比 -->
|
||||||
|
<div class="section" style="margin-top:24px;">
|
||||||
|
<h3>选中节点时的连线显示对比</h3>
|
||||||
|
<div class="split">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">默认状态 — 连线 10% 透明度</div>
|
||||||
|
<div class="mockup-body" style="padding:16px;background:#f1f5f9;">
|
||||||
|
<svg style="width:100%;height:120px;" viewBox="0 0 300 120">
|
||||||
|
<path d="M 30 60 Q 120 60 270 60" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<rect x="10" y="45" width="40" height="30" fill="#fff" stroke="#3b82f6" rx="4"/>
|
||||||
|
<text x="30" y="64" text-anchor="middle" font-size="10" fill="#1e3a8a">导入</text>
|
||||||
|
<rect x="250" y="45" width="40" height="30" fill="#fffbeb" stroke="#f59e0b" stroke-width="2" rx="4"/>
|
||||||
|
<text x="270" y="64" text-anchor="middle" font-size="10" fill="#92400e">课文</text>
|
||||||
|
</svg>
|
||||||
|
<div style="text-align:center;font-size:10px;color:#94a3b8;">连线几乎不可见,画布干净</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">选中"导入"节点 — 连线 100% 显示</div>
|
||||||
|
<div class="mockup-body" style="padding:16px;background:#f1f5f9;">
|
||||||
|
<svg style="width:100%;height:120px;" viewBox="0 0 300 120">
|
||||||
|
<path d="M 30 60 Q 120 60 270 60" stroke="#3b82f6" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
|
||||||
|
<circle cx="270" cy="60" r="5" fill="#3b82f6"/>
|
||||||
|
<rect x="10" y="45" width="40" height="30" fill="#fff" stroke="#3b82f6" stroke-width="3" rx="4"/>
|
||||||
|
<text x="30" y="64" text-anchor="middle" font-size="10" fill="#1e3a8a">导入</text>
|
||||||
|
<rect x="250" y="45" width="40" height="30" fill="#fffbeb" stroke="#f59e0b" stroke-width="2" rx="4"/>
|
||||||
|
<text x="270" y="64" text-anchor="middle" font-size="10" fill="#92400e">课文</text>
|
||||||
|
</svg>
|
||||||
|
<div style="text-align:center;font-size:10px;color:#3b82f6;">连线完整显示,高亮锚点位置</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据模型 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>数据模型设计</h3>
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
|
||||||
|
<div style="color:#94a3b8;">// 正文容器节点(特殊节点类型,不可拖动,可缩放)</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">TextbookContentNode</span> <span style="color:#f59e0b;">extends</span> <span style="color:#3b82f6;">LessonPlanNode</span> {</div>
|
||||||
|
<div> type: <span style="color:#10b981;">"textbook_content"</span>; <span style="color:#64748b;">// 新增节点类型</span></div>
|
||||||
|
<div> data: {</div>
|
||||||
|
<div> chapterId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联教材章节</span></div>
|
||||||
|
<div> content: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// Markdown 正文(缓存)</span></div>
|
||||||
|
<div> zoom: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 缩放比例 0.5-2.0</span></div>
|
||||||
|
<div> };</div>
|
||||||
|
<div> position: { x: <span style="color:#10b981;">number</span>; y: <span style="color:#10b981;">number</span> }; <span style="color:#64748b;">// 固定位置(不可拖动)</span></div>
|
||||||
|
<div> draggable: <span style="color:#10b981;">false</span>; <span style="color:#64748b;">// React Flow 节点锁定</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// 锚点连线(节点 → 正文位置)</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">AnchorEdge</span> <span style="color:#f59e0b;">extends</span> <span style="color:#3b82f6;">LessonPlanEdge</span> {</div>
|
||||||
|
<div> type: <span style="color:#10b981;">"anchor"</span>; <span style="color:#64748b;">// 锚点连线(vs "flow" 流程连线)</span></div>
|
||||||
|
<div> source: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点 ID</span></div>
|
||||||
|
<div> target: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 正文节点 ID</span></div>
|
||||||
|
<div> targetHandle: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// "anchor:123:145"(正文偏移量 start:end)</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// LessonPlanDocument v3</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">LessonPlanDocument</span> {</div>
|
||||||
|
<div> version: <span style="color:#10b981;">3</span>;</div>
|
||||||
|
<div> nodes: <span style="color:#3b82f6;">LessonPlanNode</span>[]; <span style="color:#64748b;">// 含 1 个 textbook_content + N 个教学节点</span></div>
|
||||||
|
<div> edges: <span style="color:#3b82f6;">AnchorEdge</span> | <span style="color:#3b82f6;">FlowEdge</span>[]; <span style="color:#64748b;">// 锚点连线 + 流程连线</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 交互流程 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>核心交互流程</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
|
||||||
|
<h4 style="margin:0 0 8px 0;color:#3b82f6;">🔗 锚定节点到正文</h4>
|
||||||
|
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
|
||||||
|
<li>教师选中正文某段文字(或某个字)</li>
|
||||||
|
<li>选中后弹出浮动菜单:"关联节点 →"</li>
|
||||||
|
<li>从下拉列表选择已有节点,或"新建节点"</li>
|
||||||
|
<li>创建 AnchorEdge,source=节点, target=正文节点, targetHandle="anchor:start:end"</li>
|
||||||
|
<li>正文对应文字高亮显示(节点颜色)</li>
|
||||||
|
<li>连线默认 10% 透明,选中节点时 100%</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
|
||||||
|
<h4 style="margin:0 0 8px 0;color:#22c55e;">🖱️ 拖动节点到正文</h4>
|
||||||
|
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
|
||||||
|
<li>教师从右侧节点列表拖动一个节点</li>
|
||||||
|
<li>拖动过程中,正文区域高亮可放置区域</li>
|
||||||
|
<li>拖到正文某个字前释放</li>
|
||||||
|
<li>创建点锚点(point anchor),targetHandle="anchor:pos"</li>
|
||||||
|
<li>节点自动定位到正文旁边(左或右空位)</li>
|
||||||
|
<li>连线默认 10% 透明,选中时完整显示</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>设计要点</h3>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros">
|
||||||
|
<h4>优势</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>保留画布交互</strong>:缩放/平移/拖动节点,与当前备课模块一致</li>
|
||||||
|
<li><strong>正文固定居中</strong>:不可拖动,始终是视觉中心</li>
|
||||||
|
<li><strong>连线语义化</strong>:anchor 锚点连线 vs flow 流程连线</li>
|
||||||
|
<li><strong>透明度策略</strong>:默认 10%,选中时 100%,画布不杂乱</li>
|
||||||
|
<li><strong>正文可缩放</strong>:教师可放大正文便于阅读</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="cons">
|
||||||
|
<h4>技术挑战</h4>
|
||||||
|
<ul>
|
||||||
|
<li>正文偏移量需基于纯文本(Markdown 渲染后映射)</li>
|
||||||
|
<li>正文内容变更后锚点需重新定位</li>
|
||||||
|
<li>React Flow 自定义节点需处理正文渲染</li>
|
||||||
|
<li>数据结构升级 v2 → v3,需迁移</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<h2>备课模块布局方案对比</h2>
|
||||||
|
<p class="subtitle">3 种布局方案 — 课文固定中央,教学节点围绕组织</p>
|
||||||
|
|
||||||
|
<div class="cards" data-multiselect>
|
||||||
|
<!-- 方案 A -->
|
||||||
|
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:12px;background:#f8fafc;">
|
||||||
|
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
|
||||||
|
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
|
||||||
|
<span>📖 秋天(第一课时)</span>
|
||||||
|
<span>💾 已保存</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:200px 1fr 200px;gap:4px;padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
|
||||||
|
<!-- 左侧:课前 -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="font-size:9px;color:#64748b;text-align:center;">课前</div>
|
||||||
|
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:4px;border-radius:3px;font-size:10px;">🎯 教学目标</div>
|
||||||
|
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:4px;border-radius:3px;font-size:10px;">⭐ 重难点</div>
|
||||||
|
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:4px;border-radius:3px;font-size:10px;">💡 导入</div>
|
||||||
|
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:10px;">📝 文本研习</div>
|
||||||
|
</div>
|
||||||
|
<!-- 中央:课文 -->
|
||||||
|
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;display:flex;flex-direction:column;">
|
||||||
|
<div style="font-size:10px;color:#92400e;font-weight:bold;margin-bottom:4px;">📜 课文正文</div>
|
||||||
|
<div style="font-size:9px;color:#78350f;line-height:1.5;flex:1;">
|
||||||
|
天气凉了,树叶黄了,<br>
|
||||||
|
一片片叶子从树上落下来。<br>
|
||||||
|
<span style="background:#fef08a;">天空那么蓝,那么高</span>。<br>
|
||||||
|
一群大雁往南飞,<br>
|
||||||
|
一会儿排成个"人"字,<br>
|
||||||
|
一会儿排成个"一"字。<br>
|
||||||
|
<span style="background:#bbf7d0;">啊!秋天来了!</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:8px;color:#92400e;margin-top:4px;">💡 选中文字可添加批注</div>
|
||||||
|
</div>
|
||||||
|
<!-- 右侧:课中/课后 -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="font-size:9px;color:#64748b;text-align:center;">课中</div>
|
||||||
|
<div style="background:#dcfce7;border:1px solid #22c55e;padding:4px;border-radius:3px;font-size:10px;">📚 新授</div>
|
||||||
|
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:4px;border-radius:3px;font-size:10px;">✏️ 练习</div>
|
||||||
|
<div style="background:#fee2e2;border:1px solid #ef4444;padding:4px;border-radius:3px;font-size:10px;">📌 小结</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;text-align:center;margin-top:4px;">课后</div>
|
||||||
|
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:4px;border-radius:3px;font-size:10px;">🏠 作业</div>
|
||||||
|
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:4px;border-radius:3px;font-size:10px;">📋 板书设计</div>
|
||||||
|
<div style="background:#f1f5f9;border:1px solid #64748b;padding:4px;border-radius:3px;font-size:10px;">💭 教学反思</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A. 三栏布局(课前/课文/课后)</h3>
|
||||||
|
<p>课文固定中央(琥珀色边框),左侧"课前"节点(目标/重难点/导入/文本研习),右侧"课中+课后"节点(新授/练习/小结/作业/板书/反思)。节点按教学流程纵向排列。点击节点在右侧抽屉编辑。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 方案 B -->
|
||||||
|
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:12px;background:#f8fafc;">
|
||||||
|
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
|
||||||
|
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
|
||||||
|
<span>📖 秋天(第一课时)</span>
|
||||||
|
<span>💾 已保存</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
|
||||||
|
<!-- 顶部:目标/重难点 -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:4px;">
|
||||||
|
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:4px;border-radius:3px;font-size:10px;text-align:center;">🎯 教学目标</div>
|
||||||
|
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:4px;border-radius:3px;font-size:10px;text-align:center;">⭐ 重难点</div>
|
||||||
|
</div>
|
||||||
|
<!-- 中央:课文 + 左右两侧节点 -->
|
||||||
|
<div style="display:grid;grid-template-columns:120px 1fr 120px;gap:4px;margin-bottom:4px;">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:4px;border-radius:3px;font-size:9px;text-align:center;">💡 导入</div>
|
||||||
|
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📝 文本研习</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;text-align:center;">
|
||||||
|
<div style="font-size:10px;color:#92400e;font-weight:bold;">📜 课文正文</div>
|
||||||
|
<div style="font-size:9px;color:#78350f;margin-top:4px;">天气凉了,树叶黄了...<br>天空那么蓝...<br>一群大雁往南飞...</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="background:#dcfce7;border:1px solid #22c55e;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📚 新授</div>
|
||||||
|
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:4px;border-radius:3px;font-size:9px;text-align:center;">✏️ 练习</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 底部:小结/作业/板书/反思 -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px;">
|
||||||
|
<div style="background:#fee2e2;border:1px solid #ef4444;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📌 小结</div>
|
||||||
|
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:4px;border-radius:3px;font-size:9px;text-align:center;">🏠 作业</div>
|
||||||
|
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:4px;border-radius:3px;font-size:9px;text-align:center;">📋 板书</div>
|
||||||
|
<div style="background:#f1f5f9;border:1px solid #64748b;padding:4px;border-radius:3px;font-size:9px;text-align:center;">💭 反思</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B. 上下分区布局</h3>
|
||||||
|
<p>顶部目标/重难点横排,中央课文 + 左右导入/文本研习/新授/练习,底部小结/作业/板书/反思横排。按"目标→导入→课文→新授→小结"的阅读顺序自然流动。视觉层次更清晰。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 方案 C -->
|
||||||
|
<div class="card" data-choice="c" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:12px;background:#f8fafc;">
|
||||||
|
<div style="font-family:monospace;font-size:11px;line-height:1.4;">
|
||||||
|
<div style="border:1px solid #cbd5e1;background:#e2e8f0;padding:4px 8px;border-radius:4px 4px 0 0;display:flex;justify-content:space-between;">
|
||||||
|
<span>📖 秋天(第一课时)</span>
|
||||||
|
<span>💾 已保存</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:8px;background:#fff;border:1px solid #cbd5e1;border-top:none;border-radius:0 0 4px 4px;min-height:280px;">
|
||||||
|
<!-- 左侧:课文 -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div style="background:#fffbeb;border:2px solid #f59e0b;padding:8px;border-radius:4px;flex:1;">
|
||||||
|
<div style="font-size:10px;color:#92400e;font-weight:bold;">📜 课文正文</div>
|
||||||
|
<div style="font-size:9px;color:#78350f;margin-top:4px;line-height:1.5;">
|
||||||
|
天气凉了,树叶黄了,<br>
|
||||||
|
一片片叶子从树上落下来。<br>
|
||||||
|
<span style="background:#fef08a;">天空那么蓝,那么高</span>。<br>
|
||||||
|
一群大雁往南飞...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fce7f3;border:1px solid #ec4899;padding:4px;border-radius:3px;font-size:10px;text-align:center;">📝 文本研习(批注)</div>
|
||||||
|
</div>
|
||||||
|
<!-- 右侧:教学流程时间线 -->
|
||||||
|
<div style="display:flex;flex-direction:column;gap:3px;">
|
||||||
|
<div style="font-size:9px;color:#64748b;text-align:center;">教学流程</div>
|
||||||
|
<div style="background:#dbeafe;border:1px solid #3b82f6;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">1</span>
|
||||||
|
🎯 教学目标
|
||||||
|
</div>
|
||||||
|
<div style="background:#fef3c7;border:1px solid #f59e0b;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">2</span>
|
||||||
|
⭐ 重难点
|
||||||
|
</div>
|
||||||
|
<div style="background:#e0f2fe;border:1px solid #0ea5e9;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">3</span>
|
||||||
|
💡 导入
|
||||||
|
</div>
|
||||||
|
<div style="background:#dcfce7;border:1px solid #22c55e;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">4</span>
|
||||||
|
📚 新授
|
||||||
|
</div>
|
||||||
|
<div style="background:#ede9fe;border:1px solid #8b5cf6;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#8b5cf6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">5</span>
|
||||||
|
✏️ 练习
|
||||||
|
</div>
|
||||||
|
<div style="background:#fee2e2;border:1px solid #ef4444;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#ef4444;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">6</span>
|
||||||
|
📌 小结
|
||||||
|
</div>
|
||||||
|
<div style="background:#f3e8ff;border:1px solid #a855f7;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#a855f7;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">7</span>
|
||||||
|
🏠 作业
|
||||||
|
</div>
|
||||||
|
<div style="background:#e0e7ff;border:1px solid #6366f1;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#6366f1;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">8</span>
|
||||||
|
📋 板书
|
||||||
|
</div>
|
||||||
|
<div style="background:#f1f5f9;border:1px solid #64748b;padding:3px 6px;border-radius:3px;font-size:9px;display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#64748b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;">9</span>
|
||||||
|
💭 反思
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>C. 左课文 + 右时间线</h3>
|
||||||
|
<p>左侧课文正文(固定)+ 文本研习批注,右侧教学流程时间线(编号 1-9 按顺序)。点击时间线节点展开编辑抽屉。最贴近传统教案本格式,结构清晰。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:24px;">
|
||||||
|
<h3>三种方案的核心差异</h3>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros">
|
||||||
|
<h4>方案 A 三栏</h4>
|
||||||
|
<ul>
|
||||||
|
<li>课文始终居中可见</li>
|
||||||
|
<li>课前/课后分区直观</li>
|
||||||
|
<li>节点可拖动微调位置</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="cons">
|
||||||
|
<h4>方案 A 三栏</h4>
|
||||||
|
<ul>
|
||||||
|
<li>三栏可能拥挤(小屏)</li>
|
||||||
|
<li>教学流程顺序不够明显</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros">
|
||||||
|
<h4>方案 B 上下分区</h4>
|
||||||
|
<ul>
|
||||||
|
<li>视觉层次最清晰</li>
|
||||||
|
<li>阅读顺序自然(上→下)</li>
|
||||||
|
<li>课文居中突出</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="cons">
|
||||||
|
<h4>方案 B 上下分区</h4>
|
||||||
|
<ul>
|
||||||
|
<li>节点位置较固定</li>
|
||||||
|
<li>纵向空间需求大</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros">
|
||||||
|
<h4>方案 C 左课文+右时间线</h4>
|
||||||
|
<ul>
|
||||||
|
<li>最接近传统教案</li>
|
||||||
|
<li>教学流程顺序最明确</li>
|
||||||
|
<li>课文阅读体验最佳</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="cons">
|
||||||
|
<h4>方案 C 左课文+右时间线</h4>
|
||||||
|
<ul>
|
||||||
|
<li>节点画布感弱(更像列表)</li>
|
||||||
|
<li>失去节点图连线能力</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<h2>正文占位符标记布局</h2>
|
||||||
|
<p class="subtitle">正文中嵌入占位符标记(特殊符号),默认接近透明,选中节点时完整显示</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">备课编辑器 — 默认状态(占位符 10% 透明度)</div>
|
||||||
|
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
|
||||||
|
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:560px;">
|
||||||
|
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:8px 12px;display:flex;justify-content:space-between;align-items:center;z-index:10;position:relative;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span>📖</span>
|
||||||
|
<span style="font-weight:bold;">秋天(第一课时)</span>
|
||||||
|
<span style="background:#334155;padding:2px 8px;border-radius:3px;font-size:10px;">语文 · 一年级上册</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:10px;color:#94a3b8;">💾 已保存</span>
|
||||||
|
<span style="background:#3b82f6;padding:4px 12px;border-radius:3px;font-size:10px;">💾 保存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 画布区域 -->
|
||||||
|
<div style="position:relative;width:100%;height:520px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- SVG 连线层(默认 10% 透明度) -->
|
||||||
|
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 520">
|
||||||
|
<path d="M 130 120 Q 200 140 290 175" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 130 220 Q 200 230 290 215" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 670 120 Q 600 150 510 195" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 670 220 Q 600 240 510 235" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 670 340 Q 600 320 510 275" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 中央:正文容器(不可移动) -->
|
||||||
|
<div style="position:absolute;left:280px;top:60px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
|
||||||
|
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:#78350f;line-height:2;">
|
||||||
|
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 占位符 1(导入节点)- 默认 10% 透明度 -->
|
||||||
|
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;">①</span>天气凉了,树叶黄了,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 占位符 2(文本研习)- 默认 10% 透明度 -->
|
||||||
|
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;">②</span>天空那么蓝,那么高。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
一群大雁往南飞,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
一会儿排成个"人"字<span style="display:inline-block;background:#0ea5e9;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-left:2px;">③</span>,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
一会儿排成个"一"字<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-left:2px;">④</span>。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.1;margin-right:2px;">⑤</span>啊!秋天来了!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左侧节点 -->
|
||||||
|
<div style="position:absolute;left:30px;top:90px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:30px;top:200px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧节点 -->
|
||||||
|
<div style="position:absolute;left:630px;top:90px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:630px;top:200px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">3 道题</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:630px;top:320px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">总结全文</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:4px 8px;font-size:9px;color:#64748b;">
|
||||||
|
占位符默认 10% · 选中节点时 100% 显示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中状态对比 -->
|
||||||
|
<div class="section" style="margin-top:24px;">
|
||||||
|
<h3>选中"导入"节点时的状态变化</h3>
|
||||||
|
<div class="split">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">默认 — 占位符 ① 10% 透明度</div>
|
||||||
|
<div class="mockup-body" style="padding:16px;background:#fffbeb;">
|
||||||
|
<div style="font-size:14px;color:#78350f;line-height:2;font-family:serif;">
|
||||||
|
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 5px;font-size:10px;font-weight:bold;opacity:0.1;margin-right:3px;">①</span>天气凉了,树叶黄了,
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;font-size:10px;color:#94a3b8;text-align:center;">
|
||||||
|
占位符几乎不可见 · 画布干净
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">选中"导入"节点 — 占位符 ① 100% + 连线显示</div>
|
||||||
|
<div class="mockup-body" style="padding:16px;background:#fffbeb;">
|
||||||
|
<div style="font-size:14px;color:#78350f;line-height:2;font-family:serif;">
|
||||||
|
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 5px;font-size:10px;font-weight:bold;opacity:1;margin-right:3px;box-shadow:0 0 0 2px #3b82f633;">①</span>天气凉了,树叶黄了,
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;font-size:10px;color:#3b82f6;text-align:center;">
|
||||||
|
占位符完整显示 · 连线高亮 · 锚定位置清晰
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 占位符样式选项 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>占位符样式选项</h3>
|
||||||
|
<p class="subtitle">选择占位符在正文中的视觉呈现方式</p>
|
||||||
|
<div class="cards" data-multiselect>
|
||||||
|
<div class="card" data-choice="number" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
|
||||||
|
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
|
||||||
|
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:0 6px;font-size:11px;font-weight:bold;">①</span>天气凉了
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>数字圆圈</h3>
|
||||||
|
<p>①②③④⑤ — 与节点编号对应,简洁清晰</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" data-choice="icon" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
|
||||||
|
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
|
||||||
|
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:3px;padding:1px 5px;font-size:11px;">💡</span>天气凉了
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>节点图标</h3>
|
||||||
|
<p>💡📝📚✏️📌 — 与节点类型图标一致,直观</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" data-choice="dot" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
|
||||||
|
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
|
||||||
|
<span style="display:inline-block;background:#3b82f6;color:#fff;border-radius:50%;width:10px;height:10px;font-size:8px;text-align:center;line-height:10px;">●</span>天气凉了
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>彩色圆点</h3>
|
||||||
|
<p>● — 极简,颜色对应节点,不干扰阅读</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" data-choice="bracket" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding:20px;background:#fffbeb;text-align:center;">
|
||||||
|
<div style="font-size:16px;color:#78350f;font-family:serif;line-height:2;">
|
||||||
|
<span style="color:#3b82f6;font-weight:bold;">【1】</span>天气凉了
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>方括号编号</h3>
|
||||||
|
<p>【1】【2】【3】— 类似脚注标记,学术感</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据模型更新 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>占位符数据模型</h3>
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
|
||||||
|
<div style="color:#94a3b8;">// 正文中的占位符标记</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">ContentPlaceholder</span> {</div>
|
||||||
|
<div> id: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 占位符 ID</span></div>
|
||||||
|
<div> nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点 ID</span></div>
|
||||||
|
<div> offset: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 在正文纯文本中的字符偏移量</span></div>
|
||||||
|
<div> label: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 显示的标记("①" / "💡" / "●" / "【1】")</span></div>
|
||||||
|
<div> color: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 节点颜色(用于占位符背景)</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// 正文渲染时注入占位符</div>
|
||||||
|
<div><span style="color:#f59e0b;">function</span> <span style="color:#3b82f6;">renderContentWithPlaceholders</span>(</div>
|
||||||
|
<div> content: <span style="color:#10b981;">string</span>, <span style="color:#64748b;">// Markdown 原文</span></div>
|
||||||
|
<div> placeholders: <span style="color:#3b82f6;">ContentPlaceholder</span>[]</div>
|
||||||
|
<div>): <span style="color:#10b981;">string</span> {</div>
|
||||||
|
<div> <span style="color:#64748b;">// 按 offset 排序,在对应位置插入占位符标记</span></div>
|
||||||
|
<div> <span style="color:#64748b;">// 渲染为 <span class="placeholder" data-node-id="xxx">①</span></span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// CSS 透明度控制</div>
|
||||||
|
<div>.placeholder { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.1</span>; <span style="color:#10b981;">transition</span>: <span style="color:#f59e0b;">opacity 0.2s</span>; }</div>
|
||||||
|
<div>.placeholder.active { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">1</span>; }</div>
|
||||||
|
<div>.placeholder:hover { <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.6</span>; }</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>交互流程</h3>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
|
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
|
||||||
|
<h4 style="margin:0 0 8px 0;color:#3b82f6;">🔗 添加占位符</h4>
|
||||||
|
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
|
||||||
|
<li>教师点击正文某个位置(光标位置)</li>
|
||||||
|
<li>或选中一段文字后释放</li>
|
||||||
|
<li>弹出菜单:"在此处添加节点 →"</li>
|
||||||
|
<li>选择节点类型或已有节点</li>
|
||||||
|
<li>在正文对应位置插入占位符标记</li>
|
||||||
|
<li>创建 AnchorEdge 连线</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:12px;">
|
||||||
|
<h4 style="margin:0 0 8px 0;color:#22c55e;">👁️ 选中节点时的视觉反馈</h4>
|
||||||
|
<ol style="margin:0;padding-left:16px;font-size:11px;color:#475569;line-height:1.8;">
|
||||||
|
<li>点击画布上的某个节点</li>
|
||||||
|
<li>该节点对应的占位符 opacity 从 0.1 → 1</li>
|
||||||
|
<li>连线从 10% → 100% 显示</li>
|
||||||
|
<li>占位符添加 active 样式(边框/阴影)</li>
|
||||||
|
<li>其他占位符保持 10% 透明度</li>
|
||||||
|
<li>点击空白处恢复默认状态</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<h2>两种锚定方式的视觉规则</h2>
|
||||||
|
<p class="subtitle">范围锚定(选文本)vs 点锚定(插入占位符)— 默认/选中状态对比</p>
|
||||||
|
|
||||||
|
<!-- 规则总览 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>视觉规则总览</h3>
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.8;">
|
||||||
|
<div style="color:#94a3b8;">// 两种锚定方式</div>
|
||||||
|
<div><span style="color:#f59e0b;">type</span> <span style="color:#3b82f6;">AnchorType</span> = <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>;</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// 范围锚定(选一段文本关联节点)</div>
|
||||||
|
<div><span style="color:#94a3b8;">// 文本背景色 = 节点颜色</span></div>
|
||||||
|
<div>.range-anchor {</div>
|
||||||
|
<div> <span style="color:#10b981;">background-color</span>: <span style="color:#f59e0b;">var(--node-color)</span>; <span style="color:#64748b;">// 节点颜色</span></div>
|
||||||
|
<div> <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0</span>; <span style="color:#64748b;">// 默认完全透明(和正常文本一样)</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<div>.range-anchor.active {</div>
|
||||||
|
<div> <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.3</span>; <span style="color:#64748b;">// 选中节点时显示背景色</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// 点锚定(在文本中插入占位符)</div>
|
||||||
|
<div>.point-anchor {</div>
|
||||||
|
<div> <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">0.3</span>; <span style="color:#64748b;">// 默认半透明</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<div>.point-anchor.active {</div>
|
||||||
|
<div> <span style="color:#10b981;">opacity</span>: <span style="color:#f59e0b;">1</span>; <span style="color:#64748b;">// 选中节点时不透明</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 完整画布:默认状态 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>完整画布 — 默认状态</h3>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">默认状态(未选中任何节点)</div>
|
||||||
|
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
|
||||||
|
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:480px;">
|
||||||
|
|
||||||
|
<!-- 画布 -->
|
||||||
|
<div style="position:relative;width:100%;height:480px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- SVG 连线层(默认 10% 透明度) -->
|
||||||
|
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;opacity:0.1;" viewBox="0 0 800 480">
|
||||||
|
<path d="M 130 100 Q 200 120 290 155" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 130 200 Q 200 210 290 195" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 670 100 Q 600 130 510 175" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 670 200 Q 600 220 510 215" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
<path d="M 670 320 Q 600 300 510 255" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 中央:正文容器 -->
|
||||||
|
<div style="position:absolute;left:280px;top:40px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #fde68a;padding-bottom:6px;">
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#92400e;">📜 课文正文</span>
|
||||||
|
<span style="font-size:9px;color:#94a3b8;background:#fef3c7;padding:1px 4px;border-radius:2px;">🔒 固定</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:#78350f;line-height:2.2;">
|
||||||
|
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 范围锚定:默认 opacity:0(完全透明,和正常文本一样) -->
|
||||||
|
<span style="background:#3b82f6;opacity:0;color:#78350f;">天气凉了</span>,树叶黄了,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 点锚定:默认 opacity:0.3(半透明) -->
|
||||||
|
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;">②</span>天空那么蓝,那么高。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
一群大雁往南飞,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 范围锚定:默认 opacity:0 -->
|
||||||
|
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
|
||||||
|
<!-- 点锚定:默认 opacity:0.3 -->
|
||||||
|
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;">④</span>,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
<!-- 点锚定:默认 opacity:0.3 -->
|
||||||
|
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;">⑤</span>啊!秋天来了!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 左侧节点 -->
|
||||||
|
<div style="position:absolute;left:30px;top:70px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#eff6ff;padding:1px 4px;border-radius:2px;">范围</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:30px;top:180px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#fffbeb;padding:1px 4px;border-radius:2px;">点</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧节点 -->
|
||||||
|
<div style="position:absolute;left:630px;top:70px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#f0f9ff;padding:1px 4px;border-radius:2px;">范围</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">讲解:大雁南飞</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:630px;top:180px;width:140px;background:#fff;border:1px solid #ec4899;border-left:3px solid #ec4899;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#ec4899;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">4</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#9f1239;">✏️ 练习</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#fdf2f8;padding:1px 4px;border-radius:2px;">点</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">3 道题</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:630px;top:300px;width:140px;background:#fff;border:1px solid #22c55e;border-left:3px solid #22c55e;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#22c55e;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">5</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#166534;">📌 小结</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#94a3b8;background:#f0fdf4;padding:1px 4px;border-radius:2px;">点</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">总结全文</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;padding:6px 10px;font-size:9px;color:#64748b;">
|
||||||
|
<div>🔵 范围锚定:默认 opacity:0</div>
|
||||||
|
<div>🔴 点锚定:默认 opacity:0.3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中节点 1(范围锚定)的状态 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>选中"导入"节点(范围锚定)— 文本背景显示</h3>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">选中节点 1 — "天气凉了"背景色显示</div>
|
||||||
|
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
|
||||||
|
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:320px;">
|
||||||
|
<div style="position:relative;width:100%;height:320px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- SVG 连线层(选中节点的连线 100% 显示) -->
|
||||||
|
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;" viewBox="0 0 800 320">
|
||||||
|
<!-- 选中节点的连线 100% 显示 -->
|
||||||
|
<path d="M 130 80 Q 200 100 290 135" stroke="#3b82f6" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
|
||||||
|
<circle cx="290" cy="135" r="5" fill="#3b82f6"/>
|
||||||
|
<!-- 其他连线保持 10% -->
|
||||||
|
<path d="M 130 180 Q 200 190 290 175" stroke="#f59e0b" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<path d="M 670 80 Q 600 110 510 155" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<path d="M 670 180 Q 600 200 510 195" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<path d="M 670 280 Q 600 260 510 235" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 正文容器 -->
|
||||||
|
<div style="position:absolute;left:280px;top:20px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
|
||||||
|
<div style="font-size:13px;color:#78350f;line-height:2.2;">
|
||||||
|
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 范围锚定:选中时 opacity:0.3(背景色显示) -->
|
||||||
|
<span style="background:#3b82f6;opacity:0.3;color:#78350f;border-radius:2px;">天气凉了</span>,树叶黄了,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 点锚定:未选中,保持 0.3 -->
|
||||||
|
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;">②</span>天空那么蓝,那么高。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">一群大雁往南飞,</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
|
||||||
|
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;">④</span>,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;">⑤</span>啊!秋天来了!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中的节点 1(高亮边框) -->
|
||||||
|
<div style="position:absolute;left:30px;top:50px;width:140px;background:#fff;border:2px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 0 0 3px #3b82f633,0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#fff;background:#3b82f6;padding:1px 4px;border-radius:2px;">范围·选中</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">提问:你见过秋天的树叶吗?</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 其他节点(正常状态) -->
|
||||||
|
<div style="position:absolute;left:30px;top:160px;width:140px;background:#fff;border:1px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:630px;top:50px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #3b82f6;border-radius:4px;padding:6px 10px;font-size:9px;color:#3b82f6;">
|
||||||
|
✅ 选中"导入"节点 → "天气凉了"背景显示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中节点 2(点锚定)的状态 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>选中"文本研习"节点(点锚定)— 占位符不透明</h3>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">选中节点 2 — 占位符 ② 100% 显示</div>
|
||||||
|
<div class="mockup-body" style="padding:0;background:#f1f5f9;overflow:hidden;">
|
||||||
|
<div style="font-family:monospace;font-size:12px;line-height:1.6;position:relative;height:320px;">
|
||||||
|
<div style="position:relative;width:100%;height:320px;background:#f1f5f9;background-image:radial-gradient(#cbd5e1 1px, transparent 1px);background-size:20px 20px;overflow:hidden;">
|
||||||
|
|
||||||
|
<svg style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;" viewBox="0 0 800 320">
|
||||||
|
<path d="M 130 80 Q 200 100 290 135" stroke="#3b82f6" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<!-- 选中节点的连线 100% -->
|
||||||
|
<path d="M 130 180 Q 200 190 290 175" stroke="#f59e0b" stroke-width="2.5" fill="none" stroke-dasharray="4 4" opacity="1"/>
|
||||||
|
<circle cx="290" cy="175" r="5" fill="#f59e0b"/>
|
||||||
|
<path d="M 670 80 Q 600 110 510 155" stroke="#0ea5e9" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<path d="M 670 180 Q 600 200 510 195" stroke="#ec4899" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
<path d="M 670 280 Q 600 260 510 235" stroke="#22c55e" stroke-width="2" fill="none" stroke-dasharray="4 4" opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:280px;top:20px;width:240px;background:#fffbeb;border:2px solid #f59e0b;border-radius:8px;padding:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);">
|
||||||
|
<div style="font-size:13px;color:#78350f;line-height:2.2;">
|
||||||
|
<div style="text-align:center;font-weight:bold;margin-bottom:6px;">秋天</div>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 范围锚定:未选中,opacity:0 -->
|
||||||
|
<span style="background:#3b82f6;opacity:0;color:#78350f;">天气凉了</span>,树叶黄了,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
<!-- 点锚定:选中时 opacity:1(不透明) -->
|
||||||
|
<span style="display:inline-block;background:#f59e0b;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:1;margin-right:2px;box-shadow:0 0 0 2px #f59e0b44;">②</span>天空那么蓝,那么高。
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px 0;">一群大雁往南飞,</p>
|
||||||
|
<p style="margin:0 0 6px 0;">
|
||||||
|
一会儿排成个<span style="background:#0ea5e9;opacity:0;color:#78350f;">"人"字</span>
|
||||||
|
<span style="display:inline-block;background:#ec4899;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-left:2px;">④</span>,
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;">
|
||||||
|
<span style="display:inline-block;background:#22c55e;color:#fff;border-radius:3px;padding:0 4px;font-size:9px;font-weight:bold;opacity:0.3;margin-right:2px;">⑤</span>啊!秋天来了!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:30px;top:50px;width:140px;background:#fff;border:1px solid #3b82f6;border-left:3px solid #3b82f6;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">1</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#1e3a8a;">💡 导入</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中的节点 2(高亮边框) -->
|
||||||
|
<div style="position:absolute;left:30px;top:160px;width:140px;background:#fff;border:2px solid #f59e0b;border-left:3px solid #f59e0b;border-radius:6px;padding:8px;box-shadow:0 0 0 3px #f59e0b33,0 2px 6px rgba(0,0,0,0.08);">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-bottom:4px;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">2</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#92400e;">📝 文本研习</span>
|
||||||
|
<span style="margin-left:auto;font-size:8px;color:#fff;background:#f59e0b;padding:1px 4px;border-radius:2px;">点·选中</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:9px;color:#64748b;">赏析:叠词的运用</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;left:630px;top:50px;width:140px;background:#fff;border:1px solid #0ea5e9;border-left:3px solid #0ea5e9;border-radius:6px;padding:8px;box-shadow:0 2px 6px rgba(0,0,0,0.08);opacity:0.7;">
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;">
|
||||||
|
<span style="background:#0ea5e9;color:#fff;border-radius:50%;width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:bold;">3</span>
|
||||||
|
<span style="font-size:10px;font-weight:bold;color:#075985;">📚 新授</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="position:absolute;top:12px;right:12px;background:#fff;border:1px solid #f59e0b;border-radius:4px;padding:6px 10px;font-size:9px;color:#f59e0b;">
|
||||||
|
✅ 选中"文本研习"节点 → 占位符 ② 不透明显示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 两种锚定方式对比表 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>两种锚定方式对比</h3>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#1e293b;color:#e2e8f0;">
|
||||||
|
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">特性</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">范围锚定(选文本)</th>
|
||||||
|
<th style="padding:8px 12px;text-align:left;border:1px solid #334155;">点锚定(插入占位符)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="background:#fff;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">触发方式</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">选中一段文字 → 关联节点</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">点击文本某位置 → 插入占位符</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">视觉表现</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">文本背景色 = 节点颜色</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">插入标记符号(①②③)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#fff;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">默认透明度</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0</span>
|
||||||
|
<span style="color:#64748b;font-size:10px;">(完全透明,和正常文本一样)</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.3</span>
|
||||||
|
<span style="color:#64748b;font-size:10px;">(半透明,隐约可见)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">选中时透明度</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
|
||||||
|
<span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.3</span>
|
||||||
|
<span style="color:#64748b;font-size:10px;">(背景色显示)</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">
|
||||||
|
<span style="background:#f59e0b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 1</span>
|
||||||
|
<span style="color:#64748b;font-size:10px;">(不透明,完整显示)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#fff;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">连线透明度</td>
|
||||||
|
<td colspan="2" style="padding:8px 12px;border:1px solid #e2e8f0;">
|
||||||
|
默认 <span style="background:#64748b;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 0.1</span> · 选中时 <span style="background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;font-size:10px;">opacity: 1</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background:#f8fafc;">
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;font-weight:bold;">适用场景</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">节点与具体文字内容相关(如赏析某词、讲解某句)</td>
|
||||||
|
<td style="padding:8px 12px;border:1px solid #e2e8f0;">节点对应文本某个位置(如在此处开始导入、在此处小结)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据模型 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>数据模型</h3>
|
||||||
|
<div style="background:#1e293b;color:#e2e8f0;padding:16px;border-radius:6px;font-family:monospace;font-size:11px;line-height:1.6;">
|
||||||
|
<div style="color:#94a3b8;">// 锚点 — 统一接口,区分 type</div>
|
||||||
|
<div><span style="color:#f59e0b;">interface</span> <span style="color:#3b82f6;">NodeAnchor</span> {</div>
|
||||||
|
<div> id: <span style="color:#10b981;">string</span>;</div>
|
||||||
|
<div> nodeId: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// 关联的节点</span></div>
|
||||||
|
<div> type: <span style="color:#10b981;">"range"</span> | <span style="color:#10b981;">"point"</span>; <span style="color:#64748b;">// 两种锚定方式</span></div>
|
||||||
|
<div> start: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// 正文纯文本偏移量</span></div>
|
||||||
|
<div> end?: <span style="color:#10b981;">number</span>; <span style="color:#64748b;">// range 锚定的结束偏移(point 无)</span></div>
|
||||||
|
<div> textPreview?: <span style="color:#10b981;">string</span>; <span style="color:#64748b;">// range 锚定的文字预览</span></div>
|
||||||
|
<div>}</div>
|
||||||
|
<br>
|
||||||
|
<div style="color:#94a3b8;">// 渲染规则</div>
|
||||||
|
<div><span style="color:#f59e0b;">function</span> <span style="color:#3b82f6;">getAnchorStyle</span>(anchor: <span style="color:#3b82f6;">NodeAnchor</span>, isActive: <span style="color:#10b981;">boolean</span>) {</div>
|
||||||
|
<div> <span style="color:#f59e0b;">if</span> (anchor.type === <span style="color:#10b981;">"range"</span>) {</div>
|
||||||
|
<div> <span style="color:#f59e0b;">return</span> { backgroundColor: getNodeColor(anchor.nodeId), opacity: isActive ? <span style="color:#f59e0b;">0.3</span> : <span style="color:#f59e0b;">0</span> };</div>
|
||||||
|
<div> } <span style="color:#f59e0b;">else</span> { <span style="color:#64748b;">// point</span></div>
|
||||||
|
<div> <span style="color:#f59e0b;">return</span> { opacity: isActive ? <span style="color:#f59e0b;">1</span> : <span style="color:#f59e0b;">0.3</span> };</div>
|
||||||
|
<div> }</div>
|
||||||
|
<div>}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1782143663726}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
sessionDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344
|
||||||
|
stateDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344\state
|
||||||
|
contentDir=e:\Desktop\CICD\.superpowers\brainstorm\41500-1782168322.9344\content
|
||||||
@@ -107,7 +107,31 @@ src/modules/[module]/
|
|||||||
- 使用 `cn()` 工具函数管理条件类名
|
- 使用 `cn()` 工具函数管理条件类名
|
||||||
- **禁止**字符串拼接动态类名(`bg-${color}-500`)
|
- **禁止**字符串拼接动态类名(`bg-${color}-500`)
|
||||||
- **禁止**使用任意值(`w-[137px]`),除非有充分理由并注释
|
- **禁止**使用任意值(`w-[137px]`),除非有充分理由并注释
|
||||||
- 设计令牌在 `src/app/globals.css` 中使用 CSS 变量定义
|
- 设计令牌在 `src/app/styles/tokens/` 目录中分层定义,通过 `@theme inline` 暴露为 Tailwind 类
|
||||||
|
|
||||||
|
### 设计令牌规范(强制)
|
||||||
|
|
||||||
|
- **禁止硬编码颜色**: TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,统一使用 `hsl(var(--*))` 或 Tailwind 类 `bg-*`
|
||||||
|
- **禁止硬编码字体**: 不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
|
||||||
|
- **禁止硬编码字号**: 不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
|
||||||
|
- **禁止 Tailwind 任意值**: 不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]` 等,映射到 `--space-*` 或 Tailwind 默认阶梯
|
||||||
|
- **豁免场景**(需 `// eslint-disable-next-line no-restricted-syntax -- <reason>` 注释):
|
||||||
|
- PWA manifest(`src/app/manifest.ts`)
|
||||||
|
- 邮件 HTML 内联样式(`src/modules/notifications/channels/email-channel.ts`)
|
||||||
|
- 图表 SVG 固定画布尺寸(recharts 选择器中的 `#ccc`/`#fff`)
|
||||||
|
- loading.tsx 占位骨架
|
||||||
|
- Dialog 固定宽度等无法令牌化的设计固定尺寸
|
||||||
|
- **令牌文件分布**: `src/app/styles/tokens/`(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
|
||||||
|
- **令牌分层**:
|
||||||
|
- Layer 1 Primitive(`primitive.css`):原始色板/字号/间距/阴影,业务代码不直接引用
|
||||||
|
- Layer 2 Semantic(`semantic-light.css` + `semantic-dark.css`):语义令牌,业务代码唯一引用入口
|
||||||
|
- 模块命名空间(`lesson-preparation.css`):`--lp-*` 令牌,明暗双份
|
||||||
|
- Tailwind 暴露(`tailwind-theme.css`):`@theme inline` 将 Semantic 令牌暴露为 `bg-*`/`text-*`/`font-*` 类
|
||||||
|
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004` 与 `005`
|
||||||
|
- **ESLint 强制约束**:
|
||||||
|
- `no-restricted-syntax`: 禁止 `#hex` 字面量
|
||||||
|
- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量(单词边界匹配,不影响 `Interval`/`Interactive` 等标识符)
|
||||||
|
- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML)、`manifest.ts`(PWA)
|
||||||
|
|
||||||
### 安全规范
|
### 安全规范
|
||||||
|
|
||||||
|
|||||||
1
.tsc_out.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
src/app/(dashboard)/teacher/textbooks/error.tsx(3,10): error TS2305: Module '"@/shared/components/route-error"' has no exported member 'RouteError'.
|
||||||
BIN
bugs/screenshots/announcements.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
bugs/screenshots/login_failure.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
bugs/screenshots/messages.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
bugs/screenshots/teacher_attendance_error.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
bugs/screenshots/teacher_attendance_sheet_error.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
bugs/screenshots/teacher_attendance_stats_error.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
bugs/screenshots/teacher_classes.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
bugs/screenshots/teacher_classes_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_classes_my_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_classes_schedule_error.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
bugs/screenshots/teacher_classes_students_error.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
bugs/screenshots/teacher_course-plans_error.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
bugs/screenshots/teacher_dashboard_error.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
bugs/screenshots/teacher_diagnostic.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
bugs/screenshots/teacher_diagnostic_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_elective_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_error-book.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
bugs/screenshots/teacher_error-book_error.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
bugs/screenshots/teacher_exams_all_error.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
bugs/screenshots/teacher_exams_create_error.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
bugs/screenshots/teacher_exams_error.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
bugs/screenshots/teacher_grades.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
bugs/screenshots/teacher_grades_analytics_error.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
bugs/screenshots/teacher_grades_entry.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
bugs/screenshots/teacher_grades_stats.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
bugs/screenshots/teacher_homework_assignments_create_error.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
bugs/screenshots/teacher_homework_assignments_error.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
bugs/screenshots/teacher_homework_error.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
bugs/screenshots/teacher_homework_submissions.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
bugs/screenshots/teacher_homework_submissions_error.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
bugs/screenshots/teacher_lesson-plans_error.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
bugs/screenshots/teacher_lesson-plans_new_error.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
bugs/screenshots/teacher_questions_error.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
bugs/screenshots/teacher_schedule-changes.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
bugs/screenshots/teacher_schedule-changes_error.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
bugs/screenshots/teacher_textbooks_error.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
1734
build-output.txt
Normal file
4
cookies.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
BIN
debug-exams.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
206
docs/architecture/audit/data-access-audit-framework-v1.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# 数据库访问层重构专项 - 审计框架 v1
|
||||||
|
|
||||||
|
> 创建日期:2026-07-07
|
||||||
|
> 目标:对全项目 86 个 `data-access*.ts` + ~30 个 `actions.ts` 进行深度审计,输出可执行的分级治理路线图
|
||||||
|
> 推进路径:先审计后治理(用户已确认)
|
||||||
|
> 执行方案:纯深读(用户已确认方案 B)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、审计范围
|
||||||
|
|
||||||
|
### 1.1 文件范围
|
||||||
|
|
||||||
|
| 类型 | 路径模式 | 文件数(约) | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 数据访问层 | `src/modules/**/data-access*.ts` | 86 | 主审计对象 |
|
||||||
|
| Server Actions | `src/modules/**/actions.ts` | ~30 | 辅查(权限校验、业务逻辑归属) |
|
||||||
|
| 辅助文件 | `src/modules/**/schema.ts`、`types.ts` | 按需 | 仅当 data-access 引用时查看 |
|
||||||
|
|
||||||
|
### 1.2 排除范围
|
||||||
|
|
||||||
|
- `src/app/**`:仅在 A-05 规则(app 直访 DB)触发时反向查看
|
||||||
|
- `src/shared/**`:仅在 A-07 规则(shared 反向依赖)触发时查看
|
||||||
|
- 已有的模块级 audit 报告(`docs/architecture/audit/*-audit-report.md`):作为参考但不直接复用,因本次为横切关注点
|
||||||
|
|
||||||
|
### 1.3 模块分组(并行执行单元)
|
||||||
|
|
||||||
|
| 组 | 模块 | data-access 文件数 | sub-agent |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **G1 核心教学 A** | lesson-preparation(12)+ questions + textbooks | ~16 | agent-1 |
|
||||||
|
| **G2 核心教学 B** | exams + homework(7)+ grades(6)+ diagnostic + adaptive-practice(3) | ~21 | agent-2 |
|
||||||
|
| **G3 教学管理** | classes(6)+ school + scheduling + attendance(3)+ course-plans + proctoring | ~16 | agent-3 |
|
||||||
|
| **G4 用户与沟通** | users + messaging + notifications + parent + audit + auth + rbac(3) | ~12 | agent-4 |
|
||||||
|
| **G5 扩展与设置** | elective(5)+ settings(5)+ dashboard + files + search + onboarding + ai + announcements + error-book(3) | ~21 | agent-5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、审计维度与检查规则
|
||||||
|
|
||||||
|
### 2.1 维度 1:模式标准化(Pattern Standardization)
|
||||||
|
|
||||||
|
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P-01 | `import "server-only"` 文件头 | 每个文件首行 | 静态 |
|
||||||
|
| P-02 | 类型导入使用 `import type` | 类型导入与值导入分离 | 静态 |
|
||||||
|
| P-03 | 读函数是否走 `cacheFn` 包装(Raw + Wrapper 配对) | 全部覆盖 | 深读 |
|
||||||
|
| P-04 | 函数返回类型显式标注 `Promise<T>` | 无隐式推断 | 深读 |
|
||||||
|
| P-05 | 错误处理一致 | data-access 层用 throw,actions 层用 ActionState | 深读 |
|
||||||
|
| P-06 | 分页参数命名统一 | `page`/`pageSize` 或 `limit`/`offset` 全局统一 | 深读 |
|
||||||
|
| P-07 | 日期序列化走 helper | `serializeDate`/`toISODateString` | 深读 |
|
||||||
|
| P-08 | 列表项映射走 `mapListItem` 模式 | 避免 inline mapping 重复 | 深读 |
|
||||||
|
| P-09 | `as` 断言出现次数 | 0(除 unknown 收窄) | 静态 |
|
||||||
|
| P-10 | `any` 出现次数 | 0 | 静态 |
|
||||||
|
|
||||||
|
### 2.2 维度 2:性能与查询优化(Performance)
|
||||||
|
|
||||||
|
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F-01 | 循环内 SQL 调用(N+1) | 改批量查询 + Map 解析 | 深读 |
|
||||||
|
| F-02 | `LIKE '%xxx%'` 全表扫描 | 改 FULLTEXT 或前缀匹配 | 深读 |
|
||||||
|
| F-03 | SELECT * 未指定列 | 显式列枚举 | 深读 |
|
||||||
|
| F-04 | JOIN 表数量 > 3 | 评估拆分或冗余字段 | 深读 |
|
||||||
|
| F-05 | 大表查询无 LIMIT | 添加默认 LIMIT | 深读 |
|
||||||
|
| F-06 | 重复查询同表/同条件 | 走 cacheFn 或合并查询 | 深读 |
|
||||||
|
| F-07 | 缺失索引(高频 WHERE 字段) | 提示加索引 | 深读 |
|
||||||
|
| F-08 | 跨模块多次调用 `getXxxNamesByIds` | 批量化 | 深读 |
|
||||||
|
| F-09 | 事务范围过大(含网络调用) | 收紧事务 | 深读 |
|
||||||
|
| F-10 | `count()` 全表统计无过滤 | 添加过滤条件 | 深读 |
|
||||||
|
|
||||||
|
### 2.3 维度 3:架构违规治理(Architecture)
|
||||||
|
|
||||||
|
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A-01 | data-access 含 `requirePermission` 调用 | 移至 actions | 静态 |
|
||||||
|
| A-02 | data-access 含业务逻辑(条件分支、状态机) | 移至 actions 或 lib | 深读 |
|
||||||
|
| A-03 | data-access 含 `"use server"` 标记 | 移至 actions | 静态 |
|
||||||
|
| A-04 | data-access 含 `revalidatePath` 调用 | 移至 actions | 静态 |
|
||||||
|
| A-05 | app/ 直接 import `@/shared/db` | 违规,改走 data-access | 静态 |
|
||||||
|
| A-06 | modules 间直接 import 对方 `@/shared/db/schema` 表 | 改走对方 data-access | 静态 |
|
||||||
|
| A-07 | shared/ 反向 import `@/auth`/`@/proxy`/`modules/*` | 违规 | 静态 |
|
||||||
|
| A-08 | actions.ts 漏调 `requirePermission` | 补齐 | 深读 |
|
||||||
|
| A-09 | actions.ts 含直接 DB 查询 | 移至 data-access | 深读 |
|
||||||
|
| A-10 | data-access 含 `console.log` 调试代码 | 删除 | 静态 |
|
||||||
|
|
||||||
|
### 2.4 维度 4:结构与可维护性(Structure)
|
||||||
|
|
||||||
|
| 规则 ID | 检查项 | 期望状态 | 检测方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S-01 | 文件行数 > 800 行警告,> 1000 行必须拆分 | 拆分 | 静态 |
|
||||||
|
| S-02 | 单文件导出函数数 > 20 | 警告,考虑拆分 | 静态 |
|
||||||
|
| S-03 | 重复 helper(多模块各自实现 serializeDate/buildScopeFilter 等) | 提取到 shared/lib | 深读 |
|
||||||
|
| S-04 | 过细拆分(同模块 ≥ 5 个子文件且单文件 < 100 行) | 评估合并 | 静态 |
|
||||||
|
| S-05 | 未使用导出(dead code) | 删除 | 深读 |
|
||||||
|
| S-06 | 公共导出函数缺 JSDoc | 补齐 | 深读 |
|
||||||
|
| S-07 | 跨模块重复查询逻辑 | 提取共享 data-access | 深读 |
|
||||||
|
| S-08 | 模块内 data-access 与 actions 职责混淆 | 重新分层 | 深读 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、严重性分级
|
||||||
|
|
||||||
|
| 级别 | 含义 | 示例 | 治理窗口 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0 Critical** | 架构硬违规、安全漏洞、必定性能问题 | app 直访 DB、跨模块 schema 直查、actions 漏权限、N+1 循环 SQL | 立即 |
|
||||||
|
| **P1 High** | 显著性能/可维护性问题 | 超长文件(>1000 行)、缺 cacheFn 的热路径读函数、LIKE 全表扫描 | Phase 1 |
|
||||||
|
| **P2 Medium** | 模式偏差、可优化 | 错误处理不一致、缺 JSDoc、重复 helper、分页命名不统一 | Phase 2 |
|
||||||
|
| **P3 Low** | 风格问题、可选优化 | 单行格式、import 顺序、注释措辞 | Phase 3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、执行流程
|
||||||
|
|
||||||
|
### 4.1 阶段 A:sub-agent 分组深读(并行)
|
||||||
|
|
||||||
|
每个 sub-agent 接收:
|
||||||
|
- 该组所有 `data-access*.ts` + 同模块 `actions.ts` 文件清单
|
||||||
|
- 完整规则表(4 维度 × 38 条规则)
|
||||||
|
- 统一输出格式(见 4.3)
|
||||||
|
|
||||||
|
每个 sub-agent 执行:
|
||||||
|
1. 完整读取每个文件(不使用 limit/offset)
|
||||||
|
2. 按规则表逐条检测
|
||||||
|
3. 命中即记录到问题清单
|
||||||
|
4. 对每个问题给出修复建议与预估工作量
|
||||||
|
|
||||||
|
### 4.2 阶段 B:主 agent 汇总
|
||||||
|
|
||||||
|
- 收集 5 个 sub-agent 的结构化输出
|
||||||
|
- 去重(同一问题被多 agent 命中时合并)
|
||||||
|
- 跨模块统计(如重复 helper 在多少模块出现)
|
||||||
|
- 生成优先级矩阵
|
||||||
|
- 编写治理路线图
|
||||||
|
|
||||||
|
### 4.3 sub-agent 输出格式
|
||||||
|
|
||||||
|
每个 sub-agent 产出 JSON 数组,每条问题:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "G1-001",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L123-L145",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "循环内调用 getClassNamesByIds",
|
||||||
|
"description": "在 for 循环内对每个 classId 单独查询 className,应改为批量查询后用 Map 解析",
|
||||||
|
"recommendation": "提取 classIds 数组,一次调用 getClassNamesByIds(classIds),循环内改为 map.get(classId)",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
工作量分级:
|
||||||
|
- **XS**:≤ 15 分钟(如删除 console.log、补 import type)
|
||||||
|
- **S**:≤ 30 分钟(如替换 as 断言为类型守卫)
|
||||||
|
- **M**:≤ 2 小时(如 N+1 改批量、提取 helper)
|
||||||
|
- **L**:≤ 1 天(如拆分超长文件、跨模块重构)
|
||||||
|
- **XL**:> 1 天(如架构层重构)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、报告输出
|
||||||
|
|
||||||
|
### 5.1 主报告
|
||||||
|
|
||||||
|
文件:`docs/architecture/audit/data-access-audit-v1.md`
|
||||||
|
|
||||||
|
结构:
|
||||||
|
1. **执行摘要**:总文件数、问题总数、P0/P1/P2/P3 分布、模块热度图
|
||||||
|
2. **量化指标仪表盘**:cacheFn 覆盖率、平均行数、`as` 断言数、违规 import 数等
|
||||||
|
3. **按维度分组的问题清单**:每条含 文件:行号、规则 ID、严重性、现状描述、修复建议、预估工作量
|
||||||
|
4. **按模块分组的问题清单**:每个模块的累计问题数与 Top 问题
|
||||||
|
5. **P0-P3 优先级矩阵**:四象限图(影响 × 紧迫度)
|
||||||
|
6. **分阶段治理路线图**:Phase 1 (P0) → Phase 2 (P1) → Phase 3 (P2) → Phase 4 (P3)
|
||||||
|
7. **附录**:完整规则表、sub-agent 原始输出索引
|
||||||
|
|
||||||
|
### 5.2 结构化数据
|
||||||
|
|
||||||
|
文件:`docs/architecture/audit/data-access-audit-v1-data.json`
|
||||||
|
|
||||||
|
字段:`issues[]`、`metrics{}`、`moduleSummary{}`、`roadmap{}`
|
||||||
|
|
||||||
|
### 5.3 速查手册同步
|
||||||
|
|
||||||
|
发现的新模式问题需追加到 `docs/troubleshooting/known-issues.md`(速查手册格式)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、质量约束
|
||||||
|
|
||||||
|
- **零误报**:每条问题必须给出文件:行号 + 代码证据,避免臆测
|
||||||
|
- **零遗漏**:86 个 data-access 文件必须全部深读,不得抽样
|
||||||
|
- **可执行**:每条修复建议必须具体到代码示例或操作步骤
|
||||||
|
- **不修改代码**:审计阶段只产出报告,不做任何源码修改
|
||||||
|
- **架构同步**:审计过程中发现的架构图遗漏(004/005 文档)记录到报告附录,治理阶段统一补图
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、后续衔接
|
||||||
|
|
||||||
|
审计报告 v1 完成后:
|
||||||
|
|
||||||
|
1. **用户审查报告**:确认问题清单与优先级
|
||||||
|
2. **制定治理路线图**:基于 P0-P3 分级,输出 `data-access-refactor-roadmap-v1.md`
|
||||||
|
3. **分阶段执行治理**:每阶段完成后运行 `npm run lint` + `npx tsc --noEmit` 验证
|
||||||
|
4. **同步架构文档**:每阶段完成后同步 004/005 文档与 known-issues.md
|
||||||
171
docs/architecture/audit/data-access-audit-v1-data.json
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"version": "v1",
|
||||||
|
"createdAt": "2026-07-07",
|
||||||
|
"scope": {
|
||||||
|
"dataAccessFiles": 86,
|
||||||
|
"actionsFilesAudited": 15,
|
||||||
|
"totalFilesAudited": 101
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"totalIssues": 230,
|
||||||
|
"bySeverity": {
|
||||||
|
"P0": 17,
|
||||||
|
"P1": 48,
|
||||||
|
"P2": 105,
|
||||||
|
"P3": 60
|
||||||
|
},
|
||||||
|
"byDimension": {
|
||||||
|
"pattern": 54,
|
||||||
|
"performance": 71,
|
||||||
|
"architecture": 58,
|
||||||
|
"structure": 47
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"serverOnlyMissing": 2,
|
||||||
|
"cacheFnMissingEstimated": 60,
|
||||||
|
"filesOver800Lines": 3,
|
||||||
|
"filesOver1000Lines": 1,
|
||||||
|
"filesOver20Exports": 5,
|
||||||
|
"asAssertionsNonExempt": 2,
|
||||||
|
"anyUsage": 0,
|
||||||
|
"consoleErrorCount": 25,
|
||||||
|
"nPlusOnePatterns": 11,
|
||||||
|
"likeFullScanPatterns": 7,
|
||||||
|
"selectStarCount": 35,
|
||||||
|
"noLimitQueries": 18,
|
||||||
|
"crossModuleSchemaAccess": 7,
|
||||||
|
"businessLogicInDataAccess": 18,
|
||||||
|
"unprotectedTransactions": 4,
|
||||||
|
"actionsPermissionIssues": 4,
|
||||||
|
"actionsDirectDB": 1
|
||||||
|
},
|
||||||
|
"moduleHeatmap": [
|
||||||
|
{ "module": "messaging", "p0": 1, "p1": 4, "total": 5, "risk": "critical" },
|
||||||
|
{ "module": "classes", "p0": 1, "p1": 4, "total": 14, "risk": "critical" },
|
||||||
|
{ "module": "school", "p0": 0, "p1": 5, "total": 8, "risk": "critical" },
|
||||||
|
{ "module": "lesson-preparation", "p0": 1, "p1": 3, "total": 28, "risk": "high" },
|
||||||
|
{ "module": "scheduling", "p0": 1, "p1": 2, "total": 6, "risk": "high" },
|
||||||
|
{ "module": "adaptive-practice", "p0": 1, "p1": 2, "total": 4, "risk": "high" },
|
||||||
|
{ "module": "elective", "p0": 0, "p1": 3, "total": 7, "risk": "high" },
|
||||||
|
{ "module": "textbooks", "p0": 2, "p1": 1, "total": 11, "risk": "high" },
|
||||||
|
{ "module": "onboarding", "p0": 2, "p1": 0, "total": 2, "risk": "medium" },
|
||||||
|
{ "module": "grades", "p0": 0, "p1": 2, "total": 4, "risk": "medium" },
|
||||||
|
{ "module": "questions", "p0": 1, "p1": 1, "total": 11, "risk": "medium" },
|
||||||
|
{ "module": "audit", "p0": 1, "p1": 1, "total": 3, "risk": "medium" },
|
||||||
|
{ "module": "parent", "p0": 1, "p1": 0, "total": 1, "risk": "medium" },
|
||||||
|
{ "module": "attendance", "p0": 0, "p1": 1, "total": 9, "risk": "medium" },
|
||||||
|
{ "module": "files", "p0": 0, "p1": 4, "total": 13, "risk": "medium" },
|
||||||
|
{ "module": "exams", "p0": 1, "p1": 0, "total": 1, "risk": "low" },
|
||||||
|
{ "module": "course-plans", "p0": 1, "p1": 0, "total": 5, "risk": "low" },
|
||||||
|
{ "module": "homework", "p0": 0, "p1": 1, "total": 2, "risk": "low" },
|
||||||
|
{ "module": "diagnostic", "p0": 0, "p1": 1, "total": 2, "risk": "low" }
|
||||||
|
],
|
||||||
|
"p0Issues": [
|
||||||
|
{ "id": "G4-003", "file": "src/modules/audit/actions.ts", "lines": "L192-225", "ruleId": "A-08", "title": "purgeAuditLogsAction 用读权限执行物理删除", "category": "security" },
|
||||||
|
{ "id": "G4-002", "file": "src/modules/parent/", "lines": "—", "ruleId": "A-08", "title": "parent 模块缺失 actions.ts,3 页面直访 data-access", "category": "security" },
|
||||||
|
{ "id": "G2-001", "file": "src/modules/exams/data-access.ts", "lines": "L1", "ruleId": "P-01", "title": "缺 import server-only", "category": "security" },
|
||||||
|
{ "id": "G5-001", "file": "src/modules/onboarding/data-access.ts", "lines": "L1", "ruleId": "P-01", "title": "缺 import server-only", "category": "security" },
|
||||||
|
{ "id": "G1-001", "file": "src/modules/textbooks/data-access-graph.ts", "lines": "L7-121", "ruleId": "A-06", "title": "直查 questions + diagnostic 模块表", "category": "architecture" },
|
||||||
|
{ "id": "G3-002", "file": "src/modules/scheduling/data-access.ts", "lines": "L8-17", "ruleId": "A-06", "title": "直查 classes/users/subjects 三模块表", "category": "architecture" },
|
||||||
|
{ "id": "G3-003", "file": "src/modules/scheduling/data-access-class-schedule.ts", "lines": "L28-158", "ruleId": "A-02", "title": "data-access 含校验+状态机业务逻辑", "category": "architecture" },
|
||||||
|
{ "id": "G5-002", "file": "src/modules/onboarding/actions.ts", "lines": "L15-76", "ruleId": "A-09", "title": "actions 直查 DB", "category": "architecture" },
|
||||||
|
{ "id": "G4-001", "file": "src/modules/messaging/data-access.ts", "lines": "L1-1089", "ruleId": "S-01", "title": "1089 行超 1000 硬限", "category": "structure" },
|
||||||
|
{ "id": "G1-002", "file": "src/modules/questions/data-access.ts", "lines": "L294-315", "ruleId": "F-01", "title": "deleteQuestionRecursive 递归 N+1", "category": "performance" },
|
||||||
|
{ "id": "G1-003", "file": "src/modules/questions/data-access.ts", "lines": "L350-378", "ruleId": "F-01", "title": "deleteQuestionsBatch 循环 N+1", "category": "performance" },
|
||||||
|
{ "id": "G1-004", "file": "src/modules/lesson-preparation/data-access-comments.ts", "lines": "L128-140", "ruleId": "F-01", "title": "deleteComment 递归 N+1", "category": "performance" },
|
||||||
|
{ "id": "G1-005", "file": "src/modules/textbooks/data-access.ts", "lines": "L426-458", "ruleId": "F-01", "title": "reorderChapters 循环 UPDATE", "category": "performance" },
|
||||||
|
{ "id": "G3-001", "file": "src/modules/classes/data-access.ts", "lines": "L17-313", "ruleId": "P-03", "title": "24+ 读函数未走 cacheFn", "category": "performance" },
|
||||||
|
{ "id": "G3-004", "file": "src/modules/classes/data-access-teacher.ts", "lines": "L92-116", "ruleId": "F-01", "title": "getTeacherClassesRaw 2N+1", "category": "performance" },
|
||||||
|
{ "id": "G3-005", "file": "src/modules/course-plans/data-access.ts", "lines": "L324-331", "ruleId": "F-01", "title": "reorderCoursePlanItems N+1 + 未包裹事务", "category": "performance" },
|
||||||
|
{ "id": "G2-003", "file": "src/modules/adaptive-practice/data-access-analytics.ts", "lines": "L311-384", "ruleId": "F-01", "title": "getTeacherClassPracticeOverviewsRaw 2N+1", "category": "performance" }
|
||||||
|
],
|
||||||
|
"roadmap": {
|
||||||
|
"phase0": {
|
||||||
|
"name": "紧急安全修复",
|
||||||
|
"priority": "immediate",
|
||||||
|
"tasks": [
|
||||||
|
{ "id": "G2-001", "effort": "XS", "action": "添加 import server-only 到 exams/data-access.ts" },
|
||||||
|
{ "id": "G5-001", "effort": "XS", "action": "添加 import server-only 到 onboarding/data-access.ts" },
|
||||||
|
{ "id": "G4-002", "effort": "M", "action": "新建 parent/actions.ts,3 页面改调 Action" },
|
||||||
|
{ "id": "G4-003", "effort": "S", "action": "audit purge 权限点新增 + 替换" },
|
||||||
|
{ "id": "G4-004", "effort": "S", "action": "audit retention 权限点替换" },
|
||||||
|
{ "id": "G5-002", "effort": "S", "action": "onboarding/actions.ts 移除直查 DB" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phase1": {
|
||||||
|
"name": "P0 架构与性能修复",
|
||||||
|
"priority": "high",
|
||||||
|
"tasks": [
|
||||||
|
{ "batch": "1.1", "ids": ["G1-001", "G3-002", "G1-031", "G1-032", "G1-033"], "effort": "L", "action": "跨模块 schema 直查治理" },
|
||||||
|
{ "batch": "1.2", "ids": ["G4-001", "G4-005", "G4-006", "G4-007", "G4-008"], "effort": "L", "action": "messaging 拆分" },
|
||||||
|
{ "batch": "1.3", "ids": ["G1-002", "G1-003", "G1-004", "G1-005", "G3-001", "G3-004", "G3-005", "G2-003"], "effort": "L", "action": "N+1 热路径修复" },
|
||||||
|
{ "batch": "1.4", "ids": ["G3-003", "G3-024", "G3-025"], "effort": "M", "action": "scheduling 业务逻辑下移" },
|
||||||
|
{ "batch": "1.5", "ids": ["G5-003", "G5-004"], "effort": "L", "action": "elective 业务逻辑拆分" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phase2": {
|
||||||
|
"name": "P1 性能与结构优化",
|
||||||
|
"priority": "medium",
|
||||||
|
"tasks": [
|
||||||
|
{ "batch": "2.1", "ids": ["G1-006", "G1-007", "G1-008", "G1-009", "G3-017", "G4-010"], "effort": "L", "action": "LIKE 全表扫描治理" },
|
||||||
|
{ "batch": "2.2", "ids": ["G3-007", "G2-005"], "effort": "M", "action": "超长文件拆分" },
|
||||||
|
{ "batch": "2.3", "ids": ["G3-007", "G3-008", "G3-009", "G3-010", "G3-011"], "effort": "L", "action": "school 模块重构" },
|
||||||
|
{ "batch": "2.4", "ids": ["G5-005", "G5-006", "G5-007"], "effort": "M", "action": "files 模块错误处理重构" },
|
||||||
|
{ "batch": "2.5", "ids": ["G3-006", "G3-012", "G3-025", "G4-009"], "effort": "S", "action": "事务包裹修复" },
|
||||||
|
{ "batch": "2.6", "ids": ["G1-011", "G1-012", "G1-013", "G1-050", "G1-051", "G1-052", "G1-053", "G3-031", "G3-032", "G3-041", "G3-044"], "effort": "M", "action": "无 LIMIT 查询保护" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phase3": {
|
||||||
|
"name": "P2 模式标准化",
|
||||||
|
"priority": "low",
|
||||||
|
"tasks": [
|
||||||
|
{ "batch": "3.1", "ids": ["G1-021", "G1-022", "G1-023", "G1-024", "G3-001"], "effort": "M", "action": "cacheFn 全量补齐" },
|
||||||
|
{ "batch": "3.2", "ids": ["G1-039-049", "G3-009", "G3-026-028", "G5-007"], "effort": "M", "action": "SELECT * 改显式列" },
|
||||||
|
{ "batch": "3.3", "ids": ["G3-021", "G3-047", "G1-025"], "effort": "S", "action": "日期 helper 提取" },
|
||||||
|
{ "batch": "3.4", "ids": ["G1-026", "G1-027", "G3-020", "G3-036"], "effort": "M", "action": "重复 helper 提取" },
|
||||||
|
{ "batch": "3.5", "ids": ["G1-034-036", "G1-066", "G1-067", "G3-046"], "effort": "M", "action": "JSDoc 补齐" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"phase4": {
|
||||||
|
"name": "P3 风格优化",
|
||||||
|
"priority": "optional",
|
||||||
|
"tasks": [
|
||||||
|
{ "ids": ["G3-029", "G3-030"], "effort": "XS", "action": "as widening 断言改类型标注" },
|
||||||
|
{ "ids": ["G1-060-065"], "effort": "XS", "action": "非空断言 ! 改类型守卫" },
|
||||||
|
{ "ids": ["G3-048"], "effort": "S", "action": "export * 改显式 re-export" },
|
||||||
|
{ "ids": ["G3-018"], "effort": "XS", "action": "死代码删除" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crossModuleRecommendations": {
|
||||||
|
"newSharedHelpers": [
|
||||||
|
{ "name": "toISODateString", "path": "src/shared/lib/date-utils.ts", "replaces": ["attendance/serializeDate", "scheduling/serializeDate", "school/toIso", "course-plans/toIso"] },
|
||||||
|
{ "name": "buildScopeFilter", "path": "src/shared/lib/scope-filter.ts", "replaces": ["attendance/buildScopeFilter", "grades/buildScopeFilter"] }
|
||||||
|
],
|
||||||
|
"newCrossModuleInterfaces": [
|
||||||
|
{ "name": "getActiveStudentIdsByClassIds", "module": "classes", "callers": ["adaptive-practice", "attendance"] },
|
||||||
|
{ "name": "getGradeNamesByIds", "module": "school", "callers": ["textbooks", "lesson-preparation"] },
|
||||||
|
{ "name": "getQuestionCountByKpIds", "module": "questions", "callers": ["textbooks"] },
|
||||||
|
{ "name": "getKpMasteryByTextbookId", "module": "diagnostic", "callers": ["textbooks"] }
|
||||||
|
],
|
||||||
|
"newPermissions": [
|
||||||
|
{ "name": "AUDIT_LOG_PURGE", "description": "审计日志物理删除", "roles": ["admin"] },
|
||||||
|
{ "name": "AUDIT_RETENTION_MANAGE", "description": "审计保留策略配置", "roles": ["admin"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"architectureDocGaps": [
|
||||||
|
"parent 模块缺失 actions.ts - 004 文档模块清单未标注",
|
||||||
|
"onboarding/actions.ts 直查 DB - 005 文档 dependencyMatrix 需修正",
|
||||||
|
"messaging/data-access.ts 拆分后 - 005 文档 modules.messaging.exports 需更新",
|
||||||
|
"新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE - 005 文档 permissions 需补记",
|
||||||
|
"新增 shared/lib/date-utils.ts - 004/005 shared 模块清单需补记"
|
||||||
|
],
|
||||||
|
"sourceOutputs": [
|
||||||
|
"docs/architecture/audit/g1-audit-output.json",
|
||||||
|
"docs/architecture/audit/g2-data-access-audit.json",
|
||||||
|
"docs/architecture/audit/g3-audit-output.json",
|
||||||
|
"docs/architecture/audit/g4-audit-output.json",
|
||||||
|
"docs/architecture/audit/g5-audit-output.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
525
docs/architecture/audit/data-access-audit-v1.md
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
# 数据库访问层审计报告 v1
|
||||||
|
|
||||||
|
> 创建日期:2026-07-07
|
||||||
|
> 审计范围:86 个 `data-access*.ts` + ~30 个 `actions.ts`
|
||||||
|
> 审计方案:纯深读(5 个并行 sub-agent 全量扫描)
|
||||||
|
> 框架依据:[data-access-audit-framework-v1.md](./data-access-audit-framework-v1.md)
|
||||||
|
> 原始输出:[g1-audit-output.json](./g1-audit-output.json) · [g2-data-access-audit.json](./g2-data-access-audit.json) · [g3-audit-output.json](./g3-audit-output.json) · [g4-audit-output.json](./g4-audit-output.json) · [g5-audit-output.json](./g5-audit-output.json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、执行摘要
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|---|---|
|
||||||
|
| 审计文件总数 | 101(86 data-access + 15 actions 辅查) |
|
||||||
|
| 发现问题总数 | 230 |
|
||||||
|
| P0 Critical | 17(7.4%) |
|
||||||
|
| P1 High | 48(20.9%) |
|
||||||
|
| P2 Medium | 105(45.6%) |
|
||||||
|
| P3 Low | 60(26.1%) |
|
||||||
|
|
||||||
|
### 1.1 模块热度图(按 P0+P1 数量降序)
|
||||||
|
|
||||||
|
| 模块 | P0 | P1 | P0+P1 | 总计 | 风险等级 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| messaging | 1 | 4 | 5 | 5 | 🔴 极高 |
|
||||||
|
| classes | 1 | 4 | 5 | 14 | 🔴 极高 |
|
||||||
|
| school | 0 | 5 | 5 | 8 | 🔴 极高 |
|
||||||
|
| lesson-preparation | 1 | 3 | 4 | 28 | 🟠 高 |
|
||||||
|
| scheduling | 1 | 2 | 3 | 6 | 🟠 高 |
|
||||||
|
| adaptive-practice | 1 | 2 | 3 | 4 | 🟠 高 |
|
||||||
|
| elective | 0 | 3 | 3 | 7 | 🟠 高 |
|
||||||
|
| textbooks | 2 | 1 | 3 | 11 | 🟠 高 |
|
||||||
|
| onboarding | 2 | 0 | 2 | 2 | 🟡 中 |
|
||||||
|
| grades | 0 | 2 | 2 | 4 | 🟡 中 |
|
||||||
|
| questions | 1 | 1 | 2 | 11 | 🟡 中 |
|
||||||
|
| audit | 1 | 1 | 2 | 3 | 🟡 中 |
|
||||||
|
| parent | 1 | 0 | 1 | 1 | 🟡 中 |
|
||||||
|
| attendance | 0 | 1 | 1 | 9 | 🟡 中 |
|
||||||
|
| files | 0 | 4 | 4 | 13 | 🟡 中 |
|
||||||
|
| exams | 1 | 0 | 1 | 1 | 🟢 低 |
|
||||||
|
| course-plans | 1 | 0 | 1 | 5 | 🟢 低 |
|
||||||
|
| homework | 0 | 1 | 1 | 2 | 🟢 低 |
|
||||||
|
| diagnostic | 0 | 1 | 1 | 2 | 🟢 低 |
|
||||||
|
| 其他 (auth/rbac/notifications/dashboard/search/ai/announcements/error-book/proctoring/settings) | 0 | 0 | 0 | 0-3 | 🟢 低 |
|
||||||
|
|
||||||
|
### 1.2 维度分布
|
||||||
|
|
||||||
|
| 维度 | 问题数 | 占比 | P0 | P1 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 架构违规(A-*) | 58 | 25.2% | 6 | 18 |
|
||||||
|
| 性能优化(F-*) | 71 | 30.9% | 7 | 14 |
|
||||||
|
| 结构可维护性(S-*) | 47 | 20.4% | 2 | 11 |
|
||||||
|
| 模式标准化(P-*) | 54 | 23.5% | 2 | 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、量化指标仪表盘
|
||||||
|
|
||||||
|
| 指标 | 数值 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| `import "server-only"` 缺失文件 | 2 | exams/data-access.ts、onboarding/data-access.ts |
|
||||||
|
| cacheFn 未覆盖读函数(估算) | 60+ | 集中在 classes(24+)、questions(5)、textbooks(3)、lesson-preparation(4) |
|
||||||
|
| 超长文件(>800 行) | 3 | messaging(1089,超硬限)、school(938)、grades-analytics(831) |
|
||||||
|
| 单文件导出函数 > 20 | 4 | messaging(42+)、classes/data-access.ts(25+)、school(30+)、questions(28)、textbooks(35) |
|
||||||
|
| `as` 断言(非豁免) | 2 | classes/data-access-admin.ts、classes/data-access-teacher.ts(DEFAULT_CLASS_SUBJECTS widening) |
|
||||||
|
| `any` 使用 | 0 | 全部合规 |
|
||||||
|
| `console.error` 调试代码 | 25+ | school(12)、files(12)、classes(3)、course-plans(2)、audit(9) |
|
||||||
|
| N+1 循环 SQL(F-01) | 11 | 跨 4 组 |
|
||||||
|
| `LIKE '%xxx%'` 全表扫描 | 7 | lesson-preparation(4)、questions(1)、textbooks(1)、classes(1)、messaging(1) |
|
||||||
|
| SELECT * 未指定列 | 35+ | 跨 G1(16)、G3(11)、G5(8) |
|
||||||
|
| 无 LIMIT 大表查询 | 18 | 集中在 lesson-preparation |
|
||||||
|
| 跨模块直查 schema 表(A-06) | 7 | textbooks-graph(2)、lesson-preparation(2)、questions(1)、scheduling(1)、announcements(1) |
|
||||||
|
| data-access 含业务逻辑(A-02) | 18 | 集中在 scheduling、messaging、elective、classes |
|
||||||
|
| 未包裹事务的多步写(F-09) | 4 | course-plans、classes、auth、school |
|
||||||
|
| actions 漏/错权限校验(A-08) | 4 | parent(缺失全部)、audit(purge 用读权限)、audit(retention 用读权限) |
|
||||||
|
| actions 直查 DB(A-09) | 1 | onboarding |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P0 Critical 问题清单(17 条,必须立即治理)
|
||||||
|
|
||||||
|
### 3.1 安全漏洞类(4 条)
|
||||||
|
|
||||||
|
| ID | 文件 | 问题 | 修复 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G4-003 | audit/actions.ts L192-225 | `purgeAuditLogsAction` 用 `AUDIT_LOG_READ`(读权限)执行物理删除,权限提权漏洞 | 新增 `AUDIT_LOG_PURGE` 权限点 |
|
||||||
|
| G4-002 | parent/ | 模块缺失 actions.ts,3 个 app 页面直接 import data-access,完全绕过 `requirePermission` | 新建 parent/actions.ts,3 个页面改调 Action |
|
||||||
|
| G2-001 | exams/data-access.ts L1 | 缺 `import "server-only"`,DB 逻辑可能泄露到客户端 bundle | 首行添加 `import "server-only"` |
|
||||||
|
| G5-001 | onboarding/data-access.ts L1 | 缺 `import "server-only"` | 首行添加 `import "server-only"` |
|
||||||
|
|
||||||
|
### 3.2 架构硬违规类(5 条)
|
||||||
|
|
||||||
|
| ID | 文件 | 问题 | 修复 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G1-001 | textbooks/data-access-graph.ts L7-121 | 直查 questions 模块 `questionsToKnowledgePoints` 表 + diagnostic 模块 `knowledgePointMastery` 表 | 改调对方 data-access 跨模块接口 |
|
||||||
|
| G3-002 | scheduling/data-access.ts L8-17 | 直查 classes/users/subjects 三模块的 schema 表 | 改调 `getClassNamesByIds`/`getUserNamesByIds` 等 |
|
||||||
|
| G3-003 | scheduling/data-access-class-schedule.ts | data-access 含时间校验、归属校验、状态机判断 | 校验逻辑移至 actions |
|
||||||
|
| G5-002 | onboarding/actions.ts L15-76 | actions.ts 直接 `import { db }` 并查 `users` 表,违反三层架构 | data-access 新增 `getUserOnboardedAt`,actions 改调 |
|
||||||
|
| G4-001 | messaging/data-access.ts L1-1089 | 单文件 1089 行超 1000 硬限,8 类职责混合 | 拆分为 7 个 data-access-*.ts |
|
||||||
|
|
||||||
|
### 3.3 必定性能问题类(8 条)
|
||||||
|
|
||||||
|
| ID | 文件 | 问题 | 修复 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G1-002 | questions/data-access.ts L294-315 | `deleteQuestionRecursive` 递归 N+1,每子题单独查询+删除 | 收集后代 ID + `inArray` 批量删除 |
|
||||||
|
| G1-003 | questions/data-access.ts L350-378 | `deleteQuestionsBatch` 循环调用 `deleteQuestionRecursive` 产生 N×深度 查询 | 一次性收集所有后代 + 单次 `inArray` 删除 |
|
||||||
|
| G1-004 | lesson-preparation/data-access-comments.ts L128-140 | `deleteComment` 递归 N+1 | 单次查询构建 parent→children Map + 批量删除 |
|
||||||
|
| G1-005 | textbooks/data-access.ts L426-458 | `reorderChapters` 循环内逐条 UPDATE | `CASE WHEN` 批量更新 |
|
||||||
|
| G3-001 | classes/data-access.ts L17-313 | 24+ 读函数全部未走 cacheFn,跨模块高频调用直连 DB | 补齐 Raw + Wrapper 配对 |
|
||||||
|
| G3-004 | classes/data-access-teacher.ts L92-116 | `getTeacherClassesRaw` 循环内对每班发起 2 次子查询(2N+1) | 新增批量接口 |
|
||||||
|
| G3-005 | course-plans/data-access.ts L324-331 | `reorderCoursePlanItems` 循环内 N 次 UPDATE 且未包裹事务 | 事务 + `CASE WHEN` 批量更新 |
|
||||||
|
| G2-003 | adaptive-practice/data-access-analytics.ts L311-384 | `getTeacherClassPracticeOverviewsRaw` 对每班发起 2 条 SQL(2N+1) | 批量查询 + groupBy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、P1 High 问题清单(48 条,Phase 1 治理)
|
||||||
|
|
||||||
|
### 4.1 性能类(14 条)
|
||||||
|
|
||||||
|
| ID | 文件 | 规则 | 概要 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G1-006~009 | lesson-preparation/questions/textbooks | F-02 | 4 处 `LIKE '%xxx%'` 全表扫描(课案标题、JSON content、题目 content、教材 4 字段) |
|
||||||
|
| G1-010 | lesson-preparation/data-access.ts L247-277 | F-04 | `getLessonPlansRaw` 5 表 LEFT JOIN |
|
||||||
|
| G1-011~013 | lesson-preparation (3 处) | F-05 | 列表查询无 LIMIT(getLessonPlansRaw、getPendingReviewPlansRaw、getCalendarEventsRaw) |
|
||||||
|
| G1-014~015 | lesson-preparation (2 处) | F-10 | 全表拉取后内存聚合统计 |
|
||||||
|
| G1-016 | lesson-preparation/data-access-analytics.ts L168-184 | F-06 | 5 次串行 COUNT 查询同表 |
|
||||||
|
| G1-017~018 | lesson-preparation (2 处) | F-01 | 拉全表后内存 filter |
|
||||||
|
| G1-019 | textbooks/actions.ts L396-398 | F-08 | 循环调用 `getGradeNameById`(N 次 DB) |
|
||||||
|
| G2-002 | grades/data-access-appeals.ts L122-151 | F-01 | `getPendingAppealsForReviewRaw` JS 层 filter 班级范围(潜在数据泄露) |
|
||||||
|
| G2-004 | adaptive-practice/data-access-analytics.ts L320-325 | F-08 | 循环内跨模块调用 `getActiveStudentIdsByClassId` |
|
||||||
|
| G3-006 | course-plans/data-access.ts L309-332 | F-09 | `reorderCoursePlanItems` 多次 UPDATE 未包裹事务 |
|
||||||
|
| G3-012 | school/data-access.ts L803-822 | F-09 | `promoteGrades` 循环 UPDATE 未包裹事务 |
|
||||||
|
| G3-017 | classes/data-access-students.ts L281-285 | F-02 | `LIKE '%xxx%'` 全表扫描 users.name/email |
|
||||||
|
| G3-022 | attendance/data-access-correlation.ts L46-193 | F-01/A-02 | 148 行业务编排逻辑(含跨模块调用) |
|
||||||
|
|
||||||
|
### 4.2 架构类(11 条)
|
||||||
|
|
||||||
|
| ID | 文件 | 规则 | 概要 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G4-004 | audit/actions.ts L163-190 | A-08 | `saveAuditRetentionConfigAction` 用读权限执行写操作 |
|
||||||
|
| G4-006~008 | messaging/data-access.ts (3 处) | A-02 | 状态机/防重复业务逻辑嵌入 data-access |
|
||||||
|
| G3-007~008 | school/data-access.ts | S-01/S-02 | 938 行 + 30+ 导出函数 |
|
||||||
|
| G3-010 | school/data-access.ts | A-10 | 12 处 `console.error` 吞异常 |
|
||||||
|
| G3-011 | school/data-access.ts L246-408 | A-02 | 角色判断业务逻辑嵌入 data-access |
|
||||||
|
| G3-024~025 | classes/data-access-teacher.ts L284-439 | A-02/F-09 | `enrollTeacherByInvitationCode` 155 行状态机 + 未包裹事务 |
|
||||||
|
| G5-003 | elective/data-access-operations.ts | A-02 | 业务逻辑混淆(抽签算法、冲突检测、i18n 通知) |
|
||||||
|
|
||||||
|
### 4.3 结构类(5 条)
|
||||||
|
|
||||||
|
| ID | 文件 | 规则 | 概要 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G2-005 | grades/data-access-analytics.ts | S-01 | 831 行超 800 警告线 |
|
||||||
|
| G5-004 | elective/data-access-operations.ts L222-304 | S-08 | DB 写入与抽签算法混淆 |
|
||||||
|
| G5-005 | files/data-access.ts | A-10 | 12 处 `console.error` |
|
||||||
|
| G5-006 | files/data-access.ts | P-05 | try-catch 吞错误返回 null/[]/false |
|
||||||
|
| G4-009 | auth/data-access.ts | F-09 | `createUser` 两次 INSERT 无事务包裹 |
|
||||||
|
|
||||||
|
(完整 P1 清单详见各 sub-agent JSON 输出)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、按维度分组的问题清单
|
||||||
|
|
||||||
|
### 5.1 模式标准化(P-*,54 条)
|
||||||
|
|
||||||
|
#### P-01 `import "server-only"` 缺失(2 条 P0)
|
||||||
|
|
||||||
|
| ID | 文件 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| G2-001 | exams/data-access.ts L1 | 首行添加 `import "server-only"` |
|
||||||
|
| G5-001 | onboarding/data-access.ts L1 | 首行添加 `import "server-only"` |
|
||||||
|
|
||||||
|
#### P-03 cacheFn 未覆盖(30+ 条,P2)
|
||||||
|
|
||||||
|
集中模块:
|
||||||
|
- **classes/data-access.ts**(24+ 读函数,G3-001 P0)
|
||||||
|
- **lesson-preparation**(4 个,G1-021)
|
||||||
|
- **questions**(5 个,G1-023)
|
||||||
|
- **textbooks**(3 个,G1-024)
|
||||||
|
- **lesson-preparation-substitutes**(1 个,G1-022)
|
||||||
|
|
||||||
|
修复模式:
|
||||||
|
```ts
|
||||||
|
// Before
|
||||||
|
export const getClassNamesByIds = async (classIds: string[]) => { /* SQL */ }
|
||||||
|
|
||||||
|
// After
|
||||||
|
export const getClassNamesByIdsRaw = async (classIds: string[]) => { /* SQL */ }
|
||||||
|
export const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, {
|
||||||
|
tags: ["classes:names"],
|
||||||
|
ttl: 300,
|
||||||
|
keyParts: ["classes", "getClassNamesByIds"],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### P-05 错误处理不一致(13 条,P1-P2)
|
||||||
|
|
||||||
|
集中模块:files(9 处 try-catch 吞错误)、school(12 处 console.error + 吞异常)
|
||||||
|
|
||||||
|
#### P-07 日期序列化 helper 重复(5 处,P2-P3)
|
||||||
|
|
||||||
|
- attendance/data-access.ts `serializeDate`
|
||||||
|
- attendance/data-access-stats.ts `serializeDate`
|
||||||
|
- scheduling/data-access.ts `serializeDate`
|
||||||
|
- school/data-access.ts `toIso`
|
||||||
|
- course-plans/data-access.ts `toIso`/`toIsoRequired`
|
||||||
|
|
||||||
|
修复:提取到 `src/shared/lib/date-utils.ts`
|
||||||
|
|
||||||
|
#### P-09 `as` 断言(2 条 P3,非豁免)
|
||||||
|
|
||||||
|
- classes/data-access-admin.ts L36 `DEFAULT_CLASS_SUBJECTS as readonly string[]`
|
||||||
|
- classes/data-access-teacher.ts L41 同上
|
||||||
|
|
||||||
|
#### P-10 `any` 使用
|
||||||
|
|
||||||
|
零违规,全部合规。
|
||||||
|
|
||||||
|
### 5.2 性能优化(F-*,71 条)
|
||||||
|
|
||||||
|
#### F-01 N+1 循环 SQL(11 条,跨 P0/P1/P2)
|
||||||
|
|
||||||
|
| ID | 文件 | 模式 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1-002 | questions deleteQuestionRecursive | 递归内单独查询+删除 |
|
||||||
|
| G1-003 | questions deleteQuestionsBatch | 循环调用递归删除 |
|
||||||
|
| G1-004 | lesson-preparation deleteComment | 递归内单独查询+删除 |
|
||||||
|
| G1-005 | textbooks reorderChapters | 循环内逐条 UPDATE |
|
||||||
|
| G1-017 | lesson-preparation getSchedulesByDateRangeRaw | 拉全表后内存 filter |
|
||||||
|
| G1-018 | lesson-preparation getResponsesByStudentIdRaw | 拉全量后内存 filter |
|
||||||
|
| G2-002 | grades getPendingAppealsForReviewRaw | JS 层 filter 班级范围 |
|
||||||
|
| G2-003 | adaptive-practice getTeacherClassPracticeOverviewsRaw | Promise.all 内 2N+1 |
|
||||||
|
| G3-004 | classes getTeacherClassesRaw | 循环内 2 次子查询 |
|
||||||
|
| G3-005 | course-plans reorderCoursePlanItems | 循环内 N 次 UPDATE |
|
||||||
|
| G3-042 | classes generateUniqueInvitationCode | 循环内重试查询 |
|
||||||
|
|
||||||
|
#### F-02 `LIKE '%xxx%'` 全表扫描(7 条 P1)
|
||||||
|
|
||||||
|
| ID | 文件 | 字段 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1-006 | lesson-preparation | lessonPlans.title |
|
||||||
|
| G1-007 | lesson-preparation-knowledge | lessonPlans.content (JSON) |
|
||||||
|
| G1-008 | questions | questions.content (JSON, +LOWER+CAST) |
|
||||||
|
| G1-009 | textbooks | title/subject/grade/publisher 4 字段 |
|
||||||
|
| G3-017 | classes-students | users.name/email |
|
||||||
|
| G4-010 | messaging | messages.subject/content |
|
||||||
|
|
||||||
|
修复策略:
|
||||||
|
- 短期:前缀匹配 `LIKE 'xxx%'`(可走索引)
|
||||||
|
- 中期:FULLTEXT 索引 + `MATCH AGAINST IN BOOLEAN MODE`(questions 表已实施,参见架构图 1.1.4)
|
||||||
|
- 长期:关联表存储提取后的关系(如 lesson_plan_knowledge_point_refs)
|
||||||
|
|
||||||
|
#### F-03 SELECT * 未指定列(35+ 条 P2-P3)
|
||||||
|
|
||||||
|
集中模块:lesson-preparation(16 处)、school(4 处)、scheduling(3 处)、attendance(2 处)、course-plans(7 处)、files(8 处)
|
||||||
|
|
||||||
|
#### F-05 无 LIMIT 大表查询(18 条 P1-P2)
|
||||||
|
|
||||||
|
集中模块:lesson-preparation(7 处)、textbooks(2 处)、classes(3 处)、proctoring(1 处)
|
||||||
|
|
||||||
|
#### F-09 事务范围问题(4 条 P1)
|
||||||
|
|
||||||
|
| ID | 文件 | 问题 |
|
||||||
|
|---|---|---|
|
||||||
|
| G3-006 | course-plans reorderCoursePlanItems | 多次 UPDATE 未包裹事务 |
|
||||||
|
| G3-012 | school promoteGrades | 循环 UPDATE 未包裹事务 |
|
||||||
|
| G3-025 | classes enrollTeacherByInvitationCode | 多次写操作未包裹事务 |
|
||||||
|
| G4-009 | auth createUser | 两次 INSERT 无事务包裹 |
|
||||||
|
|
||||||
|
#### F-10 全表 COUNT 无过滤(4 条 P2)
|
||||||
|
|
||||||
|
集中模块:textbooks、questions、lesson-preparation、classes
|
||||||
|
|
||||||
|
### 5.3 架构违规(A-*,58 条)
|
||||||
|
|
||||||
|
#### A-02 data-access 含业务逻辑(18 条 P0-P2)
|
||||||
|
|
||||||
|
| 模块 | 文件 | 业务逻辑类型 |
|
||||||
|
|---|---|---|
|
||||||
|
| scheduling | data-access-class-schedule.ts | 时间校验 + 归属校验 + 状态机 |
|
||||||
|
| scheduling | data-access.ts | — |
|
||||||
|
| messaging | data-access.ts | 撤回状态机 + 防重复 + 页面编排 |
|
||||||
|
| elective | data-access-operations.ts | 抽签算法 + 冲突检测 + i18n 通知 |
|
||||||
|
| classes | data-access-teacher.ts | 邀请码状态机 + 角色校验 |
|
||||||
|
| classes | data-access-invitations.ts | 懒清理状态迁移 |
|
||||||
|
| school | data-access.ts | 角色判断 + 权限感知查询 |
|
||||||
|
| attendance | data-access-correlation.ts | 跨模块编排 + 成绩归一化 |
|
||||||
|
| attendance | data-access-stats.ts | 纯计算函数导出 |
|
||||||
|
| lesson-preparation | data-access-review.ts | 状态机迁移 |
|
||||||
|
| lesson-preparation | data-access-ai-evaluation.ts | 评分算法纯函数 |
|
||||||
|
| textbooks | data-access.ts | 重排序算法 |
|
||||||
|
| diagnostic | data-access.ts | 掌握度累积计算 |
|
||||||
|
| homework | data-access.ts | computeOverdueCount 闭包 |
|
||||||
|
|
||||||
|
#### A-06 跨模块直查 schema 表(7 条 P0-P2)
|
||||||
|
|
||||||
|
| ID | 文件 | 被查模块 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1-001 | textbooks/data-access-graph.ts | questions + diagnostic |
|
||||||
|
| G1-031 | lesson-preparation/data-access.ts | textbooks (textbooks/chapters) |
|
||||||
|
| G1-032 | lesson-preparation/data-access-schedules.ts | classes |
|
||||||
|
| G1-033 | questions/data-access.ts | textbooks (knowledgePoints) |
|
||||||
|
| G3-002 | scheduling/data-access.ts | classes + users + subjects |
|
||||||
|
|
||||||
|
#### A-08 actions 权限校验问题(4 条 P0-P1)
|
||||||
|
|
||||||
|
| ID | 文件 | 问题 |
|
||||||
|
|---|---|---|
|
||||||
|
| G4-002 | parent/ | 模块缺失 actions.ts,3 页面直访 data-access |
|
||||||
|
| G4-003 | audit/actions.ts | purge 用读权限 |
|
||||||
|
| G4-004 | audit/actions.ts | retention 配置用读权限 |
|
||||||
|
| G4-047 | rbac/data-access-assignments.ts | 内存 post-fetch 过滤导致 total 错误(伴随 A-02) |
|
||||||
|
|
||||||
|
#### A-09 actions 直查 DB(1 条 P0)
|
||||||
|
|
||||||
|
| ID | 文件 | 问题 |
|
||||||
|
|---|---|---|
|
||||||
|
| G5-002 | onboarding/actions.ts L15-76 | 直接 `import { db }` 并查 `users` 表 |
|
||||||
|
|
||||||
|
#### A-10 `console.error` 调试代码(25+ 条 P1-P2)
|
||||||
|
|
||||||
|
| 模块 | 文件 | 数量 |
|
||||||
|
|---|---|---|
|
||||||
|
| school | data-access.ts | 12 |
|
||||||
|
| files | data-access.ts | 12 |
|
||||||
|
| classes | data-access-teacher/students/admin | 3 |
|
||||||
|
| course-plans | data-access.ts | 2 |
|
||||||
|
| audit | data-access.ts | 9 |
|
||||||
|
|
||||||
|
### 5.4 结构与可维护性(S-*,47 条)
|
||||||
|
|
||||||
|
#### S-01 超长文件(3 条 P0-P1)
|
||||||
|
|
||||||
|
| ID | 文件 | 行数 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G4-001 | messaging/data-access.ts | 1089 | 超 1000 硬限,必须拆分 |
|
||||||
|
| G3-007 | school/data-access.ts | 938 | 超 800 警告,接近硬限 |
|
||||||
|
| G2-005 | grades/data-access-analytics.ts | 831 | 超 800 警告 |
|
||||||
|
|
||||||
|
#### S-02 单文件导出函数过多(4 条 P2)
|
||||||
|
|
||||||
|
| 文件 | 导出数 |
|
||||||
|
|---|---|
|
||||||
|
| messaging/data-access.ts | 42+ |
|
||||||
|
| school/data-access.ts | 30+ |
|
||||||
|
| textbooks/data-access.ts | 35 |
|
||||||
|
| questions/data-access.ts | 28 |
|
||||||
|
| classes/data-access.ts | 25+ |
|
||||||
|
|
||||||
|
#### S-03 重复 helper(8 条 P2)
|
||||||
|
|
||||||
|
| helper | 出现模块 |
|
||||||
|
|---|---|
|
||||||
|
| serializeDate/toIso | attendance、scheduling、school、course-plans |
|
||||||
|
| toLessonPlanStatus | lesson-preparation(2 文件) |
|
||||||
|
| isStringArray | lesson-preparation(2 文件) |
|
||||||
|
| fetchClassesWithSubjects | classes(2 函数 145+124 行重复) |
|
||||||
|
| fetchGradesWithHeads | school(3 函数重复) |
|
||||||
|
|
||||||
|
#### S-06 缺 JSDoc(15+ 条 P2-P3)
|
||||||
|
|
||||||
|
集中模块:lesson-preparation(versions/templates)、questions、textbooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、P0-P3 优先级矩阵
|
||||||
|
|
||||||
|
```
|
||||||
|
高影响
|
||||||
|
│
|
||||||
|
│ P0 立即治理 P1 Phase 1
|
||||||
|
│ ───────────────── ─────────────────
|
||||||
|
│ • parent 权限漏洞 • N+1 循环 SQL(非热路径)
|
||||||
|
│ • audit 权限提权 • LIKE 全表扫描
|
||||||
|
│ • server-only 缺失 • 超长文件(school/grades)
|
||||||
|
│ • 跨模块 schema 直查 • 业务逻辑嵌入 data-access
|
||||||
|
│ • N+1 循环 SQL(热路径) • 事务未包裹
|
||||||
|
│ • messaging 超硬限 • console.error 吞异常
|
||||||
|
│
|
||||||
|
├──────────────────────────────────────────────
|
||||||
|
│
|
||||||
|
│ P2 Phase 2 P3 Phase 3
|
||||||
|
│ ───────────────── ─────────────────
|
||||||
|
│ • cacheFn 未覆盖 • as 断言(widening)
|
||||||
|
│ • SELECT * 未指定列 • 非空断言 !
|
||||||
|
│ • 无 LIMIT 大表查询 • JSDoc 补齐
|
||||||
|
│ • 重复 helper • 动态 import 注释
|
||||||
|
│ • 单文件导出过多 • export * 改显式
|
||||||
|
│
|
||||||
|
低影响
|
||||||
|
高紧迫 ─────────────────── 低紧迫
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、分阶段治理路线图
|
||||||
|
|
||||||
|
### Phase 0:紧急安全修复(XS-S,立即执行)
|
||||||
|
|
||||||
|
| 任务 | ID | 工作量 | 验证 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 添加 `import "server-only"` 到 exams/data-access.ts | G2-001 | XS | tsc + lint |
|
||||||
|
| 添加 `import "server-only"` 到 onboarding/data-access.ts | G5-001 | XS | tsc + lint |
|
||||||
|
| 新建 parent/actions.ts,3 页面改调 Action | G4-002 | M | 手动测试 3 页面 |
|
||||||
|
| audit purge 权限点新增 + 替换 | G4-003 | S | 权限矩阵测试 |
|
||||||
|
| audit retention 权限点替换 | G4-004 | S | 权限矩阵测试 |
|
||||||
|
| onboarding/actions.ts 移除直查 DB | G5-002 | S | tsc + lint |
|
||||||
|
|
||||||
|
**Phase 0 完成标准**:所有 P0 安全漏洞修复,`npm run lint` + `npx tsc --noEmit` 零错误。
|
||||||
|
|
||||||
|
### Phase 1:P0 架构与性能修复(M-L,1-2 周)
|
||||||
|
|
||||||
|
| 任务批次 | 涉及 ID | 工作量 | 依赖 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1.1 跨模块 schema 直查治理** | G1-001, G3-002, G1-031~033 | L | 需在 questions/diagnostic/textbooks/classes 模块新增跨模块接口 |
|
||||||
|
| **1.2 messaging 拆分** | G4-001, G4-005~008 | L | 拆分为 7 个子文件 + 业务逻辑移至 actions |
|
||||||
|
| **1.3 N+1 热路径修复** | G1-002~005, G3-001, G3-004, G3-005, G2-003 | L | classes 补齐 cacheFn 是基础 |
|
||||||
|
| **1.4 scheduling 业务逻辑下移** | G3-003, G3-024, G3-025 | M | data-access-class-schedule.ts 重写 |
|
||||||
|
| **1.5 elective 业务逻辑拆分** | G5-003, G5-004 | L | 提取 lib/lottery.ts + lib/schedule-conflict.ts |
|
||||||
|
|
||||||
|
**Phase 1 完成标准**:所有 P0 修复,关键路径性能提升,架构分层清晰。
|
||||||
|
|
||||||
|
### Phase 2:P1 性能与结构优化(M-L,2-3 周)
|
||||||
|
|
||||||
|
| 任务批次 | 涉及 ID | 工作量 |
|
||||||
|
|---|---|---|
|
||||||
|
| **2.1 LIKE 全表扫描治理** | G1-006~009, G3-017, G4-010 | L(FULLTEXT 索引 + 查询重写) |
|
||||||
|
| **2.2 超长文件拆分** | G3-007, G2-005 | M(school 按职责拆 8 文件、grades-analytics 按维度拆) |
|
||||||
|
| **2.3 school 模块重构** | G3-007~011 | L(拆分 + 角色判断移至 actions + 删除 console.error) |
|
||||||
|
| **2.4 files 模块错误处理重构** | G5-005, G5-006, G5-007 | M(删除 try-catch + console.error) |
|
||||||
|
| **2.5 事务包裹修复** | G3-006, G3-012, G3-025, G4-009 | S |
|
||||||
|
| **2.6 无 LIMIT 查询保护** | G1-011~013, G1-050~053, G3-031~032, G3-041, G3-044 | M |
|
||||||
|
|
||||||
|
**Phase 2 完成标准**:所有 P1 修复,无超长文件,无 LIKE 全表扫描,无未包裹事务。
|
||||||
|
|
||||||
|
### Phase 3:P2 模式标准化(S-M,1-2 周)
|
||||||
|
|
||||||
|
| 任务批次 | 涉及 ID | 工作量 |
|
||||||
|
|---|---|---|
|
||||||
|
| **3.1 cacheFn 全量补齐** | G1-021~024, G3-001(剩余) | M |
|
||||||
|
| **3.2 SELECT * 改显式列** | G1-039~049, G3-009, G3-026~028, G5-007 | M(机械替换) |
|
||||||
|
| **3.3 日期 helper 提取** | G3-021, G3-047, G1-025 | S(提取 shared/lib/date-utils.ts) |
|
||||||
|
| **3.4 重复 helper 提取** | G1-026~027, G3-020, G3-036 | M |
|
||||||
|
| **3.5 JSDoc 补齐** | G1-034~036, G1-066~067, G3-046 | M |
|
||||||
|
|
||||||
|
**Phase 3 完成标准**:所有 P2 修复,模式统一,helper 集中到 shared/lib。
|
||||||
|
|
||||||
|
### Phase 4:P3 风格优化(XS,按需)
|
||||||
|
|
||||||
|
| 任务 | 涉及 ID | 工作量 |
|
||||||
|
|---|---|---|
|
||||||
|
| `as` widening 断言改类型标注 | G3-029~030 | XS |
|
||||||
|
| 非空断言 `!` 改类型守卫 | G1-060~065 | XS |
|
||||||
|
| `export *` 改显式 re-export | G3-048 | S |
|
||||||
|
| 死代码删除 | G3-018 | XS |
|
||||||
|
|
||||||
|
**Phase 4 完成标准**:零 `as`(非豁免)、零 `!`、零死代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、跨模块治理建议
|
||||||
|
|
||||||
|
### 8.1 新增 shared/lib 公共 helper
|
||||||
|
|
||||||
|
| helper | 路径 | 用途 | 替代模块 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `toISODateString` | shared/lib/date-utils.ts | 日期序列化 | attendance/scheduling/school/course-plans |
|
||||||
|
| `buildScopeFilter` | shared/lib/scope-filter.ts | DataScope → SQL 过滤 | attendance/grades/homework 等重复实现 |
|
||||||
|
| `serializeDate` | (合并到 date-utils.ts) | 同 toISODateString | — |
|
||||||
|
|
||||||
|
### 8.2 新增跨模块批量接口
|
||||||
|
|
||||||
|
| 接口 | 模块 | 用途 | 调用方 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `getActiveStudentIdsByClassIds(classIds)` | classes | 批量获取多班学生 ID | adaptive-practice、attendance |
|
||||||
|
| `getGradeNamesByIds(gradeIds)` | school | 批量获取年级名称 | textbooks、lesson-preparation |
|
||||||
|
| `getQuestionCountByKpIds(kpIds)` | questions | 知识点关联题目数 | textbooks |
|
||||||
|
| `getKpMasteryByTextbookId(textbookId)` | diagnostic | 教材下知识点掌握度 | textbooks |
|
||||||
|
|
||||||
|
### 8.3 新增权限点
|
||||||
|
|
||||||
|
| 权限点 | 用途 | 角色映射 |
|
||||||
|
|---|---|---|
|
||||||
|
| `AUDIT_LOG_PURGE` | 审计日志物理删除 | admin 专属 |
|
||||||
|
| `AUDIT_RETENTION_MANAGE` | 审计保留策略配置 | admin 专属 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、附录
|
||||||
|
|
||||||
|
### 9.1 完整规则表
|
||||||
|
|
||||||
|
见 [data-access-audit-framework-v1.md](./data-access-audit-framework-v1.md) 第二节。
|
||||||
|
|
||||||
|
### 9.2 sub-agent 原始输出索引
|
||||||
|
|
||||||
|
| 组 | 文件 | 问题数 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | [g1-audit-output.json](./g1-audit-output.json) | 67 |
|
||||||
|
| G2 | [g2-data-access-audit.json](./g2-data-access-audit.json) | 11 |
|
||||||
|
| G3 | [g3-audit-output.json](./g3-audit-output.json) | 50 |
|
||||||
|
| G4 | [g4-audit-output.json](./g4-audit-output.json) | 61 |
|
||||||
|
| G5 | [g5-audit-output.json](./g5-audit-output.json) | 41 |
|
||||||
|
|
||||||
|
### 9.3 架构图遗漏记录
|
||||||
|
|
||||||
|
审计过程中发现的架构图(004/005)需补记项(治理阶段统一补图):
|
||||||
|
|
||||||
|
1. **parent 模块缺失 actions.ts** —— 004 文档模块清单未标注此异常
|
||||||
|
2. **onboarding/actions.ts 直查 DB** —— 005 文档 dependencyMatrix 需修正
|
||||||
|
3. **messaging/data-access.ts 拆分后** —— 005 文档 modules.messaging.exports 需更新
|
||||||
|
4. **新增权限点 AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE** —— 005 文档 permissions 节点需补记
|
||||||
|
5. **新增 shared/lib/date-utils.ts** —— 004/005 shared 模块清单需补记
|
||||||
|
|
||||||
|
### 9.4 治理验证检查清单
|
||||||
|
|
||||||
|
每个 Phase 完成后必须通过:
|
||||||
|
|
||||||
|
- [ ] `npm run lint` 零错误
|
||||||
|
- [ ] `npx tsc --noEmit` 零错误
|
||||||
|
- [ ] 架构文档 004/005 同步更新
|
||||||
|
- [ ] `docs/troubleshooting/known-issues.md` 追加新模式
|
||||||
|
- [ ] 受影响模块的功能测试通过
|
||||||
|
- [ ] P0/P1 问题在 issues JSON 中标记为 resolved
|
||||||
806
docs/architecture/audit/g1-audit-output.json
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "G1-001",
|
||||||
|
"file": "src/modules/textbooks/data-access-graph.ts",
|
||||||
|
"lines": "L7-L13, L46-L53, L107-L121",
|
||||||
|
"ruleId": "A-06",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "textbooks 模块直接查询 questions/diagnostic 模块的表",
|
||||||
|
"description": "data-access-graph.ts 从 @/shared/db/schema 导入 questionsToKnowledgePoints(属 questions 模块)和 knowledgePointMastery(属 diagnostic 模块),并直接执行 SELECT FROM 查询(L46-53 查 questionsToKnowledgePoints,L107-121 查 knowledgePointMastery)。这违反了三层架构'模块间通过对方 data-access 通信,不直接查询对方 DB 表'的规则。",
|
||||||
|
"recommendation": "1) questionsToKnowledgePoints 的关联题目数查询应改为调用 questions 模块 data-access 暴露的跨模块接口(如 getQuestionCountByKpIds);2) knowledgePointMastery 查询应改为调用 diagnostic 模块 data-access 暴露的接口(如 getKpMasteryByTextbookId)。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-002",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L294-L315",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "deleteQuestionRecursive 递归 N+1:每个子题单独查询+删除",
|
||||||
|
"description": "deleteQuestionRecursive 在递归中对每个子题先 SELECT 子题列表(L305-308),再 for 循环递归调用自身(L310-312),最后 DELETE 当前题(L314)。对于有 N 层子题的复合题,会产生 2N 次数据库往返。",
|
||||||
|
"recommendation": "改为先递归收集所有后代 ID 到一个数组(单次查询children即可),然后用 inArray 批量 DELETE:`await tx.delete(questions).where(inArray(questions.id, allDescendantIds))`。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-003",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L350-L378",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "deleteQuestionsBatch 循环调用 deleteQuestionRecursive 产生 N+1",
|
||||||
|
"description": "deleteQuestionsBatch 在 L372-374 对 targetIds 数组 for 循环,每个 id 单独调用 deleteQuestionRecursive,每次调用内部又递归查询子题。批量删除 M 个题目时产生 M × (递归深度) 次查询。",
|
||||||
|
"recommendation": "先将所有 targetIds 的后代 ID 一次性收集(用 inArray 批量查询 parentId in targetIds,递归用 Map 解析),再单次 inArray 批量删除所有后代+自身。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-004",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-comments.ts",
|
||||||
|
"lines": "L128-L140",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "deleteComment 递归 N+1:每个子回复单独查询+删除",
|
||||||
|
"description": "deleteComment 先 SELECT 子回复列表(L130-133),再 for 循环递归调用 deleteComment(L134-136),最后 DELETE 当前评论(L137-139)。嵌套回复深时产生大量 DB 往返。",
|
||||||
|
"recommendation": "改为先用单次查询获取该 plan 下所有评论,在内存中构建 parent→children Map,收集所有后代 ID 后用 inArray 批量删除。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-005",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L426-L458",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "reorderChapters 循环内逐条 UPDATE(N+1)",
|
||||||
|
"description": "reorderChapters 在事务内 for 循环遍历所有兄弟章节(L445-457),每个章节单独执行 tx.update(L448-454)。重排 N 个章节产生 N 次 UPDATE 语句。",
|
||||||
|
"recommendation": "使用 CASE WHEN 批量更新:`UPDATE chapters SET order = CASE id WHEN ... THEN ... END, parentId = CASE id WHEN ... THEN ... END WHERE id IN (...)`,或用 sql`VALUES(...)` 构造批量更新。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-006",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L237",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "LIKE '%query%' 全表扫描查询课案标题",
|
||||||
|
"description": "getLessonPlansRaw 在 L237 使用 `like(lessonPlans.title, \\`%${escapeLikePattern(params.query)}%\\`)`,前导通配符 % 导致无法使用索引,全表扫描。课案表数据量大时严重影响性能。",
|
||||||
|
"recommendation": "对 lessonPlans.title 建立全文索引(MySQL FULLTEXT INDEX),改用 `sql\\`MATCH(title) AGAINST(${query} IN BOOLEAN MODE)\\``;或至少对高频查询场景使用前缀匹配 `like(title, query + '%')`。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-007",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
|
||||||
|
"lines": "L99, L129",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "LIKE '%id%' 全表扫描 JSON content 字段",
|
||||||
|
"description": "getLessonPlansByKnowledgePointRaw(L99)和 getLessonPlansByQuestionRaw(L129)对 lessonPlans.content(JSON 列)使用 `like(content, \\`%${kpId}%\\`)` 做粗筛。JSON 列上的 LIKE 全表扫描代价极高,且无法走索引。",
|
||||||
|
"recommendation": "建立关联表 lesson_plan_knowledge_point_refs(plan_id, knowledge_point_id) 和 lesson_plan_question_refs(plan_id, question_id) 存储提取后的关联关系,改用 inArray 等值查询。短期可加 LIMIT 并在 actions 层缓存结果。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-008",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L60-L65",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "LOWER(CAST(content AS CHAR)) LIKE '%q%' 全表扫描",
|
||||||
|
"description": "getQuestionsRaw 在 L61-64 使用 `sql\\`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}\\`` 对 JSON content 列做 LIKE 模糊搜索,包含 LOWER + CAST + 前导 % 三重性能杀手,无法走索引。",
|
||||||
|
"recommendation": "对 questions 表增加 searchable_text 列(存储从 content 提取的纯文本),建立 FULLTEXT 索引;或引入 Meilisearch/TypeSense 等外部搜索引擎处理题目全文检索。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-009",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L48-L54, L545-L551",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "LIKE '%q%' 全表扫描 4 个字段",
|
||||||
|
"description": "getTextbooksRaw(L48-54)和 getTextbooksWithScopeRaw(L545-551)对 title/subject/grade/publisher 四个字段做 `like(field, \\`%${q}%\\`)` OR 查询,4 个前导通配符 LIKE 全表扫描。",
|
||||||
|
"recommendation": "对 title 建立全文索引;或将 subject/grade/publisher 改为等值过滤(下拉选择),仅 title 做前缀匹配。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-010",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L247-L277",
|
||||||
|
"ruleId": "F-04",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getLessonPlansRaw 5 表 LEFT JOIN",
|
||||||
|
"description": "getLessonPlansRaw 在 L270-275 对 lessonPlans LEFT JOIN textbooks/chapters/subjects/grades/users 共 5 个表。JOIN 表数量 > 3,查询计划复杂度高,且无 LIMIT。",
|
||||||
|
"recommendation": "拆分为两步:1) 先查 lessonPlans 主表(带 scope + 过滤条件 + LIMIT + ORDER BY);2) 用 collect 的 textbookId/chapterId/subjectId/gradeId/creatorId 批量查 textbooks/chapters/subjects/grades/users 名称,在内存中 Map 关联。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-011",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L247-L277",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getLessonPlansRaw 列表查询无 LIMIT",
|
||||||
|
"description": "getLessonPlansRaw 查询课案列表时无 LIMIT,当课案数量增长时会一次性拉取全表数据到内存做分组聚合(L283-316),可能导致 OOM。",
|
||||||
|
"recommendation": "添加默认分页 `.limit(pageSize).offset(offset)`,或至少 `.limit(500)` 保护;版本聚合逻辑应改为分页后处理。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-012",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-review.ts",
|
||||||
|
"lines": "L168-L218, L255-L294",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getPendingReviewPlansRaw / getPlansByStatusesRaw 无 LIMIT",
|
||||||
|
"description": "getPendingReviewPlansRaw(L184-196)和 getPlansByStatusesRaw(L275-285)均无 LIMIT,且后者还在内存中做 filter(L199-208)而非 SQL 过滤。待审核/按状态查询的课案可能很多。",
|
||||||
|
"recommendation": "添加分页参数 page/pageSize,SQL 层用 inArray 过滤 gradeId/subjectId 而非内存 filter;加 `.limit(pageSize).offset(offset)`。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-013",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-calendar.ts",
|
||||||
|
"lines": "L43-L60, L102-L120, L136-L153",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getCalendarEventsRaw 三段查询均无 LIMIT",
|
||||||
|
"description": "getCalendarEventsRaw 对 lessonPlans(L43)、lessonPlanVersions(L102)、lessonPlanReviewRecords(L136)三段查询均无 LIMIT。日历范围跨度大时可能拉取大量记录。",
|
||||||
|
"recommendation": "每段查询添加 `.limit(500)` 上限保护,或在 actions 层强制限制日期范围跨度(如最多 90 天)。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-014",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-formative.ts",
|
||||||
|
"lines": "L212-L239",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getFormativeItemStatsRaw 全表拉取后内存聚合统计",
|
||||||
|
"description": "getFormativeItemStatsRaw 在 L215-218 SELECT 所有作答记录(无 LIMIT),然后在 L220-232 内存循环统计 total/correct/incorrect/avgDuration。一个互动组件可能有上千条作答。",
|
||||||
|
"recommendation": "改用 SQL 聚合:`SELECT COUNT(*) as total, SUM(isCorrect=1) as correct, SUM(isCorrect=0) as incorrect, AVG(durationSec) as avgDuration FROM ... WHERE itemId=?`,单次查询完成。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-015",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-comments.ts",
|
||||||
|
"lines": "L145-L157",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "countUnresolvedCommentsRaw SELECT 全部 ID 后取 length 计数",
|
||||||
|
"description": "countUnresolvedCommentsRaw 在 L146-155 SELECT 所有匹配的 id 字段,然后 L156 `return rows.length` 计数。应直接用 SQL COUNT 聚合,避免拉取全部行数据。",
|
||||||
|
"recommendation": "改为 `.select({ count: count() }).from(...).where(...)`,返回 `Number(rows[0]?.count ?? 0)`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-016",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
|
||||||
|
"lines": "L168-L184",
|
||||||
|
"ruleId": "F-06",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getGlobalLessonPlanStatsRaw 5 次串行查询同表",
|
||||||
|
"description": "getGlobalLessonPlanStatsRaw 对 lessonPlans/lessonPlanStandards 表执行 5 次 SELECT COUNT 查询(L168-184),且是串行 await。仪表盘每次加载产生 5 次 DB 往返。",
|
||||||
|
"recommendation": "合并为单次 GROUP BY 查询:`SELECT status, COUNT(*) FROM lessonPlans GROUP BY status`,或用 Promise.all 并行执行;lessonPlanStandards 计数可合并到同一查询。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-017",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
|
||||||
|
"lines": "L77-L123",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getSchedulesByDateRangeRaw 拉全表后内存 filter",
|
||||||
|
"description": "getSchedulesByDateRangeRaw 仅按日期范围查询(L99-104),然后用 `rows.filter((r) => teacherPlanIds.includes(r.planId))`(L108-109)在内存过滤教师课案。注释 L101 自述'简化:仅按日期范围过滤'。当全校课案绑定量大时拉取大量无关数据。",
|
||||||
|
"recommendation": "将 planId 过滤下推到 SQL:`inArray(lessonPlanSchedules.planId, teacherPlanIds)`,配合日期范围条件,避免拉取无关行。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-018",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-formative.ts",
|
||||||
|
"lines": "L182-L205",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getResponsesByStudentIdRaw 拉全量作答后内存 filter",
|
||||||
|
"description": "getResponsesByStudentIdRaw 当传入 planId 时(L187-198):先查该 plan 的 formative items ID(L188-191),再 SELECT 该学生的全部 responses(L194-197 无 itemId 过滤),最后内存 filter `itemIds.includes(r.itemId)`(L198)。应直接用 inArray 在 SQL 过滤。",
|
||||||
|
"recommendation": "在 L196 的 WHERE 中增加 `inArray(lessonPlanFormativeResponses.itemId, itemIds)` 条件,移除内存 filter。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-019",
|
||||||
|
"file": "src/modules/textbooks/actions.ts",
|
||||||
|
"lines": "L396-L398",
|
||||||
|
"ruleId": "F-08",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getKnowledgeGraphDataAction 循环调用 getGradeNameById(N+1)",
|
||||||
|
"description": "getKnowledgeGraphDataAction 在 L396-398 用 `Promise.all(allowedGradeIds.map((gid) => getGradeNameById(gid)))` 逐个查询年级名称。虽然 Promise.all 并行了请求,但仍是 N 次 DB 查询。",
|
||||||
|
"recommendation": "school 模块应提供批量接口 `getGradeNamesByIds(gradeIds): Promise<Map<string,string>>`,单次 inArray 查询返回映射。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-020",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L39-L49",
|
||||||
|
"ruleId": "P-04",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getQuestionsRaw 缺少显式返回类型标注",
|
||||||
|
"description": "getQuestionsRaw(L39)使用 `=> {` 箭头函数,未显式标注返回类型 `Promise<T>`,依赖 TypeScript 推断。违反 P-04 规则'函数返回值必须显式标注,特别是 Promise<T>'。",
|
||||||
|
"recommendation": "定义返回类型并显式标注:`export const getQuestionsRaw = async (params: GetQuestionsParams = {}): Promise<QuestionsListResult> => { ... }`,将返回结构提取为命名类型。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-021",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L574-L590, L416-L426, L429-L444, L593-L617",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "4 个读函数未走 cacheFn 包装",
|
||||||
|
"description": "getLessonPlanStats(L574)、getTextbooksForPicker(L416)、getChaptersForPicker(L429)、getTemplateById(L593)均为纯读函数但未用 cacheFn 包装。其中 getTemplateById 在 createLessonPlan 热路径中被调用(L366),缺少缓存影响创建性能。",
|
||||||
|
"recommendation": "为每个读函数添加 Raw + cacheFn 配对:`export const getTemplateById = cacheFn(getTemplateByIdRaw, { tags: [...], ttl: 300, keyParts: [...] })`。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-022",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
|
||||||
|
"lines": "L125-L141",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "canTeacherAccessPlan 读函数未走 cacheFn",
|
||||||
|
"description": "canTeacherAccessPlan(L125)是读函数(查询 plan + 查询 substitutes),但未用 cacheFn 包装。该函数可能在权限校验热路径被频繁调用。",
|
||||||
|
"recommendation": "拆为 canTeacherAccessPlanRaw + cacheFn 包装,注意 TTL 应较短(60s)因权限相关。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-023",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L380-L384, L391-L399, L406-L426, L433-L436, L577-L622",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "5 个读函数未走 cacheFn 包装",
|
||||||
|
"description": "getKnowledgePointOptions(L380)、getTextbookOptions(L391)、getChapterOptions(L406)、getKnowledgePointOptionsByChapter(L433)、exportQuestions(L577)均为读函数但未用 cacheFn。前四个是级联筛选下拉数据,频繁调用。",
|
||||||
|
"recommendation": "为 getKnowledgePointOptions/getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter 添加 cacheFn(ttl 可较长 600s)。exportQuestions 因可能导出大结果集,可不缓存或短 TTL。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-024",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L492-L504, L511-L524, L689-L703",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "3 个读函数未走 cacheFn 包装",
|
||||||
|
"description": "verifyChapterBelongsToTextbook(L492)、verifyKnowledgePointBelongsToTextbook(L511)、getPrerequisiteEdgesForTextbook(L689)均为读函数但未用 cacheFn。verify* 函数在 actions 层归属校验热路径中被频繁调用(actions.ts 中多处调用)。",
|
||||||
|
"recommendation": "添加 cacheFn 包装,TTL 较短(60-120s)。getPrerequisiteEdgesForTextbook 用于循环检测,可缓存 300s。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-025",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
|
||||||
|
"lines": "L29-L34",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "toDateStr 本地实现日期序列化,未用 shared helper",
|
||||||
|
"description": "toDateStr(L29-34)手动拼接 YYYY-MM-DD 字符串,未使用项目统一的 serializeDate/toISODateString helper。其他模块(如 data-access.ts 的 mapRowToLessonPlan)使用 `.toISOString()` 序列化。",
|
||||||
|
"recommendation": "统一使用 shared/lib 中的日期序列化 helper,或将 toDateStr 提取到 shared/lib/date-utils.ts 供所有模块复用。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-026",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
|
||||||
|
"lines": "L16-L18",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "isStringArray 与 lib/type-guards 重复实现",
|
||||||
|
"description": "data-access-knowledge.ts 在 L16-18 本地定义 isStringArray,而 data-access-ai-evaluation.ts L14 已从 './lib/type-guards' 导入同名函数。同一模块内重复实现 helper。",
|
||||||
|
"recommendation": "删除 data-access-knowledge.ts L16-18 的本地实现,改为 `import { isStringArray } from './lib/type-guards'`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-027",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-review.ts",
|
||||||
|
"lines": "L16-L23",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "toLessonPlanStatus/toReviewDecision 在多个文件重复定义",
|
||||||
|
"description": "data-access-review.ts(L16-18)和 data-access-calendar.ts(L16-18)各自定义了 toLessonPlanStatus 函数,逻辑完全相同(isLessonPlanStatus 守卫失败回退 'draft')。toReviewDecision(L21-23)也仅在本文件定义但可共享。",
|
||||||
|
"recommendation": "将 toLessonPlanStatus 提取到 lib/type-guards.ts 或 lib/serialize.ts,两个 data-access 文件统一导入。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-028",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-ai-evaluation.ts",
|
||||||
|
"lines": "L141-L192",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "evaluateDocument 业务逻辑(评分算法)放在 data-access 层",
|
||||||
|
"description": "evaluateDocument(L141-192)是纯业务逻辑函数(基于规则计算 5 维度评分 + 生成建议),不涉及任何 DB 操作,却导出在 data-access 文件中。违反 A-02'data-access 不含业务逻辑'规则。",
|
||||||
|
"recommendation": "将 evaluateDocument 移至 lib/ai-evaluation.ts(纯函数模块),data-access-ai-evaluation.ts 仅保留 DB CRUD。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-029",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-review.ts",
|
||||||
|
"lines": "L40-L45, L50-L76, L82-L137, L225-L250",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "状态机逻辑(isValidTransition + 状态迁移)放在 data-access 层",
|
||||||
|
"description": "isValidTransition(L40-45)是状态机校验纯函数;submitForReview(L50-76)、reviewPlan(L82-137)、withdrawSubmission(L225-250)内部包含状态迁移判断逻辑(L66-68、L100-107、L240-242),属于业务编排而非纯数据访问。",
|
||||||
|
"recommendation": "将 isValidTransition 和状态迁移判断逻辑移至 actions-review.ts 或 lib/status-machine.ts;data-access 仅暴露 updateStatus(planId, newStatus) 和 insertReviewRecord() 等纯数据操作。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-030",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L426-L458",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "reorderChapters 重排序业务逻辑放在 data-access 层",
|
||||||
|
"description": "reorderChapters(L426-458)包含排序算法(splice 插入 L442)、parentId 变更判断(L447)等业务逻辑,且在事务内循环更新。这些编排逻辑应属于 actions 层。",
|
||||||
|
"recommendation": "将排序算法和变更判断移至 actions.ts,data-access 仅暴露 updateChapterOrder(tx, id, order, parentId) 单条更新接口,由 actions 在事务内调用。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-031",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L10-L15",
|
||||||
|
"ruleId": "A-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "导入 textbooks/chapters 表(属 textbooks 模块)用于 JOIN",
|
||||||
|
"description": "data-access.ts L10-15 从 @/shared/db/schema 导入 textbooks、chapters 表(属 textbooks 模块)用于 L271-272 的 LEFT JOIN。虽然 L27 也通过 textbooks data-access 导入查询函数,但 JOIN 仍直接引用对方表。",
|
||||||
|
"recommendation": "短期:保留 JOIN 引用但添加注释说明;长期:重构为两步查询(先查 lessonPlans,再用 ID 批量查 textbooks/chapters 名称),彻底消除跨模块 schema 引用。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-032",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
|
||||||
|
"lines": "L9",
|
||||||
|
"ruleId": "A-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "导入 classes 表(属 classes 模块)用于 JOIN",
|
||||||
|
"description": "data-access-schedules.ts L9 从 @/shared/db/schema 导入 classes 表(属 classes 模块),在 L55、L98、L164 的 LEFT JOIN 中获取 className。应通过 classes 模块 data-access 获取。",
|
||||||
|
"recommendation": "改为两步:1) 查 lessonPlanSchedules(不含 JOIN);2) 收集 classId 后调用 classes 模块的 getClassNamesByIds(classIds) 批量获取名称,内存 Map 关联。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-033",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L4",
|
||||||
|
"ruleId": "A-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "导入 knowledgePoints 表(属 textbooks 模块)用于 JOIN",
|
||||||
|
"description": "data-access.ts L4 从 @/shared/db/schema 导入 knowledgePoints 表(属 textbooks 模块),在 L463 的 INNER JOIN 中获取知识点名称。虽然 L8-14 已通过 textbooks data-access 导入查询函数,此处 JOIN 仍直接引用对方表。",
|
||||||
|
"recommendation": "getKnowledgePointsForQueries 改为两步:1) 查 questionsToKnowledgePoints(本模块表)获取 questionId→knowledgePointId 映射;2) 调用 textbooks data-access 批量获取知识点名称,内存关联。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-034",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-versions.ts",
|
||||||
|
"lines": "L35-L55, L59-L95, L97-L126, L128-L165, L167-L205",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "5 个公共导出函数缺少 JSDoc 注释",
|
||||||
|
"description": "getLessonPlansRaw(L35)、createLessonPlanVersion(L59)、getVersionContentRaw(L97)、revertToVersion(L128)、pruneAutoVersions(L167)均无 JSDoc。仅 L132/L140 有内联注释。公共导出函数应补齐 JSDoc 说明用途、参数、返回值。",
|
||||||
|
"recommendation": "为每个导出函数添加 JSDoc,如 `/** 创建课案版本,在事务内 max(versionNo)+1 防止并发重复 */`。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-035",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-templates.ts",
|
||||||
|
"lines": "L45-L72, L76-L112, L114-L125",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "3 个公共导出函数缺少 JSDoc 注释",
|
||||||
|
"description": "getLessonPlansRaw(L45)、saveAsTemplate(L76)、deletePersonalTemplate(L114)均无 JSDoc。saveAsTemplate 的 sourcePlanId→skeleton 提取逻辑(L94-100)需要文档说明。",
|
||||||
|
"recommendation": "添加 JSDoc 说明函数用途、参数含义、返回值。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-036",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L39-L193, L214-L247, L258-L292",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "核心函数 getQuestionsRaw/insertQuestionWithRelations/updateQuestionById 缺少 JSDoc",
|
||||||
|
"description": "getQuestionsRaw(L39)、insertQuestionWithRelations(L214)、updateQuestionById(L258)等核心函数无 JSDoc。getQuestionsRaw 的级联筛选逻辑(L75-122)较复杂,需要文档说明筛选优先级。",
|
||||||
|
"recommendation": "为这些函数添加 JSDoc,特别是 getQuestionsRaw 的 knowledgePointId > chapterId > textbookId 级联筛选优先级。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-037",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L1-L662",
|
||||||
|
"ruleId": "S-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "单文件导出函数数约 28 个,超过 20 警告阈值",
|
||||||
|
"description": "data-access.ts 导出约 28 个符号(含类型、函数、接口),包括 getQuestions/getQuestionsDashboardStats/createQuestionWithRelations/updateQuestionById/deleteQuestionByIdRecursive/deleteQuestionsBatch/getKnowledgePointOptions/getTextbookOptions/getChapterOptions/getKnowledgePointOptionsByChapter/getKnowledgePointsForQuestions/getQuestionsContentForErrorCollection/getQuestionTypeMapByIds/exportQuestions/importQuestions 等。职责混合了 CRUD + 跨模块接口 + 导入导出。",
|
||||||
|
"recommendation": "按职责拆分为 data-access.ts(核心 CRUD)、data-access-cross-module.ts(跨模块只读接口)、data-access-import-export.ts(导入导出)。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-038",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L1-L703",
|
||||||
|
"ruleId": "S-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "单文件导出函数数约 35 个,超过 20 警告阈值",
|
||||||
|
"description": "data-access.ts 导出约 35 个符号,涵盖教材 CRUD、章节 CRUD、知识点 CRUD、排序、统计、归属校验、scope 查询、跨模块接口、前置依赖 CRUD。职责过重。",
|
||||||
|
"recommendation": "拆分为 data-access.ts(教材+章节)、data-access-knowledge-points.ts(知识点+前置依赖)、data-access-cross-module.ts(跨模块只读接口)。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-039",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-ai-evaluation.ts",
|
||||||
|
"lines": "L58, L77",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getEvaluationsByPlanIdRaw / getLatestEvaluationRaw)",
|
||||||
|
"description": "getEvaluationsByPlanIdRaw(L58)和 getLatestEvaluationRaw(L77)使用 `.select()` 无参数,SELECT 所有列。表字段可能后续增加,且传输不需要的列浪费带宽。",
|
||||||
|
"recommendation": "改为显式列枚举 `.select({ id: ..., planId: ..., ... })`,仅查询 mapRowToEvaluation 实际使用的字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-040",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
|
||||||
|
"lines": "L63, L210",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getTeacherInvestmentRaw / upsertDailyAnalytics)",
|
||||||
|
"description": "getTeacherInvestmentRaw(L63)和 upsertDailyAnalytics 内的查询(L210)使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-041",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-review.ts",
|
||||||
|
"lines": "L146",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getReviewRecordsByPlanIdRaw)",
|
||||||
|
"description": "getReviewRecordsByPlanIdRaw(L146)使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-042",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
|
||||||
|
"lines": "L34, L52",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getSubstitutesByPlanIdRaw / getActiveSubstitutesByTeacherIdRaw)",
|
||||||
|
"description": "两个读函数 L34、L52 均使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-043",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-versions.ts",
|
||||||
|
"lines": "L50",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getLessonPlanVersionsRaw)",
|
||||||
|
"description": "getLessonPlanVersionsRaw(L50)使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-044",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-templates.ts",
|
||||||
|
"lines": "L61",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getLessonPlansRaw)",
|
||||||
|
"description": "getLessonPlansRaw(L61)使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-045",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
|
||||||
|
"lines": "L93, L123",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getLessonPlansByKnowledgePointRaw / getLessonPlansByQuestionRaw)",
|
||||||
|
"description": "两个函数 L93、L123 均使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段,仅查询 mapRowToListItemWithoutJoin 实际使用的列。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-046",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-formative.ts",
|
||||||
|
"lines": "L51, L68, L169, L195, L201, L216",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "6 处 SELECT * 未指定列",
|
||||||
|
"description": "getFormativeItemsByPlanIdRaw(L51)、getFormativeItemByIdRaw(L68)、getResponsesByItemIdRaw(L169)、getResponsesByStudentIdRaw(L195、L201)、getFormativeItemStatsRaw(L216)均使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。getFormativeItemStatsRaw 尤其应仅查聚合字段。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-047",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-comments.ts",
|
||||||
|
"lines": "L33, L51",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(getCommentsByPlanIdRaw / getCommentsByBlockIdRaw)",
|
||||||
|
"description": "两个读函数 L33、L51 均使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-048",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-attachments.ts",
|
||||||
|
"lines": "L31, L49, L123",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "3 处 SELECT * 未指定列",
|
||||||
|
"description": "getAttachmentsByPlanIdRaw(L31)、getAttachmentsByBlockIdRaw(L49)、getAttachmentByIdRaw(L123)均使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-049",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L427, L431",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "SELECT * 未指定列(reorderChapters 内查询)",
|
||||||
|
"description": "reorderChapters 中 L427 `db.select().from(chapters)` 和 L431 `db.select().from(chapters)` 使用 `.select()` 无参数。",
|
||||||
|
"recommendation": "显式枚举所需字段(id, textbookId, parentId, order, title)。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-050",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L43-L91, L627-L660",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getTextbooksRaw / getKnowledgePointOptionsRaw 无 LIMIT",
|
||||||
|
"description": "getTextbooksRaw(L64-79)和 getKnowledgePointOptionsRaw(L628-648)无 LIMIT。getKnowledgePointOptionsRaw 拉取全量知识点+章节+教材 JOIN,数据量大时风险高。",
|
||||||
|
"recommendation": "getTextbooksRaw 添加分页或 `.limit(200)`;getKnowledgePointOptionsRaw 应改为按 textbookId/subject 参数过滤,或前端懒加载。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-051",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-formative.ts",
|
||||||
|
"lines": "L165-L175, L182-L205",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getResponsesByItemIdRaw / getResponsesByStudentIdRaw 无 LIMIT",
|
||||||
|
"description": "两个函数查询学生作答记录均无 LIMIT。一个互动组件可能有上千条作答,一个学生可能有大量作答历史。",
|
||||||
|
"recommendation": "添加分页参数或 `.limit(500)` 上限保护。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-052",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-comments.ts",
|
||||||
|
"lines": "L29-L39, L46-L62",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getCommentsByPlanIdRaw / getCommentsByBlockIdRaw 无 LIMIT",
|
||||||
|
"description": "两个函数查询评论均无 LIMIT。热门课案评论数可能很多。",
|
||||||
|
"recommendation": "添加分页参数或 `.limit(200)` 上限保护。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-053",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-knowledge.ts",
|
||||||
|
"lines": "L92-L101, L122-L131",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getLessonPlansByKnowledgePointRaw / getLessonPlansByQuestionRaw 无 LIMIT",
|
||||||
|
"description": "两个函数对 lessonPlans 全表 LIKE 扫描后无 LIMIT,且无分页。匹配数量不可控。",
|
||||||
|
"recommendation": "添加 `.limit(100)` 上限保护,或改为分页查询。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-054",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L465-L474",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getTextbooksDashboardStatsRaw 全表 COUNT 无过滤",
|
||||||
|
"description": "getTextbooksDashboardStatsRaw(L465-474)对 textbooks 和 chapters 表各执行 `count()` 无 WHERE 过滤,统计全量数据。仪表盘统计应至少按可见范围过滤。",
|
||||||
|
"recommendation": "如需按权限范围统计,传入 scope 参数添加 WHERE 条件;若确为管理员全局统计,可保留但加缓存(已有 cacheFn)。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-055",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L204-L207",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getQuestionsDashboardStatsRaw 全表 COUNT 无过滤",
|
||||||
|
"description": "getQuestionsDashboardStatsRaw(L204-207)对 questions 表执行 `count()` 无 WHERE 过滤。仪表盘应按用户可见范围统计。",
|
||||||
|
"recommendation": "传入 scope/authorId 参数添加 WHERE 条件,或确认是否为管理员全局统计。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-056",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L574-L590",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getLessonPlanStats 全表 GROUP BY 无过滤",
|
||||||
|
"description": "getLessonPlanStats(L574-590)对 lessonPlans 全表 GROUP BY status 统计,无 WHERE 过滤。管理员看板统计应限定范围(如本学期/本学年)。",
|
||||||
|
"recommendation": "添加时间范围 WHERE 条件(如 createdAt >= 学期开始日期),避免统计历史归档数据。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-057",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
|
||||||
|
"lines": "L125-L141",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "canTeacherAccessPlan 含权限判断业务逻辑",
|
||||||
|
"description": "canTeacherAccessPlan(L125-141)包含'原教师→true / 代课教师→true'的权限判断逻辑,属于业务编排。虽然查询了 DB,但'是否可访问'的判断应属于 actions 或权限层。",
|
||||||
|
"recommendation": "将 canTeacherAccessPlan 的判断逻辑移至 actions 层,data-access 仅暴露 getPlanCreatorId 和 getActiveSubstitutesByTeacherId 两个纯读接口。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-058",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
|
||||||
|
"lines": "L201-L249",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "upsertDailyAnalytics 含 read-then-write 业务逻辑",
|
||||||
|
"description": "upsertDailyAnalytics(L201-249)先 SELECT 判断是否存在(L209-218),存在则 UPDATE 累加(L222-234),不存在则 INSERT(L236-247)。该 upsert 编排逻辑可下放到 actions 或用 SQL `INSERT ... ON DUPLICATE KEY UPDATE` 替代。",
|
||||||
|
"recommendation": "改用 MySQL `INSERT ... ON DUPLICATE KEY UPDATE` 单语句完成 upsert,或在 actions 层编排 read-then-write。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-059",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access.ts",
|
||||||
|
"lines": "L336, L535, L612",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "3 处 SELECT * 未指定列",
|
||||||
|
"description": "getLessonPlanByIdRaw(L336)、duplicateLessonPlan(L535)、getTemplateById(L612)使用 `.select()` 无参数。其中 getLessonPlanByIdRaw 查询后用 mapRowToLessonPlan 映射,所需字段已知。",
|
||||||
|
"recommendation": "显式枚举 mapRowToLessonPlan 所需的 14 个字段。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-060",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-substitutes.ts",
|
||||||
|
"lines": "L136",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "plan[0]!.creatorId 非空断言",
|
||||||
|
"description": "L136 `if (plan[0]!.creatorId === teacherId) return true;` 在已检查 `plan.length === 0`(L135)后使用 `!` 非空断言。虽逻辑正确,但可改为更安全的 `const row = plan[0]; if (row && row.creatorId === teacherId) ...`。",
|
||||||
|
"recommendation": "用 `const row = plan[0]; if (!row) return false; if (row.creatorId === teacherId) return true;` 替代非空断言。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-061",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-calendar.ts",
|
||||||
|
"lines": "L181",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "split('T')[0]! 非空断言",
|
||||||
|
"description": "L181 `e.occurredAt.toISOString().split('T')[0]!` 对数组取值使用 `!`。虽然 toISOString() 必定含 'T',但 `!` 属非空断言。",
|
||||||
|
"recommendation": "改为 `e.occurredAt.toISOString().split('T')[0] ?? ''` 或用专门的 toISODateString helper。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-062",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-formative.ts",
|
||||||
|
"lines": "L72",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "rows[0]! 非空断言",
|
||||||
|
"description": "L72 `return rows.length === 0 ? null : mapRowToItem(rows[0]!);` 使用 `!`。虽逻辑正确,但可避免。",
|
||||||
|
"recommendation": "改为 `const row = rows[0]; return row ? mapRowToItem(row) : null;`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-063",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-comments.ts",
|
||||||
|
"lines": "L118",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "rows[0]!.resolved 非空断言",
|
||||||
|
"description": "L118 `const newResolved = !rows[0]!.resolved;` 使用 `!`。已检查 `rows.length === 0`(L117)但风格上可改进。",
|
||||||
|
"recommendation": "改为 `const row = rows[0]; if (!row) return; const newResolved = !row.resolved;`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-064",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-schedules.ts",
|
||||||
|
"lines": "L167",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "created[0]! 非空断言",
|
||||||
|
"description": "L167 `const r = created[0]!;` 在 createSchedule 中查询刚插入的记录后使用 `!`。INSERT 后立即查询,理论上必定有值,但 `!` 不够安全。",
|
||||||
|
"recommendation": "改为 `const r = created[0]; if (!r) throw new Error('SCHEDULE_CREATE_FAILED');`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-065",
|
||||||
|
"file": "src/modules/lesson-preparation/data-access-analytics.ts",
|
||||||
|
"lines": "L98",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "r.templateId! 非空断言",
|
||||||
|
"description": "L98 `templateId: r.templateId!,` 在 WHERE 已过滤 `templateId IS NOT NULL`(L93)后使用 `!`。",
|
||||||
|
"recommendation": "改为 `templateId: r.templateId ?? ''`,或用类型守卫收窄。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-066",
|
||||||
|
"file": "src/modules/questions/data-access.ts",
|
||||||
|
"lines": "L1-L662",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "deleteQuestionRecursive/insertQuestionWithRelations 缺少 JSDoc",
|
||||||
|
"description": "deleteQuestionRecursive(L294)、insertQuestionWithRelations(L214)等内部函数无 JSDoc。环检测逻辑(L299-303)需要文档说明。",
|
||||||
|
"recommendation": "补充 JSDoc 说明环检测目的和 visited Set 的作用。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G1-067",
|
||||||
|
"file": "src/modules/textbooks/data-access.ts",
|
||||||
|
"lines": "L170-L206, L208-L210, L212-L240, L242-L273, L275-L333",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "createTextbook/updateTextbook/deleteTextbook/createChapter 等多个函数缺少 JSDoc",
|
||||||
|
"description": "createTextbook(L170)、updateTextbook(L192)、deleteTextbook(L208)、createChapter(L212)、updateChapterContent(L242)、deleteChapter(L275)、createKnowledgePoint(L398)、updateKnowledgePoint(L411)、deleteKnowledgePoint(L422)、reorderChapters(L426)均无 JSDoc。deleteChapter 的级联删除逻辑(L310-332)较复杂,需要文档。",
|
||||||
|
"recommendation": "为这些函数添加 JSDoc,特别是 deleteChapter 需说明级联删除知识点+前置依赖的行为。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
}
|
||||||
|
]
|
||||||
134
docs/architecture/audit/g2-data-access-audit.json
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "G2-001",
|
||||||
|
"file": "src/modules/exams/data-access.ts",
|
||||||
|
"lines": "L1",
|
||||||
|
"ruleId": "P-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "文件首行缺少 import \"server-only\" 标记",
|
||||||
|
"description": "data-access.ts 首行为 `import { db } from \"@/shared/db\"`,未在文件头声明 `import \"server-only\"`。该文件包含直接 DB 访问(exams/examQuestions 表的 CRUD),若被客户端组件意外引入,会将数据库连接与查询逻辑泄露到客户端 bundle,造成安全漏洞。同模块的 data-access-error-collection.ts(L1)与 data-access-cross-module.ts(L1)均已正确声明,唯独主文件遗漏。",
|
||||||
|
"recommendation": "在文件第一行(所有 import 之前)添加 `import \"server-only\"`。注意:必须位于首行,否则 next.js 的 server-only 边界检测可能不生效。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-002",
|
||||||
|
"file": "src/modules/grades/data-access-appeals.ts",
|
||||||
|
"lines": "L122-L151",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getPendingAppealsForReviewRaw 在 JS 层过滤班级范围而非 SQL WHERE",
|
||||||
|
"description": "函数 WHERE 子句仅过滤 `gradeAppeals.status = 'pending'`(L134),未对 classIds 加任何过滤,导致 SQL 返回全库所有 pending 申诉(含 gradeRecord 全字段 innerJoin),随后在 L141 用 `rows.filter((r) => classIds.includes(r.gradeRecord.classId))` 在 JS 层过滤。代码注释写明「在 JS 层过滤班级范围(避免复杂 SQL join)」,但 innerJoin gradeRecords 已存在,加 `inArray(gradeRecords.classId, classIds)` 并不复杂。当 pending 申诉总量增长时(全校维度),单次查询会拉取大量无关行,造成内存与网络压力;同时若 JS filter 被误删将引发跨班级数据泄露。",
|
||||||
|
"recommendation": "在 L132-L137 的 `and()` 内追加 `inArray(gradeRecords.classId, classIds)` 条件(classIds 为空时已在 L123 提前返回),删除 L140-L141 的 JS 层 filter,直接返回 rows.map(...)。这样既收窄 SQL 结果集,又消除数据泄露风险。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-003",
|
||||||
|
"file": "src/modules/adaptive-practice/data-access-analytics.ts",
|
||||||
|
"lines": "L311-L384",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getTeacherClassPracticeOverviewsRaw 在 Promise.all 内对每个班级循环发起 2 条 SQL(2N+1 模式)",
|
||||||
|
"description": "函数对 classIds 数组执行两次 Promise.all 循环:(1) L320-L325 对每个 classId 调用 `getActiveStudentIdsByClassId(classId)`(每班 1 条 SQL,共 N 条);(2) L328-L365 对每个班级再发起 1 条 `db.select().from(practiceSessions).where(inArray(studentId, ...))` 聚合查询(共 N 条)。加上 L317 的 getClassNamesByIds(1 条),总计 2N+1 条 SQL。当教师所教班级数 N 较大(如年级主任辖 10+ 班级)时,单次请求产生 20+ 条 SQL,且 Promise.all 仅并发 IO 不减少 DB 负载。",
|
||||||
|
"recommendation": "改为批量查询:(1) 一次性获取所有班级的学生 ID 映射(可用单条 SQL `SELECT classId, studentId FROM class_members WHERE classId IN (...) AND status='active'` 后在 JS 层 groupBy);(2) 用单条聚合 SQL `SELECT classId, count(...), SUM(...), COUNT(DISTINCT studentId) FROM practiceSessions WHERE studentId IN (全部学生) GROUP BY studentId` 后在 JS 层按班级归并;或直接 JOIN class_members 按 classId 分组。目标:将 2N+1 降至 2-3 条 SQL。",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-004",
|
||||||
|
"file": "src/modules/adaptive-practice/data-access-analytics.ts",
|
||||||
|
"lines": "L320-L325",
|
||||||
|
"ruleId": "F-08",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "跨模块在循环内多次调用 getActiveStudentIdsByClassId(classes 模块)",
|
||||||
|
"description": "在 Promise.all 内对每个 classId 单独调用 `@/modules/classes/data-access` 的 `getActiveStudentIdsByClassId`,属于 F-08 跨模块多次调用 getXxxByIds 模式。该函数内部本身可能已 cacheFn 包装,但首次填充缓存时仍会产生 N 条 SQL。应改用批量接口 `getActiveStudentIdsByClassIds(classIds)`(如不存在则需在 classes 模块新增)。",
|
||||||
|
"recommendation": "在 classes/data-access 新增 `getActiveStudentIdsByClassIds(classIds: string[]): Promise<Map<string, string[]>>` 批量接口(单条 SQL `WHERE classId IN (...)` 后 groupBy),本函数改为一次调用获取全量映射。与 G2-003 的修复可合并执行。",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-005",
|
||||||
|
"file": "src/modules/grades/data-access-analytics.ts",
|
||||||
|
"lines": "L1-L831",
|
||||||
|
"ruleId": "S-01",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "文件 831 行超过 800 行警告阈值",
|
||||||
|
"description": "文件总计 831 行,超过 S-01 规则的 800 行警告线(虽未达 1000 行硬性上限)。文件内含多个独立分析维度:年级分布(getGradeDistribution*)、班级统计(getClassGradeStats*)、学生摘要(getStudentGradeSummary*)、排名(getClassRanking*)等。职责虽同属 grades 分析,但可按分析维度进一步拆分以提升可维护性。",
|
||||||
|
"recommendation": "按分析维度拆分为 data-access-analytics-grade-distribution.ts / data-access-analytics-class-stats.ts / data-access-analytics-student-summary.ts 等,每个子文件 ≤ 300 行。或暂不拆分但监控增长,一旦逼近 1000 行必须拆分。",
|
||||||
|
"effort": "L (≤1 天)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-006",
|
||||||
|
"file": "src/modules/homework/data-access.ts",
|
||||||
|
"lines": "L207-L212",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "data-access 内联 computeOverdueCount 业务计算闭包",
|
||||||
|
"description": "在 getHomeworkAssignmentsRaw 的数据组装段内定义了 `computeOverdueCount` 闭包,包含条件分支 `if (!dueAt || dueAt > now) return 0` 及逾期人数推导逻辑 `Math.max(0, targetCount - submittedCount)`。虽为纯计算(非状态机),但「逾期」的业务定义(dueAt 已过且未提交)属于业务规则,下沉到 data-access 后未来若规则变更(如加宽限期、按作业类型区分)需改 data-access 而非 actions/lib。属 A-02 边界情形。",
|
||||||
|
"recommendation": "将 computeOverdueCount 提取到 homework/lib/overdue.ts 作为纯函数 `computeOverdueCount(dueAt, targetCount, submittedCount, now)`,data-access 仅负责数据获取与组装,业务规则集中到 lib。优先级较低,可在重构窗口处理。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-007",
|
||||||
|
"file": "src/modules/grades/data-access-drafts.ts",
|
||||||
|
"lines": "L367-L382",
|
||||||
|
"ruleId": "P-04",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "releaseDraftLock 返回值依赖隐式类型推断的元组解构",
|
||||||
|
"description": "L367-L378 执行 `db.update(gradeDrafts).set(...).where(...)` 后,L381 用 `const [header] = result` 解构,L382 返回 `(header?.affectedRows ?? 0) > 0`。drizzle MySQL 的 update 返回类型为 `MySqlRawQueryResult`(即 `[ResultSetHeader, FieldPacket[]]`),header 类型由推断得到。代码逻辑正确,但依赖 drizzle 内部类型推断而非显式标注,未来 drizzle 版本变更返回类型时可能静默失效。函数签名已显式标注 `Promise<boolean>`(L364),属轻微模式偏差。",
|
||||||
|
"recommendation": "可在解构处补充类型注释 `const [header] = result as [ResultSetHeader, unknown]`(此处 as 属从 unknown/drizzle 内部类型收窄,符合豁免);或保持现状但增加单元测试覆盖锁释放场景。优先级低。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-008",
|
||||||
|
"file": "src/modules/diagnostic/data-access.ts",
|
||||||
|
"lines": "L1-L553",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "诊断掌握度累积计算函数(updateMasteryFrom*)含业务规则分支",
|
||||||
|
"description": "文件含 3 个掌握度累积函数:updateMasteryFromSubmission / updateMasteryFromHomeworkSubmission / updateMasteryFromExamScore。这些函数内部包含掌握度合并算法(加权平均/最大值取值等业务规则)与 DB 写入混合。掌握度计算属于诊断业务规则,理想分层应将算法提取到 diagnostic/lib/mastery-calculator.ts,data-access 仅负责读写 knowledgePointMastery 表。当前实现可行但职责混合,属 A-02 边界。",
|
||||||
|
"recommendation": "提取纯函数 `computeMasteryAfterSubmission(current: MasteryState, submission: SubmissionInput): MasteryState` 到 diagnostic/lib/,data-access 函数改为:读取当前掌握度 → 调用纯函数计算新值 → 写回 DB。优先级中等,可在掌握度算法需调整时一并重构。",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-009",
|
||||||
|
"file": "src/modules/adaptive-practice/data-access.ts",
|
||||||
|
"lines": "L307",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "sourceMeta as unknown 用于 JSON 序列化字段写入(属豁免范畴)",
|
||||||
|
"description": "L307 `sourceMeta: sourceMeta as unknown` 将类型化对象转为 unknown 以写入 JSON 列。此处的 as 属于「向 unknown 转换」的合规用法(框架 P-09 豁免:从 unknown 收窄或反向序列化)。仅作记录,非违规。",
|
||||||
|
"recommendation": "无需修改。若追求严谨,可改用 `JSON.parse(JSON.stringify(sourceMeta))` 显式序列化,但当前写法已合规。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-010",
|
||||||
|
"file": "src/modules/exams/data-access.ts",
|
||||||
|
"lines": "L316",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "new Map(generated.map((q) => [q.id, q] as const)) 使用 as const 构造 Map(属豁免)",
|
||||||
|
"description": "L316 `[q.id, q] as const` 用于向 Map 构造器提供 readonly tuple 类型。as const 属于 TypeScript 类型工具的合规用法(P-09 豁免),非类型断言违规。仅作记录。",
|
||||||
|
"recommendation": "无需修改。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G2-011",
|
||||||
|
"file": "src/modules/homework/data-access-write.ts",
|
||||||
|
"lines": "L16,L20",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "import 语句中的 as 为模块别名(非类型断言)",
|
||||||
|
"description": "L16 `getClassTeacherById as getClassTeacherIdFromClass` 与 L20 `getExamWithQuestionsForHomework as getExamWithQuestionsFromExams` 为 ES module import 别名,用于避免跨模块同名函数冲突。非 P-09 规则所约束的类型断言。仅作记录,零违规。",
|
||||||
|
"recommendation": "无需修改。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
}
|
||||||
|
]
|
||||||
602
docs/architecture/audit/g3-audit-output.json
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "G3-001",
|
||||||
|
"file": "src/modules/classes/data-access.ts",
|
||||||
|
"lines": "L17-L313",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "classes/data-access.ts 中 24+ 个读函数未走 cacheFn 包装",
|
||||||
|
"description": "文件中导出的读函数(getClassSubjects、getAccessibleClassIdsForTeacher、getClassGradeIdsByClassIds、getTeacherSubjectIdsForClass、getClassTeacherById、getStudentIdsByClassId、getStudentIdsByClassIds、getActiveStudentIdsByClassId、getClassActiveStudentsWithInfo、getTeacherSubjectIdsByClass、getTeacherIdsByClassIds、getStudentActiveClassId、getStudentActiveClass、getStudentActiveGradeId、getClassExists、getClassNameById、getClassGradeId、getGradeIdsByClassIds、getClassNamesByIds、getClassesByGradeId、getClassIdsByGradeIds 等)全部直接执行 DB 查询,未使用项目标准的 `cacheFn(raw, { tags, ttl, keyParts })` 模式。这些函数被跨模块高频调用(attendance、scheduling、course-plans、proctoring 等模块都依赖),每次调用都直接命中 DB,导致重复查询与缓存失效。",
|
||||||
|
"recommendation": "为每个公开读函数添加 Raw + Wrapper 配对模式。例如:\n```ts\nexport const getClassNamesByIdsRaw = async (classIds: string[]): Promise<Map<string, string>> => { /* 原 SQL 逻辑 */ }\nexport const getClassNamesByIds = cacheFn(getClassNamesByIdsRaw, {\n tags: [\"classes:names\"],\n ttl: 300,\n keyParts: [\"classes\", \"getClassNamesByIds\"],\n})\n```\n注意:getSessionTeacherId、getTeacherIdForMutations、verifyTeacherOwnsClass 等用于权限校验的函数可不缓存(避免缓存权限提升风险)。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-002",
|
||||||
|
"file": "src/modules/scheduling/data-access.ts",
|
||||||
|
"lines": "L8-L17",
|
||||||
|
"ruleId": "A-06",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "scheduling 模块直接 import classes/users/subjects 等其他模块的 schema 表",
|
||||||
|
"description": "文件头部 `import { classes, classSchedule, classSubjectTeachers, classrooms, scheduleChanges, schedulingRules, subjects, users } from \"@/shared/db/schema\"` 中,`classes`、`classSubjectTeachers` 属于 classes 模块,`subjects` 属于 school 模块,`users` 属于 users 模块。scheduling 模块直接查询这些表违反了架构规则 A-06:modules 之间应通过对方 data-access 通信,不直接查询对方 DB 表。\n\n证据:\n- L122-L124 `getScheduleChangesRaw` 直接 INNER JOIN `classes` 表查询班级名称\n- L128-L146 直接查询 `users` 表解析 substituteTeacher/approver 姓名\n- L295-L302 `getTeachersForSchedulingRaw` 直接查询 `users` 表\n- L323-L335 `getClassSubjectsForSchedulingRaw` 直接 JOIN `subjects` 与 `classSubjectTeachers`",
|
||||||
|
"recommendation": "改为通过对方 data-access 调用:\n```ts\nimport { getClassNamesByIds } from \"@/modules/classes/data-access\"\nimport { getUserNamesByIds } from \"@/modules/users/data-access\"\nimport { getSubjectNameMapByIds } from \"@/modules/school/data-access\"\n\n// 替代直接 JOIN classes:\nconst classNameMap = await getClassNamesByIds(classIds)\n// 替代直接查询 users:\nconst userMap = await getUserNamesByIds(userIds)\n```\n对于 `classSubjectTeachers` 的查询,应在 classes 模块新增 `getSubjectTeachersForScheduling(classId)` 暴露给 scheduling 调用。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-003",
|
||||||
|
"file": "src/modules/scheduling/data-access-class-schedule.ts",
|
||||||
|
"lines": "L28-L57, L64-L136, L142-L158",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "data-access-class-schedule.ts 包含大量业务逻辑(校验、归属校验、状态机)",
|
||||||
|
"description": "createClassScheduleItem、updateClassScheduleItem、deleteClassScheduleItem 三个函数包含:\n- 时间格式校验 `isTimeHHMM`(L42)\n- 业务规则校验 `startTime >= endTime`(L43)、`weekday < 1 || weekday > 7`(L44)\n- 归属校验 `verifyTeacherOwnsClass`(L46、L85、L94、L155)\n- 字段合并与冲突检测(L121-L127)\n- 通过 `getTeacherIdForMutations()` 获取当前教师 ID(L31、L68、L143)\n\n这些业务逻辑应位于 actions 层(编排层),data-access 层应只负责 DB 读写。当前实现导致职责混淆(S-08),且这些函数既不是 \"use server\" 也不是纯 data-access,处于灰色地带。",
|
||||||
|
"recommendation": "将校验与归属校验逻辑移至 actions-schedule.ts:\n```ts\n// actions-schedule.ts\n\"use server\"\nexport async function createClassScheduleItemAction(prevState, formData) {\n const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST)\n // 校验输入\n if (!isTimeHHMM(startTime)) return { success: false, message: \"Invalid time\" }\n // 归属校验\n const owned = await verifyTeacherOwnsClass(classId, ctx.userId)\n if (!owned) return { success: false, message: \"Class not found\" }\n // 调用 data-access\n const id = await insertClassScheduleItem({ classId, weekday, ... })\n await invalidateFor(\"scheduling.create\")\n return { success: true, data: id }\n}\n```\ndata-access-class-schedule.ts 仅保留 `insertClassScheduleItem`、`updateClassScheduleItemById`、`deleteClassScheduleItemById` 等纯 DB 操作(这些已在 data-access.ts 中定义,本文件可考虑删除)。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-004",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L92-L116",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getTeacherClassesRaw 循环内对每个班级发起 2 次子查询(N+1)",
|
||||||
|
"description": "`getTeacherClassesRaw` 在获取班级列表后,使用 `Promise.all(list.map(async (c) => { ... }))` 对每个班级并行调用 `getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 })` 和 `getClassSchedule({ classId: c.id, teacherId })`。虽然使用了 Promise.all 并行化,但如果教师有 N 个班级,将产生 2N 次子查询(每次 getClassHomeworkInsights 内部还有多轮 DB 查询:accessibleIds、classRow、enrollments、assignments、submissions 等),总查询数可能达到 10N+。对于任教 10+ 班级的教师,单次列表加载可能触发 100+ DB 查询。",
|
||||||
|
"recommendation": "改为批量查询:\n1. 一次性获取所有班级的 homework insights:在 data-access-stats.ts 新增 `getBatchClassHomeworkInsights(classIds: string[], teacherId: string)` 批量函数\n2. 一次性获取所有班级的 schedule:新增 `getBatchClassSchedule(classIds: string[])`\n3. 在 getTeacherClassesRaw 中并行调用这两个批量函数,然后用 Map 在内存中关联到班级\n\n```ts\nconst [insightsMap, scheduleMap] = await Promise.all([\n getBatchClassHomeworkInsights(list.map(c => c.id), teacherId),\n getBatchClassSchedule(list.map(c => c.id)),\n])\nconst listWithTrends = list.map(c => {\n const insights = insightsMap.get(c.id)\n const schedule = scheduleMap.get(c.id) ?? []\n return { ...c, recentAssignments: ..., schedule }\n})\n```",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-005",
|
||||||
|
"file": "src/modules/course-plans/data-access.ts",
|
||||||
|
"lines": "L324-L331",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "reorderCoursePlanItems 循环内发起 N 次 UPDATE 查询(N+1)",
|
||||||
|
"description": "`reorderCoursePlanItems` 使用 `Promise.all(items.map((item) => db.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))))` 对每个 item 发起独立的 UPDATE 查询。如果一次排序涉及 20 个条目,将产生 20 次 DB 往返。此外,这些更新没有包裹在事务中(F-09),若中间某个更新失败,会导致部分条目排序已变更、部分未变更的不一致状态。",
|
||||||
|
"recommendation": "改为单次事务 + 批量更新(使用 CASE WHEN 或单事务内顺序更新):\n```ts\nexport async function reorderCoursePlanItems(planId: string, items: ReorderCoursePlanItemInput[]): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n // 方案1:使用 CASE WHEN 单次 UPDATE\n const caseExpr = sql`CASE ${items.map((item, i) => sql`WHEN id = ${item.id} THEN ${item.week}`).join(' ')} END`\n await tx.update(coursePlanItems).set({ week: caseExpr }).where(eq(coursePlanItems.planId, planId))\n // 方案2:事务内顺序更新(简单但仍是 N 次查询,至少保证原子性)\n // for (const item of items) {\n // await tx.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))\n // }\n })\n}\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-006",
|
||||||
|
"file": "src/modules/course-plans/data-access.ts",
|
||||||
|
"lines": "L309-L332",
|
||||||
|
"ruleId": "F-09",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "reorderCoursePlanItems 多次 UPDATE 未包裹事务",
|
||||||
|
"description": "`reorderCoursePlanItems` 对多条 coursePlanItems 执行 UPDATE,未使用 `db.transaction` 包裹。若中间某次更新失败,已成功的更新无法回滚,导致周次排序部分变更的不一致状态。同样问题存在于 `bulkUpdateItemCompleted`(L337-L349)使用单次 inArray UPDATE,虽然单语句本身原子,但若业务上需要级联校验则缺少事务边界。",
|
||||||
|
"recommendation": "```ts\nexport async function reorderCoursePlanItems(planId: string, items: ReorderCoursePlanItemInput[]): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n for (const item of items) {\n await tx.update(coursePlanItems).set({ week: item.week }).where(eq(coursePlanItems.id, item.id))\n }\n })\n}\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-007",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L1-L938",
|
||||||
|
"ruleId": "S-01",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "school/data-access.ts 938 行,超过 800 行警告阈值,接近 1000 行硬上限",
|
||||||
|
"description": "文件总行数 938 行,已超过项目规范的 800 行警告阈值(Server Actions / Data Access 模块建议 ≤ 800 行),接近 1000 行硬上限。文件同时包含:5 类读函数(departments/academicYears/schools/grades/staffOptions)、3 类权限感知查询(getSchoolsForUser/getGradesForUser/getOrgTree)、12 个 mutation 函数(create/update/delete × department/school/grade/academicYear)、6 个跨模块查询接口(getSubjectOptions/getGradeOptions/getGradeNameById/getSubjectNameById/getSubjectNameMapByIds/isGradeHead/isGradeManager/findGradeIdByHeadAndName)、2 个统计函数(getGradeOverviewStats/promoteGrades)。",
|
||||||
|
"recommendation": "按职责拆分为多个文件:\n```\nsrc/modules/school/\n├─ data-access.ts # 主入口(re-export)\n├─ data-access-departments.ts # 部门 CRUD\n├─ data-access-schools.ts # 学校 CRUD + getSchoolsForUser\n├─ data-access-grades.ts # 年级 CRUD + getGradesForUser + promoteGrades\n├─ data-access-academic-years.ts # 学年 CRUD\n├─ data-access-staff.ts # getStaffOptions + getGradesForStaff\n├─ data-access-options.ts # getSubjectOptions + getGradeOptions + getXxxNameById\n├─ data-access-permissions.ts # isGradeHead + isGradeManager + findGradeIdByHeadAndName\n└─ data-access-org-tree.ts # getOrgTree + getGradeOverviewStats\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-008",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L1-L938",
|
||||||
|
"ruleId": "S-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "school/data-access.ts 导出 30+ 函数,远超 20 个警告阈值",
|
||||||
|
"description": "文件导出函数清单(30+ 个):getDepartments、getAcademicYears、getSchools、getGrades、getStaffOptions、getGradesForStaff、getSchoolsForUser、getGradesForUser、createDepartment、updateDepartment、deleteDepartment、createSchool、updateSchool、deleteSchool、createGrade、updateGrade、deleteGrade、createAcademicYear、updateAcademicYear、deleteAcademicYear、getSubjectOptions、getGradeOptions、getGradeNameById、getSubjectNameById、getSubjectNameMapByIds、isGradeHead、isGradeManager、findGradeIdByHeadAndName、promoteGrades、getOrgTree、getGradeOverviewStats(含 Raw 版本则达 50+ 个)。导出函数过多导致文件职责不单一,维护困难。",
|
||||||
|
"recommendation": "按职责拆分(见 G3-007 建议),每个拆分文件导出函数数控制在 5-10 个以内。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-009",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L29, L50, L73, L294",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "school/data-access.ts 多处使用 db.select() 未指定列(SELECT *)",
|
||||||
|
"description": "以下查询使用 `db.select().from(table)` 返回所有列,违反 F-03 规则:\n- L29 `db.select().from(departments)` (getDepartmentsRaw)\n- L50 `db.select().from(academicYears)` (getAcademicYearsRaw)\n- L73 `db.select().from(schools)` (getSchoolsRaw)\n- L294 `db.select().from(schools)` (getSchoolsForUserRaw 内部)\n\n虽然这些表列数较少,但 SELECT * 会返回不需要的列(如 updatedAt、内部审计字段),增加网络传输与内存开销,且在 schema 变更时可能意外暴露新字段。",
|
||||||
|
"recommendation": "显式枚举所需列:\n```ts\nconst rows = await db\n .select({\n id: departments.id,\n name: departments.name,\n description: departments.description,\n createdAt: departments.createdAt,\n updatedAt: departments.updatedAt,\n })\n .from(departments)\n .orderBy(asc(departments.name))\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-010",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L38, L61, L82, L141, L168, L229, L307, L405, L573, L605, L877, L930",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "school/data-access.ts 包含 12 处 console.error 调试代码",
|
||||||
|
"description": "文件中 12 个读函数内都有 `console.error(\"xxx failed:\", error)` 后返回空数组的模式(如 L38、L61、L82、L141、L168、L229、L307、L405、L573、L605、L877、L930)。这违反 A-10 规则(data-access 含 console.log 调试代码)。更重要的是,这种模式吞掉异常并返回空数组,导致调用方无法区分"无数据"和"查询失败",是错误的错误处理模式(P-05 也要求 data-access 层用 throw)。",
|
||||||
|
"recommendation": "删除所有 console.error,改为 throw 让 actions 层处理:\n```ts\nexport const getDepartmentsRaw = async (): Promise<DepartmentListItem[]> => {\n const rows = await db.select({...}).from(departments).orderBy(asc(departments.name))\n return rows.map(...)\n // 移除 try/catch,让异常向上传播\n}\n```\n若需保留容错,应在 actions 层用 try/catch 包裹并返回 ActionState。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-011",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L246-L310, L324-L408",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "getSchoolsForUserRaw / getGradesForUserRaw 包含角色判断业务逻辑",
|
||||||
|
"description": "`getSchoolsForUserRaw`(L246-L310)和 `getGradesForUserRaw`(L324-L408)内部包含:\n- 查询用户角色 `db.select({ name: roles.name }).from(roles)...`\n- 基于角色分支:`if (roleNames.has(\"admin\"))` / `if (roleNames.has(\"grade_head\"))` / `if (roleNames.has(\"teacher\"))`\n- 动态导入 classes data-access 并调用 `getAccessibleClassIdsForTeacher`、`getGradeIdsByClassIds`\n\n这是典型的权限感知业务编排逻辑,应位于 actions 层或 lib 层,而非 data-access 层。data-access 层应只提供原子查询能力,由 actions 层根据用户角色选择调用哪个查询。",
|
||||||
|
"recommendation": "将角色判断逻辑移至 actions.ts 或新建 lib/school-scope-resolver.ts:\n```ts\n// actions.ts\nexport async function getSchoolsForUserAction(userId: string): Promise<ActionState<SchoolListItem[]>> {\n const ctx = await requirePermission(Permissions.SCHOOL_READ)\n // 基于 ctx.dataScope 与 roles 决定调用哪个 data-access 函数\n if (ctx.dataScope.type === \"all\") {\n return { success: true, data: await getSchools() }\n }\n // ... 其他分支\n}\n```\ndata-access 层保留 getSchools()、getSchoolsByIds(ids) 等原子函数。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-012",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L803-L822",
|
||||||
|
"ruleId": "F-09",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "promoteGrades 循环内多次 UPDATE 未包裹事务",
|
||||||
|
"description": "`promoteGrades` 查询所有年级后,在 `for (const row of rows)` 循环中对每个年级执行独立的 `db.update(grades).set(...)`,未使用事务。若中间某次更新失败(如唯一约束冲突、连接断开),已升级的年级无法回滚,导致年级数据部分升级、部分未升级的不一致状态。注释虽提到"从高到低升级,避免唯一约束冲突",但这只是降低风险,不能替代事务。",
|
||||||
|
"recommendation": "```ts\nexport async function promoteGrades(schoolId: string): Promise<{ promoted: number }> {\n const rows = await db.select(...).from(grades).where(eq(grades.schoolId, schoolId)).orderBy(desc(grades.order))\n let promoted = 0\n await db.transaction(async (tx) => {\n for (const row of rows) {\n const newOrder = (row.order ?? 0) + 1\n const newName = promoteGradeName(row.name)\n await tx.update(grades).set({ order: newOrder, name: newName }).where(eq(grades.id, row.id))\n promoted += 1\n }\n })\n return { promoted }\n}\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-013",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L73",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "classes/data-access-teacher.ts 包含 console.error 调试代码",
|
||||||
|
"description": "L73 `console.error(\"getTeacherClasses query failed:\", error)` 后 `throw new Error(\"Failed to load teacher classes\")`。虽然这里重新抛出了错误(比 school 模块的吞异常好),但 console.error 仍违反 A-10 规则。生产环境应使用结构化日志(如 logAudit 或 trackEvent),而非 console.error。",
|
||||||
|
"recommendation": "删除 console.error,直接 throw:\n```ts\n} catch (error) {\n throw new Error(\"Failed to load teacher classes\")\n}\n```\n若需记录错误上下文,使用项目统一的日志工具。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-014",
|
||||||
|
"file": "src/modules/classes/data-access-students.ts",
|
||||||
|
"lines": "L170",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "classes/data-access-students.ts 包含 console.error 调试代码",
|
||||||
|
"description": "L170 `console.error(\"getStudentClasses primary query failed, falling back:\", error)` 后执行 fallback 查询。这种模式将异常吞掉并降级,调用方无法感知主查询失败。console.error 违反 A-10,且 fallback 逻辑(使用 `sql\\`NULL\\`` 替代 schoolName)隐藏了潜在 schema 问题。",
|
||||||
|
"recommendation": "删除 console.error 与 fallback,让异常向上传播由 actions 层处理。若确实需要 fallback(如兼容旧 schema),应使用结构化日志并添加监控埋点。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-015",
|
||||||
|
"file": "src/modules/classes/data-access-admin.ts",
|
||||||
|
"lines": "L91, L252",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "classes/data-access-admin.ts 包含 console.error 调试代码",
|
||||||
|
"description": "L91 `console.error(\"getAdminClasses primary query failed, falling back:\", error)` 和 L252 `console.error(\"getGradeManagedClasses primary query failed:\", error)`。与 G3-014 类似,主查询失败后执行 fallback 并吞掉异常。",
|
||||||
|
"recommendation": "同 G3-014,删除 console.error,移除 fallback 或改用结构化日志。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-016",
|
||||||
|
"file": "src/modules/course-plans/data-access.ts",
|
||||||
|
"lines": "L167, L202",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "course-plans/data-access.ts 包含 console.error 调试代码",
|
||||||
|
"description": "L167 `console.error(\"getCoursePlans failed:\", error)` 返回空数组;L202 `console.error(\"getCoursePlanById failed:\", error)` 返回 null。两处都吞掉异常,调用方无法区分"无数据"与"查询失败"。",
|
||||||
|
"recommendation": "删除 try/catch 与 console.error,让异常向上传播。actions 层已有 handleActionError 统一处理。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-017",
|
||||||
|
"file": "src/modules/classes/data-access-students.ts",
|
||||||
|
"lines": "L281-L285",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getClassStudentsRaw 使用 LIKE '%xxx%' 全表扫描",
|
||||||
|
"description": "L282-L285:\n```ts\nconst needle = `%${q}%`\nconditions.push(\n sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})`\n)\n```\n`%xxx%` 前缀通配符 LIKE 无法使用 B-Tree 索引,会导致 users 表全表扫描。当 users 表数据量增长(如 10 万学生),此查询性能会急剧下降。同时 LOWER() 函数包裹列也会阻止索引使用。",
|
||||||
|
"recommendation": "1. 短期:改为前缀匹配 `LIKE ${q}%`(可使用索引),或限制搜索字段为 email(唯一索引)\n2. 中期:为 users.name 与 users.email 添加 FULLTEXT 索引(MySQL)或 pg_trgm 索引(PostgreSQL)\n3. 使用生成的列索引:`ALTER TABLE users ADD COLUMN name_lower VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) STORED, ADD INDEX idx_name_lower (name_lower)`\n\n```ts\n// 前缀匹配方案(可走索引)\nconst needle = `${q}%`\nconditions.push(\n or(\n like(users.name, needle),\n like(users.email, needle)\n )\n)\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-018",
|
||||||
|
"file": "src/modules/scheduling/data-access.ts",
|
||||||
|
"lines": "L462-L464",
|
||||||
|
"ruleId": "S-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getScheduleEntriesForAdminRaw 为死代码(永远返回空数组)",
|
||||||
|
"description": "L462-L464:\n```ts\nexport async function getScheduleEntriesForAdminRaw(): Promise<ScheduleEntry[]> {\n return []\n}\n```\n函数体只有 `return []`,注释说明"simplified implementation returns an empty array; a real implementation should join classSchedule with classes/users..."。这是未实现的桩函数,但仍被 `cacheFn` 包装并导出,属于 dead code。调用方若依赖此函数将永远拿到空数据,可能导致前端显示异常而无报错。",
|
||||||
|
"recommendation": "要么完整实现该函数(JOIN classSchedule + classes + users 填充 teacherName/className/subject/room),要么删除该函数及其 cacheFn 包装。若暂不实现,应抛出 `throw new Error(\"Not implemented\")` 而非静默返回空数组。",
|
||||||
|
"effort": "XS (≤15 分钟) 删除 / M (≤2h) 实现"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-019",
|
||||||
|
"file": "src/modules/classes/data-access-stats.ts",
|
||||||
|
"lines": "L520-L523",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getClassesDashboardStatsRaw 使用 count() 无过滤条件全表统计",
|
||||||
|
"description": "L521:`db.select({ value: count() }).from(classes)` 没有 WHERE 子句,对 classes 表执行全表 COUNT(*)。虽然 COUNT(*) 在 InnoDB 上仍有性能开销(尤其大表),且此处无任何业务过滤(如按学校、学年、状态过滤),统计的是历史所有班级总数,可能不符合业务预期(如已删除的班级是否应计入?)。",
|
||||||
|
"recommendation": "添加业务过滤条件:\n```ts\nexport const getClassesDashboardStatsRaw = async (): Promise<ClassesDashboardStats> => {\n const [row] = await db\n .select({ value: count() })\n .from(classes)\n .where(eq(classes.deletedAt, null)) // 若有软删除字段\n // 或按学年过滤:.where(eq(classes.academicYearId, currentAcademicYearId))\n return { classCount: Number(row?.value ?? 0) }\n}\n```\n若确实需要全表统计,考虑使用缓存或物化视图。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-020",
|
||||||
|
"file": "src/modules/classes/data-access-admin.ts",
|
||||||
|
"lines": "L42-L186, L193-L316",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getAdminClassesRaw 与 getGradeManagedClassesRaw 大量代码重复",
|
||||||
|
"description": "`getAdminClassesRaw`(L42-L186,145 行)与 `getGradeManagedClassesRaw`(L193-L316,124 行)有大量重复代码:\n- 相同的 select 字段列表(id/schoolName/schoolId/name/grade/gradeId/...)\n- 相同的 groupBy 子句\n- 相同的 orderBy 子句\n- 相同的 try/catch + fallback 逻辑\n- 相同的 subjectsByClassId Map 构建逻辑\n- 相同的 list.map + compareClassLike 排序逻辑\n\n唯一差异:getGradeManagedClasses 多了 `where(inArray(classes.gradeId, gradeIds))` 过滤条件。",
|
||||||
|
"recommendation": "提取共享 helper:\n```ts\nasync function fetchClassesWithSubjects(\n whereClause?: SQL\n): Promise<AdminClassListItem[]> {\n const [rows, subjectRows] = await Promise.all([\n db.select({...}).from(classes).innerJoin(users, ...).leftJoin(classEnrollments, ...)\n .where(whereClause)\n .groupBy(...).orderBy(...),\n db.select({...}).from(classSubjectTeachers)...\n ])\n // 共享的 Map 构建与排序逻辑\n return list\n}\n\nexport const getAdminClassesRaw = async () => fetchClassesWithSubjects()\nexport const getGradeManagedClassesRaw = async (userId: string) => {\n const gradeIds = await getManagedGradeIds(userId)\n return fetchClassesWithSubjects(inArray(classes.gradeId, gradeIds))\n}\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-021",
|
||||||
|
"file": "src/modules/attendance/data-access.ts",
|
||||||
|
"lines": "L50-L51",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "serializeDate helper 在 attendance/scheduling 多个文件中重复定义",
|
||||||
|
"description": "`serializeDate` 函数在以下文件中重复定义,且实现略有差异(返回 \"\" vs null):\n- attendance/data-access.ts L50: `(d: Date | string | null): string => d ? new Date(d).toISOString().slice(0, 10) : \"\"`\n- attendance/data-access-stats.ts L97: 同上(返回 \"\")\n- scheduling/data-access.ts L27: `(d: Date | string | null): string | null => d ? new Date(d).toISOString().slice(0, 10) : null`\n- school/data-access.ts L25: `const toIso = (d: Date): string => d.toISOString()`\n- course-plans/data-access.ts L28-L31: `toIso` + `toIsoRequired` 两个函数\n\nP-07 规则要求日期序列化走 helper,但目前每个模块自定义 helper,违反 S-03(重复 helper 应提取到 shared/lib)。",
|
||||||
|
"recommendation": "在 `src/shared/lib/date-utils.ts` 统一导出:\n```ts\nexport const toISODateString = (d: Date | string | null): string | null =>\n d ? new Date(d).toISOString().slice(0, 10) : null\n\nexport const toISODateStringOrEmpty = (d: Date | string | null): string =>\n d ? new Date(d).toISOString().slice(0, 10) : \"\"\n\nexport const toISODateTimeString = (d: Date | string | null): string | null =>\n d ? new Date(d).toISOString() : null\n```\n各模块改为 `import { toISODateString } from \"@/shared/lib/date-utils\"`。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-022",
|
||||||
|
"file": "src/modules/attendance/data-access-correlation.ts",
|
||||||
|
"lines": "L46-L193",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "getAttendanceGradeCorrelationRaw 包含大量业务编排逻辑",
|
||||||
|
"description": "`getAttendanceGradeCorrelationRaw`(L46-L193,148 行)包含:\n- scope 权限校验(L57-L62):`if (scope && scope.type === \"class_taught\")` / `if (scope && scope.type === \"owned\") return null`\n- 时间范围默认值计算(L70-L77):`DEFAULT_RANGE_DAYS = 90` 天回溯\n- 跨模块数据编排:调用 `getClassNameById`、`getClassActiveStudentsWithInfo`、`getGradeRecords`\n- 成绩归一化计算(L139-L157):`normalized = (r.score / r.fullScore) * 100`、加权平均\n- 考勤率计算(L167-L169)\n- 调用纯函数 `computeCorrelationSummary`(L186-L192)\n\n这是典型的业务编排逻辑,应位于 actions 层或 lib 层,data-access 层应只提供原子查询(如 `getAttendanceStatsByStudent`、`getGradeRecordsByClass`)。",
|
||||||
|
"recommendation": "拆分职责:\n1. data-access 层:保留 `getAttendanceAggByStudent(classId, startDate, endDate)` 原子查询\n2. lib 层:新建 `correlation-compute.ts`(已存在)存放纯计算逻辑\n3. actions 层:新建 `getAttendanceGradeCorrelationAction`,负责 scope 校验、时间范围计算、跨模块编排、调用纯计算\n\n```ts\n// actions.ts\nexport async function getAttendanceGradeCorrelationAction(classId: string, ...) {\n const ctx = await requirePermission(Permissions.ATTENDANCE_READ)\n // scope 校验\n if (ctx.dataScope.type === \"owned\") return { success: false, message: \"...\" }\n // 编排\n const className = await getClassNameById(classId)\n const students = await getClassActiveStudentsWithInfo(classId)\n const attendanceAgg = await getAttendanceAggByStudent(classId, ...)\n const gradeRecords = await getGradeRecords({ classId, ... })\n // 计算纯函数\n const summary = computeCorrelationSummary(...)\n return { success: true, data: summary }\n}\n```",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-023",
|
||||||
|
"file": "src/modules/attendance/data-access-correlation.ts",
|
||||||
|
"lines": "L145",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "correlation 模块使用 Array.includes 进行 O(n*m) 查找",
|
||||||
|
"description": "L145:`if (!studentIds.includes(r.studentId)) continue` 在 `for (const r of filteredGradeRecords)` 循环内。若 studentIds 有 N 个学生,filteredGradeRecords 有 M 条成绩记录,则此处为 O(N*M) 复杂度。虽然 N 通常较小(< 100),但 M 可能较大(多年成绩记录),应使用 Set 优化。",
|
||||||
|
"recommendation": "```ts\nconst studentIdSet = new Set(studentIds)\nfor (const r of filteredGradeRecords) {\n if (!studentIdSet.has(r.studentId)) continue\n // ...\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-024",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L284-L439",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "enrollTeacherByInvitationCode 包含复杂业务状态机逻辑",
|
||||||
|
"description": "`enrollTeacherByInvitationCode`(L284-L439,155 行)包含:\n- 教师身份校验(L320-L328)\n- 邀请码校验(L331-L335)\n- 班级归属校验(L337-L343)\n- 科目查找与分配逻辑(L346-L431):\n - 已分配科目冲突检测(L365 `throw new Error(\"Subject already assigned\")`)\n - 自动选择首选科目(L401 `DEFAULT_CLASS_SUBJECTS.find`)\n - 多次 SELECT + INSERT + UPDATE 实现教师-科目绑定状态机\n- 邀请码消耗(L433-L436)\n\n这是典型的业务状态机,应位于 actions 层。data-access 层应只提供 `insertClassSubjectTeacher`、`updateClassSubjectTeacher`、`getClassSubjectTeacher` 等原子操作。",
|
||||||
|
"recommendation": "将 enrollTeacherByInvitationCode 拆分:\n1. data-access 层:提供 `getTeacherExistingAssignment(classId, teacherId)`、`assignTeacherToSubject(classId, subjectId, teacherId)`、`findUnassignedSubject(classId)` 等原子函数\n2. actions 层:`enrollTeacherByInvitationCodeAction` 编排校验、状态机、调用原子函数、包裹事务\n3. 整个流程应用 `db.transaction` 包裹,确保邀请码消耗与教师分配原子性",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-025",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L284-L439",
|
||||||
|
"ruleId": "F-09",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "enrollTeacherByInvitationCode 多次写操作未包裹事务",
|
||||||
|
"description": "`enrollTeacherByInvitationCode` 内部执行多次写操作:\n- L368-L372 `db.insert(classSubjectTeachers).values(...).onDuplicateKeyUpdate(...)`\n- L382-L385 `db.update(classSubjectTeachers).set({ teacherId: tid })...`\n- L407-L416 `db.update(classSubjectTeachers).set({ teacherId: tid })...`\n- L304 `consumeInvitationCode(code)`(内部 UPDATE)\n\n这些写操作未包裹在事务中。若中间失败(如 consumeInvitationCode 失败),教师已被分配到科目但邀请码未消耗,导致数据不一致(邀请码可被重复使用)。",
|
||||||
|
"recommendation": "```ts\nexport async function enrollTeacherByInvitationCode(...): Promise<string> {\n // 校验逻辑...\n return await db.transaction(async (tx) => {\n // 所有写操作使用 tx\n await tx.insert(classSubjectTeachers).values(...)\n await tx.update(classSubjectTeachers).set(...)\n if (result.codeId) {\n await tx.update(classInvitationCodes).set({ usedCount: sql`${classInvitationCodes.usedCount} + 1` })...\n }\n return cls.id\n })\n}\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-026",
|
||||||
|
"file": "src/modules/scheduling/data-access.ts",
|
||||||
|
"lines": "L53, L69, L294",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "scheduling/data-access.ts 多处使用 db.select() 未指定列",
|
||||||
|
"description": "以下查询使用 `db.select().from(table)` 返回所有列:\n- L53 `db.select().from(schedulingRules)` (getSchedulingRulesRaw)\n- L69 `db.select().from(schedulingRules)` (upsertSchedulingRules 内部查询)\n- L294 `db.select({ id: classes.id, name: classes.name, ... })` - 此处已指定列 ✓\n\nschedulingRules 表可能包含较多字段(classId、maxDailyHours、maxContinuousHours、lunchBreakStart、lunchBreakEnd、morningStart、afternoonEnd、avoidBackToBack、balancedSubjects、createdAt、updatedAt),SELECT * 会返回所有字段。",
|
||||||
|
"recommendation": "显式指定所需列:\n```ts\nconst rows = await db\n .select({\n id: schedulingRules.id,\n classId: schedulingRules.classId,\n maxDailyHours: schedulingRules.maxDailyHours,\n // ... 其他所需字段\n })\n .from(schedulingRules)\n .where(...)\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-027",
|
||||||
|
"file": "src/modules/attendance/data-access.ts",
|
||||||
|
"lines": "L314, L341",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "attendance/data-access.ts 多处使用 db.select() 未指定列",
|
||||||
|
"description": "L314 `db.select().from(attendanceRules)` (getAttendanceRulesRaw) 和 L341 `db.select().from(attendanceRules)` (upsertAttendanceRules 内部) 使用 SELECT *。attendanceRules 表字段较多(classId、lateThresholdMinutes、earlyLeaveThresholdMinutes、enableAutoMark、attendanceRateThreshold、consecutiveAbsenceThreshold、createdAt、updatedAt),返回全部字段会增加开销。",
|
||||||
|
"recommendation": "显式指定所需列,同 G3-026 建议。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-028",
|
||||||
|
"file": "src/modules/course-plans/data-access.ts",
|
||||||
|
"lines": "L159, L183, L192, L366, L374, L450, L461",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "course-plans/data-access.ts 多处使用 db.select() 未指定列",
|
||||||
|
"description": "以下 7 处查询使用 `db.select().from(table)` 返回所有列:\n- L159 `db.select().from(coursePlans)` (getCoursePlansRaw)\n- L183 `db.select().from(coursePlans)` (getCoursePlanByIdRaw)\n- L192 `db.select().from(coursePlanItems)` (getCoursePlanByIdRaw 内部)\n- L366 `db.select().from(coursePlans)` (copyCoursePlanToClasses 内部)\n- L374 `db.select().from(coursePlanItems)` (copyCoursePlanToClasses 内部)\n- L450 `db.select().from(coursePlans)` (getGradeCoursePlanProgressRaw)\n- L461 `db.select().from(coursePlanItems)` (getGradeCoursePlanProgressRaw)\n\ncoursePlans 表字段较多(id、classId、subjectId、teacherId、academicYearId、semester、totalHours、completedHours、weeklyHours、startDate、endDate、syllabus、objectives、status、createdBy、createdAt、updatedAt),全量返回会增加网络与内存开销。",
|
||||||
|
"recommendation": "显式指定所需列。对于 copyCoursePlanToClasses 等需要全字段的场景,可保留 SELECT * 但添加注释说明。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-029",
|
||||||
|
"file": "src/modules/classes/data-access-admin.ts",
|
||||||
|
"lines": "L36-L40",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "使用 as 断言将 DEFAULT_CLASS_SUBJECTS 转为 readonly string[]",
|
||||||
|
"description": "L36-L40:\n```ts\nconst isClassSubject = (v: unknown): v is ClassSubject =>\n typeof v === \"string\" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)\n```\n`DEFAULT_CLASS_SUBJECTS as readonly string[]` 是类型断言(从具体元组类型 widening 为 readonly string[])。虽然这是 widening 断言(比 narrowing 安全),但仍违反 P-09 规则(禁止 as 断言)。`.includes(v)` 需要 `readonly string[]` 类型参数,而 DEFAULT_CLASS_SUBJECTS 可能是 `readonly [\"语文\", \"数学\", ...]` 元组类型。",
|
||||||
|
"recommendation": "改用类型安全的方式:\n```ts\nconst CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS\nconst isClassSubject = (v: unknown): v is ClassSubject =>\n typeof v === \"string\" && CLASS_SUBJECTS_READONLY.includes(v)\n```\n或在 types.ts 中将 DEFAULT_CLASS_SUBJECTS 类型显式标注为 `readonly string[]`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-030",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L41",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "使用 as 断言将 DEFAULT_CLASS_SUBJECTS 转为 readonly string[]",
|
||||||
|
"description": "L41:`typeof v === \"string\" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)`,与 G3-029 相同的 as 断言模式。",
|
||||||
|
"recommendation": "同 G3-029。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-031",
|
||||||
|
"file": "src/modules/classes/data-access.ts",
|
||||||
|
"lines": "L83-L92",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAccessibleClassIdsForTeacher 等多个查询无 LIMIT 保护",
|
||||||
|
"description": "`getAccessibleClassIdsForTeacher`(L83-L92)查询教师所有可访问班级 ID,无 LIMIT。其他无 LIMIT 的查询:\n- getStudentIdsByClassId(L147-L153)\n- getStudentIdsByClassIds(L159-L166)\n- getTeacherIdsByClassIds(L208-L228)\n- getClassesByGradeId(L352-L359)\n- getClassIdsByGradeIds(L365-L373)\n- getClassNamesByIds(L334-L346)\n\n虽然班级数量通常有限(< 100),但若数据异常增长(如测试数据、迁移错误),可能导致一次查询返回大量数据。",
|
||||||
|
"recommendation": "为可能返回大量数据的查询添加默认 LIMIT:\n```ts\nexport const getStudentIdsByClassIds = async (classIds: string[]): Promise<string[]> => {\n if (classIds.length === 0) return []\n const rows = await db\n .select({ studentId: classEnrollments.studentId })\n .from(classEnrollments)\n .where(inArray(classEnrollments.classId, classIds))\n .limit(10000) // 安全上限\n return Array.from(new Set(rows.map((r) => r.studentId)))\n}\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-032",
|
||||||
|
"file": "src/modules/classes/data-access-admin.ts",
|
||||||
|
"lines": "L42-L186",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAdminClassesRaw 无 LIMIT,可能返回全量班级数据",
|
||||||
|
"description": "`getAdminClassesRaw` 查询所有班级(无 WHERE、无 LIMIT),并 LEFT JOIN classEnrollments 计算学生数。若系统有 1000+ 班级,此查询会返回 1000+ 行,每行还包含聚合计算,性能压力大。同样问题存在于 `getGradeManagedClassesRaw`(L193-L316)和 `getTeacherClassesRaw`(classes/data-access-teacher.ts L46-L119)。",
|
||||||
|
"recommendation": "添加分页参数或默认 LIMIT:\n```ts\nexport const getAdminClassesRaw = async (params?: { limit?: number; offset?: number }): Promise<AdminClassListItem[]> => {\n const limit = Math.min(params?.limit ?? 200, 500)\n const offset = params?.offset ?? 0\n // 查询添加 .limit(limit).offset(offset)\n}\n```\n前端列表应实现分页或虚拟滚动。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-033",
|
||||||
|
"file": "src/modules/scheduling/data-access-class-schedule.ts",
|
||||||
|
"lines": "L72-L81, L147-L151",
|
||||||
|
"ruleId": "A-09",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "data-access-class-schedule.ts 直接查询 classSchedule 表(应走 scheduling/data-access.ts 统一入口)",
|
||||||
|
"description": "L72-L81 `updateClassScheduleItem` 内部直接查询 `db.select({...}).from(classSchedule).where(eq(classSchedule.id, id))`,L147-L151 `deleteClassScheduleItem` 内部同样直接查询 classSchedule 表。虽然 classSchedule 是 scheduling 模块的表,但 scheduling/data-access.ts 已提供了 `insertClassScheduleItem`、`updateClassScheduleItemById`、`deleteClassScheduleItemById` 统一写入入口(L343-L410)。当前文件绕过这些入口直接查询,导致查询逻辑分散在两个文件中,维护困难。",
|
||||||
|
"recommendation": "将 L72-L81 的查询逻辑移至 scheduling/data-access.ts,新增 `getClassScheduleItemById(id)` 函数:\n```ts\n// scheduling/data-access.ts\nexport async function getClassScheduleItemById(id: string) {\n const [row] = await db.select({...}).from(classSchedule).where(eq(classSchedule.id, id)).limit(1)\n return row ?? null\n}\n```\ndata-access-class-schedule.ts 调用此函数,或直接删除该文件将逻辑合并到 actions-schedule.ts(见 G3-003)。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-034",
|
||||||
|
"file": "src/modules/scheduling/data-access.ts",
|
||||||
|
"lines": "L108-L172",
|
||||||
|
"ruleId": "F-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getScheduleChangesRaw 内部二次查询 users 表(可与主查询合并)",
|
||||||
|
"description": "L140-L146:在主查询(JOIN classes + LEFT JOIN users)后,又对 users 表执行第二次查询 `db.select({ id, name }).from(users).where(inArray(users.id, userIds))` 来解析 substituteTeacher/approver/requester 姓名。虽然这是为了避免 JOIN 歧义,但若 scheduleChanges 数据量大(如 100 条变更),userIds 可能只有 5-10 个,二次查询开销可控。然而,此模式可通过 cacheFn 缓存 getUserNamesByIds 来优化。",
|
||||||
|
"recommendation": "改为调用 users 模块 data-access:\n```ts\nimport { getUserNamesByIds } from \"@/modules/users/data-access\"\n// 替代直接查询 users 表\nconst userMap = await getUserNamesByIds(userIds)\n```\n这样既符合架构规则 A-06,又能利用 users data-access 的 cacheFn 缓存。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-035",
|
||||||
|
"file": "src/modules/attendance/data-access-stats.ts",
|
||||||
|
"lines": "L53-L67",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "computeStats 纯计算函数导出在 data-access 文件中",
|
||||||
|
"description": "L53-L67 `export const computeStats = (rows: { status: string }[]): AttendanceStats => {...}` 是纯计算函数(无 DB 访问、无 IO),但定义并导出自 data-access-stats.ts。这违反职责分层:纯计算函数应位于 lib/ 或 compute/ 目录。同文件还有 `statsFromAggregate`(L72-L95)也是纯函数但未导出(private)。",
|
||||||
|
"recommendation": "将 computeStats 移至 `src/modules/attendance/lib/stats-compute.ts` 或 `src/modules/attendance/stats-compute.ts`(与现有 `correlation-compute.ts`、`trend-compute.ts`、`warning-compute.ts` 同级):\n```ts\n// attendance/stats-compute.ts\nexport const computeStats = (rows: { status: string }[]): AttendanceStats => {...}\nexport const statsFromAggregate = (row: {...}): AttendanceStats => {...}\n```\ndata-access-stats.ts 改为 import 调用。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-036",
|
||||||
|
"file": "src/modules/school/data-access.ts",
|
||||||
|
"lines": "L92-L149, L178-L232, L324-L408",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getGradesRaw / getGradesForStaffRaw / getGradesForUserRaw(teacher 分支) 三处重复查询逻辑",
|
||||||
|
"description": "三个函数都执行类似的 grades INNER JOIN schools 查询,并解析 gradeHead/teachingHead 姓名:\n- getGradesRaw(L92-L149):全量查询\n- getGradesForStaffRaw(L178-L232):按 staffId 过滤\n- getGradesForUserRaw 的 teacher 分支(L356-L400):按 gradeIds 过滤\n\n三处的 select 字段列表、headIds 收集、headById Map 构建、rows.map 返回逻辑几乎完全相同(每处约 30 行重复)。",
|
||||||
|
"recommendation": "提取共享 helper:\n```ts\nasync function fetchGradesWithHeads(whereClause?: SQL): Promise<GradeListItem[]> {\n const rows = await db.select({...}).from(grades).innerJoin(schools, ...).where(whereClause).orderBy(...)\n const headIds = Array.from(new Set(rows.flatMap(r => [r.gradeHeadId, r.teachingHeadId]).filter(...)))\n const heads = headIds.length ? await db.select({...}).from(users).where(inArray(users.id, headIds)) : []\n const headById = new Map(heads.map(u => [u.id, {...}]))\n return rows.map(r => ({...}))\n}\n\nexport const getGradesRaw = async () => fetchGradesWithHeads()\nexport const getGradesForStaffRaw = async (staffId: string) =>\n fetchGradesWithHeads(or(eq(grades.gradeHeadId, staffId), eq(grades.teachingHeadId, staffId)))\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-037",
|
||||||
|
"file": "src/modules/classes/data-access.ts",
|
||||||
|
"lines": "L18-L20",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getSessionTeacherId 内部使用动态 import 加载 auth 模块",
|
||||||
|
"description": "L18 `const { auth } = await import(\"@/auth\")` 使用动态 import 加载 auth 模块。虽然这可能是为了避免循环依赖,但动态 import 在 TypeScript 类型推断与打包分析上不如静态 import。此外,auth 模块导入应位于文件顶部,除非有明确的循环依赖问题。",
|
||||||
|
"recommendation": "若不存在循环依赖,改为静态 import:\n```ts\nimport { auth } from \"@/auth\"\n```\n若存在循环依赖,保留动态 import 但添加注释说明原因:\n```ts\n// 动态 import 避免 classes ↔ auth 循环依赖\nconst { auth } = await import(\"@/auth\")\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-038",
|
||||||
|
"file": "src/modules/classes/data-access-invitations.ts",
|
||||||
|
"lines": "L254-L307",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "validateInvitationCode 包含懒清理业务逻辑",
|
||||||
|
"description": "`validateInvitationCode`(L254-L307)除了校验邀请码有效性外,还包含"懒清理"业务逻辑:\n- L266-L272:发现邀请码已过期时,主动 UPDATE status 为 'expired'\n- L274-L284:发现邀请码已用尽时,主动 UPDATE status 为 'exhausted'\n- L294-L304:fallback 到旧格式 6 位数字码(classes.invitationCode)\n\n这是业务状态机逻辑(状态迁移:active → expired/exhausted),应位于 actions 层或独立的清理逻辑中,而非 data-access 层的校验函数内。校验函数应只读,状态迁移应显式调用。",
|
||||||
|
"recommendation": "拆分职责:\n1. `validateInvitationCode` 只做校验,返回 `{ valid, classId, codeId, status, needsCleanup: true }`\n2. 调用方(actions)根据 needsCleanup 决定是否调用 `markInvitationCodeExpired(codeId)` 或 `markInvitationCodeExhausted(codeId)`\n3. 懒清理逻辑可作为独立函数 `cleanupExpiredCodes()` 由定时任务调用\n\n或保留当前实现但在 JSDoc 中明确标注"此函数有副作用:会更新过期/用尽的邀请码状态"。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-039",
|
||||||
|
"file": "src/modules/classes/data-access.ts",
|
||||||
|
"lines": "L1-L406",
|
||||||
|
"ruleId": "S-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "classes/data-access.ts 导出 25+ 函数,超过 20 个警告阈值",
|
||||||
|
"description": "文件导出函数清单:getSessionTeacherId、getTeacherIdForMutations、getClassSubjects、compareClassLike、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getClassGradeIdsByClassIds、getTeacherSubjectIdsForClass、getClassTeacherById、getStudentIdsByClassId、getStudentIdsByClassIds、getActiveStudentIdsByClassId、getClassActiveStudentsWithInfo、getTeacherSubjectIdsByClass、getTeacherIdsByClassIds、getStudentActiveClassId、getStudentActiveClass、getStudentActiveGradeId、getClassExists、getClassNameById、getClassGradeId、getGradeIdsByClassIds、getClassNamesByIds、getClassesByGradeId、getClassIdsByGradeIds、getClassIdsByGradeIdsSubquery(26 个)。此外还有 `export * from \"./data-access-stats\"` 等 6 个 re-export,实际导出函数总数达 50+。",
|
||||||
|
"recommendation": "按职责拆分为多个文件:\n- data-access.ts(主入口,re-export)\n- data-access-teacher-scope.ts(getSessionTeacherId、getAccessibleClassIdsForTeacher、verifyTeacherOwnsClass、getTeacherScopeData)\n- data-access-class-queries.ts(getClassExists、getClassNameById、getClassGradeId、getClassNamesByIds、getClassesByGradeId 等)\n- data-access-student-queries.ts(getStudentIdsByClassId、getStudentActiveClass、getStudentActiveGradeId 等)\n- data-access-helpers.ts(compareClassLike、normalizeSortText 等纯函数)",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-040",
|
||||||
|
"file": "src/modules/attendance/data-access.ts",
|
||||||
|
"lines": "L106-L168",
|
||||||
|
"ruleId": "F-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAttendanceRecordsRaw 每次分页查询都重复调用 getUserNamesByIds/getClassNamesByIds",
|
||||||
|
"description": "`getAttendanceRecordsRaw` 在每次分页查询时(L148-L152)都调用 `getUserNamesByIds(studentIds)`、`getClassNamesByIds(classIds)`、`resolveRecorderNames(rows)` 解析姓名。虽然这些函数内部可能有 cacheFn 缓存,但每页的 studentIds/classIds 可能高度重叠(如同一班级的不同页记录),缓存命中率取决于 TTL 与 keyParts。对于高频分页场景(如教师翻页查看考勤记录),这可能产生重复查询。",
|
||||||
|
"recommendation": "1. 确保 getUserNamesByIds 与 getClassNamesByIds 已使用 cacheFn 包装(若未包装,参见 G3-001)\n2. 考虑在前端缓存姓名映射,避免每次翻页都重新解析\n3. 对于 recorderName,可在 INSERT 时冗余存储 recordedByName 字段,避免每次查询都 JOIN(反范式优化)",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-041",
|
||||||
|
"file": "src/modules/proctoring/data-access.ts",
|
||||||
|
"lines": "L113-L171",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getProctoringEventsRaw 查询无 LIMIT,可能返回大量事件",
|
||||||
|
"description": "`getProctoringEventsRaw`(L113-L171)查询某场考试的所有监考事件,无 LIMIT。若考试持续 2 小时,30 个学生每个产生 50+ 事件,总事件数可能达 1500+。一次性返回所有事件会导致内存压力与网络延迟。虽然有 `getRecentProctoringEvents` 函数(L398-L429)提供 LIMIT 版本,但 getProctoringEvents 本身无保护。",
|
||||||
|
"recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getProctoringEventsRaw = async (\n examId: string,\n filters?: GetProctoringEventsFilters & { limit?: number; offset?: number },\n): Promise<ProctoringEventWithDetails[]> => {\n const limit = Math.min(filters?.limit ?? 500, 1000)\n const offset = filters?.offset ?? 0\n // 查询添加 .limit(limit).offset(offset)\n}\n```\n前端面板应优先使用 getRecentProctoringEvents(默认 20 条),完整列表走分页。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-042",
|
||||||
|
"file": "src/modules/classes/data-access-invitations.ts",
|
||||||
|
"lines": "L127-L138, L146-L157",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "generateUniqueInvitationCode / generateUniqueCode 循环内查询 DB(N+1 重试模式)",
|
||||||
|
"description": "`generateUniqueInvitationCode`(L127-L138)和 `generateUniqueCode`(L146-L157)都使用 for 循环最多 40 次重试,每次循环内执行 `db.select(...).where(eq(classes.invitationCode, code)).limit(1)` 查询 DB 检查码是否已存在。虽然正常情况下 1-2 次就能成功(碰撞概率低),但最坏情况下 40 次 DB 查询。这种模式无法批量化(每次生成的码随机),但可通过 INSERT 失败捕获唯一约束错误来优化。",
|
||||||
|
"recommendation": "改为"先生成再 INSERT,捕获唯一约束错误"模式:\n```ts\nexport async function generateUniqueInvitationCode(): Promise<string> {\n for (let attempt = 0; attempt < 40; attempt += 1) {\n const code = generateInvitationCode()\n try {\n // 直接尝试 INSERT 一个临时记录或使用 SELECT FOR UPDATE 检查\n // 更优:直接在调用方 INSERT 时捕获 duplicate 错误\n return code\n } catch (err) {\n if (isDuplicateInvitationCodeError(err)) continue\n throw err\n }\n }\n throw new Error(\"Failed to generate invitation code\")\n}\n```\n或保留当前模式但将 40 次重试降为 5 次(碰撞概率极低,5 次足够)。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-043",
|
||||||
|
"file": "src/modules/scheduling/data-access.ts",
|
||||||
|
"lines": "L213-L244",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getClassConflictsRaw 使用 O(n²) 双重循环比较课表项",
|
||||||
|
"description": "`getClassConflictsRaw`(L213-L244)查询班级所有课表项后,使用双重循环 `for (let i = 0; i < rows.length; i++) { for (let j = i + 1; j < rows.length; j++) {...} }` 检测时间冲突。若班级有 N 个课表项,比较次数为 N*(N-1)/2。虽然 N 通常较小(< 50),但可优化为 O(N) 的扫描线算法。",
|
||||||
|
"recommendation": "优化为按 weekday 分组 + 排序后单次扫描:\n```ts\nexport async function getClassConflictsRaw(classId: string): Promise<ScheduleConflict[]> {\n const rows = await db.select({...}).from(classSchedule).where(eq(classSchedule.classId, classId)).orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))\n const conflicts: ScheduleConflict[] = []\n // 按 weekday 分组\n const byWeekday = new Map<number, typeof rows>()\n for (const r of rows) {\n const list = byWeekday.get(r.weekday) ?? []\n list.push(r)\n byWeekday.set(r.weekday, list)\n }\n // 每个 weekday 内已按 startTime 排序,只需比较相邻项\n for (const [weekday, items] of byWeekday) {\n for (let i = 0; i < items.length - 1; i++) {\n const a = items[i]\n const b = items[i + 1]\n if (a && b && a.startTime < b.endTime && b.startTime < a.endTime) {\n conflicts.push({...})\n }\n }\n }\n return conflicts\n}\n```\n注意:相邻比较只能检测相邻冲突,若需检测所有重叠仍需 O(n²),但可先用排序+早退优化。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-044",
|
||||||
|
"file": "src/modules/attendance/data-access-stats.ts",
|
||||||
|
"lines": "L299-L307",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getClassAttendanceWarningsRaw 查询无 LIMIT,可能返回大量考勤记录",
|
||||||
|
"description": "`getClassAttendanceWarningsRaw`(L299-L307)查询班级在时间范围内的所有考勤记录(select studentId, date, status),无 LIMIT。若时间范围跨 1 学期(约 100 天),30 学生每天 1 条记录,总记录数达 3000+。全部加载到内存按 studentId 聚合,内存压力大。",
|
||||||
|
"recommendation": "改为 SQL 聚合查询(GROUP BY studentId),避免拉全量记录:\n```ts\nconst rows = await db\n .select({\n studentId: attendanceRecords.studentId,\n total: count(),\n present: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,\n // ... 其他状态统计\n })\n .from(attendanceRecords)\n .where(where)\n .groupBy(attendanceRecords.studentId)\n```\n连续缺勤检测若需要日期序列,可单独查询有 absent 记录的日期,而非全量加载。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-045",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L1-L631",
|
||||||
|
"ruleId": "S-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "classes/data-access-teacher.ts 631 行,接近 800 行警告阈值",
|
||||||
|
"description": "文件 631 行,已超过 500 行组件建议上限(虽 data-access 建议 ≤ 800 行,但仍偏高)。文件包含:教师班级查询、教师选项查询、教师科目查询、班级 CRUD(createTeacherClass、updateTeacherClass、deleteTeacherClass)、邀请码管理(ensureClassInvitationCode、regenerateClassInvitationCode)、学生注册(enrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail)、科目教师分配(setClassSubjectTeachers)、DataScope 辅助(getTeacherScopeData)。职责过多。",
|
||||||
|
"recommendation": "进一步拆分:\n- data-access-teacher-queries.ts(getTeacherClasses、getTeacherOptions、getTeacherTeachingSubjects、getTeacherScopeData)\n- data-access-teacher-mutations.ts(createTeacherClass、updateTeacherClass、deleteTeacherClass、setClassSubjectTeachers)\n- data-access-teacher-enrollment.ts(enrollStudentByInvitationCode、enrollTeacherByInvitationCode、enrollStudentByEmail、setStudentEnrollmentStatus)\n- data-access-teacher-invitations.ts(ensureClassInvitationCode、regenerateClassInvitationCode)",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-046",
|
||||||
|
"file": "src/modules/classes/data-access-stats.ts",
|
||||||
|
"lines": "L126-L277",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getClassHomeworkInsightsRaw 151 行,缺少详细 JSDoc 说明返回结构与分支逻辑",
|
||||||
|
"description": "`getClassHomeworkInsightsRaw`(L126-L277)是复杂的聚合函数,包含:教师归属判断(homeroom vs subject teacher)、活跃学生筛选、作业查询、提交解析、统计计算。函数仅有简短 JSDoc `cacheFn(getClassHomeworkInsightsRaw, {...})`,未说明:\n- 返回的 ClassHomeworkInsights 结构字段含义\n- isHomeroomTeacher 分支与 subjectIdFilter 分支的区别\n- 当 subjectIdFilter 为空且非 homeroom teacher 时的早返回逻辑\n- latest/overallScores 的计算方式\n\n同样问题存在于 `getGradeHomeworkInsightsRaw`(L290-L509,219 行)。",
|
||||||
|
"recommendation": "补充详细 JSDoc:\n```ts\n/**\n * 获取班级作业洞察汇总。\n *\n * 权限分支:\n * - 班主任(homeroom teacher):返回所有科目的作业统计\n * - 任课教师(subject teacher):仅返回其所教科目的作业统计\n *\n * 返回结构:\n * - class: 班级基本信息\n * - studentCounts: 活跃/非活跃学生数\n * - assignments: 各作业的提交/批改/分数统计\n * - latest: 最近一次作业统计\n * - overallScores: 所有作业分数汇总\n *\n * @param params.classId 班级 ID\n * @param params.teacherId 教师 ID(默认从 session 获取)\n * @param params.limit 作业数量上限(默认 50)\n */\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-047",
|
||||||
|
"file": "src/modules/course-plans/data-access.ts",
|
||||||
|
"lines": "L1-L530",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "course-plans/data-access.ts 自定义 toIso/toIsoRequired 而非使用 shared helper",
|
||||||
|
"description": "L28-L31 定义了 `toIso` 和 `toIsoRequired` 两个日期序列化函数,与 attendance/scheduling 模块的 `serializeDate` 功能重叠。P-07 规则要求日期序列化走 helper,但每个模块自定义导致行为不一致(返回 null vs \"\" vs undefined)。",
|
||||||
|
"recommendation": "参见 G3-021,统一使用 `@/shared/lib/date-utils` 中的 helper。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-048",
|
||||||
|
"file": "src/modules/classes/data-access.ts",
|
||||||
|
"lines": "L384-L389",
|
||||||
|
"ruleId": "S-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "data-access.ts 通过 export * re-export 6 个子文件,职责边界模糊",
|
||||||
|
"description": "L384-L389:\n```ts\nexport * from \"./data-access-stats\"\nexport * from \"./data-access-schedule\"\nexport * from \"./data-access-students\"\nexport * from \"./data-access-admin\"\nexport * from \"./data-access-invitations\"\nexport * from \"./data-access-teacher\"\n```\n主文件通过 `export *` 聚合 6 个子文件的导出,导致:\n1. 单一导入路径 `@/modules/classes/data-access` 暴露 50+ 函数,职责边界模糊\n2. 无法 tree-shake(即使只用了 getClassNamesByIds,也会加载所有子模块)\n3. 命名冲突风险(若两个子文件导出同名函数,ES 模块语义下后者覆盖前者,且无警告)\n4. 文件头部注释(L391-L406)提到曾有 `getTeacherScopeData` 重复定义问题,正是 export * 的风险体现",
|
||||||
|
"recommendation": "改为显式 re-export:\n```ts\nexport { getClassHomeworkInsights, getGradeHomeworkInsights, getClassesDashboardStats } from \"./data-access-stats\"\nexport { getStudentSchedule, getClassSchedule, getClassIdByScheduleId } from \"./data-access-schedule\"\nexport { getStudentClasses, getClassStudents, getStudentScopeData } from \"./data-access-students\"\n// ... 其他子文件\n```\n这样可避免命名冲突,且便于 IDE 跳转追踪。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-049",
|
||||||
|
"file": "src/modules/attendance/data-access-correlation.ts",
|
||||||
|
"lines": "L121-L137",
|
||||||
|
"ruleId": "F-08",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "correlation 模块跨模块调用 getGradeRecords 后内存过滤时间范围(非最优)",
|
||||||
|
"description": "L125-L129 调用 `getGradeRecords({ classId, scope, limit: 100 })` 获取成绩记录,注释说明"getGradeRecords 不直接支持 createdAt 范围筛选,因此这里传入 classId + scope,后续在内存中按 createdAt 过滤时间范围"。这意味着:\n1. DB 返回 100 条记录(可能大部分不在时间范围内)\n2. 内存中再过滤,效率低\n3. LIMIT 100 可能截断有效记录(若 100 条都是旧记录,时间范围内可能 0 条)\n\n这是跨模块 data-access 接口能力不足导致的性能问题。",
|
||||||
|
"recommendation": "在 grades 模块 data-access 中扩展 `getGradeRecords` 支持时间范围筛选:\n```ts\n// grades/data-access.ts\nexport async function getGradeRecords(params: {\n classId?: string\n scope: DataScope\n limit?: number\n startDate?: string // 新增\n endDate?: string // 新增\n}) {\n // WHERE 条件添加 createdAt 范围过滤\n}\n```\n这样 attendance 模块可直接调用并让 DB 过滤,避免内存过滤。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G3-050",
|
||||||
|
"file": "src/modules/classes/data-access-teacher.ts",
|
||||||
|
"lines": "L538-L570",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "enrollStudentByEmail 包含身份校验与角色验证业务逻辑",
|
||||||
|
"description": "`enrollStudentByEmail`(L538-L570)包含:\n- 教师归属校验(L543-L549)\n- 学生邮箱查询(L551-L555)\n- 学生角色校验(L558-L564):查询 usersToRoles JOIN roles 确认用户是学生\n- 注册写入(L566-L569)\n\n角色校验是业务逻辑,应位于 actions 层。data-access 层应提供 `getUserByEmail`、`getUserRole` 等原子查询,由 actions 编排。",
|
||||||
|
"recommendation": "将角色校验移至 actions 层:\n```ts\n// actions-invitations.ts\nexport async function enrollStudentByEmailAction(classId, email) {\n const ctx = await requirePermission(Permissions.CLASS_ENROLL)\n // 归属校验\n const owns = await verifyTeacherOwnsClass(classId, ctx.userId)\n if (!owns) return { success: false, message: \"...\" }\n // 查询学生\n const student = await getUserByEmail(email)\n if (!student) return { success: false, message: \"Student not found\" }\n // 角色校验\n const isStudent = await hasRole(student.id, ROLE_NAMES.STUDENT)\n if (!isStudent) return { success: false, message: \"User is not a student\" }\n // 注册\n await enrollStudent(classId, student.id)\n return { success: true }\n}\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
}
|
||||||
|
]
|
||||||
734
docs/architecture/audit/g4-audit-output.json
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "G4-001",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L1-L1089",
|
||||||
|
"ruleId": "S-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "messaging/data-access.ts 超 1000 行硬性上限",
|
||||||
|
"description": "文件总长 1089 行,违反项目硬性规则「任何文件不超过 1000 行,超过必须拆分」。文件混合了消息 CRUD、群发、撤回、举报、屏蔽、草稿、模板、附件 8 类职责。",
|
||||||
|
"recommendation": "按职责拆分为:(1) data-access-messages.ts(消息 CRUD + 线程);(2) data-access-group.ts(群发 sendGroupMessage);(3) data-access-recall.ts(撤回 + 批量操作);(4) data-access-reports.ts(举报 + 屏蔽);(5) data-access-drafts.ts(草稿 CRUD);(6) data-access-templates.ts(模板 CRUD);(7) data-access-recipients.ts(收件人解析器)。原 data-access.ts 仅作 barrel re-export。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-002",
|
||||||
|
"file": "src/modules/parent/",
|
||||||
|
"lines": "—",
|
||||||
|
"ruleId": "A-08",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "parent 模块缺失 actions.ts,data-access 被 app/ 直接引用",
|
||||||
|
"description": "parent 模块目录下只有 data-access.ts 与 types.ts,无 actions.ts。Grep 证实 src/app/(dashboard)/parent/children/[studentId]/page.tsx、parent/leave/page.tsx、parent/elective/page.tsx 三处页面直接 import @/modules/parent/data-access,绕过 Server Action 层与 requirePermission 校验。getParentDashboardData / getChildDashboardData / getChildren 等敏感数据查询无任何权限校验。",
|
||||||
|
"recommendation": "新建 src/modules/parent/actions.ts,为每个对外暴露的读函数包装 Server Action:\n```ts\n\"use server\"\nimport { requirePermission } from \"@/shared/lib/auth-guard\"\nimport { Permissions } from \"@/shared/types/permissions\"\nimport { getParentDashboardData } from \"./data-access\"\nexport async function getParentDashboardDataAction() {\n const ctx = await requirePermission(Permissions.PARENT_VIEW)\n return getParentDashboardData(ctx.userId)\n}\n```\n3 个页面改为调用 Action。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-003",
|
||||||
|
"file": "src/modules/audit/actions.ts",
|
||||||
|
"lines": "L192-L225",
|
||||||
|
"ruleId": "A-08",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "purgeAuditLogsAction 使用 AUDIT_LOG_READ 权限执行破坏性清理",
|
||||||
|
"description": "purgeAuditLogsAction 在 L197 调用 `await requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 purgeExpiredAuditLogs 会物理删除审计日志。读权限用于删除操作是严重权限提权漏洞——任何能查看审计日志的用户都能清空审计痕迹。",
|
||||||
|
"recommendation": "新增专用权限点 Permissions.AUDIT_LOG_PURGE(admin 专属),改为:\n```ts\nawait requirePermission(Permissions.AUDIT_LOG_PURGE)\n```\n同步更新 src/shared/types/permissions.ts 与角色-权限映射。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-004",
|
||||||
|
"file": "src/modules/audit/actions.ts",
|
||||||
|
"lines": "L163-L190",
|
||||||
|
"ruleId": "A-08",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "saveAuditRetentionConfigAction 用读权限执行写操作",
|
||||||
|
"description": "saveAuditRetentionConfigAction 在 L167 调用 `requirePermission(Permissions.AUDIT_LOG_READ)`,但该 Action 调用 saveAuditRetentionConfig 写入保留策略配置。读权限不应授予配置写入能力。",
|
||||||
|
"recommendation": "新增 Permissions.AUDIT_RETENTION_MANAGE 或复用 Permissions.AUDIT_LOG_EXPORT,将 requirePermission 改为该写权限点。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-005",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L1-L1089",
|
||||||
|
"ruleId": "S-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "messaging/data-access.ts 导出 42 个函数 + 4 个常量/接口,远超 20 上限",
|
||||||
|
"description": "Grep 统计 `^export (async )?(function|const)` 共 44 个导出(含 Raw+Wrapper 配对),加上 SendGroupMessageInput/SendGroupResult 2 个 interface 共 46 个公共导出。职责混杂导致单文件难以维护。",
|
||||||
|
"recommendation": "与 G4-001 拆分方案同步执行,拆分后每个 data-access-*.ts 导出数控制在 8-12 个。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-006",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L449-L476",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "recallMessage 在 data-access 层嵌入状态机业务逻辑",
|
||||||
|
"description": "recallMessage 函数内部实现 4 态状态机(\"ok\"/\"not_found\"/\"expired\"/\"already_recalled\"),包含时间窗口校验(MESSAGE_RECALL_WINDOW_MS = 2 分钟)、已撤回判断、elapsed 时间计算。这些是业务规则,不应放在 data-access 层。data-access 应只做 DB 读写。",
|
||||||
|
"recommendation": "将状态机移至 actions.ts:\n```ts\n// data-access 只保留纯 DB 操作\nexport async function markMessageRecalled(id: string): Promise<void> {\n await db.update(messages).set({ recalledAt: new Date() }).where(eq(messages.id, id))\n}\nexport async function getMessageForRecallCheck(id: string, userId: string) {\n return db.select({id, senderId, recalledAt, createdAt}).from(messages)\n .where(and(eq(messages.id, id), eq(messages.senderId, userId))).limit(1)\n}\n// actions.ts 中 recallMessageAction 实现状态机\n```",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-007",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L695-L718, L641-L668",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "blockUser / reportMessage 在 data-access 层实现防重复业务规则",
|
||||||
|
"description": "blockUser 实现 self_block/already_blocked 双业务校验(L699, L712);reportMessage 实现 already_reported 防重复校验(L644-L656)。这些是业务规则,应在 actions 层通过 Zod + 状态判断完成,data-access 仅提供 unique 索引写入与查询原语。",
|
||||||
|
"recommendation": "data-access 层只暴露纯 insert/block 查询;actions 层负责状态判断与错误码映射。可利用 DB unique 索引(userBlocks(blockerId, blockedId))直接 insert + catch 冲突判定 already_blocked,省去一次 SELECT。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-008",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L617-L630",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "getMessageDetailPageData 是页面编排函数,不应在 data-access 层",
|
||||||
|
"description": "getMessageDetailPageData 内部组合 getMessageById + 条件性 markMessageAsRead,是典型的页面层编排逻辑(orchestration)。文件头注释 L20 明确说「getMessagesPageData 已迁出至 messages/page.tsx」,但本函数仍保留在 data-access,违反同层职责一致性。",
|
||||||
|
"recommendation": "删除该函数,将其逻辑移至 app/(dashboard)/messages/[id]/page.tsx 或包装为 getMessageDetailAction Server Action。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-009",
|
||||||
|
"file": "src/modules/auth/data-access.ts",
|
||||||
|
"lines": "L48-L84",
|
||||||
|
"ruleId": "F-09",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "createUser 两次 INSERT 无事务,存在数据不一致风险",
|
||||||
|
"description": "createUser 先 db.insert(users)(L55),再 db.query.roles.findFirst 查角色(L71),最后 db.insert(usersToRoles)(L78)。三次操作无事务包裹。若第二步 roleRow 未找到抛错,用户已写入但无角色;若第三步失败,用户存在但无角色关联,导致下次登录 resolvePermissions 失败。",
|
||||||
|
"recommendation": "用 db.transaction 包裹:\n```ts\nawait db.transaction(async (tx) => {\n await tx.insert(users).values({...})\n const roleRow = await tx.query.roles.findFirst({ where: eq(roles.name, roleName) })\n if (!roleRow) throw new Error('DEFAULT_ROLE_NOT_FOUND')\n await tx.insert(usersToRoles).values({ userId, roleId: roleRow.id })\n})\n```\n注意:抛错会回滚用户记录,避免孤儿用户。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-010",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L137-L141",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getMessages 对 messages.subject/content 使用 LIKE '%kw%' 全表扫描",
|
||||||
|
"description": "L138-139 `like(messages.subject, kw), like(messages.content, kw)` 中 kw = `%${params.keyword.trim()}%`。前导通配符使 B-tree 索引失效,messages 表增长后查询退化。同时 getMessages 还要 count() 同条件总数,单次列表请求触发 2 次全表扫描。",
|
||||||
|
"recommendation": "(1) 短期:对短关键词改前缀匹配 `kw%` 可用索引;(2) 长期:在 messages 表加 FULLTEXT 索引 `ALTER TABLE messages ADD FULLTEXT idx_subject_content(subject, content)`,改用 `match(messages.subject, messages.content).against(kw)`;(3) count 也可考虑用估算值或缓存。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-011",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L506,L517,L531,L542,L555,L805,L807",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "messaging/data-access.ts 出现 7 处 `as` 类型断言",
|
||||||
|
"description": "Grep 证实 7 处 `as` 断言:(1) L506/517/531/542/555 `role: \"admin\" as RecipientRole` 等 5 处把 string literal 断言为联合类型 RecipientRole;(2) L805 `r.reason as MessageReportReason`;(3) L807 `r.status as MessageReport[\"status\"]`。规则 P-09 要求 `as` 出现次数为 0(除 unknown 收窄)。",
|
||||||
|
"recommendation": "(1) RecipientRole 字面量断言:改用类型守卫函数 `function toRecipientRole(v: string): RecipientRole { return RECIPIENT_ROLES.includes(v) ? (v as RecipientRole) : 'admin' }` 或在 map 回调显式标注返回类型让 TS 推断;(2) reason/status 断言:仿照 notifications/data-access.ts L39-49 的 isNotificationType 类型守卫模式。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-012",
|
||||||
|
"file": "src/modules/messaging/actions.ts",
|
||||||
|
"lines": "L812",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "messaging/actions.ts L812 `as` 联合类型断言",
|
||||||
|
"description": "L812 `reason: input.reason as \"spam\" | \"harassment\" | \"inappropriate\" | \"other\"` 直接把 Zod 解析后的 string 断言为联合类型。ReportMessageSchema 应在 Zod 层用 z.enum() 收窄类型,避免后续 `as`。",
|
||||||
|
"recommendation": "修改 schema.ts 的 ReportMessageSchema:\n```ts\nreason: z.enum(['spam', 'harassment', 'inappropriate', 'other'])\n```\n然后删除 `as` 断言,TS 会从 Zod 推断正确类型。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-013",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L86-L94",
|
||||||
|
"ruleId": "S-07",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "resolveUserNames 与 users/data-access.getUserNamesByIds 逻辑重复",
|
||||||
|
"description": "messaging/data-access.ts L86-94 自定义 resolveUserNames 函数,查询 users 表返回 Map<userId, name>。但同模块 L41 已 import getUserNamesByIds from users/data-access,且后者返回 Map<userId, UserNameOption>(含 id/name/email)。功能高度重复,违反 S-07 跨模块重复查询逻辑。",
|
||||||
|
"recommendation": "删除 resolveUserNames,统一使用 getUserNamesByIds:\n```ts\nconst nameMap = await getUserNamesByIds(userIds)\n// 取值改为 nameMap.get(id)?.name ?? null\n```\n可获得 cacheFn 缓存收益。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-014",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L815-L825, L843-L850, L1001-L1012, L673-L688, L740-L755, L761-L776, L781-L787",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "多个读函数未走 cacheFn Raw+Wrapper 配对",
|
||||||
|
"description": "以下读函数均直接 export async function 而未提供 Raw + cacheFn Wrapper 配对:getMessageReports(L815)、getUserBlocks(L843)、getMessageDraftById(L1001)、hasUserReportedMessage(L673)、isUserBlocked(L740)、isEitherUserBlocked(L761)、getBlockedUserIds(L781)、getMessageAttachments(L864)。违反 P-03「读函数是否走 cacheFn 包装 - 全部覆盖」。",
|
||||||
|
"recommendation": "为每个读函数补齐 Raw + Wrapper 配对:\n```ts\nexport const getMessageReportsRaw = async (...) => {...}\nexport const getMessageReports = cacheFn(getMessageReportsRaw, { tags: ['messaging'], ttl: 60, keyParts: ['messaging', 'getMessageReports'] })\n```\n注意 hasUserReportedMessage 等布尔回传函数 ttl 可设短(30s)。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-015",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L429-L498",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getAdminUsers / getAdminUserRoles 读函数未走 cacheFn",
|
||||||
|
"description": "getAdminUsers(L429) 与 getAdminUserRoles(L495) 是 admin 后台读函数,均未提供 Raw + Wrapper 配对,直接 export async function。getAdminUsers 内部还有 2 次 SQL(用户列表 + count)+ 1 次批量查角色,无缓存导致每次后台访问都全量打 DB。",
|
||||||
|
"recommendation": "补齐 cacheFn 包装:\n```ts\nexport const getAdminUsersRaw = async (params): Promise<AdminUserListResult> => {...}\nexport const getAdminUsers = cacheFn(getAdminUsersRaw, { tags: ['users'], ttl: 60, keyParts: ['users', 'getAdminUsers'] })\n```\ngetAdminUserRoles 同理。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-016",
|
||||||
|
"file": "src/modules/rbac/data-access-assignments.ts",
|
||||||
|
"lines": "L95-L177",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getUserRoleAssignments 读函数未走 cacheFn",
|
||||||
|
"description": "getUserRoleAssignments 是分页读函数,未提供 Raw + Wrapper 配对。该函数被 rbac/actions.ts 的角色分配页面调用,无缓存导致每次列表访问都触发 2 次 SQL + 1 次批量查角色。",
|
||||||
|
"recommendation": "拆为 getUserRoleAssignmentsRaw + getUserRoleAssignments = cacheFn(...) 配对,ttl 设 60s。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-017",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L88, L146, L165, L225, L248, L267, L380, L445, L470",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "audit/data-access.ts 9 处 console.error 调试代码",
|
||||||
|
"description": "Grep 证实 9 处 `console.error(...)`:L88 getAuditLogs、L146 getLoginLogs、L165 getAuditModuleOptions、L225 getDataChangeLogs、L248 getDataChangeStats、L267 getDataChangeTableOptions、L380 getAuditOverviewStats、L445 getAuditTrend、L470 getDataChangeActionStats。规则 A-10 明确禁止 data-access 含 console.log 调试代码。",
|
||||||
|
"recommendation": "接入统一日志服务(shared/lib/logger,需先创建)。过渡期可改为:\n```ts\nimport { logger } from '@/shared/lib/logger'\ncatch (error) { logger.error('getAuditLogs failed', { error }); throw error }\n```\n或直接删除 try-catch 让上层处理(data-access 应 throw,不应吞错)。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-018",
|
||||||
|
"file": "src/modules/notifications/data-access.ts",
|
||||||
|
"lines": "L261, L282",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "notifications/data-access.ts 含 console.info / console.error",
|
||||||
|
"description": "L261 `console.info('[NotificationLog] OK/FAIL ...')`、L282 `console.error('[NotificationLog] Failed to persist log:', dbError)`。代码已标注 TODO V3-P2-8 接入统一日志服务但未实施。违反 A-10。",
|
||||||
|
"recommendation": "创建 shared/lib/logger 后替换;过渡期可用 trackEvent 写入 audit_logs。console.info 至少应改为可关闭的 debug 级别。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-019",
|
||||||
|
"file": "src/modules/auth/actions.ts",
|
||||||
|
"lines": "L248-L250",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "auth/actions.ts 含 console.warn 调试代码",
|
||||||
|
"description": "L248-250 `console.warn('[register] Invitation code ... was already consumed ...')` 在 actions 层打印邀请码与邮箱到日志,可能泄露用户隐私信息到日志文件。",
|
||||||
|
"recommendation": "改用 trackEvent 上报埋点(不含 email 明文),或改为 logger.warn 并脱敏 email。删除 console.warn。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-020",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L14-L22, L27-L35",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "clampPageSize / clampPage 在 audit 与 rbac 重复定义",
|
||||||
|
"description": "audit/data-access.ts L24-35 定义 DEFAULT_PAGE_SIZE / MAX_PAGE_SIZE / clampPageSize / clampPage;rbac/data-access-assignments.ts L11-22 完全相同地重复定义这 4 个常量与函数。违反 S-03 重复 helper 应提取到 shared/lib。",
|
||||||
|
"recommendation": "提取到 shared/lib/pagination.ts:\n```ts\nexport const DEFAULT_PAGE_SIZE = 20\nexport const MAX_PAGE_SIZE = 100\nexport function clampPageSize(size?: number): number {...}\nexport function clampPage(page?: number): number {...}\nexport function computeOffset(page: number, pageSize: number): number { return (page - 1) * pageSize }\n```\n两处 import 替换。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-021",
|
||||||
|
"file": "src/modules/notifications/data-access.ts",
|
||||||
|
"lines": "L37, L67 (messaging), L37 (notifications)",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "toIso / toIsoRequired 在多个模块重复定义",
|
||||||
|
"description": "notifications/data-access.ts L37 `const toIsoRequired = (d: Date): string => d.toISOString()`;messaging/data-access.ts L67-69 `toIso` + `toIsoRequired`;audit/data-access.ts L22 `toIso`;parent/data-access.ts L63 直接调用 `r.createdAt.toISOString()`。多处重复实现日期序列化 helper,违反 S-03 与 P-07(日期序列化走 helper)。",
|
||||||
|
"recommendation": "在 shared/lib/datetime.ts 统一导出:\n```ts\nexport const toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toIsoDateString = (d: Date): string => d.toISOString().slice(0, 10)\n```\n各模块 import 替换本地实现。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-022",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L960-L993",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "updateMessageDraft 在 data-access 实现乐观锁版本冲突业务逻辑",
|
||||||
|
"description": "updateMessageDraft 内部实现乐观锁:查询 existing.version → 比对 expectedVersion → 返回 \"ok\"/\"not_found\"/\"conflict\" 三态。这是业务状态机,应在 actions 层处理。data-access 应只暴露 getVersion + update 两个原语。",
|
||||||
|
"recommendation": "拆分:data-access 提供 getMessageDraftVersion(id, userId) + updateMessageDraftRaw(id, userId, data, expectedVersion)(用 WHERE version = expectedVersion 实现原子检查);actions 层根据 affectedRows 判定冲突。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-023",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L348-L362, L400-L426",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "toggleMessageStar / bulkToggleMessagesStar 在 data-access 层做状态分支",
|
||||||
|
"description": "toggleMessageStar 先 SELECT 当前 isStarred,再 UPDATE 为相反值;bulkToggleMessagesStar 更复杂——SELECT 后按 toStar/toUnstar 分组分别 UPDATE。这是条件分支业务逻辑,应在 actions 层完成。",
|
||||||
|
"recommendation": "data-access 暴露 setMessageStarred(ids, userId, starred: boolean) 原语;actions 层先查询当前状态、决定目标值、调用原语。或更优:用 SQL `SET isStarred = NOT isStarred WHERE id IN (...)` 单语句完成翻转。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-024",
|
||||||
|
"file": "src/modules/parent/data-access.ts",
|
||||||
|
"lines": "L214-L237, L245-L268",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "parent/data-access 含 dashboard 编排逻辑",
|
||||||
|
"description": "getChildDashboardDataRaw(L214) 内部 Promise.all 调用 6 个跨模块 data-access 函数(getStudentClasses、getStudentSchedule、getStudentHomeworkAssignments、getStudentDashboardGrades、getStudentGradeSummary、getStudentExamResults),是典型的 dashboard 编排。getParentDashboardDataRaw(L245) 同理。data-access 层应只负责本模块表查询,跨模块编排应在 actions 或 services 层。",
|
||||||
|
"recommendation": "新建 src/modules/parent/services/parent-dashboard-service.ts 容纳编排逻辑;data-access 只保留 getChildren / verifyParentChildRelation / getParentIdsByStudentIds 等本模块表查询。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-025",
|
||||||
|
"file": "src/modules/parent/data-access.ts",
|
||||||
|
"lines": "L260-L262",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getParentDashboardData 并行 N 次 getChildDashboardData,每次内部 6 次跨模块查询",
|
||||||
|
"description": "L260-262 `Promise.all(relations.map((r) => getChildDashboardData(r.studentId, r.relation)))`。若家长有 N 个孩子,触发 N × 6 = 6N 次跨模块 data-access 调用,每调用可能再触发 DB 查询。多子女家长场景下性能差。",
|
||||||
|
"recommendation": "重构为批量查询:getStudentClasses(studentIds[]) / getStudentSchedule(studentIds[]) 等批量接口,一次拉取所有孩子数据,再在内存按 studentId 分组组装。需 classes/homework/grades 模块提供批量查询函数。",
|
||||||
|
"effort": "L (≤1d)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-026",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L281-L294, L299-L312, L317-L330",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "三个 ForExport 函数用 while 循环分页拉取全表,N 次往返",
|
||||||
|
"description": "getAuditLogsForExport / getLoginLogsForExport / getDataChangeLogsForExport 均 `while (hasMore) { result = await getXxxLogs({page, pageSize: 100}); ... }`。导出大表时每 100 条一次 DB 往返,10 万条审计日志 = 1000 次查询。且每次都走 cacheFn 包装层,无意义缓存。",
|
||||||
|
"recommendation": "新增不带分页的导出专用查询(流式或单次大查询):\n```ts\nexport async function getAuditLogsForExportRaw(params): Promise<AuditLog[]> {\n return db.select().from(auditLogs).where(where).orderBy(desc(auditLogs.createdAt)).limit(100000)\n}\n```\n或用 cursor-based 流式导出。导出函数不应走 cacheFn(数据量大、不复用)。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-027",
|
||||||
|
"file": "src/modules/notifications/data-access.ts",
|
||||||
|
"lines": "L290-L294",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "logNotificationSendBatch 用 Promise.all 串行 N 次 INSERT",
|
||||||
|
"description": "L294 `Promise.all(results.map((result) => logNotificationSend(result, payload)))`。每个 logNotificationSend 内部 L268-278 一次 db.insert(notificationLogs)。N 条日志 = N 次 INSERT 往返,应批量插入。注意 Promise.all 是并发但 DB 连接池有限,仍 N 次查询。",
|
||||||
|
"recommendation": "改批量 INSERT:\n```ts\nexport async function logNotificationSendBatch(results, payload) {\n const rows = results.map(r => ({ id: createId(), userId: payload.userId, title: payload.title, channel: r.channel, status: r.success ? 'success' : 'failure', messageId: r.messageId ?? null, error: r.error ?? null, sentAt: r.sentAt }))\n await db.insert(notificationLogs).values(rows)\n}\n```\n一次 INSERT 完成所有日志写入。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-028",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L176, L197-198, L226, L237, L502, L820, L844, L913, L1002, L1041",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "messaging/data-access 多处 db.select().from(table) 未显式枚举列",
|
||||||
|
"description": "多处使用 `db.select().from(messages)` / `db.select().from(users)` / `db.select().from(messageDrafts)` / `db.select().from(messageTemplates)` / `db.select().from(messageReports)` / `db.select().from(userBlocks)` 全列查询。其中 L502 `db.select({ id, name, email }).from(users)` 是好的反例。messages 表含 content 长文本字段,列表查询全列拉取浪费带宽。",
|
||||||
|
"recommendation": "列表查询显式枚举所需列:\n```ts\ndb.select({ id: messages.id, senderId: messages.senderId, receiverId: messages.receiverId, subject: messages.subject, content: messages.content, isRead: messages.isRead, isStarred: messages.isStarred, recalledAt: messages.recalledAt, readAt: messages.readAt, parentMessageId: messages.parentMessageId, groupMessageId: messages.groupMessageId, createdAt: messages.createdAt }).from(messages)\n```\n列表场景若不需 content,可省略该列。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-029",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L56, L117, L194",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "audit/data-access 三个分页查询用 db.select() 全列",
|
||||||
|
"description": "getAuditLogsRaw L56、getLoginLogsRaw L117、getDataChangeLogsRaw L194 均 `db.select().from(auditLogs/loginLogs/dataChangeLogs)`。audit_logs 表含 detail (JSON)、userAgent (长字符串) 等大字段,分页列表全列拉取浪费。dataChangeLogs.oldValue/newValue 是大 JSON。",
|
||||||
|
"recommendation": "列表查询显式枚举列,详情字段(detail / oldValue / newValue / userAgent)按需在详情页查询时拉取。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-030",
|
||||||
|
"file": "src/modules/rbac/data-access.ts",
|
||||||
|
"lines": "L39",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getRolesRaw 用 db.select().from(roles) 全列查询",
|
||||||
|
"description": "L39 `db.select().from(roles).orderBy(roles.name)` 查询所有角色。roles 表通常很小(< 20 行),影响有限,但仍应显式枚举列以避免 schema 变更后意外暴露字段。",
|
||||||
|
"recommendation": "改为 `db.select({ id, name, description, isSystem, isEnabled, createdAt, updatedAt }).from(roles)`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-031",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L449-L457",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAdminUsers 用 db.select() 全列查询 users 表",
|
||||||
|
"description": "L451-452 `db.select().from(users).where(where).orderBy(...)`。users 表含 password (bcrypt hash)、image、address 等敏感或大字段,全列拉取既浪费又可能泄露 password hash 到内存对象。",
|
||||||
|
"recommendation": "显式枚举所需列:`db.select({ id, name, email, phone, createdAt }).from(users)`。绝不能 select password 列。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-032",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L441-L444",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAdminUsers 对 name/email 使用 ilike '%search%' 全表扫描",
|
||||||
|
"description": "L443 `or(ilike(users.name, search), ilike(users.email, search))`,search = `%${params.search}%`。前导通配符使索引失效,users 表增长后搜索退化。",
|
||||||
|
"recommendation": "(1) 短期:email 改前缀匹配 `search%`(用户邮箱通常前缀输入);(2) 长期:加 FULLTEXT 索引或用 Elasticsearch。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-033",
|
||||||
|
"file": "src/modules/rbac/data-access-assignments.ts",
|
||||||
|
"lines": "L107-L108",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getUserRoleAssignments 对 name/email 使用 ilike '%term%' 全表扫描",
|
||||||
|
"description": "L108 `or(ilike(users.name, term), ilike(users.email, term))`,term = `%${params.search}%`。同 G4-032 问题。",
|
||||||
|
"recommendation": "同 G4-032:email 前缀匹配,或加 FULLTEXT 索引。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-034",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L47",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAuditLogsRaw 对 action 字段使用 like '%action%' 模糊匹配",
|
||||||
|
"description": "L47 `like(auditLogs.action, \\`%${params.action}%\\`)`。action 字段通常是固定枚举值(如 'user.login'),用 `%xxx%` 匹配既慢又可能误匹配('user.login' 会匹配 'admin.user.login')。应改 eq 精确匹配。",
|
||||||
|
"recommendation": "改为 `eq(auditLogs.action, params.action)`;若需多值匹配,用 inArray([actions])。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-035",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L502",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "resolveAdminRecipients 全量查询 users 表无 LIMIT",
|
||||||
|
"description": "L502 `db.select({ id, name, email }).from(users)` 无 limit。admin 角色收件人解析时全量拉取所有用户,超大学校(万级用户)会 OOM。注释虽在 users/data-access.ts getAllUserIds 提到 P3-7 加 LIMIT 1000,但此处未应用。",
|
||||||
|
"recommendation": "加分页或 LIMIT:\n```ts\ndb.select({ id, name, email }).from(users).limit(1000)\n```\n或改用 getTeachersByIds / 按角色筛选避免全量。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-036",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L520-L532",
|
||||||
|
"ruleId": "F-08",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "resolveGradeManagedRecipients 循环 N 次调用 getClassesByGradeId",
|
||||||
|
"description": "L525 `await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))`。年级主任管理的年级通常 1-3 个,但模式上仍是 N 次跨模块调用。每个 getClassesByGradeId 内部一次 DB 查询,N 个年级 = N 次查询。classes 模块缺少 getClassesByGradeIds(批量) 接口。",
|
||||||
|
"recommendation": "在 classes/data-access 新增 `getClassesByGradeIds(gradeIds: string[])` 批量查询接口,本处改为单次调用。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-037",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L549",
|
||||||
|
"ruleId": "F-08",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "resolveChildrenRecipients 循环 N 次调用 getStudentActiveClassId",
|
||||||
|
"description": "L549 `await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))`。家长有 N 个孩子则 N 次调用。多子女家长场景下性能差。",
|
||||||
|
"recommendation": "在 classes/data-access 新增 `getStudentActiveClassIds(studentIds: string[])` 批量查询接口。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-038",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L370",
|
||||||
|
"ruleId": "F-10",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAuditOverviewStatsRaw 含 count() 全表统计",
|
||||||
|
"description": "L370 `db.select({ value: count() }).from(auditLogs)` 无 WHERE 过滤,统计审计日志总数。audit_logs 表会持续增长(保留期 180 天),全表 count 在大表上慢(MyISAM 快但 InnoDB 慢)。",
|
||||||
|
"recommendation": "(1) 用元数据表缓存总数,定时刷新;(2) 或用 `SELECT table_rows FROM information_schema.tables WHERE table_name='audit_logs'`(近似值);(3) 或限定统计近 30 天。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-039",
|
||||||
|
"file": "src/modules/notifications/data-access.ts",
|
||||||
|
"lines": "L98",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getNotificationsRaw 用 db.select() 全列查询",
|
||||||
|
"description": "L98 `db.select().from(messageNotifications).where(where).orderBy(...)`。messageNotifications.content 可能为长文本,列表查询全列拉取浪费。",
|
||||||
|
"recommendation": "显式枚举列,content 字段按需拉取。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-040",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L196",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getUsersDashboardStatsRaw 直接调用 toISOString 而非 helper",
|
||||||
|
"description": "L196 `createdAt: u.createdAt.toISOString()` 直接调用,未使用项目统一日期序列化 helper(serializeDate / toISODateString)。其他模块(messaging/notifications/audit)均使用 toIso/toIsoRequired helper。",
|
||||||
|
"recommendation": "import { toIsoRequired } from '@/shared/lib/datetime'(需先创建,见 G4-021),替换为 `createdAt: toIsoRequired(u.createdAt)`。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-041",
|
||||||
|
"file": "src/modules/parent/data-access.ts",
|
||||||
|
"lines": "L63",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getChildrenRaw 直接调用 toISOString 而非 helper",
|
||||||
|
"description": "L63 `createdAt: r.createdAt.toISOString()` 直接调用,与 G4-040 同类问题。",
|
||||||
|
"recommendation": "同 G4-021 / G4-040:使用统一 helper。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-042",
|
||||||
|
"file": "src/modules/rbac/data-access.ts",
|
||||||
|
"lines": "L28-L31",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "toRoleRecord 返回 Date 对象而非 ISO 字符串",
|
||||||
|
"description": "L28-31 `createdAt: row.createdAt, updatedAt: row.updatedAt` 直接返回 Date 对象。其他模块(notifications/audit/messaging)均返回 ISO 字符串。RoleRecord 类型定义可能是 Date,但跨层传递 Date 在 Server Action 序列化时会丢失时区信息,应统一为 ISO 字符串。",
|
||||||
|
"recommendation": "改为 `createdAt: toIsoRequired(row.createdAt), updatedAt: toIsoRequired(row.updatedAt)`;同步更新 RoleRecord 类型为 string。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-043",
|
||||||
|
"file": "src/modules/rbac/data-access-assignments.ts",
|
||||||
|
"lines": "L160",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getUserRoleAssignments 返回 Date 对象而非 ISO 字符串",
|
||||||
|
"description": "L160 `createdAt: u.createdAt` 直接返回 Date。同 G4-042 问题。",
|
||||||
|
"recommendation": "改为 `createdAt: toIsoRequired(u.createdAt)`;同步更新 UserRoleAssignment 类型。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-044",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L510-L538",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "deleteUserById 在 data-access 层嵌入 last-admin 保护业务逻辑",
|
||||||
|
"description": "deleteUserById L510-538 实现「最后管理员保护」:查询 admin 角色 → count admin 数量 → 若 ≤1 再检查目标是否 admin → 抛错。这是业务安全规则,应在 actions 层校验,data-access 只做 delete 原语。",
|
||||||
|
"recommendation": "actions.ts deleteUserAction 在调用 deleteUserById 前先调用 isLastAdmin(userId) 校验:\n```ts\n// data-access 暴露 isLastAdmin\nexport async function isLastAdmin(userId: string): Promise<boolean> {...}\n// actions.ts\nif (await isLastAdmin(userId)) return { success: false, message: 'Cannot delete last admin' }\nawait deleteUserById(userId)\n```\ndata-access.deleteUserById 只保留 `db.delete(users).where(eq(users.id, userId))`。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-045",
|
||||||
|
"file": "src/modules/rbac/data-access.ts",
|
||||||
|
"lines": "L146-L201",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "rbac/data-access 多处嵌入 admin 角色保护业务逻辑",
|
||||||
|
"description": "updateRole(L151-153 admin name 锁)、deleteRole(L177-179 system role 锁)、setRoleEnabled(L192-194 admin disable 锁)、setRolePermissions(L233-235 admin perm 锁) 均在 data-access 层嵌入角色保护业务规则。这些是 RBAC 安全策略,应在 actions 层统一校验。",
|
||||||
|
"recommendation": "data-access 层提供纯 CRUD 原语;actions.ts 在调用前校验 isAdminRole / isSystemRole 并抛错。可复用 rbac/actions.ts 已有的 isAdminRole 函数。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-046",
|
||||||
|
"file": "src/modules/rbac/data-access-assignments.ts",
|
||||||
|
"lines": "L52-L89",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "assignRolesToUser 在 data-access 层做角色存在性/disabled 校验",
|
||||||
|
"description": "assignRolesToUser L57-58 校验 user 存在、L71-75 校验 role 名全部存在、L78 过滤 disabled 角色。这些是业务校验,应在 actions 层完成。data-access 应只做 transactional insert/delete。",
|
||||||
|
"recommendation": "actions.ts assignUserRolesAction 在调用前用 Zod + 业务校验:检查 user 存在、role 名有效、无 disabled。data-access 只暴露 replaceUserRoles(userId, roleIds) 原语。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-047",
|
||||||
|
"file": "src/modules/rbac/data-access-assignments.ts",
|
||||||
|
"lines": "L164-L166",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "getUserRoleAssignments 在 data-access 层 post-fetch 过滤角色",
|
||||||
|
"description": "L164-166 `const filtered = params?.role ? items.filter((i) => i.roleNames.includes(params.role ?? '')) : items`。这是在内存中做角色过滤,但 total 仍是未过滤前的总数(L168),导致分页 totalPages 错误。这是业务逻辑 + bug。",
|
||||||
|
"recommendation": "把 role 过滤下推到 SQL:用 EXISTS 子查询或 JOIN usersToRoles。或至少在 SQL 层用 `inArray(users.id, (db.select({userId}).from(usersToRoles).innerJoin(roles...).where(eq(roles.name, role))))` 子查询过滤。同时修正 total 计算。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-048",
|
||||||
|
"file": "src/modules/messaging/actions.ts",
|
||||||
|
"lines": "L1-L972",
|
||||||
|
"ruleId": "S-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "messaging/actions.ts 972 行,接近上限",
|
||||||
|
"description": "文件 972 行,已超过 React 组件 / actions 建议 800 行上限(虽然 actions 硬上限是 1000)。文件包含 25+ 个 Server Action,覆盖消息 CRUD、群发、撤回、批量、草稿、模板、举报、屏蔽、附件 9 类功能。",
|
||||||
|
"recommendation": "按功能拆分为 actions-messages.ts / actions-group.ts / actions-drafts.ts / actions-templates.ts / actions-reports.ts / actions-blocks.ts / actions-attachments.ts。原 actions.ts 作 barrel re-export。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-049",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L26, L429, L495",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getUserProfileRaw / getAdminUsers / getAdminUserRoles 缺 JSDoc",
|
||||||
|
"description": "getUserProfileRaw(L26) 无 JSDoc;getAdminUsers(L429) 无 JSDoc;getAdminUserRoles(L495) 无 JSDoc。其他函数(updateUserProfileById、updateUserAvatar、deleteUserById)均有 JSDoc。规则 S-06 要求公共导出函数补齐 JSDoc。",
|
||||||
|
"recommendation": "为这 3 个函数补 JSDoc,说明用途、参数、返回值、副作用。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-050",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L325, L348, L369, L384, L400",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "messaging/data-access 多个公共函数仅有单行注释缺 JSDoc",
|
||||||
|
"description": "markMessageAsRead(L325)、toggleMessageStar(L348)、bulkMarkMessagesAsRead(L369)、bulkDeleteMessages(L384)、bulkToggleMessagesStar(L400) 等函数仅有 `/** P2-1: ... */` 单行注释,缺标准 JSDoc(@param / @returns / @throws)。",
|
||||||
|
"recommendation": "补全 JSDoc,至少说明参数语义、返回值含义、异常情况。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-051",
|
||||||
|
"file": "src/modules/notifications/data-access.ts",
|
||||||
|
"lines": "L156, L163, L170, L177",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "notifications/data-access 部分 CRUD 函数缺 JSDoc",
|
||||||
|
"description": "markNotificationAsRead(L156)、markAllNotificationsAsRead(L163)、archiveNotification(L170)、unarchiveNotification(L177) 4 个公共函数均无 JSDoc。createNotification / createNotifications / getUserContactInfoRaw 等有 JSDoc。",
|
||||||
|
"recommendation": "为这 4 个函数补 JSDoc。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-052",
|
||||||
|
"file": "src/modules/parent/data-access.ts",
|
||||||
|
"lines": "L42, L97",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "parent/data-access getChildrenRaw / getChildBasicInfoRaw 缺 JSDoc",
|
||||||
|
"description": "getChildrenRaw(L42) 与 getChildBasicInfoRaw(L97) 是模块核心读函数,均无 JSDoc。其他函数(verifyParentChildRelationRaw、getParentIdsByStudentIdsRaw)有 JSDoc。",
|
||||||
|
"recommendation": "补全 JSDoc,说明入参与返回结构。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-053",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L272-L279",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getDataChangeTableOptions JSDoc 错位",
|
||||||
|
"description": "L272-274 的 JSDoc 注释 `Export-ready: fetch all audit logs matching params` 是给 getAuditLogsForExport 用的,但实际位于 getDataChangeTableOptionsRaw 上方,且内容描述与函数名不符(注释说 audit logs,函数查 dataChangeLogs.tableName 选项)。",
|
||||||
|
"recommendation": "修正 JSDoc:getDataChangeTableOptionsRaw 的注释应为「获取数据变更日志中所有出现过的表名选项」;getAuditLogsForExport 的注释应放在 L281 上方。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-054",
|
||||||
|
"file": "src/modules/rbac/data-access.ts",
|
||||||
|
"lines": "L22-L32",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "toRoleRecord 内部 helper 无 JSDoc",
|
||||||
|
"description": "toRoleRecord(L22) 是 row→record 映射 helper,无 JSDoc。虽是内部函数,但项目规则建议 helper 也补简短说明。",
|
||||||
|
"recommendation": "补单行 JSDoc:`/** 将 roles 表行映射为 RoleRecord 类型 */`",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-055",
|
||||||
|
"file": "src/modules/rbac/actions.ts",
|
||||||
|
"lines": "L393-L394",
|
||||||
|
"ruleId": "S-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "isAdminRole 作为 Server Action 但不返回 ActionState",
|
||||||
|
"description": "L393-394 `export async function isAdminRole(roleName: string): Promise<boolean>` 直接返回 boolean,未包装 ActionState。其他所有 Action 均返回 ActionState<T>。不一致,且 client 调用方无法区分「权限拒绝」与「不是 admin」。",
|
||||||
|
"recommendation": "改为标准 ActionState:\n```ts\nexport async function isAdminRoleAction(roleName: string): Promise<ActionState<boolean>> {\n try { await requirePermission(Permissions.ROLE_READ); return { success: true, data: roleName === ADMIN_ROLE_NAME } }\n catch (e) { return { success: false, message: '...' } }\n}\n```\n或下沉为纯 data-access 函数(非 Action)。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-056",
|
||||||
|
"file": "src/modules/auth/data-access.ts",
|
||||||
|
"lines": "L25-L33",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "isEmailAvailable 读函数未走 cacheFn Raw+Wrapper 配对",
|
||||||
|
"description": "isEmailAvailable(L25) 是读函数,直接 export async function,未提供 Raw + Wrapper 配对。虽是注册前可用性检查(缓存可能引入脏读),但模式不一致。其他模块读函数均配对。",
|
||||||
|
"recommendation": "若担心缓存影响可用性判断,可设 ttl: 5s 短缓存,至少模式一致。或显式标注 `// 不缓存:注册可用性检查需实时` 并在 lint 规则中加豁免。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-057",
|
||||||
|
"file": "src/modules/notifications/data-access.ts",
|
||||||
|
"lines": "L252-L285",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "logNotificationSend 含 try-catch + console 降级,偏业务编排",
|
||||||
|
"description": "logNotificationSend(L252-285) 内部 try-catch DB 写入失败时降级为 console.error,是日志写入的容错策略,属业务编排而非纯数据访问。data-access 应只做 DB 写入,失败应 throw 由上层决定降级。",
|
||||||
|
"recommendation": "data-access 层只做 `db.insert(notificationLogs).values(...)`,失败 throw;上层(channels/dispatcher)catch 后决定是否降级。console 部分按 G4-018 处理。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-058",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L617-L630",
|
||||||
|
"ruleId": "S-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getMessageDetailPageData 疑似 dead code",
|
||||||
|
"description": "文件头注释 L18-20 明确说「getMessagesPageData 已迁出至 messages/page.tsx 页面层,保持模块独立性」。getMessageDetailPageData 是同类编排函数,仍保留在 data-access。需确认是否被调用,若无调用方则为 dead code。",
|
||||||
|
"recommendation": "Grep 调用方:`getMessageDetailPageData`。若无 app/ 或 actions 调用,删除;若有调用,按 G4-008 迁移至页面层。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-059",
|
||||||
|
"file": "src/modules/messaging/data-access.ts",
|
||||||
|
"lines": "L67-L69",
|
||||||
|
"ruleId": "P-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "messaging/data-access 用本地 toIso/toIsoRequired 而非 mapListItem 模式",
|
||||||
|
"description": "L67-69 定义 toIso/toIsoRequired 本地 helper,每个 map* 函数(mapMessage、mapDraft、mapTemplate、mapUserBlock、mapMessageReport)内联调用。规则 P-08 建议列表项映射走 mapListItem 模式统一。当前 5 个 mapper 各自实现,虽结构相似但无统一抽象。",
|
||||||
|
"recommendation": "提取 shared/lib/map-helpers.ts 通用 `mapListItem<T>(row, mapper)` 工具,或至少在模块内统一 mapper 签名风格。优先级低,当前实现可读性尚可。",
|
||||||
|
"effort": "M (≤2h)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-060",
|
||||||
|
"file": "src/modules/users/data-access.ts",
|
||||||
|
"lines": "L476-L484",
|
||||||
|
"ruleId": "P-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getAdminUsers 列表项 inline map 而非 mapListItem 模式",
|
||||||
|
"description": "L476-483 `items: userRows.map((u) => ({ id, name, email, roles, phone, createdAt }))` 内联 map。其他模块(messaging/notifications)均有专用 mapper 函数(mapMessage/mapNotification)。",
|
||||||
|
"recommendation": "提取 `mapAdminUserListItem(row, rolesByUserId)` mapper 函数,与 mapMessage 等保持一致风格。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G4-061",
|
||||||
|
"file": "src/modules/audit/data-access.ts",
|
||||||
|
"lines": "L67-L81, L128-L139, L206-L218",
|
||||||
|
"ruleId": "P-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "audit/data-access 三个分页查询 inline map 列表项",
|
||||||
|
"description": "getAuditLogsRaw L67-81、getLoginLogsRaw L128-139、getDataChangeLogsRaw L206-218 均用 `rows.map((r) => ({...}))` 内联映射,无独立 mapper 函数。与 messaging/notifications 模式不一致。",
|
||||||
|
"recommendation": "提取 mapAuditLog / mapLoginLog / mapDataChangeLog mapper 函数,便于复用与单测。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
}
|
||||||
|
]
|
||||||
494
docs/architecture/audit/g5-audit-output.json
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "G5-001",
|
||||||
|
"file": "src/modules/onboarding/data-access.ts",
|
||||||
|
"lines": "L1-L5",
|
||||||
|
"ruleId": "P-01",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "data-access 文件缺少 `import \"server-only\"` 文件头",
|
||||||
|
"description": "文件首行直接为 `import { eq, and } from \"drizzle-orm\"`,未声明 `import \"server-only\"`,导致此 data-access 模块可能被客户端代码意外引入,存在将 DB schema 与查询逻辑泄露到客户端 bundle 的安全风险。同模块其他 data-access(如 elective/data-access.ts L1、settings/data-access.ts L1)均规范声明了 `import \"server-only\"`。",
|
||||||
|
"recommendation": "在文件第一行添加 `import \"server-only\"`,与项目其他 data-access 文件保持一致:\n```ts\nimport \"server-only\"\n\nimport { eq, and } from \"drizzle-orm\"\n// ...\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-002",
|
||||||
|
"file": "src/modules/onboarding/actions.ts",
|
||||||
|
"lines": "L15-L16, L72-L76",
|
||||||
|
"ruleId": "A-09",
|
||||||
|
"severity": "P0",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "actions.ts 含直接 DB 查询,违反三层架构",
|
||||||
|
"description": "actions.ts 在 L15-L16 直接 `import { db } from \"@/shared/db\"` 与 `import { users } from \"@/shared/db/schema\"`,并在 completeOnboardingAction 内部 L72-L76 直接执行 `db.select({ onboardedAt: users.onboardedAt }).from(users).where(eq(users.id, userId)).limit(1)`。按架构规则 `app/ → modules/ → shared/` 单向依赖,actions 层是编排层,应通过 data-access 访问 DB,不得直查 schema 表。直查 DB 还会绕过 cacheFn 缓存层。",
|
||||||
|
"recommendation": "在 onboarding/data-access.ts 中新增 `getUserOnboardedAt(userId): Promise<Date | null>` 读函数(含 cacheFn 包装),actions.ts 改为调用该函数:\n```ts\n// data-access.ts\nexport const getUserOnboardedAtRaw = async (userId: string): Promise<Date | null> => {\n const [row] = await db.select({ onboardedAt: users.onboardedAt })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row?.onboardedAt ?? null\n}\nexport const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, {\n tags: [\"onboarding\"], ttl: 300, keyParts: [\"onboarding\", \"onboarded-at\"],\n})\n\n// actions.ts\nimport { getUserOnboardedAt } from \"./data-access\"\nconst existingOnboardedAt = await getUserOnboardedAt(userId)\nif (existingOnboardedAt) { /* ... */ }\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-003",
|
||||||
|
"file": "src/modules/elective/data-access-operations.ts",
|
||||||
|
"lines": "L19-L46, L90-L174, L222-L304, L306-L396",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "data-access 含大量业务逻辑(状态机、冲突检测、抽签算法、i18n 通知)",
|
||||||
|
"description": "文件混入了大量本应位于 actions 或 lib 的业务逻辑:\n1. L19-L46 自定义 `ElectiveBusinessError` 业务错误类(含 i18n code 映射)\n2. L62-L78 `DAY_NORMALIZE_MAP` 星期归一化映射\n3. L90-L119 `parseSchedule` 时间段解析(含正则)\n4. L125-L131 `isScheduleConflict` 时间冲突判定\n5. L137-L174 `checkScheduleConflict` 业务校验\n6. L185-L220 `checkCreditLimit` 学分上限业务校验\n7. L222-L304 `runLottery` Fisher-Yates 抽签算法\n8. L410-L446 `notifyCapacityThresholdIfNeeded` 调用 `getTranslations` + `sendNotification` 跨模块通知\ndata-access 层应只做 CRUD 与简单映射,业务规则、状态机、跨模块编排应下沉到 lib 或 actions。",
|
||||||
|
"recommendation": "拆分为三层:\n1. `elective/lib/schedule-conflict.ts` —— 纯函数 `parseSchedule` / `isScheduleConflict` / `normalizeDay`\n2. `elective/lib/lottery.ts` —— `runLotteryShuffle` 纯函数\n3. `elective/lib/business-rules.ts` —— `checkScheduleConflict` / `checkCreditLimit` / `ElectiveBusinessError`(接受 tx 与必要 data-access 函数作为参数)\n4. `elective/actions.ts` —— `notifyCapacityThresholdIfNeeded` 移至 actions(含 i18n + sendNotification)\ndata-access-operations.ts 只保留 `selectCourse` / `dropCourse` / `runLottery` 的 DB 写入部分。",
|
||||||
|
"effort": "L (≤1 天)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-004",
|
||||||
|
"file": "src/modules/elective/data-access-operations.ts",
|
||||||
|
"lines": "L222-L304",
|
||||||
|
"ruleId": "S-08",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "runLottery 中 DB 写入与抽签算法混淆,职责不清",
|
||||||
|
"description": "`runLottery` 函数同时承担:1) 查询课程与选课记录(DB 访问);2) Fisher-Yates shuffle 抽签(业务算法);3) 事务内批量更新状态(DB 写入)。函数 83 行,复杂度高,难以单元测试抽签逻辑(需 mock DB)。",
|
||||||
|
"recommendation": "拆分为:\n```ts\n// lib/lottery.ts\nexport function runLotteryShuffle(selections: CourseSelection[], capacity: number): {\n enrolledIds: string[]; waitlistIds: string[]\n} { /* 纯函数 Fisher-Yates */ }\n\n// data-access-operations.ts\nexport async function persistLotteryResult(\n courseId: string, enrolledIds: string[], waitlistIds: string[], capacity: number\n): Promise<void> { /* 仅 DB 写入 */ }\n\n// actions.ts\nexport async function runLotteryAction(...) {\n const [course, selections] = await Promise.all([...])\n const { enrolledIds, waitlistIds } = runLotteryShuffle(selections, course.capacity)\n await persistLotteryResult(courseId, enrolledIds, waitlistIds, course.capacity)\n}\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-005",
|
||||||
|
"file": "src/modules/files/data-access.ts",
|
||||||
|
"lines": "L55, L73, L101, L126, L150, L169, L186, L195, L244, L281, L305, L328",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "data-access 含 12 处 console.error 调试代码",
|
||||||
|
"description": "文件中几乎所有函数都用 `console.error(...)` 记录错误:L55 `createFileAttachment failed`、L73 `getFileAttachment failed`、L101 `getFileAttachmentsByTarget failed`、L126 `getFileAttachmentsByUploader failed`、L150 `getAllFileAttachments failed`、L169 `deleteFileAttachment failed`、L186/L195 `deleteFileAttachments batch/single failed`、L244 `getFileAttachmentsWithFilters failed`、L281 `getFileStats failed`、L305 `getFileByUrl failed`、L328 `getFileAttachmentsByIds failed`。data-access 层应通过 throw 上抛错误由 actions 层统一处理,不应自行 console 输出,污染生产日志且违反 A-10 规则。",
|
||||||
|
"recommendation": "删除所有 `console.error`,改为 throw 上抛:\n```ts\nexport async function createFileAttachment(data: CreateFileAttachmentInput): Promise<FileAttachment | null> {\n await db.insert(fileAttachments).values({...})\n return getFileAttachment(data.id)\n}\n// actions 层已有 handleActionError 统一处理\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-006",
|
||||||
|
"file": "src/modules/files/data-access.ts",
|
||||||
|
"lines": "L35-L58, L63-L76, L87-L105, L116-L129, L140-L153, L212-L248, L259-L284, L295-L308, L319-L331",
|
||||||
|
"ruleId": "P-05",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "data-access 层用 try-catch 返回 null/false/空数组,违反 throw 上抛约定",
|
||||||
|
"description": "所有函数均用 `try { ... } catch (error) { console.error(...); return null/[]/false }` 模式吞掉错误。这导致:1) actions 层无法区分“记录不存在”与“DB 异常”;2) 错误被静默吞掉,监控告警失效;3) 违反 P-05 规则(data-access 层用 throw,actions 层用 ActionState)。例如 createFileAttachment 失败返回 null,actions 层只能返回“Failed to persist file record”,丢失原始错误信息。",
|
||||||
|
"recommendation": "移除 try-catch,让错误自然上抛:\n```ts\nexport async function getFileAttachmentRaw(id: string): Promise<FileAttachment | null> {\n const [row] = await db.select().from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n// actions.ts 的 handleActionError 会捕获并转为 ActionState\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-007",
|
||||||
|
"file": "src/modules/files/data-access.ts",
|
||||||
|
"lines": "L66, L89, L119, L142, L236, L262, L298, L322",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "多处 `db.select().from(fileAttachments)` 未指定列,等价 SELECT *",
|
||||||
|
"description": "8 处查询使用 `db.select().from(fileAttachments)` 未显式枚举列,等价于 `SELECT *`。返回所有列包括 `storagePath`、`url` 等敏感字段,增加网络传输与内存占用,且 schema 变更时可能引入意外字段。对比 announcements/data-access.ts L84-L99 显式枚举了 13 个列。",
|
||||||
|
"recommendation": "显式枚举所需列:\n```ts\nconst FILE_FIELDS = {\n id: fileAttachments.id,\n filename: fileAttachments.filename,\n originalName: fileAttachments.originalName,\n mimeType: fileAttachments.mimeType,\n size: fileAttachments.size,\n storagePath: fileAttachments.storagePath,\n url: fileAttachments.url,\n uploaderId: fileAttachments.uploaderId,\n targetType: fileAttachments.targetType,\n targetId: fileAttachments.targetId,\n createdAt: fileAttachments.createdAt,\n} as const\n\nexport const getFileAttachmentRaw = async (id: string) => {\n const [row] = await db.select(FILE_FIELDS).from(fileAttachments)\n .where(eq(fileAttachments.id, id)).limit(1)\n return row ? mapRow(row) : null\n}\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-008",
|
||||||
|
"file": "src/modules/search/data-access.ts",
|
||||||
|
"lines": "L150, L191, L235",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "使用 `!` 非空断言绕过 null 检查",
|
||||||
|
"description": "三处对 `or(...)` 返回值使用 `!` 非空断言:L150 `or(like(textbooks.title, kw), like(textbooks.subject, kw), like(textbooks.publisher, kw))!`、L191 `or(like(exams.title, kw), like(exams.description, kw))!`、L235 `or(like(announcements.title, kw), like(announcements.content, kw))!`。`or()` 在所有参数为 undefined 时返回 null,使用 `!` 断言会绕过类型系统的安全保护,违反 P-09 规则(禁止 as 断言与非空断言,除 unknown 收窄)。",
|
||||||
|
"recommendation": "使用条件判断或 filter 模式:\n```ts\nconst conditions = [\n like(textbooks.title, kw),\n like(textbooks.subject, kw),\n like(textbooks.publisher, kw),\n].filter(Boolean) as ReturnType<typeof like>[]\nconst where = conditions.length > 0 ? or(...conditions) : undefined\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-009",
|
||||||
|
"file": "src/modules/search/data-access.ts",
|
||||||
|
"lines": "L147-L150, L191, L235",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "textbooks/exams/announcements 三表搜索使用 `LIKE '%kw%'` 全表扫描",
|
||||||
|
"description": "searchTextbooksRaw L147-L150、searchExamsRaw L191、searchAnnouncementsRaw L235 均使用 `like(column, kw)` 其中 kw 为 `%${search}%` 格式,前缀通配符导致无法走索引,全表扫描。注释 L18-L20 已说明 questions 表用了 FULLTEXT 索引(L101 `MATCH ... AGAINST ... IN BOOLEAN MODE`),但其他三表仍用 LIKE。随着数据量增长,搜索性能会急剧下降。",
|
||||||
|
"recommendation": "为 textbooks.title/subject/publisher、exams.title/description、announcements.title/content 添加 FULLTEXT 索引(MySQL)或 GIN 索引(PostgreSQL),改用 MATCH AGAINST:\n```sql\nALTER TABLE textbooks ADD FULLTEXT INDEX ft_textbooks_search (title, subject, publisher);\nALTER TABLE exams ADD FULLTEXT INDEX ft_exams_search (title, description);\nALTER TABLE announcements ADD FULLTEXT INDEX ft_announcements_search (title, content);\n```\n```ts\n.where(sql`MATCH(${textbooks.title}, ${textbooks.subject}, ${textbooks.publisher}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)\n```",
|
||||||
|
"effort": "L (≤1 天)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-010",
|
||||||
|
"file": "src/modules/onboarding/data-access.ts",
|
||||||
|
"lines": "L87-L133",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "bindParentToChild 含三因子验证业务逻辑,应移至 lib 或 actions",
|
||||||
|
"description": "`bindParentToChild` 函数 L87-L133 包含:1) 三因子验证业务规则(邮箱 + 生日 + 手机后4位,L97-L111);2) 幂等检查(L114-L131);3) 错误返回 `{ error: string }` 而非 throw。这是核心业务逻辑(对标 PowerSchool Access ID 验证),不应位于 data-access 层。data-access 应只做 CRUD,验证规则应下沉到 lib/business-rules 或 actions。",
|
||||||
|
"recommendation": "拆分:\n1. `onboarding/lib/parent-binding.ts` —— `validateChildFactors(child, params): string | null` 纯函数验证三因子\n2. `onboarding/data-access.ts` —— `insertParentStudentRelation(parentId, studentId, relation): Promise<void>` 仅做幂等插入\n3. `onboarding/actions.ts` —— 编排:查询 child → 调用 validateChildFactors → 调用 insertParentStudentRelation",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-011",
|
||||||
|
"file": "src/modules/settings/data-access.ts",
|
||||||
|
"lines": "L23-L38",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAiProviderSummariesRaw 查询所有 AI Provider 无 LIMIT",
|
||||||
|
"description": "`getAiProviderSummariesRaw` 查询 `aiProviders` 表全部记录,仅 `.orderBy(desc(aiProviders.updatedAt))`,无 LIMIT 限制。若管理员未清理历史 Provider 记录,可能返回数百条数据。对比 files/data-access.ts 的 `getAllFileAttachmentsRaw` 有默认 `limit = 100`。",
|
||||||
|
"recommendation": "添加默认 LIMIT:\n```ts\nexport async function getAiProviderSummariesRaw(limit = 200): Promise<AiProviderSummary[]> {\n const rows = await db.select({...}).from(aiProviders)\n .orderBy(desc(aiProviders.updatedAt))\n .limit(limit)\n return rows\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-012",
|
||||||
|
"file": "src/modules/settings/data-access-system-settings.ts",
|
||||||
|
"lines": "L131-L142",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "upsertSystemSettings 循环内调用 upsertSystemSetting,每次含查询+写入,N+1 问题",
|
||||||
|
"description": "`upsertSystemSettings` L140-L142 对每个 item 调用 `upsertSystemSetting`,而 `upsertSystemSetting` 内部 L110-L126 先 `getSystemSetting`(虽走缓存)再 `db.update` 或 `db.insert`。批量保存 N 个设置项时执行 N 次独立写入,无事务包裹,且中间失败会导致部分成功部分失败的数据不一致。",
|
||||||
|
"recommendation": "改为单次事务批量 upsert(MySQL `INSERT ... ON DUPLICATE KEY UPDATE`):\n```ts\nexport async function upsertSystemSettings(\n items: ReadonlyArray<{...}>, updatedBy?: string\n): Promise<void> {\n if (items.length === 0) return\n await db.transaction(async (tx) => {\n const rows = items.map((item) => ({\n category: item.category, key: item.key, value: item.value,\n valueType: item.valueType, updatedBy: updatedBy ?? null, updatedAt: new Date(),\n }))\n await tx.insert(systemSettings).values(rows)\n .onDuplicateKeyUpdate({\n set: { value: sql`VALUES(value)`, valueType: sql`VALUES(value_type)`,\n updatedBy: sql`VALUES(updated_by)`, updatedAt: sql`VALUES(updated_at)` }\n })\n })\n}\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-013",
|
||||||
|
"file": "src/modules/elective/data-access.ts",
|
||||||
|
"lines": "L165-L169",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getElectiveCoursesRaw 大表查询无默认 LIMIT",
|
||||||
|
"description": "`getElectiveCoursesRaw` 查询 `electiveCourses` 表,仅 `.orderBy(desc(electiveCourses.createdAt))`,无 LIMIT。当管理员查询全部课程或 gradeId 过滤返回大量记录时,会一次性返回所有匹配行。对比 announcements/data-access.ts L104 有 `pageSize` 分页。",
|
||||||
|
"recommendation": "添加默认 LIMIT 或分页参数:\n```ts\nexport const getElectiveCoursesRaw = async (\n params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string; limit?: number }\n): Promise<ElectiveCourseWithDetails[]> => {\n const limit = params?.limit ?? 200\n // ...\n const rows = await (conditions.length > 0\n ? query.where(and(...conditions)) : query\n ).orderBy(desc(electiveCourses.createdAt)).limit(limit)\n // ...\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-014",
|
||||||
|
"file": "src/modules/elective/data-access-operations.ts",
|
||||||
|
"lines": "L228-L241",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P1",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "runLottery 内 `db.select().from(...)` 未指定列,等价 SELECT *",
|
||||||
|
"description": "`runLottery` L228-L241 两处 `db.select().from(electiveCourses)` 和 `db.select().from(courseSelections)` 未指定列,等价 SELECT *。electiveCourses 表含 description、schedule 等长文本字段,courseSelections 含 dropReason 等字段,全量返回浪费内存与网络带宽。此外 selectCourse L317-L321、dropCourse L454-L464 也使用 `db.select().from(...)` 全列查询。",
|
||||||
|
"recommendation": "显式枚举所需列:\n```ts\nconst [courseRows, selections] = await Promise.all([\n db.select({\n id: electiveCourses.id, capacity: electiveCourses.capacity,\n selectionMode: electiveCourses.selectionMode, enrolledCount: electiveCourses.enrolledCount,\n }).from(electiveCourses).where(eq(electiveCourses.id, courseId)).limit(1),\n db.select({\n id: courseSelections.id, priority: courseSelections.priority,\n selectedAt: courseSelections.selectedAt,\n }).from(courseSelections).where(...).orderBy(...),\n])\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-015",
|
||||||
|
"file": "src/modules/error-book/data-access.ts",
|
||||||
|
"lines": "L135-L138",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getErrorBookItemsRaw 使用 `LIKE '%q%'` 全表扫描搜索 note 字段",
|
||||||
|
"description": "L136-L137 构造 `needle = '%${q.trim().toLowerCase()}%'` 并使用 `sql\\`LOWER(CAST(${errorBookItems.note} AS CHAR)) LIKE ${needle}\\``。前缀通配符 `%` 导致无法走索引,且 `LOWER(CAST(... AS CHAR))` 函数包裹进一步阻止索引使用。学生错题量增长后搜索性能下降。",
|
||||||
|
"recommendation": "为 errorBookItems.note 添加 FULLTEXT 索引,或改为前缀匹配 `LIKE '${q}%'`(可走索引):\n```sql\nALTER TABLE error_book_items ADD FULLTEXT INDEX ft_note_search (note);\n```\n```ts\nif (q && q.trim().length > 0) {\n conditions.push(sql`MATCH(${errorBookItems.note}) AGAINST(${q.trim()} IN BOOLEAN MODE)`)\n}\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-016",
|
||||||
|
"file": "src/modules/error-book/data-access-analytics.ts",
|
||||||
|
"lines": "L531, L542",
|
||||||
|
"ruleId": "P-09",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "使用 `as string` 断言,应改用类型守卫",
|
||||||
|
"description": "L531 `const subjectIds = filtered.map((r) => r.subjectId as string)` 和 L542 `const sid = row.subjectId as string` 使用 `as string` 断言。虽然 L527 已通过 `rows.filter((r) => r.subjectId !== null)` 过滤了 null,但 `as` 断言绕过了类型系统,违反 P-09 规则。项目规范要求用类型守卫替代 as 断言。",
|
||||||
|
"recommendation": "使用类型守卫:\n```ts\nconst subjectIds = filtered\n .map((r) => r.subjectId)\n .filter((s): s is string => s !== null)\n// ...\nreturn filtered.map((row) => {\n const sid = row.subjectId as string // 改为:\n const sid: string = row.subjectId ?? \"\" // 或提前 filter\n // ...\n})\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-017",
|
||||||
|
"file": "src/modules/settings/data-access.ts",
|
||||||
|
"lines": "L259-L315",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "密码相关读函数未走 cacheFn 包装(Raw + Wrapper 配对)",
|
||||||
|
"description": "`getUserPasswordHash` (L259-L268)、`getPasswordSecurityByUserId` (L270-L279) 是读函数但未提供 Raw + cacheFn Wrapper 配对。对比同文件 `getAiProviderSummariesRaw` + `getAiProviderSummaries` 配对模式。密码相关查询虽可能不希望缓存(安全考虑),但应明确注释说明不缓存的原因,或提供短 TTL 缓存。",
|
||||||
|
"recommendation": "明确注释不缓存原因,或提供短 TTL 缓存:\n```ts\n/** 读取用户密码哈希(不缓存,安全敏感数据) */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> {\n // 安全考虑:密码哈希不缓存,避免缓存泄露风险\n const [row] = await db.select({ password: users.password })\n .from(users).where(eq(users.id, userId)).limit(1)\n return row ?? null\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-018",
|
||||||
|
"file": "src/modules/settings/data-access-two-factor.ts",
|
||||||
|
"lines": "L30-L129",
|
||||||
|
"ruleId": "P-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "2FA 读函数未提供 Raw + Wrapper 配对,依赖上游 cacheFn",
|
||||||
|
"description": "`getTwoFactorEnabled` (L30-L33)、`getTwoFactorEnabledAt` (L48-L53)、`getTotpSecret` (L70-L73)、`getBackupCodesHashed` (L101-L105) 等读函数均直接调用 `getSystemSetting`(已 cacheFn 包装),但自身未提供 Raw + Wrapper 配对。严格按 P-03 规则,所有读函数应有 Raw + cacheFn 配对。当前模式虽依赖上游缓存,但 keyParts 不会包含 2FA 特定维度,缓存粒度不准确。",
|
||||||
|
"recommendation": "为 2FA 读函数提供独立 cacheFn 配对:\n```ts\nexport const getTwoFactorEnabledRaw = async (userId: string): Promise<boolean> => {\n const record = await getSystemSetting(CATEGORY, k(\"twoFactorEnabled\", userId))\n return record?.value === \"true\"\n}\nexport const getTwoFactorEnabled = cacheFn(getTwoFactorEnabledRaw, {\n tags: [\"settings\", \"two-factor\"], ttl: 60,\n keyParts: [\"settings\", \"two-factor\", \"enabled\"],\n})\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-019",
|
||||||
|
"file": "src/modules/elective/data-access.ts",
|
||||||
|
"lines": "L61-L62",
|
||||||
|
"ruleId": "P-07",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "startDate/endDate 序列化未走 toIso helper,硬编码 slice(0, 10)",
|
||||||
|
"description": "L61-L62 `startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null` 和 `endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null` 硬编码了 `.toISOString().slice(0, 10)` 逻辑。同文件 L22-L25 已定义 `toIso`/`toIsoRequired` helper,但此处未复用,且 `slice(0, 10)` 截取日期部分的行为应封装为 `toISODateString` helper 统一管理。",
|
||||||
|
"recommendation": "提取共享 helper 并复用:\n```ts\nconst toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n\n// mapCourseRow 中\nstartDate: toISODateString(r.startDate),\nendDate: toISODateString(r.endDate),\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-020",
|
||||||
|
"file": "src/modules/announcements/data-access.ts",
|
||||||
|
"lines": "L413-L463, L472-L485",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "resolveUserAudience / isAnnouncementVisibleToAudience 含业务逻辑",
|
||||||
|
"description": "`resolveUserAudience` (L413-L463) 根据 dataScope.type 分支处理 5 种受众类型,含 classIds → gradeIds 解析、childrenIds 遍历等业务编排逻辑;`isAnnouncementVisibleToAudience` (L472-L485) 是纯业务规则判断函数。两者属于业务规则层,应移至 lib 或 actions。当前 data-access 同时承担数据查询与业务规则判断,职责混淆。",
|
||||||
|
"recommendation": "将业务逻辑下沉到 `announcements/lib/audience.ts`:\n```ts\n// lib/audience.ts\nexport function isAnnouncementVisibleToAudience(announcement, audience): boolean { /* 纯函数 */ }\nexport async function resolveUserAudience(\n userId: string, dataScope: DataScope,\n deps: { getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId }\n): Promise<UserAudience | null> { /* 接受依赖注入 */ }\n```\ndata-access 仅保留 `getAnnouncementByIdForUser` 的数据查询部分。",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-021",
|
||||||
|
"file": "src/modules/announcements/data-access.ts",
|
||||||
|
"lines": "全文 606 行",
|
||||||
|
"ruleId": "S-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "文件 606 行,接近 800 行警告线",
|
||||||
|
"description": "announcements/data-access.ts 共 606 行,已超过项目规范的“建议 ≤ 800 行”软上限的 75%。文件同时包含:CRUD(insertAnnouncement 等)、分页查询(getAnnouncements)、已读回执(markAnnouncementAsRead 等)、编排函数(getAdminAnnouncementsPageData 等 5 个)、业务逻辑(resolveUserAudience 等)。继续增长将突破警告线。",
|
||||||
|
"recommendation": "拆分为:\n1. `data-access.ts` —— 纯 CRUD(insert/update/delete/publish/archive)\n2. `data-access-reads.ts` —— 查询函数(getAnnouncements, getAnnouncementById, countAnnouncements, 已读回执查询)\n3. `data-access-page.ts` —— 编排函数(getAdminAnnouncementsPageData 等)\n4. `lib/audience.ts` —— resolveUserAudience, isAnnouncementVisibleToAudience",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-022",
|
||||||
|
"file": "src/modules/settings/data-access-system-settings.ts",
|
||||||
|
"lines": "L65-L68",
|
||||||
|
"ruleId": "F-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getAllSystemSettingsRaw 使用 `db.select().from(systemSettings)` 未指定列",
|
||||||
|
"description": "`getAllSystemSettingsRaw` L66 `const rows = await db.select().from(systemSettings)` 未显式枚举列,等价 SELECT *。systemSettings 表含 id、category、key、value、valueType、updatedBy、updatedAt、createdAt 等列,全量返回增加传输开销。对比 getSystemSettingRaw L83-L87 虽也未枚举但有 limit(1)。",
|
||||||
|
"recommendation": "显式枚举列:\n```ts\nexport async function getAllSystemSettingsRaw(): Promise<SystemSettingRecord[]> {\n const rows = await db.select({\n id: systemSettings.id, category: systemSettings.category,\n key: systemSettings.key, value: systemSettings.value,\n valueType: systemSettings.valueType, updatedBy: systemSettings.updatedBy,\n updatedAt: systemSettings.updatedAt,\n }).from(systemSettings)\n return rows\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-023",
|
||||||
|
"file": "src/modules/elective/data-access-selections.ts",
|
||||||
|
"lines": "L43-L46",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "toIso/toIsoRequired helper 在 elective 模块内重复定义",
|
||||||
|
"description": "L43-L46 定义了 `toIso` 和 `toIsoRequired` helper,但 elective/data-access.ts L22-L25 已定义了相同的 helper。两处实现完全一致:\n```ts\nconst toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null\nconst toIsoRequired = (d: Date): string => d.toISOString()\n```\n违反 DRY 原则,应提取到 shared/lib 或模块内 lib 目录。",
|
||||||
|
"recommendation": "提取到 `elective/lib/date-utils.ts` 或复用 `@/shared/lib/date-utils`:\n```ts\n// elective/lib/date-utils.ts\nexport const toIso = (d: Date | null | undefined): string | null =>\n d ? d.toISOString() : null\nexport const toIsoRequired = (d: Date): string => d.toISOString()\nexport const toISODateString = (d: Date | null | undefined): string | null =>\n d ? d.toISOString().slice(0, 10) : null\n```\n两个 data-access 文件统一 import。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-024",
|
||||||
|
"file": "src/modules/onboarding/data-access.ts",
|
||||||
|
"lines": "L89, L97-L111",
|
||||||
|
"ruleId": "P-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "bindParentToChild 返回 `{ error: string }` 而非 throw,违反 data-access 错误处理约定",
|
||||||
|
"description": "`bindParentToChild` 返回类型为 `Promise<{ studentId: string } | { error: string }>`,L97/L98/L99/L104/L110 用 `return { error: \"...\" }` 表示业务错误。按 P-05 规则,data-access 层应用 throw 上抛错误(如 `throw new BusinessError(...)`),actions 层用 ActionState 捕获。当前模式让调用方需要用 `\"error\" in result` 判断,违反统一错误处理约定。",
|
||||||
|
"recommendation": "改用 throw + 自定义 BusinessError:\n```ts\nexport class OnboardingBusinessError extends BusinessError {\n constructor(public readonly code: string, public readonly params?: Record<string, string | number>) {\n super(`onboarding.errors.${code}`, code)\n }\n}\n\nexport async function bindParentToChild(params: BindParentToChildParams): Promise<{ studentId: string }> {\n // ...\n if (!child) throw new OnboardingBusinessError(\"childNotFound\")\n if (!child.birthDate) throw new OnboardingBusinessError(\"childNoBirthDate\")\n // ...\n return { studentId: child.id }\n}\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-025",
|
||||||
|
"file": "src/modules/settings/data-access.ts",
|
||||||
|
"lines": "L259-L315",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "密码相关函数缺 JSDoc 文档",
|
||||||
|
"description": "`getUserPasswordHash` (L259)、`getPasswordSecurityByUserId` (L270)、`updateUserPassword` (L281)、`upsertPasswordSecurityOnPasswordChange` (L292) 四个公共导出函数均无 JSDoc 注释。对比同文件 `getAiProviderSummariesRaw` (L18-L22)、`getAiProviderForUpdateRaw` (L102-L106) 均有 JSDoc。S-06 规则要求公共导出函数补齐 JSDoc。",
|
||||||
|
"recommendation": "为每个函数添加 JSDoc:\n```ts\n/**\n * 读取用户密码哈希(用于密码变更时校验旧密码)\n * @param userId 用户 ID\n * @returns 密码哈希记录,用户不存在时返回 null\n */\nexport async function getUserPasswordHash(userId: string): Promise<{ password: string | null } | null> { ... }\n\n/**\n * 查询用户的密码安全记录(用于判断是否需要强制改密)\n * @param userId 用户 ID\n * @returns 记录 ID(存在时),不存在返回 null\n */\nexport async function getPasswordSecurityByUserId(userId: string): Promise<{ id: string } | null> { ... }\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-026",
|
||||||
|
"file": "src/modules/onboarding/actions.ts",
|
||||||
|
"lines": "L38, L68",
|
||||||
|
"ruleId": "A-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "使用 requireAuth 而非 requirePermission,权限校验粒度不足",
|
||||||
|
"description": "`getOnboardingStatusAction` L38 和 `completeOnboardingAction` L68 使用 `requireAuth()` 而非 `requirePermission(Permissions.XXX)`。`requireAuth` 仅校验登录态,不校验具体权限点。onboarding 流程虽面向所有已登录用户,但按 A-08 规则应使用 requirePermission 显式声明权限点(如 ONBOARDING_READ / ONBOARDING_COMPLETE),便于权限审计与角色-权限矩阵管理。",
|
||||||
|
"recommendation": "添加 ONBOARDING 权限点并使用 requirePermission:\n```ts\n// shared/types/permissions.ts\nexport const Permissions = {\n // ...\n ONBOARDING_READ: \"onboarding:read\",\n ONBOARDING_COMPLETE: \"onboarding:complete\",\n} as const\n\n// actions.ts\nconst ctx = await requirePermission(Permissions.ONBOARDING_COMPLETE)\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-027",
|
||||||
|
"file": "src/modules/elective/data-access-settings.ts",
|
||||||
|
"lines": "L58-L73",
|
||||||
|
"ruleId": "F-06",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getElectiveCreditLimitRaw 串行两次 readSettingValue,可合并查询",
|
||||||
|
"description": "`getElectiveCreditLimitRaw` L62-L73 先查 `creditLimit:grade:<gradeId>`,若未命中再查 `creditLimit:default`,两次串行 DB 查询。虽每次有 cacheFn,但首次未命中缓存时仍需 2 次往返。可用 OR 查询合并为单次:\n```ts\nconst [gradeRow, defaultRow] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n])\n```",
|
||||||
|
"recommendation": "改为 Promise.all 并行查询:\n```ts\nexport const getElectiveCreditLimitRaw = async (gradeId?: string | null): Promise<number> => {\n if (gradeId) {\n const [gradeValue, defaultValue] = await Promise.all([\n readSettingValue(`creditLimit:grade:${gradeId}`),\n readSettingValue(\"creditLimit:default\"),\n ])\n if (gradeValue !== null) {\n const parsed = Number(gradeValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n if (defaultValue !== null) {\n const parsed = Number(defaultValue)\n if (!Number.isNaN(parsed) && parsed > 0) return parsed\n }\n return DEFAULT_MAX_CREDIT_PER_TERM\n }\n // ...\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-028",
|
||||||
|
"file": "src/modules/elective/data-access-selections.ts",
|
||||||
|
"lines": "L107-L109, L123-L125",
|
||||||
|
"ruleId": "F-05",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getCourseSelectionsRaw / getStudentSelectionsRaw 无 LIMIT",
|
||||||
|
"description": "`getCourseSelectionsRaw` L107-L109 按 courseId 查询选课记录,`getStudentSelectionsRaw` L123-L125 按 studentId 查询,均无 LIMIT。教师视角下热门课程可能有数百条选课记录,学生视角下四年累计选课也可能较多。建议添加默认 LIMIT 防止极端情况。",
|
||||||
|
"recommendation": "添加默认 LIMIT:\n```ts\nexport const getCourseSelectionsRaw = async (courseId: string): Promise<CourseSelectionWithDetails[]> => {\n const rows = await buildSelectionCoreSelect()\n .where(eq(courseSelections.courseId, courseId))\n .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))\n .limit(500) // 默认上限\n // ...\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-029",
|
||||||
|
"file": "src/modules/files/data-access.ts",
|
||||||
|
"lines": "L224-L230",
|
||||||
|
"ruleId": "F-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "getFileAttachmentsWithFiltersRaw 使用 `LIKE '%search%'` 全表扫描",
|
||||||
|
"description": "L225 `const kw = '%${search}%'`,L227-L229 `like(fileAttachments.originalName, kw)` 和 `like(fileAttachments.filename, kw)` 使用前缀通配符 `%`,无法走索引。管理员文件管理页面搜索文件时全表扫描 fileAttachments 表。",
|
||||||
|
"recommendation": "为 originalName 和 filename 添加 FULLTEXT 索引,或改为前缀匹配:\n```sql\nALTER TABLE file_attachments ADD FULLTEXT INDEX ft_filename_search (original_name, filename);\n```\n```ts\nif (search) {\n conditions.push(sql`MATCH(${fileAttachments.originalName}, ${fileAttachments.filename}) AGAINST(${search} IN BOOLEAN MODE)`)\n}\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-030",
|
||||||
|
"file": "src/modules/announcements/data-access.ts",
|
||||||
|
"lines": "L343-L344, L366-L367, L426, L438, L454, L575, L592, L597",
|
||||||
|
"ruleId": "A-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "大量使用 dynamic import 调用跨模块 data-access,建议改为静态 import",
|
||||||
|
"description": "文件中 8 处使用 `await import(\"@/modules/xxx/data-access\")` 动态导入:L343 `getGrades`、L344 `getAdminClasses`、L366 `getGrades`、L426 `getClassGradeId`、L438 `getStudentActiveClassId`/`getStudentActiveGradeId`、L454 同上、L575 `getAllUserIds`、L592 `getUserIdsByGradeId`、L597 `getStudentIdsByClassId`/`getTeacherIdsByClassIds`。动态 import 增加运行时开销,且无法被构建工具静态分析优化。虽可能为避免循环依赖,但 announcements 与 classes/users/school 模块间无循环依赖风险。",
|
||||||
|
"recommendation": "改为静态 import:\n```ts\nimport { getGrades } from \"@/modules/school/data-access\"\nimport { getAdminClasses, getClassGradeId, getStudentActiveClassId, getStudentActiveGradeId, getStudentIdsByClassId, getTeacherIdsByClassIds } from \"@/modules/classes/data-access\"\nimport { getAllUserIds, getUserIdsByGradeId } from \"@/modules/users/data-access\"\n```\n若确有循环依赖,应重构模块边界而非用 dynamic import 规避。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-031",
|
||||||
|
"file": "src/modules/elective/data-access-operations.ts",
|
||||||
|
"lines": "L378-L379, L410-L446",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "notifyCapacityThresholdIfNeeded 调用 getTranslations + sendNotification,含 i18n 与通知编排",
|
||||||
|
"description": "`notifyCapacityThresholdIfNeeded` L410-L446 在 data-access 层调用 `getTranslations(\"elective\")` (L421) 获取 i18n 文案,并调用 `sendNotification` (L430) 发送跨模块通知。i18n 与通知编排属于业务逻辑层职责,不应位于 data-access。此外 L379 在事务内 `void notifyCapacityThresholdIfNeeded(course, newEnrolledCount)` 触发 fire-and-forget,虽不阻塞事务,但 data-access 层不应有副作用编排。",
|
||||||
|
"recommendation": "移至 actions 层:\n```ts\n// data-access-operations.ts\nexport async function selectCourse(courseId, studentId, priority?): Promise<{ status: CourseSelectionStatus; course: Course }> {\n // ... 返回 course 与 newEnrolledCount 供 actions 判断\n}\n\n// actions.ts\nconst { status, course, newEnrolledCount } = await selectCourse(...)\nif (status === \"enrolled\") {\n await notifyCapacityThresholdIfNeeded(course, newEnrolledCount)\n}\nawait invalidateFor(...)\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-032",
|
||||||
|
"file": "src/modules/settings/data-access.ts",
|
||||||
|
"lines": "L153-L171, L188-L205",
|
||||||
|
"ruleId": "F-09",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "updateAiProvider / createAiProvider 事务内含条件分支但范围合理",
|
||||||
|
"description": "`updateAiProvider` (L153-L171) 和 `createAiProvider` (L188-L205) 在事务内执行:1) 可选的重置其他默认 Provider;2) 主表 update/insert。事务范围仅含 DB 操作,无网络调用,范围合理。但 `resetOtherDefaults` 在事务内全表 update,当 Provider 数量多时可能锁表。标记为 P3 提示关注。",
|
||||||
|
"recommendation": "可接受现状。若 Provider 数量增长,可考虑:1) 添加 WHERE 过滤条件缩小 update 范围;2) 用乐观锁替代事务。当前实现合理,无需立即修改。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-033",
|
||||||
|
"file": "src/modules/error-book/data-access.ts",
|
||||||
|
"lines": "L400-L438",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "recordReview 含 SM-2 算法应用逻辑,处于边界",
|
||||||
|
"description": "`recordReview` L409-L413 调用 `calculateNewInterval`、`calculateNewMastery`、`deriveStatus`、`calculateNextReviewAt`、`calculateNewCorrectStreak` 计算 SM-2 算法派生值。这些计算虽是业务逻辑,但已封装在 `sm2-algorithm.ts` 纯函数模块中,data-access 仅调用并持久化结果。处于可接受边界,但严格按 A-02 规则,算法应用应位于 lib 或 actions。",
|
||||||
|
"recommendation": "可接受现状。若严格遵循规则,可将 SM-2 计算移至 `error-book/lib/review.ts`,data-access 仅接受计算结果并持久化:\n```ts\n// lib/review.ts\nexport function computeReviewResult(item, result): { newInterval, newMastery, newStatus, nextReviewAt } { ... }\n\n// data-access.ts\nconst reviewResult = computeReviewResult(item, result)\nawait db.transaction(async (tx) => { /* 持久化 reviewResult */ })\n```",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-034",
|
||||||
|
"file": "src/modules/ai/data-access.ts",
|
||||||
|
"lines": "L33-L49, L63-L139",
|
||||||
|
"ruleId": "A-02",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "内存事件存储(eventStore)位于 data-access,应为独立 service",
|
||||||
|
"description": "L35 `const eventStore: StoredAiEvent[] = []` 模块级内存数组,L43-L49 `recordAiEvent` 写入函数,L63-L139 `getAiUsageStatsRaw` 聚合统计。data-access 层应封装 DB 访问,而内存事件存储是临时实现(注释 L9-L18 说明生产环境应替换为 DB/Redis)。当前实现虽可工作,但混合了存储实现与数据访问接口。",
|
||||||
|
"recommendation": "提取为独立 service:\n```ts\n// ai/services/usage-tracker-store.ts\nconst eventStore: StoredAiEvent[] = []\nexport function recordAiEvent(event: StoredAiEvent): void { ... }\n\n// ai/data-access.ts\nimport { eventStore } from \"./services/usage-tracker-store\"\nexport async function getAiUsageStatsRaw(): Promise<AiUsageStats> { ... }\n```\n注释已说明是临时实现,生产替换为 DB 后此问题自动消失。",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-035",
|
||||||
|
"file": "src/modules/elective/data-access-operations.ts",
|
||||||
|
"lines": "L62-L78",
|
||||||
|
"ruleId": "S-06",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "DAY_NORMALIZE_MAP 常量与 normalizeDay 函数缺模块级 JSDoc",
|
||||||
|
"description": "L62-L78 `DAY_NORMALIZE_MAP` 常量虽有注释说明用途,但 `normalizeDay` 函数 (L76-L78) 无 JSDoc。此函数被 `isScheduleConflict` 内部调用,是冲突检测的核心。按 S-06 规则,公共导出函数应补齐 JSDoc。虽然 `normalizeDay` 未导出,但作为可测试纯函数建议导出并补 JSDoc。",
|
||||||
|
"recommendation": "添加 JSDoc 并考虑导出便于测试:\n```ts\n/**\n * 将星期字符串归一化为 1-7 数字字符串。\n * 支持中文(周一/星期一)、英文全称(monday)、英文缩写(mon)。\n * @param day 原始星期字符串\n * @returns 归一化后的 1-7 数字字符串,无法识别时返回原值\n */\nexport function normalizeDay(day: string): string {\n return DAY_NORMALIZE_MAP[day.toLowerCase()] ?? day\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-036",
|
||||||
|
"file": "src/modules/elective/data-access-selections.ts",
|
||||||
|
"lines": "L104-L112, L120-L128",
|
||||||
|
"ruleId": "P-08",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "pattern",
|
||||||
|
"title": "getCourseSelectionsRaw / getStudentSelectionsRaw 重复 map+resolve 模式",
|
||||||
|
"description": "`getCourseSelectionsRaw` L107-L111 和 `getStudentSelectionsRaw` L123-L127 重复了相同的模式:`buildSelectionCoreSelect().where(...).orderBy(...)` → `resolveStudentDisplayNames(rows)` → `rows.map((r) => mapSelectionRow(r, studentNames))`。仅 where 条件和 orderBy 不同,可提取为共享 helper。",
|
||||||
|
"recommendation": "提取共享查询 helper:\n```ts\nasync function querySelectionsWithDisplayNames(\n where: SQL, orderBy: SQL\n): Promise<CourseSelectionWithDetails[]> {\n const rows = await buildSelectionCoreSelect().where(where).orderBy(orderBy)\n const studentNames = await resolveStudentDisplayNames(rows)\n return rows.map((r) => mapSelectionRow(r, studentNames))\n}\n\nexport const getCourseSelectionsRaw = async (courseId: string) =>\n querySelectionsWithDisplayNames(\n eq(courseSelections.courseId, courseId),\n asc(courseSelections.priority)\n )\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-037",
|
||||||
|
"file": "src/modules/announcements/data-access.ts",
|
||||||
|
"lines": "L122-L157",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getAnnouncements 与 countAnnouncements 的 audience 过滤逻辑重复",
|
||||||
|
"description": "`getAnnouncementsRaw` L66-L82 和 `countAnnouncements` L133-L149 的 audience 过滤逻辑完全一致:gradeClause、classClause、orClauses 构造。两处复制粘贴,修改时需同步,易遗漏。",
|
||||||
|
"recommendation": "提取共享 helper:\n```ts\nfunction buildAudienceConditions(audience?: UserAudience): SQL[] {\n if (!audience) return []\n const { gradeIds, classIds } = audience\n const gradeClause = gradeIds.length > 0\n ? and(eq(announcements.type, \"grade\"), inArray(announcements.targetGradeId, gradeIds))\n : undefined\n const classClause = classIds.length > 0\n ? and(eq(announcements.type, \"class\"), inArray(announcements.targetClassId, classIds))\n : undefined\n const orClauses = [eq(announcements.type, \"school\"), gradeClause, classClause]\n .filter((c): c is NonNullable<typeof c> => c !== undefined)\n return orClauses.length > 1 ? [or(...orClauses)] : []\n}\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-038",
|
||||||
|
"file": "src/modules/settings/actions.ts",
|
||||||
|
"lines": "L220",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "actions.ts 含 console.error 调试代码(actions 层可接受但建议用 logger)",
|
||||||
|
"description": "L220 `console.error(\"[upsertAiProviderAction] Failed to save AI provider:\", error)` 在 actions 层使用 console.error。A-10 规则主要针对 data-access 层禁止 console.log,actions 层用 console.error 记录错误属常见做法,但项目有 `trackEvent` 与 `logAudit` 统一日志通道,建议统一使用。",
|
||||||
|
"recommendation": "改用 trackEvent 或统一 logger:\n```ts\nvoid trackEvent({\n event: \"ai.provider_upsert_failed\",\n targetType: \"ai_provider\",\n properties: { error: error instanceof Error ? error.message : String(error) },\n})\nreturn { success: false, message: \"Failed to save AI provider\" }\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-039",
|
||||||
|
"file": "src/modules/announcements/actions.ts",
|
||||||
|
"lines": "L42, L92",
|
||||||
|
"ruleId": "A-10",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "architecture",
|
||||||
|
"title": "actions.ts 含 console.error(handleActionError 与 notifyAnnouncementPublished)",
|
||||||
|
"description": "L42 `console.error(\\`[announcements] ${actionName} failed:\\`, e)` 和 L92 `console.error(\"Failed to send announcement notifications:\", error)` 在 actions 层使用 console.error。与 G5-038 同理,actions 层可接受但建议统一日志通道。",
|
||||||
|
"recommendation": "改用 trackEvent 统一上报:\n```ts\nvoid trackEvent({\n event: \"announcement.action_error\",\n targetType: \"announcement\",\n properties: { action: actionName, error: e instanceof Error ? e.message : String(e) },\n})\n```",
|
||||||
|
"effort": "XS (≤15 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-040",
|
||||||
|
"file": "src/modules/error-book/data-access-analytics.ts",
|
||||||
|
"lines": "L161-L240, L309-L407",
|
||||||
|
"ruleId": "S-03",
|
||||||
|
"severity": "P3",
|
||||||
|
"dimension": "structure",
|
||||||
|
"title": "getKnowledgePointWeakness 与 getChapterWeakness 的知识点聚合逻辑重复",
|
||||||
|
"description": "`getKnowledgePointWeaknessRaw` L172-L191 和 `getChapterWeaknessRaw` L320-L338 都执行了相同的模式:1) 查询 errorBookItems 的 status + knowledgePointIds;2) JS 展开知识点到 kpMap;3) 查询 knowledgePoints 表获取名称与 chapterId。两处代码高度相似,仅最终聚合维度不同(按知识点 vs 按章节)。",
|
||||||
|
"recommendation": "提取共享的知识点聚合查询:\n```ts\nasync function loadKpErrorStats(\n studentIds: string[], subjectId?: string | null\n): Promise<Map<string, { errorCount: number; masteredCount: number }>> {\n const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)\n const rows = await db.select({\n status: errorBookItems.status,\n knowledgePointIds: errorBookItems.knowledgePointIds,\n }).from(errorBookItems).where(whereClause)\n // ... 展开 kpMap\n return kpMap\n}\n```\n两个函数复用此 helper。",
|
||||||
|
"effort": "S (≤30 分钟)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "G5-041",
|
||||||
|
"file": "src/modules/elective/data-access-operations.ts",
|
||||||
|
"lines": "L137-L174, L185-L220",
|
||||||
|
"ruleId": "F-01",
|
||||||
|
"severity": "P2",
|
||||||
|
"dimension": "performance",
|
||||||
|
"title": "checkScheduleConflict / checkCreditLimit 在事务内多次查询,可批量优化",
|
||||||
|
"description": "`checkScheduleConflict` L150-L161 查询学生已选课程 schedule,`checkCreditLimit` L198-L209 查询学生已选课程 credit。两个函数在 `selectCourse` 事务内串行调用 (L347, L353),且都查询了 `courseSelections innerJoin electiveCourses` 相同的 join。可合并为单次查询减少事务内往返。",
|
||||||
|
"recommendation": "合并为单次查询:\n```ts\nasync function checkScheduleAndCredit(\n tx, studentId, newCourseId, studentGradeId\n): Promise<{ hasConflict: boolean; creditExceeded: boolean; current: number; max: number }> {\n const [newCourse, existingCourses] = await Promise.all([\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(electiveCourses).where(eq(electiveCourses.id, newCourseId)).limit(1),\n tx.select({ schedule: electiveCourses.schedule, credit: electiveCourses.credit })\n .from(courseSelections)\n .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))\n .where(and(eq(courseSelections.studentId, studentId),\n inArray(courseSelections.status, [\"selected\", \"enrolled\", \"waitlist\"]))),\n ])\n // 一次遍历计算 conflict + credit\n}\n```",
|
||||||
|
"effort": "M (≤2 小时)"
|
||||||
|
}
|
||||||
|
]
|
||||||
1175
docs/architecture/audit/performance-budget-audit-report.md
Normal file
1536
docs/superpowers/plans/2026-07-04-design-tokens-refactor.md
Normal file
2752
docs/superpowers/plans/2026-07-05-cache-strategy.md
Normal file
1438
docs/superpowers/plans/2026-07-06-component-refactoring.md
Normal file
2682
docs/superpowers/plans/2026-07-07-documentation-system-redesign.md
Normal file
2149
docs/superpowers/plans/2026-07-07-logging-refactor.md
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
# 设计令牌专项重构 · 设计文档
|
||||||
|
|
||||||
|
> 创建日期:2026-07-04
|
||||||
|
> 状态:Draft(待用户审查)
|
||||||
|
> 执行方式:大爆炸式(单 PR)
|
||||||
|
> 关联规则:`docs/architecture/004_architecture_impact_map.md`、`.trae/rules/project_rules.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 现状摘要
|
||||||
|
|
||||||
|
项目当前令牌散落在 [`src/app/globals.css`](file:///e:/Desktop/CICD/src/app/globals.css)(477 行)与 [`tailwind.config.ts`](file:///e:/Desktop/CICD/tailwind.config.ts)(极简,无 `theme.extend`)。已有 5 类令牌:
|
||||||
|
|
||||||
|
1. **shadcn 标准令牌**(HSL 格式,完整暗色)
|
||||||
|
2. **chart-1~5 数据可视化令牌**(HSL,完整暗色)
|
||||||
|
3. **sidebar 令牌**(HSL,完整暗色)
|
||||||
|
4. **`--lp-*` 备课编辑器令牌**(HEX 格式,**无暗色**,**未在 `@theme inline` 中暴露**,无法 `bg-lp-paper`)
|
||||||
|
5. **Material Design 3 Surface 映射令牌**(在 `@theme inline` 中,**经扫描零使用**,属死代码)
|
||||||
|
|
||||||
|
### 1.2 关键技术债
|
||||||
|
|
||||||
|
扫描结果:
|
||||||
|
|
||||||
|
| 问题类型 | 出现次数 | 文件数 |
|
||||||
|
|---|---|---|
|
||||||
|
| 硬编码颜色 `#xxx` | 91 处 | 15 文件 |
|
||||||
|
| 硬编码字体 `'Inter'/'Fraunces'/'JetBrains Mono'` | 8 处 | 2 文件 |
|
||||||
|
| 硬编码 `font-size: Npx` | 15 处 | 3 文件 |
|
||||||
|
| Tailwind 任意值 `w-[Npx]/h-[Npx]/p-[Npx]/...` | 98 处 | 40 文件 |
|
||||||
|
| 缺失:font/spacing/shadow/text-size 尺度令牌 | — | — |
|
||||||
|
| `--lp-*` 无暗色 / 未暴露为 Tailwind 类 | — | — |
|
||||||
|
| M3 Surface 令牌零使用(死代码) | — | — |
|
||||||
|
|
||||||
|
项目规则(`.trae/rules/project_rules.md`)明确禁止"任意值"和"动态拼接类名",但实际任意值已散落到 40 个文件,需要专项治理。
|
||||||
|
|
||||||
|
### 1.3 重构目标
|
||||||
|
|
||||||
|
- **核心目标**:全量体系化重建设计令牌,建立完整令牌契约,清理全部硬编码与任意值
|
||||||
|
- **命名体系**:双层架构(Primitive + Semantic)+ shadcn 语义扩展
|
||||||
|
- **覆盖范围**:font/size/leading/weight/radius/shadow/spacing/duration/ease/z-index 全阶梯
|
||||||
|
- **暗色覆盖**:明暗双色全面覆盖(所有令牌都有 `:root` 与 `.dark` 双份)
|
||||||
|
- **强制约束**:ESLint 规则 + 注释豁免,防止技术债复发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 令牌架构
|
||||||
|
|
||||||
|
### 2.1 双层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 1: Primitive (Raw Tokens) — 原始色板/字号/间距/阴影 │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ 命名: --color-zinc-50 ~ 950 │
|
||||||
|
│ --color-stone-50 ~ 950 │
|
||||||
|
│ --color-indigo-500/600 │
|
||||||
|
│ --font-size-1 ~ --font-size-9 │
|
||||||
|
│ --space-0 ~ --space-32 │
|
||||||
|
│ --shadow-1 ~ --shadow-6 │
|
||||||
|
│ --font-family-sans/serif/mono │
|
||||||
|
│ 特征: 不直接使用,只被 Semantic 层引用 │
|
||||||
|
│ 主题: 无主题差异(色板层不区分明暗) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ 引用
|
||||||
|
│
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 2: Semantic (Component/Context Tokens) — 语义层 │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ shadcn 兼容: --background / --foreground / --primary / ... │
|
||||||
|
│ 扩展层级: --background-elevated / --text-secondary / ... │
|
||||||
|
│ 模块命名空间: --lp-* (lesson-preparation) │
|
||||||
|
│ chart/sidebar: --chart-1~5 / --sidebar-* │
|
||||||
|
│ 特征: 业务代码唯一引用入口,Tailwind 通过 @theme inline 暴露 │
|
||||||
|
│ 主题: :root 与 .dark 双份定义,所有令牌都有暗色 │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ 引用
|
||||||
|
│
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 3: Tailwind 类 (via @theme inline) │
|
||||||
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
|
│ bg-background / text-foreground / bg-primary / ... │
|
||||||
|
│ bg-lp-paper / text-lp-inline-node-meta / ... │
|
||||||
|
│ font-sans / font-serif / font-mono │
|
||||||
|
│ text-size-2 / leading-snug / weight-semibold │
|
||||||
|
│ radius-md / shadow-2 / space-4 / duration-fast / z-modal │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心原则
|
||||||
|
|
||||||
|
- 业务代码 **只引用 Semantic 层**(直接 `var(--background)` 或 Tailwind 类 `bg-background`),禁止引用 Primitive 层
|
||||||
|
- Primitive 层只为 Semantic 层提供"原材料",便于未来多主题切换(只改 Semantic 层映射,不动 Primitive)
|
||||||
|
- shadcn 命名严格保留(`--background`/`--foreground`/`--primary` 等),`shared/components/ui/*` 零改动
|
||||||
|
- `--lp-*` 保留命名空间(已有 9 文件使用,改名风险大),但补全暗色与 Tailwind 暴露
|
||||||
|
|
||||||
|
### 2.3 文件分布
|
||||||
|
|
||||||
|
当前 [`globals.css`](file:///e:/Desktop/CICD/src/app/globals.css) 477 行,补全令牌后估算 1200+ 行,超过项目规则硬性上限 1000 行。拆分为令牌专题目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├─ globals.css # 仅保留 @import、base layer、组件硬编码样式清理后的样式
|
||||||
|
└─ styles/
|
||||||
|
└─ tokens/
|
||||||
|
├─ primitive.css # Layer 1: 原始色板/字号/间距/阴影阶梯
|
||||||
|
├─ semantic-light.css # Layer 2: :root 语义令牌(明色)
|
||||||
|
├─ semantic-dark.css # Layer 2: .dark 语义令牌(暗色)
|
||||||
|
├─ lesson-preparation.css # --lp-* 令牌(明暗双份)
|
||||||
|
├─ tailwind-theme.css # @theme inline 暴露规则
|
||||||
|
└─ index.css # 汇总 @import 入口
|
||||||
|
```
|
||||||
|
|
||||||
|
`globals.css` 顶部改为:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "./styles/tokens/index.css";
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Tailwind v4 集成
|
||||||
|
|
||||||
|
[`tailwind.config.ts`](file:///e:/Desktop/CICD/tailwind.config.ts) 保持极简(只 content + plugins),所有令牌通过 `@theme inline` 在 `tailwind-theme.css` 中暴露。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Primitive 层(原始阶梯)
|
||||||
|
|
||||||
|
### 3.1 色板(以 Zinc 为基准中性色,与现有 shadcn 一致)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* primitive.css */
|
||||||
|
:root {
|
||||||
|
/* Zinc 中性色板(shadcn 默认) */
|
||||||
|
--color-zinc-50: 0 0% 99%;
|
||||||
|
--color-zinc-100: 240 4.8% 95.9%;
|
||||||
|
--color-zinc-200: 240 5.9% 90%;
|
||||||
|
--color-zinc-300: 240 4.8% 83.9%;
|
||||||
|
--color-zinc-400: 240 5% 64.9%;
|
||||||
|
--color-zinc-500: 240 3.8% 46.1%;
|
||||||
|
--color-zinc-600: 240 5.2% 33.9%;
|
||||||
|
--color-zinc-700: 240 5.3% 26.1%;
|
||||||
|
--color-zinc-800: 240 5.9% 10%;
|
||||||
|
--color-zinc-900: 240 5.9% 3.9%;
|
||||||
|
--color-zinc-950: 240 10% 3.9%;
|
||||||
|
|
||||||
|
/* Stone 暖灰(用于 lp-* 纸感,与现有 --lp-paper-edge #f8f8f7 对齐) */
|
||||||
|
--color-stone-50: 60 4.8% 95.9%;
|
||||||
|
--color-stone-100: 60 5.1% 90%;
|
||||||
|
--color-stone-200: 20 5.9% 90%;
|
||||||
|
--color-stone-300: 24 5.7% 82.9%;
|
||||||
|
--color-stone-400: 24 5.4% 63.9%;
|
||||||
|
--color-stone-500: 25 5.1% 44.7%;
|
||||||
|
--color-stone-600: 33 5% 39.8%;
|
||||||
|
--color-stone-700: 30 5.2% 32.7%;
|
||||||
|
--color-stone-800: 12 6.5% 31.4%;
|
||||||
|
--color-stone-900: 24 10% 10%;
|
||||||
|
--color-stone-950: 20 14.3% 4.1%;
|
||||||
|
|
||||||
|
/* Indigo 强调色(lp-interaction 用) */
|
||||||
|
--color-indigo-500: 238.7 83.5% 66.7%;
|
||||||
|
--color-indigo-600: 238.6 84.5% 59.8%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: 仅记录 Primitive 原料,业务代码不直接引用。
|
||||||
|
|
||||||
|
### 3.2 字号阶梯(替代硬编码 13.5px/14px/12.5px 等)
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--font-size-1` | 12px | 元信息/角色标签 |
|
||||||
|
| `--font-size-2` | 13px | inline-node body |
|
||||||
|
| `--font-size-3` | 13.5px | inline-node 主文(备课纸感) |
|
||||||
|
| `--font-size-4` | 14px | 标题/按钮 |
|
||||||
|
| `--font-size-5` | 16px | 正文(Fraunces 16px) |
|
||||||
|
| `--font-size-6` | 18px | H2 |
|
||||||
|
| `--font-size-7` | 20px | H1 |
|
||||||
|
| `--font-size-8` | 24px | 区块标题 |
|
||||||
|
| `--font-size-9` | 32px | 页面标题 |
|
||||||
|
|
||||||
|
### 3.3 间距阶梯(覆盖 98 处任意值)
|
||||||
|
|
||||||
|
| Token | 值 | 替代示例 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--space-0` | 0 | |
|
||||||
|
| `--space-0_5` | 0.125rem (2px) | `p-[2px]` |
|
||||||
|
| `--space-1` | 0.25rem (4px) | `p-[4px]` |
|
||||||
|
| `--space-1_5` | 0.375rem (6px) | `gap-[6px]` |
|
||||||
|
| `--space-2` | 0.5rem (8px) | |
|
||||||
|
| `--space-2_5` | 0.625rem (10px) | `p-[10px]` |
|
||||||
|
| `--space-3` | 0.75rem (12px) | |
|
||||||
|
| `--space-3_5` | 0.875rem (14px) | `p-[14px]` |
|
||||||
|
| `--space-4` | 1rem (16px) | |
|
||||||
|
| `--space-5` | 1.25rem (20px) | `m-[20px]` |
|
||||||
|
| `--space-6` | 1.5rem (24px) | |
|
||||||
|
| `--space-7` | 1.75rem (28px) | `w-[28px]` (lp-tb-btn) |
|
||||||
|
| `--space-8` | 2rem (32px) | |
|
||||||
|
| `--space-10` | 2.5rem (40px) | |
|
||||||
|
| `--space-12` | 3rem (48px) | |
|
||||||
|
| `--space-16` | 4rem (64px) | |
|
||||||
|
| `--space-18` | 4.5rem (72px) | `px-[72px]` (lp-paper-toolbar) |
|
||||||
|
|
||||||
|
### 3.4 阴影阶梯(替代硬编码 box-shadow)
|
||||||
|
|
||||||
|
| Token | 值 | 替代 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--shadow-1` | `0 1px 2px rgba(15,15,15,0.04)` | shadow-xs |
|
||||||
|
| `--shadow-2` | `0 1px 3px rgba(15,15,15,0.06), 0 1px 2px rgba(15,15,15,0.04)` | shadow-sm |
|
||||||
|
| `--shadow-3` | `0 4px 6px rgba(15,15,15,0.05), 0 2px 4px rgba(15,15,15,0.04)` | shadow-md |
|
||||||
|
| `--shadow-4` | `0 1px 2px rgba(15,15,15,0.04), 0 8px 24px rgba(15,15,15,0.04)` | lp-paper-shadow |
|
||||||
|
| `--shadow-5` | `0 1px 2px rgba(15,15,15,0.06), 0 12px 36px rgba(15,15,15,0.08)` | lp-paper-shadow-active |
|
||||||
|
| `--shadow-6` | `0 10px 15px rgba(15,15,15,0.1), 0 4px 6px rgba(15,15,15,0.05)` | shadow-xl |
|
||||||
|
|
||||||
|
### 3.5 字体家族(替代 8 处硬编码)
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--font-family-sans` | `'Inter', system-ui, sans-serif` | UI 元素 |
|
||||||
|
| `--font-family-serif` | `'Fraunces', Georgia, serif` | 主文/备课纸感 |
|
||||||
|
| `--font-family-mono` | `'JetBrains Mono', monospace` | 角色标签 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Semantic 层(shadcn 扩展 + lp-* 升级 + M3 清理)
|
||||||
|
|
||||||
|
### 4.1 shadcn 标准令牌(零改动,确保 ui/ 兼容)
|
||||||
|
|
||||||
|
`--background`/`--foreground`/`--card`/`--popover`/`--primary`/`--secondary`/`--muted`/`--accent`/`--destructive`/`--border`/`--input`/`--ring`/`--radius` 全部保留现有 HSL 值,`:root` 与 `.dark` 双份定义不变。
|
||||||
|
|
||||||
|
### 4.2 语义层扩展(新增层级)
|
||||||
|
|
||||||
|
| Token | 明色 HSL | 暗色 HSL | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--background-elevated` | `0 0% 100%` | `240 6% 10%` | 浮层/card-on-card |
|
||||||
|
| `--background-sunken` | `240 4.8% 95.9%` | `240 6% 8%` | 凹陷区/inset |
|
||||||
|
| `--text-primary` | `= --foreground` | `= --foreground` | 主文 |
|
||||||
|
| `--text-secondary` | `240 3.8% 46.1%` | `240 5% 64.9%` | 副文(= muted-foreground) |
|
||||||
|
| `--text-tertiary` | `240 4% 65%` | `240 5% 50%` | 占位/元信息 |
|
||||||
|
| `--border-strong` | `240 5.9% 70%` | `240 5% 40%` | 强调边框 |
|
||||||
|
| `--border-subtle` | `240 5.9% 95%` | `240 5% 18%` | 弱边框 |
|
||||||
|
|
||||||
|
**说明**: `--text-secondary` 等是 `--muted-foreground` 的语义别名,业务代码优先用语义名。
|
||||||
|
|
||||||
|
### 4.3 chart-1~5 / sidebar 令牌(零改动)
|
||||||
|
|
||||||
|
保留现有 HSL 值,`:root` 与 `.dark` 双份定义不变。
|
||||||
|
|
||||||
|
### 4.4 --lp-* 令牌升级(HEX→HSL + 暗色 + Tailwind 暴露)
|
||||||
|
|
||||||
|
**明色(`:root`)**:
|
||||||
|
|
||||||
|
| Token | 旧 HEX | 新 HSL | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--lp-paper` | `#fefefe` | `0 0% 99.6%` | 纸面 |
|
||||||
|
| `--lp-paper-edge` | `#f8f8f7` | `60 9% 97%` | 纸边(对齐 stone-100) |
|
||||||
|
| `--lp-paper-shadow` | (box-shadow) | `= --shadow-4` 引用 | 阴影令牌化 |
|
||||||
|
| `--lp-paper-shadow-active` | (box-shadow) | `= --shadow-5` 引用 | 阴影令牌化 |
|
||||||
|
| `--lp-anchor-range` | `rgba(28,25,23,0.08)` | `20 14.3% 4.1% / 0.08` | 引用 stone-950 |
|
||||||
|
| `--lp-anchor-range-active` | `rgba(28,25,23,0.16)` | `20 14.3% 4.1% / 0.16` | |
|
||||||
|
| `--lp-anchor-point` | `#1c1917` | `20 14.3% 4.1%` | = stone-950 |
|
||||||
|
| `--lp-inline-node-border` | `#d6d3d1` | `30 5.7% 82.9%` | = stone-300 |
|
||||||
|
| `--lp-inline-node-text` | `#44403c` | `24 5.4% 26.9%` | ≈ stone-700 微调 |
|
||||||
|
| `--lp-inline-node-meta` | `#a8a29e` | `30 5% 64.9%` | = stone-400 |
|
||||||
|
| `--lp-interaction` | `#6366f1` | `238.6 84.5% 59.8%` | = indigo-600 |
|
||||||
|
|
||||||
|
**12 个 --lp-dot-* 节点色点**: 全部映射到 zinc/stone 色板对应值,统一为 HSL。
|
||||||
|
|
||||||
|
**暗色(`.dark`)**: 全部补齐。例如:
|
||||||
|
|
||||||
|
- `--lp-paper: 240 6% 10%`(暗背景纸面)
|
||||||
|
- `--lp-paper-edge: 240 6% 8%`
|
||||||
|
- `--lp-anchor-point: 0 0% 98%`(反相)
|
||||||
|
- `--lp-interaction: 238.7 83.5% 66.7%`(= indigo-500,暗色微亮)
|
||||||
|
|
||||||
|
### 4.5 M3 Surface 映射令牌清理
|
||||||
|
|
||||||
|
[`globals.css:185-198`](file:///e:/Desktop/CICD/src/app/globals.css#L185-L198) 中的 M3 令牌零使用(已扫描确认),全部删除:
|
||||||
|
|
||||||
|
- `--color-surface` / `--color-on-surface` / `--color-on-surface-variant`
|
||||||
|
- `--color-surface-container-lowest/low/high/highest`
|
||||||
|
- `--color-outline-variant` / `--color-outline`
|
||||||
|
- `--color-error` / `--color-tertiary` / `--color-tertiary-container`
|
||||||
|
|
||||||
|
### 4.6 globals.css 硬编码样式令牌化
|
||||||
|
|
||||||
|
| 硬编码 | 令牌化后 |
|
||||||
|
|---|---|
|
||||||
|
| `.lp-tb-btn { width: 28px; ... font-family: 'Inter'; font-size: 13px; color: var(--foreground) }` | `width: var(--space-7); font-family: var(--font-family-sans); font-size: var(--font-size-2); color: hsl(var(--foreground))` |
|
||||||
|
| `.lp-inline-node { font-family: 'Inter'; font-size: 13.5px; ... border-left: 2px solid var(--lp-inline-node-border); color: var(--lp-inline-node-text) }` | 全部用令牌引用 |
|
||||||
|
| `.lp-qa-role.teacher { color: #1c1917 }` | `color: hsl(var(--lp-anchor-point))` |
|
||||||
|
| `.lp-qa-role.student { color: #6b7280 }` | `color: hsl(var(--color-zinc-500))` |
|
||||||
|
| `.lp-inline-node-title { color: #1a1a1a }` | `color: hsl(var(--lp-anchor-point))` |
|
||||||
|
| `.lp-inline-node-body { color: #404040 }` | `color: hsl(var(--lp-inline-node-text))` |
|
||||||
|
| `.lp-qa-content { color: #404040 }` | 同上 |
|
||||||
|
| `.lp-qa-prompt { color: #525252 }` | `color: hsl(var(--color-zinc-600))` |
|
||||||
|
| `.range-anchor { background-color: var(--node-color, #1976d2) }` | fallback 改为 `hsl(var(--lp-interaction))` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 尺度令牌补齐清单
|
||||||
|
|
||||||
|
### 5.1 圆角阶梯
|
||||||
|
|
||||||
|
| Token | 值 | Tailwind 类 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--radius-sm` | `calc(var(--radius) - 4px)` | `rounded-sm` |
|
||||||
|
| `--radius-md` | `calc(var(--radius) - 2px)` | `rounded-md` |
|
||||||
|
| `--radius-lg` | `var(--radius)` = `0.5rem` | `rounded-lg` |
|
||||||
|
| `--radius-xl` | `calc(var(--radius) + 4px)` | `rounded-xl` |
|
||||||
|
| `--radius-2xl` | `calc(var(--radius) + 8px)` | `rounded-2xl` |
|
||||||
|
| `--radius-full` | `9999px` | `rounded-full` |
|
||||||
|
|
||||||
|
### 5.2 行高与字重
|
||||||
|
|
||||||
|
| Token | 值 |
|
||||||
|
|---|---|
|
||||||
|
| `--leading-tight` | 1.2 |
|
||||||
|
| `--leading-snug` | 1.35 |
|
||||||
|
| `--leading-normal` | 1.5 |
|
||||||
|
| `--leading-relaxed` | 1.65 |
|
||||||
|
| `--leading-loose` | 1.8 |
|
||||||
|
| `--weight-regular` | 400 |
|
||||||
|
| `--weight-medium` | 500 |
|
||||||
|
| `--weight-semibold` | 600 |
|
||||||
|
| `--weight-bold` | 700 |
|
||||||
|
|
||||||
|
### 5.3 动效与 z-index
|
||||||
|
|
||||||
|
| Token | 值 | Tailwind 类 |
|
||||||
|
|---|---|---|
|
||||||
|
| `--duration-fast` | 150ms | `duration-fast` |
|
||||||
|
| `--duration-normal` | 200ms | `duration-normal` |
|
||||||
|
| `--duration-slow` | 300ms | `duration-slow` |
|
||||||
|
| `--ease-in` | `cubic-bezier(0.4, 0, 1, 1)` | `ease-in` |
|
||||||
|
| `--ease-out` | `cubic-bezier(0, 0, 0.2, 1)` | `ease-out` |
|
||||||
|
| `--ease-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out` |
|
||||||
|
| `--z-dropdown` | 1000 | `z-dropdown` |
|
||||||
|
| `--z-sticky` | 1100 | `z-sticky` |
|
||||||
|
| `--z-modal` | 1300 | `z-modal` |
|
||||||
|
| `--z-popover` | 1400 | `z-popover` |
|
||||||
|
| `--z-toast` | 1500 | `z-toast` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 硬编码清理映射表(分级)
|
||||||
|
|
||||||
|
### 6.1 91 处 #hex 颜色 — 按文件分类
|
||||||
|
|
||||||
|
| 文件 | 处数 | 清理策略 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/globals.css` | 32 | 全部令牌化(见 4.6 节) |
|
||||||
|
| `app/manifest.ts` | 2 | 保留(theme_color 元数据,需字面量),加注释 `// arbitrary-value: PWA manifest requires literal hex` |
|
||||||
|
| `app/layout.tsx` | 2 | 检查后令牌化或注释 |
|
||||||
|
| `shared/components/ui/chart.tsx` | 5 | 令牌化(用 `--chart-1~5`) |
|
||||||
|
| `modules/notifications/channels/email-channel.ts` | 7 | 保留(邮件 HTML 内联样式需字面量),加注释 |
|
||||||
|
| `modules/textbooks/components/knowledge-graph.tsx` | 10 | 令牌化(用 `--chart-*` 或新增 `--graph-node-*`) |
|
||||||
|
| `modules/textbooks/components/force-graph.tsx` | 17 | 同上 |
|
||||||
|
| `modules/lesson-preparation/components/paper-editor/*` | ~6 | 令牌化(用 `--lp-*`) |
|
||||||
|
| `modules/attendance/components/attendance-grade-correlation-card.tsx` | 3 | 令牌化(用 `--chart-*`) |
|
||||||
|
| `modules/classes/components/my-classes-grid.tsx` | 2 | 令牌化 |
|
||||||
|
| `modules/lesson-preparation/components/version-diff-view.tsx` | 1 | 令牌化(用 `--lp-*` 或新增 `--diff-add`/`--diff-remove`) |
|
||||||
|
|
||||||
|
**新增语义令牌**:
|
||||||
|
|
||||||
|
- `--diff-add` / `--diff-add-bg`(版本对比新增)
|
||||||
|
- `--diff-remove` / `--diff-remove-bg`(版本对比删除)
|
||||||
|
- `--graph-node-1` ~ `--graph-node-6`(知识图谱节点色)
|
||||||
|
|
||||||
|
### 6.2 8 处硬编码 font-family — 全部令牌化
|
||||||
|
|
||||||
|
| 文件 | 处数 | 令牌化 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/globals.css` | 7 | `var(--font-family-sans)` / `var(--font-family-serif)` / `var(--font-family-mono)` |
|
||||||
|
| `modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` | 1 | 同上 |
|
||||||
|
|
||||||
|
### 6.3 15 处硬编码 font-size — 全部令牌化
|
||||||
|
|
||||||
|
| 文件 | 处数 | 令牌化 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/globals.css` | 13 | `var(--font-size-N)` |
|
||||||
|
| `modules/notifications/channels/email-channel.ts` | 1 | 保留(邮件 HTML),加注释 |
|
||||||
|
| `modules/lesson-preparation/components/paper-editor/textbook-tiptap-editor.tsx` | 1 | `var(--font-size-N)` |
|
||||||
|
|
||||||
|
### 6.4 98 处 Tailwind 任意值 — 分级清理
|
||||||
|
|
||||||
|
**Tier 1 — 直接映射 Tailwind 默认阶梯**(约 40 处):
|
||||||
|
|
||||||
|
- `w-[28px]` → `w-7`(28px ≈ 1.75rem,但 w-7 = 1.75rem ✓)
|
||||||
|
- `gap-[6px]` → `gap-1.5`(6px = 0.375rem,gap-1.5 ✓)
|
||||||
|
- `p-[10px]` → 需新增 `--space-2_5`(10px = 0.625rem)
|
||||||
|
- `text-[13px]` → `text-size-2`(13px = `--font-size-2`)
|
||||||
|
|
||||||
|
**Tier 2 — 新增间距令牌后引用**(约 30 处):
|
||||||
|
|
||||||
|
- `w-[72px]` → `w-space-18`(`--space-18` = 4.5rem)
|
||||||
|
- `h-[16px]` → `h-space-4`(`--space-4` = 1rem)
|
||||||
|
- `p-[20px]` → `p-space-5`(`--space-5` = 1.25rem)
|
||||||
|
|
||||||
|
**Tier 3 — 注释豁免**(约 28 处):
|
||||||
|
|
||||||
|
- 图表 SVG 尺寸(如 `w-[400px]` 在 chart 容器)→ 加注释 `// arbitrary-value: chart canvas fixed size`
|
||||||
|
- 邮件 HTML 内联样式
|
||||||
|
- loading.tsx 占位骨架特殊尺寸
|
||||||
|
|
||||||
|
**ESLint 规则**: `tailwindcss/no-arbitrary-value: error`,允许通过 `// eslint-disable-next-line` + 注释理由豁免。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 强制约束机制
|
||||||
|
|
||||||
|
### 7.1 ESLint 规则新增
|
||||||
|
|
||||||
|
**新增依赖**: `eslint-plugin-tailwindcss`(需验证与 Tailwind v4 兼容性,若不兼容则自定义规则)
|
||||||
|
|
||||||
|
**`eslint.config.mjs` 新增规则**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
// 禁止 Tailwind 任意值(允许 eslint-disable + 注释豁免)
|
||||||
|
'tailwindcss/no-arbitrary-value': 'error',
|
||||||
|
// 禁止 TSX/CSS 中硬编码颜色字面量(白名单:globals.css primitive 定义、email-channel.ts、manifest.ts)
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
|
||||||
|
message: '禁止硬编码 hex 颜色,使用设计令牌 var(--*) 或 Tailwind 类 bg-*',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**自定义规则文件**: `eslint-rules/no-hardcoded-design-tokens.js`
|
||||||
|
|
||||||
|
- 检测 TSX/TS 中 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量
|
||||||
|
- 检测 `font-family:` CSS 属性中的字面量字体名
|
||||||
|
- 检测 `font-size: Npx` 硬编码
|
||||||
|
- 白名单:`globals.css`、`styles/tokens/*.css`、`email-channel.ts`、`manifest.ts`
|
||||||
|
|
||||||
|
### 7.2 项目规则更新
|
||||||
|
|
||||||
|
[`.trae/rules/project_rules.md`](file:///e:/Desktop/CICD/.trae/rules/project_rules.md) "Tailwind 规范"章节新增:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 设计令牌规范(强制)
|
||||||
|
|
||||||
|
- **禁止硬编码颜色**: TSX/TS/CSS 中不得出现 `#hex` 颜色字面量,统一使用 `var(--*)` 或 Tailwind 类
|
||||||
|
- **禁止硬编码字体**: 不得出现 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量,使用 `var(--font-family-sans/serif/mono)`
|
||||||
|
- **禁止硬编码字号**: 不得出现 `font-size: Npx`,使用 `var(--font-size-1~9)`
|
||||||
|
- **禁止 Tailwind 任意值**: 不得使用 `w-[Npx]`/`h-[Npx]`/`p-[Npx]` 等,映射到 `--space-*` 或 Tailwind 默认阶梯
|
||||||
|
- **豁免场景**(需 `// arbitrary-value: <reason>` 注释):
|
||||||
|
- PWA manifest(`app/manifest.ts`)
|
||||||
|
- 邮件 HTML 内联样式(`email-channel.ts`)
|
||||||
|
- 图表 SVG 固定画布尺寸
|
||||||
|
- loading.tsx 占位骨架
|
||||||
|
- **令牌文件分布**: `src/app/styles/tokens/`(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme)
|
||||||
|
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004` 与 `005`
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Lint 命令不变
|
||||||
|
|
||||||
|
`npm run lint` 与 `npx tsc --noEmit` 保持零错误零警告。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验证策略与架构图同步
|
||||||
|
|
||||||
|
### 8.1 验证清单
|
||||||
|
|
||||||
|
| 验证项 | 命令/方法 | 通过标准 |
|
||||||
|
|---|---|---|
|
||||||
|
| TypeScript | `npx tsc --noEmit` | 零错误 |
|
||||||
|
| ESLint | `npm run lint` | 零错误零警告 |
|
||||||
|
| 视觉回归 | `npm run test:visual` | 快照无差异(或更新后人工确认) |
|
||||||
|
| 备课编辑器视觉 | 手动检查 `teacher/lesson-plans/[id]/edit` | 纸感、inline-node、师生对话样式无变化 |
|
||||||
|
| 暗色模式 | 切换暗色主题 | 所有页面可读,无对比度问题 |
|
||||||
|
| shadcn ui 组件 | 抽查 button/card/dialog/table | 零回归 |
|
||||||
|
|
||||||
|
### 8.2 架构图同步(项目规则强制)
|
||||||
|
|
||||||
|
修改完成后,同步更新:
|
||||||
|
|
||||||
|
| 文档 | 同步内容 |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/architecture/004_architecture_impact_map.md`](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) | 新增"设计令牌体系"章节(双层架构图、文件分布、令牌分类) |
|
||||||
|
| [`docs/architecture/005_architecture_data.json`](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) | `modules.shared.exports` 新增 `design-tokens` 节点;`dependencyMatrix` 新增令牌依赖关系 |
|
||||||
|
| [`docs/troubleshooting/known-issues.md`](file:///e:/Desktop/CICD/docs/troubleshooting/known-issues.md) | 新增"设计令牌"问题分类,记录 Tailwind v4 + ESLint 兼容性、HEX→HSL 转换、任意值豁免规则 |
|
||||||
|
|
||||||
|
### 8.3 大爆炸式执行顺序(单 PR 内)
|
||||||
|
|
||||||
|
1. 创建 `src/app/styles/tokens/` 6 个文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
|
||||||
|
2. `globals.css` 改为 `@import` 引入,删除已迁移内容
|
||||||
|
3. `globals.css` 中剩余硬编码样式令牌化(lp-tb-btn/lp-inline-node/lp-qa-*)
|
||||||
|
4. 全局扫描替换:91 处 `#hex` → 令牌引用(按 6.1 表)
|
||||||
|
5. 全局扫描替换:8 处 font-family + 15 处 font-size → 令牌引用(按 6.2/6.3 表)
|
||||||
|
6. 全局扫描替换:98 处任意值 → Tier 1/2/3 分级处理(按 6.4 表)
|
||||||
|
7. ESLint 规则新增 + 自定义规则文件
|
||||||
|
8. 项目规则文档更新
|
||||||
|
9. 架构图同步(004/005)
|
||||||
|
10. `docs/troubleshooting/known-issues.md` 更新
|
||||||
|
11. `npm run lint` + `npx tsc --noEmit` + `npm run test:visual` 验证
|
||||||
|
12. 单次 commit:`refactor(design-tokens): 全量体系化重建设计令牌`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| Tailwind v4 + `eslint-plugin-tailwindcss` 兼容性未知 | 实施时先验证,不兼容则自定义 AST 规则 |
|
||||||
|
| 大爆炸式改动 50+ 文件,review 困难 | 严格按 8.3 执行顺序,每步独立验证;视觉回归测试保障 |
|
||||||
|
| `--lp-*` HEX→HSL 转换可能有色差 | 视觉回归测试 + 手动备课编辑器检查 |
|
||||||
|
| 98 处任意值分级清理可能遗漏 | Tier 3 注释豁免需明确理由,ESLint 规则锁定 |
|
||||||
|
| 暗色补全可能对比度不足 | 暗色模式手动抽查所有页面 |
|
||||||
|
| shadcn ui 组件理论零改动但需验证 | 抽查 button/card/dialog/table 4 个核心组件 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 范围与非目标
|
||||||
|
|
||||||
|
### 10.1 范围内
|
||||||
|
|
||||||
|
- 令牌底座重建(Primitive + Semantic 双层)
|
||||||
|
- 尺度令牌补齐(font/size/leading/weight/radius/shadow/spacing/duration/ease/z-index)
|
||||||
|
- `--lp-*` 升级(HEX→HSL + 暗色 + Tailwind 暴露)
|
||||||
|
- M3 Surface 死代码清理
|
||||||
|
- 91 处硬编码颜色清理
|
||||||
|
- 8 处硬编码 font-family 清理
|
||||||
|
- 15 处硬编码 font-size 清理
|
||||||
|
- 98 处 Tailwind 任意值清理
|
||||||
|
- ESLint 强制约束规则
|
||||||
|
- 项目规则文档更新
|
||||||
|
- 架构图同步(004/005)
|
||||||
|
- known-issues.md 更新
|
||||||
|
|
||||||
|
### 10.2 范围外(非目标)
|
||||||
|
|
||||||
|
- 第三主题(如 high-contrast、paper 纸感)预留 — YAGNI
|
||||||
|
- `shared/components/ui/*` shadcn 组件内部样式改动 — 理论零改动,仅验证
|
||||||
|
- `cn()` 工具函数修改 — 不变
|
||||||
|
- `tailwind.config.ts` 内容扩展 — 保持极简
|
||||||
|
- 设计令牌文档站(Storybook 等)— 不在本期范围
|
||||||
|
- 多主题切换架构 — 仅明暗双色
|
||||||
742
docs/superpowers/specs/2026-07-05-cache-strategy-design.md
Normal file
@@ -0,0 +1,742 @@
|
|||||||
|
# 缓存策略落地专项重构设计文档
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|---|
|
||||||
|
| 文档版本 | v1 |
|
||||||
|
| 创建日期 | 2026-07-05 |
|
||||||
|
| 作者 | Trae 协作生成 |
|
||||||
|
| 状态 | 待用户审查 |
|
||||||
|
| 范围 | 全栈缓存策略一致性(基础设施 + 规范 + 标杆示范) |
|
||||||
|
| 标杆模块 | classes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与现状
|
||||||
|
|
||||||
|
### 1.1 项目缓存现状
|
||||||
|
|
||||||
|
| 层 | 现状 | 问题 |
|
||||||
|
|----|------|------|
|
||||||
|
| 服务端数据缓存 | 仅用 `react.cache`(请求级 memoization,30 个 data-access 文件)+ `revalidatePath`(路径级失效) | 未用 `unstable_cache` / `revalidateTag` / Redis 数据缓存;多实例下无跨实例共享 |
|
||||||
|
| 客户端缓存 | TanStack Query V5 基础设施已建(`createQueryClient` 默认 staleTime 30s/retry 1) | `useActionQuery` / `useActionMutation` 用 useEffect + useState 自实现,绕过 QueryClient;30+ 组件直接 useQuery,缺乏统一 queryKey 约定 |
|
||||||
|
| 失效编排 | revalidatePath 散落在 30+ actions 文件 | 易漏失效;无集中审计点 |
|
||||||
|
| 单测可 mock 性 | data-access 函数直接 export 缓存包装版本 | 难以绕过缓存层 mock(架构文档 P2 待办) |
|
||||||
|
|
||||||
|
### 1.2 已有可复用资产
|
||||||
|
|
||||||
|
| 资产 | 文件 | 复用方式 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Redis driver 切换模式 | `shared/lib/rate-limit/{index.ts,redis-limiter.ts,memory-limiter.ts,types.ts}` | 缓存层同构复用 `CACHE_DRIVER=memory\|redis` + 动态 import + `webpackIgnore` |
|
||||||
|
| Upstash Redis 类型声明 | `shared/lib/rate-limit/upstash-modules.d.ts` | 缓存层直接复用,避免重复声明 |
|
||||||
|
| TanStack Query 工厂 | `shared/lib/query-client.ts` | 不动,新增 queryKey 工厂与之配合 |
|
||||||
|
| `useActionQuery` / `useActionMutation` Hook | `shared/hooks/use-action-{query,mutation}.ts` | 重构内部实现,对外 API 调整(破坏性变更,需迁移消费方) |
|
||||||
|
| env 校验 | `src/env.mjs` | 新增 `CACHE_DRIVER` 字段 |
|
||||||
|
|
||||||
|
### 1.3 部署形态约束
|
||||||
|
|
||||||
|
- 多实例部署(PM2 cluster / Kubernetes 多 Pod)
|
||||||
|
- 必须支持跨实例共享缓存 → 引入 Redis 后端
|
||||||
|
- 复用 `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` 环境变量
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 2.1 目标
|
||||||
|
|
||||||
|
1. 建立 `shared/lib/cache/` 数据缓存基础设施,支持 `memory` / `redis` 双 driver 切换
|
||||||
|
2. 建立 `shared/lib/query-keys.ts` 客户端 queryKey 工厂,集中化命名
|
||||||
|
3. 建立 `shared/lib/cache/invalidation-map.ts` 集中式失效映射表
|
||||||
|
4. 重构 `useActionQuery` / `useActionMutation` 接入 QueryClient + 自动 invalidate
|
||||||
|
5. 完成 classes 标杆模块全场景迁移(6 data-access + 5 actions)
|
||||||
|
6. 引入 ESLint 规则强制使用 `invalidateFor`,禁止直接调用 `revalidatePath` / `revalidateTag`
|
||||||
|
7. 同步架构文档(004 / 005 / known-issues)
|
||||||
|
|
||||||
|
### 2.2 非目标
|
||||||
|
|
||||||
|
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
|
||||||
|
- 其他客户端组件迁移到 useQuery(下期按页面推进)
|
||||||
|
- L1+L2 两级缓存(YAGNI,先用单层 Redis 验证)
|
||||||
|
- 缓存预热 / SWR 主动刷新(YAGNI)
|
||||||
|
- 缓存命中率监控埋点(YAGNI)
|
||||||
|
- 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 架构设计
|
||||||
|
|
||||||
|
### 3.1 模块边界与文件布局
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/lib/
|
||||||
|
├─ cache/ # 新增:数据缓存基础设施
|
||||||
|
│ ├─ index.ts # 公共 API:cacheFn / invalidateFor / getCacheStore
|
||||||
|
│ ├─ types.ts # CacheFnOptions / CacheStore / InvalidationRule 接口
|
||||||
|
│ ├─ memory-store.ts # 内存 LRU 实现(默认,单实例降级用)
|
||||||
|
│ ├─ redis-store.ts # Redis 实现(多实例,复用 @upstash/redis)
|
||||||
|
│ ├─ store-factory.ts # 按 CACHE_DRIVER 动态加载(webpackIgnore)
|
||||||
|
│ ├─ cache-fn.ts # cacheFn(fn, { tags, ttl, keyParts }) 包装器
|
||||||
|
│ ├─ invalidation-map.ts # mutation → { tags, queryKeys, paths } 集中映射表
|
||||||
|
│ ├─ client-invalidation-map.ts # 仅 queryKeys 子集,供客户端 bundle 使用
|
||||||
|
│ ├─ invalidate.ts # invalidateFor(actionId, params) 编排函数
|
||||||
|
│ └─ upstash-modules.d.ts # 复用 rate-limit 已有的 @upstash/redis 类型声明(提升至 shared/lib/,旧路径保留 re-export shim)
|
||||||
|
├─ query-keys.ts # 新增:queryKey 工厂(按 模块/资源/操作 分层)
|
||||||
|
├─ redis-client.ts # 新增:共享 Redis 单例(rate-limit + cache 共用)
|
||||||
|
└─ rate-limit/ # 既有:速率限制(仅 redis-limiter.ts 改为引用 redis-client.ts)
|
||||||
|
|
||||||
|
src/shared/hooks/
|
||||||
|
├─ use-action-query.ts # 重构:内部改走 useQuery(QueryClient)
|
||||||
|
└─ use-action-mutation.ts # 重构:内部改走 useMutation + 自动 invalidate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 依赖方向(严格遵循三层架构)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/(dashboard)/.../page.tsx
|
||||||
|
↓ 调用
|
||||||
|
modules/[module]/actions.ts → 调用 invalidateFor("classes.update", { id }) 编排失效
|
||||||
|
↓ 调用
|
||||||
|
modules/[module]/data-access.ts → 用 cacheFn(fn, { tags, ttl, keyParts }) 包装查询
|
||||||
|
↓ 调用
|
||||||
|
shared/lib/cache/ # 缓存基础设施
|
||||||
|
↓ 内部
|
||||||
|
shared/lib/redis-client.ts # 复用 rate-limit 的 @upstash/redis 客户端
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 环境变量
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `CACHE_DRIVER` | `memory` | `memory\|redis`,对标 `RATE_LIMIT_DRIVER` |
|
||||||
|
| `UPSTASH_REDIS_REST_URL` | - | 复用 rate-limit 已有变量 |
|
||||||
|
| `UPSTASH_REDIS_REST_TOKEN` | - | 复用 rate-limit 已有变量 |
|
||||||
|
|
||||||
|
### 3.4 故障降级策略
|
||||||
|
|
||||||
|
- Redis 不可用:catch 错误 → `console.error` + 透传到原函数(直查 DB)→ 不阻断主流程
|
||||||
|
- 与 rate-limit 模块的 "fail-open 限流降级" 策略一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 服务端缓存层详细设计
|
||||||
|
|
||||||
|
### 4.1 `cacheFn` API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/cache/types.ts
|
||||||
|
export interface CacheFnOptions {
|
||||||
|
/** 缓存标签,用于按 tag 失效(必填,至少 1 个) */
|
||||||
|
tags: readonly string[]
|
||||||
|
/** TTL 秒数;不传则永久缓存(仅靠 tag 失效) */
|
||||||
|
ttl?: number
|
||||||
|
/** 自定义 key 段,默认根据 fn.name + 参数 JSON 自动生成 */
|
||||||
|
keyParts?: readonly unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// shared/lib/cache/cache-fn.ts
|
||||||
|
export function cacheFn<T extends (...args: any[]) => Promise<unknown>>(
|
||||||
|
fn: T,
|
||||||
|
options: CacheFnOptions
|
||||||
|
): T
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 调用示例(classes data-access)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 修改前
|
||||||
|
export const getClassById = cache(async (id: string) => {
|
||||||
|
return db.query.classes.findFirst({ where: eq(classes.id, id) })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
export const getClassByIdRaw = async (id: string) => {
|
||||||
|
return db.query.classes.findFirst({ where: eq(classes.id, id) })
|
||||||
|
}
|
||||||
|
export const getClassById = cacheFn(getClassByIdRaw, {
|
||||||
|
tags: ["classes:detail", "classes:detail:{id}"],
|
||||||
|
ttl: 300,
|
||||||
|
keyParts: ["classes", "by-id"],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 双层缓存包装
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cache as reactCache } from "react"
|
||||||
|
|
||||||
|
export const cacheFn = <T>(fn: T, options: CacheFnOptions): T => {
|
||||||
|
// 外层:react.cache 提供请求级 memoization(同一 RSC 请求内去重)
|
||||||
|
// 内层:cacheStore.getOrSet 提供跨请求/跨实例数据缓存
|
||||||
|
return reactCache(((...args: any[]) => {
|
||||||
|
return cacheStore.getOrSet(buildKey(fn, args, options), () => fn(...args), options)
|
||||||
|
}) as T)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**双层职责**:
|
||||||
|
- `react.cache`:请求内去重(同请求多次调用只查 1 次 DB)—— 现有行为
|
||||||
|
- `cacheStore`:跨请求/跨实例数据缓存 —— 新增能力
|
||||||
|
|
||||||
|
### 4.4 CacheStore 接口
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/cache/types.ts
|
||||||
|
export interface CacheStore {
|
||||||
|
getOrSet<T>(
|
||||||
|
key: string,
|
||||||
|
producer: () => Promise<T>,
|
||||||
|
options: { tags: readonly string[]; ttl?: number }
|
||||||
|
): Promise<T>
|
||||||
|
invalidateTags(tags: readonly string[]): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 两种实现
|
||||||
|
|
||||||
|
**memory-store.ts**(默认):
|
||||||
|
- `Map<string, { value, expireAt, tags }>` + LRU 淘汰(maxEntries=500)
|
||||||
|
- 适用于单实例 dev/test
|
||||||
|
|
||||||
|
**redis-store.ts**(多实例):
|
||||||
|
- 复用 `shared/lib/redis-client.ts` 单例
|
||||||
|
- key 格式:`next-edu:cache:{keyParts hash}`
|
||||||
|
- value:`JSON.stringify`
|
||||||
|
- tag 反查索引:`next-edu:cache:tag:{tag}` → Redis Set 存放 keys
|
||||||
|
- TTL:`redis EX` 原子设置
|
||||||
|
- `invalidateTags`:`SMEMBERS` → `DEL` 批量删除
|
||||||
|
|
||||||
|
### 4.6 `redis-client.ts` 共享单例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/redis-client.ts
|
||||||
|
// rate-limit 与 cache 共用的 Redis 客户端单例
|
||||||
|
// 复用 env.UPSTASH_REDIS_REST_URL / TOKEN
|
||||||
|
// 动态 import + webpackIgnore(与 redis-limiter.ts 同模式)
|
||||||
|
// 故障降级:返回 null,调用方走 fail-open
|
||||||
|
```
|
||||||
|
|
||||||
|
**改造 `redis-limiter.ts`**:将 `getRedisClient()` 抽出至 `redis-client.ts`,`redis-limiter.ts` 改为 `import { getRedisClient } from "@/shared/lib/redis-client"`。`rate-limit/index.ts` 单例选择逻辑不变。
|
||||||
|
|
||||||
|
### 4.7 `store-factory.ts` driver 切换
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let singleton: CacheStore | null = null
|
||||||
|
|
||||||
|
export async function getCacheStore(): Promise<CacheStore> {
|
||||||
|
if (singleton) return singleton
|
||||||
|
if (env.CACHE_DRIVER === "redis") {
|
||||||
|
const { RedisCacheStore } = await import("./redis-store")
|
||||||
|
singleton = new RedisCacheStore()
|
||||||
|
} else {
|
||||||
|
singleton = new MemoryCacheStore()
|
||||||
|
}
|
||||||
|
return singleton
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.8 Tag 命名规范
|
||||||
|
|
||||||
|
| 格式 | 示例 | 失效场景 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `{module}` | `classes`, `grades`, `users` | 模块任意写操作全模块失效 |
|
||||||
|
| `{module}:{resource}` | `classes:list`, `classes:detail`, `classes:stats` | 资源级失效 |
|
||||||
|
| `{module}:{resource}:{id}` | `classes:detail:cls_abc123` | 单条记录失效 |
|
||||||
|
|
||||||
|
**classes 模块约定的 tags**:
|
||||||
|
- `classes` —— 任意写操作全模块失效
|
||||||
|
- `classes:detail` + `classes:detail:{id}` —— 单条详情失效
|
||||||
|
- `classes:students:{classId}` —— 学生列表失效
|
||||||
|
- `classes:schedule:{classId}` —— 课表失效
|
||||||
|
- `classes:stats` —— 聚合统计失效
|
||||||
|
- `classes:invitations:{classId}` —— 邀请码失效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 失效编排与客户端缓存
|
||||||
|
|
||||||
|
### 5.1 集中式失效映射表
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/cache/invalidation-map.ts
|
||||||
|
|
||||||
|
export interface InvalidationRule {
|
||||||
|
readonly tags: readonly string[]
|
||||||
|
readonly queryKeys: readonly (readonly (string | number)[])[]
|
||||||
|
readonly paths: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INVALIDATION_MAP = {
|
||||||
|
// ===== classes 模块 =====
|
||||||
|
"classes.create": {
|
||||||
|
tags: ["classes", "classes:list"],
|
||||||
|
queryKeys: [["classes", "list"]],
|
||||||
|
paths: ["/teacher/classes/my", "/admin/classes"],
|
||||||
|
},
|
||||||
|
"classes.update": {
|
||||||
|
tags: ["classes", "classes:detail", "classes:detail:{id}"],
|
||||||
|
queryKeys: [["classes", "detail"], ["classes", "list"]],
|
||||||
|
// paths 不含动态段:revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由)
|
||||||
|
paths: ["/teacher/classes/my", "/admin/classes"],
|
||||||
|
},
|
||||||
|
"classes.delete": {
|
||||||
|
tags: ["classes", "classes:list", "classes:detail:{id}"],
|
||||||
|
queryKeys: [["classes", "list"], ["classes", "detail"]],
|
||||||
|
paths: ["/teacher/classes/my", "/admin/classes"],
|
||||||
|
},
|
||||||
|
"classes.students.update": {
|
||||||
|
tags: ["classes:students:{classId}"],
|
||||||
|
queryKeys: [["classes", "students"]],
|
||||||
|
paths: ["/teacher/classes/my"],
|
||||||
|
},
|
||||||
|
"classes.schedule.update": {
|
||||||
|
tags: ["classes:schedule:{classId}"],
|
||||||
|
queryKeys: [["classes", "schedule"]],
|
||||||
|
paths: ["/teacher/classes/schedule"],
|
||||||
|
},
|
||||||
|
"classes.grade.update": {
|
||||||
|
tags: ["classes"],
|
||||||
|
queryKeys: [["classes", "list"]],
|
||||||
|
paths: [],
|
||||||
|
},
|
||||||
|
"classes.invitation.create": {
|
||||||
|
tags: ["classes:invitations:{classId}"],
|
||||||
|
queryKeys: [["classes", "invitations"]],
|
||||||
|
paths: ["/teacher/classes/my/{classId}"],
|
||||||
|
},
|
||||||
|
"classes.invitation.revoke": {
|
||||||
|
tags: ["classes:invitations:{classId}"],
|
||||||
|
queryKeys: [["classes", "invitations"]],
|
||||||
|
paths: ["/teacher/classes/my/{classId}"],
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, InvalidationRule>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `invalidateFor` 编排函数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/cache/invalidate.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 Server Action 在写操作成功后调用,集中编排缓存失效。
|
||||||
|
*
|
||||||
|
* 三件事:
|
||||||
|
* 1. cacheStore.invalidateTags(服务端数据缓存失效)
|
||||||
|
* 2. revalidateTag × N(Next.js fetch 缓存 + unstable_cache 失效)
|
||||||
|
* 3. revalidatePath × N(RSC 静态缓存失效)
|
||||||
|
*
|
||||||
|
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
|
||||||
|
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
|
||||||
|
*/
|
||||||
|
export async function invalidateFor(
|
||||||
|
actionId: string,
|
||||||
|
params: Record<string, string> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const rule = INVALIDATION_MAP[actionId]
|
||||||
|
if (!rule) {
|
||||||
|
throw new Error(`[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 服务端数据缓存失效(Redis / 内存)
|
||||||
|
const resolvedTags = rule.tags.map((t) => fillTemplate(t, params))
|
||||||
|
const store = await getCacheStore()
|
||||||
|
await store.invalidateTags(resolvedTags)
|
||||||
|
|
||||||
|
// 2. Next.js fetch 缓存 + unstable_cache 失效
|
||||||
|
resolvedTags.forEach((tag) => revalidateTag(tag))
|
||||||
|
|
||||||
|
// 3. 路径级 RSC 缓存失效
|
||||||
|
rule.paths.forEach((p) => revalidatePath(fillTemplate(p, params)))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 actions 层调用范式(classes 标杆)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// modules/classes/actions-teacher.ts(重构后)
|
||||||
|
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { invalidateFor } from "@/shared/lib/cache"
|
||||||
|
import { updateClassInScope } from "../data-access-teacher"
|
||||||
|
|
||||||
|
export async function updateClassAction(
|
||||||
|
input: UpdateClassInput
|
||||||
|
): Promise<ActionState<{ classId: string }>> {
|
||||||
|
await requirePermission(Permission.CLASS_MANAGE)
|
||||||
|
const result = await updateClassInScope(input)
|
||||||
|
|
||||||
|
// 一行代替原来 3-5 行 revalidatePath 散乱调用
|
||||||
|
await invalidateFor("classes.update", { id: result.classId })
|
||||||
|
|
||||||
|
return { success: true, data: { classId: result.classId } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 客户端 queryKey 工厂
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/query-keys.ts
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
classes: {
|
||||||
|
all: ["classes"] as const,
|
||||||
|
lists: () => [...queryKeys.classes.all, "list"] as const,
|
||||||
|
list: (filters: ClassFilters) => [...queryKeys.classes.lists(), filters] as const,
|
||||||
|
details: () => [...queryKeys.classes.all, "detail"] as const,
|
||||||
|
detail: (id: string) => [...queryKeys.classes.details(), id] as const,
|
||||||
|
students: (classId: string) => [...queryKeys.classes.all, "students", classId] as const,
|
||||||
|
schedule: (classId: string) => [...queryKeys.classes.all, "schedule", classId] as const,
|
||||||
|
stats: (classId: string) => [...queryKeys.classes.all, "stats", classId] as const,
|
||||||
|
invitations: (classId: string) => [...queryKeys.classes.all, "invitations", classId] as const,
|
||||||
|
},
|
||||||
|
// 后续模块按此模式扩展...
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type QueryKeys = typeof queryKeys
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 `useActionQuery` 重构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/hooks/use-action-query.ts(重构后)
|
||||||
|
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
|
export interface UseActionQueryOptions<T> extends Omit<
|
||||||
|
UseQueryOptions<ActionState<T>>,
|
||||||
|
"queryKey" | "queryFn"
|
||||||
|
> {
|
||||||
|
/** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id) */
|
||||||
|
queryKey: readonly (string | number | object)[]
|
||||||
|
/** 是否启用,默认 true */
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActionQuery<T>(
|
||||||
|
action: () => Promise<ActionState<T>>,
|
||||||
|
options: UseActionQueryOptions<T>
|
||||||
|
): { data: T | undefined; loading: boolean; error: Error | null; refetch: () => void } {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: options.queryKey,
|
||||||
|
queryFn: action,
|
||||||
|
enabled: options.enabled ?? true,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解包 ActionState:失败时抛错,成功时返回 data
|
||||||
|
const data = query.data?.success ? query.data.data : undefined
|
||||||
|
const error = query.error ?? (query.data && !query.data.success
|
||||||
|
? new Error(query.data.message ?? "Action failed")
|
||||||
|
: null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
loading: query.isLoading,
|
||||||
|
error,
|
||||||
|
refetch: () => { void query.refetch() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**破坏性变更**:`useActionQuery` 必须传 `queryKey`(之前不需要)。强制集中化 queryKey 是本次重构的核心目标。
|
||||||
|
|
||||||
|
### 5.6 `useActionMutation` 重构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/hooks/use-action-mutation.ts(重构后)
|
||||||
|
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { notify } from "@/shared/lib/notify"
|
||||||
|
import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map"
|
||||||
|
|
||||||
|
export interface UseActionMutationOptions<T> {
|
||||||
|
/** mutationFn */
|
||||||
|
mutationFn: () => Promise<ActionState<T>>
|
||||||
|
/** 关联的 INVALIDATION_MAP actionId,成功后自动失效相关 queryKey */
|
||||||
|
actionId?: string
|
||||||
|
/** 模板参数,如 { id: "cls_123" } */
|
||||||
|
params?: Record<string, string>
|
||||||
|
/** 显式额外失效的 queryKey(不通过 actionId 时使用) */
|
||||||
|
invalidateQueryKeys?: readonly (readonly (string | number | object)[])[]
|
||||||
|
successMessage?: string | false
|
||||||
|
errorMessage?: string | false
|
||||||
|
onSuccess?: (data: T | undefined) => void
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActionMutation<T = unknown>(options: UseActionMutationOptions<T>) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const mutation = useMutation<ActionState<T>, Error, void>({
|
||||||
|
mutationFn: options.mutationFn,
|
||||||
|
onError: (error) => {
|
||||||
|
if (options.errorMessage !== false) {
|
||||||
|
notify.error(options.errorMessage ?? error.message)
|
||||||
|
}
|
||||||
|
options.onError?.(error)
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (result.success) {
|
||||||
|
if (options.successMessage !== false) {
|
||||||
|
notify.success(options.successMessage ?? result.message ?? "Operation succeeded")
|
||||||
|
}
|
||||||
|
// 自动失效:根据 actionId 查 CLIENT_INVALIDATION_MAP 的客户端 queryKey 列表
|
||||||
|
const queryKeysToInvalidate = resolveClientInvalidations(options.actionId, options.params)
|
||||||
|
await Promise.all(
|
||||||
|
queryKeysToInvalidate.map((qk) => queryClient.invalidateQueries({ queryKey: qk }))
|
||||||
|
)
|
||||||
|
options.onSuccess?.(result.data)
|
||||||
|
} else {
|
||||||
|
if (options.errorMessage !== false) {
|
||||||
|
notify.error(options.errorMessage ?? result.message ?? "Operation failed")
|
||||||
|
}
|
||||||
|
options.onError?.(new Error(result.message ?? "Action returned failure"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isWorking: mutation.isPending, mutate: mutation.mutateAsync }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 客户端可见的失效映射子集
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/lib/cache/client-invalidation-map.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅客户端 queryKey 失效映射(INVALIDATION_MAP 的子集)。
|
||||||
|
* 从 invalidation-map.ts 提取 queryKeys 字段,避免客户端 bundle 拉入 revalidateTag 等 server-only。
|
||||||
|
*/
|
||||||
|
export const CLIENT_INVALIDATION_MAP = {
|
||||||
|
"classes.create": { queryKeys: [["classes", "list"]] },
|
||||||
|
"classes.update": { queryKeys: [["classes", "detail"], ["classes", "list"]] },
|
||||||
|
"classes.delete": { queryKeys: [["classes", "list"], ["classes", "detail"]] },
|
||||||
|
"classes.students.update": { queryKeys: [["classes", "students"]] },
|
||||||
|
"classes.schedule.update": { queryKeys: [["classes", "schedule"]] },
|
||||||
|
"classes.grade.update": { queryKeys: [["classes", "list"]] },
|
||||||
|
"classes.invitation.create": { queryKeys: [["classes", "invitations"]] },
|
||||||
|
"classes.invitation.revoke": { queryKeys: [["classes", "invitations"]] },
|
||||||
|
} as const
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 ESLint 强制规则
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// eslint.config.js(节选)
|
||||||
|
{
|
||||||
|
files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.name='revalidatePath']",
|
||||||
|
message: "使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.name='revalidateTag']",
|
||||||
|
message: "使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// 豁免:shared/lib/cache/ 内部允许调用
|
||||||
|
overrides: [{
|
||||||
|
files: ["src/shared/lib/cache/**/*.ts"],
|
||||||
|
rules: { "no-restricted-syntax": "off" },
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. classes 标杆迁移
|
||||||
|
|
||||||
|
### 6.1 data-access 迁移清单
|
||||||
|
|
||||||
|
| 文件 | 当前 react.cache 用法 | 迁移后 cacheFn 用法 |
|
||||||
|
|------|------|------|
|
||||||
|
| `data-access-teacher.ts` | `cache(getClassesByTeacherId)` 等 | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` |
|
||||||
|
| `data-access-admin.ts` | `cache(getAllClassesForAdmin)` | `cacheFn(fn, { tags: ["classes", "classes:list"], ttl: 300 })` |
|
||||||
|
| `data-access-students.ts` | `cache(getStudentsByClass)` | `cacheFn(fn, { tags: ["classes:students:{classId}"], ttl: 60 })` |
|
||||||
|
| `data-access-stats.ts` | `cache(getClassStats)` | `cacheFn(fn, { tags: ["classes:stats"], ttl: 60 })` |
|
||||||
|
| `data-access-schedule.ts` | `cache(getScheduleByClass)` | `cacheFn(fn, { tags: ["classes:schedule:{classId}"], ttl: 600 })` |
|
||||||
|
| `data-access.ts` | 共享类型与行映射 | 不动(无 cache 调用) |
|
||||||
|
|
||||||
|
### 6.2 TTL 策略
|
||||||
|
|
||||||
|
| 资源 | TTL | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 列表/详情(`classes`, `classes:detail`) | 300s | 容忍 5 分钟滞后,写后 tag 立即失效 |
|
||||||
|
| 学生名单(`classes:students:{classId}`) | 60s | 转班/调班需较快感知 |
|
||||||
|
| 课表(`classes:schedule:{classId}`) | 600s | 课表变更频率极低 |
|
||||||
|
| 统计(`classes:stats`) | 60s | 聚合数据短 TTL 兜底 |
|
||||||
|
| 邀请码(`classes:invitations:{classId}`) | 300s | 与列表一致 |
|
||||||
|
|
||||||
|
### 6.3 actions 迁移清单
|
||||||
|
|
||||||
|
| 文件 | 当前 revalidatePath | 迁移后 |
|
||||||
|
|------|------|------|
|
||||||
|
| `actions-teacher.ts` | `revalidatePath("/teacher/classes/my")` 等 | `invalidateFor("classes.update", { id })` |
|
||||||
|
| `actions-admin.ts` | 多处 revalidatePath | `invalidateFor("classes.create/update/delete")` |
|
||||||
|
| `actions-grade.ts` | `revalidatePath("/admin/grades")` | `invalidateFor("classes.grade.update")` |
|
||||||
|
| `actions-invitations.ts` | 多处 | `invalidateFor("classes.invitation.create/revoke")` |
|
||||||
|
| `actions-schedule.ts` | 多处 | `invalidateFor("classes.schedule.update")` |
|
||||||
|
|
||||||
|
### 6.4 双导出模式(修复 P2 单测可 mock 性)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// data-access-teacher.ts
|
||||||
|
export const getClassByIdRaw = async (id: string) => {
|
||||||
|
return db.query.classes.findFirst({ where: eq(classes.id, id) })
|
||||||
|
}
|
||||||
|
export const getClassById = cacheFn(getClassByIdRaw, {
|
||||||
|
tags: ["classes:detail", "classes:detail:{id}"],
|
||||||
|
ttl: 300,
|
||||||
|
keyParts: ["classes", "by-id"],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
测试时 import `getClassByIdRaw` 直接 mock,绕过 cacheStore。
|
||||||
|
|
||||||
|
### 6.5 客户端组件迁移示范
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/(dashboard)/teacher/classes/my/[id]/page.tsx(重构后)
|
||||||
|
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { queryKeys } from "@/shared/lib/query-keys"
|
||||||
|
import { getClassDetailAction } from "@/modules/classes/actions-teacher"
|
||||||
|
|
||||||
|
export default function ClassDetailPage({ params }: { params: { id: string } }) {
|
||||||
|
// 之前:手动 useState + useEffect + Action().then()
|
||||||
|
// 现在:声明式 useQuery,自动跨页共享缓存
|
||||||
|
const { data: classDetail, isLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.classes.detail(params.id),
|
||||||
|
queryFn: () => getClassDetailAction({ classId: params.id }),
|
||||||
|
})
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**互操作验证**:当 `useActionMutation({ actionId: "classes.update" })` 成功后,`queryClient.invalidateQueries({ queryKey: ["classes", "detail"] })` 自动触发 refetch,无需在 onSuccess 中手写 `router.refresh()`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 测试策略
|
||||||
|
|
||||||
|
| 类型 | 文件 | 验证目标 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 单元 | `cache/memory-store.test.ts` | LRU 淘汰、TTL 过期、tag 索引维护 |
|
||||||
|
| 单元 | `cache/redis-store.test.ts` | mock Redis 客户端,验证 key 格式、SMEMBERS+DEL 批量删除 |
|
||||||
|
| 单元 | `cache/cache-fn.test.ts` | react.cache + cacheStore 双层包装;keyParts 生成;tags 传递 |
|
||||||
|
| 单元 | `cache/invalidation-map.test.ts` | 模板填充 `{id}` 占位符;未知 actionId 抛错 |
|
||||||
|
| 集成 | `cache/invalidate.test.ts` | invalidateFor 三步编排顺序(store.invalidateTags → revalidateTag → revalidatePath) |
|
||||||
|
| 集成 | `hooks/use-action-query.test.tsx` | QueryClient 集成;queryKey 失效后自动 refetch |
|
||||||
|
| 集成 | `hooks/use-action-mutation.test.tsx` | actionId 关联自动 invalidate;显式 invalidateQueryKeys 回退 |
|
||||||
|
| 标杆 | `modules/classes/**/*.test.ts` | 现有测试不破(仅替换 cache 包装) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 架构文档同步
|
||||||
|
|
||||||
|
修改完成后同步以下文档(项目规则强制):
|
||||||
|
|
||||||
|
| 文档 | 修改内容 |
|
||||||
|
|------|---------|
|
||||||
|
| `docs/architecture/004_architecture_impact_map.md` | 新增"缓存基础设施"章节(位于 rate-limit 章节后);更新 classes 模块章节(标注 cacheFn 包装) |
|
||||||
|
| `docs/architecture/005_architecture_data.json` | `shared.lib.cache.*` 节点;classes 模块的 `cacheUsage` 字段;INVALIDATION_MAP 节点 |
|
||||||
|
| `docs/troubleshooting/known-issues.md` | 新增"缓存策略规则"章节(cacheFn 用法、invalidateFor 强制、queryKey 工厂强制) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收标准
|
||||||
|
|
||||||
|
| 标准 | 验证方法 |
|
||||||
|
|------|---------|
|
||||||
|
| `npm run lint` 零错误 | 含新增 ESLint 规则 |
|
||||||
|
| `npx tsc --noEmit` 零错误 | 含 cacheFn 类型推导 |
|
||||||
|
| classes 模块 6 个 data-access 全部 cacheFn 包装 | grep 验证 |
|
||||||
|
| classes 模块 5 个 actions 全部用 invalidateFor | ESLint 验证零 revalidatePath 直接调用 |
|
||||||
|
| classes 模块新增 2+ 单测通过 | `npm test cache memory-store` |
|
||||||
|
| Redis 模式下多实例缓存共享验证 | 手动:CACHE_DRIVER=redis 启动两实例,A 实例写后 B 实例读取验证 |
|
||||||
|
| 架构文档同步 | git diff 验证 004/005/known-issues 三个文件已更新 |
|
||||||
|
| rate-limit 模块迁移至 redis-client.ts 共享 | 现有 rate-limit 单测不破 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| `useActionQuery` 破坏性变更影响 11 个文件 | 本期仅迁移 classes 标杆页面;其他文件保持 useEffect 模式待下期;TypeScript 编译会立即提示漏传 queryKey |
|
||||||
|
| `useActionMutation` 破坏性变更影响 50+ 文件 | 提供 `invalidateQueryKeys` 显式回退入口,不强制传 actionId;旧调用方先迁移 onSuccess 的 toast 逻辑,actionId 后续补 |
|
||||||
|
| Redis 故障导致缓存穿透 | fail-open 策略:catch 错误 → 直查 DB;与 rate-limit 一致 |
|
||||||
|
| 多实例下 tag 失效延迟 | Redis pub/sub 暂不引入;依赖 tag 索引在 Redis 中立即可见(同实例内 revalidateTag 立即生效;跨实例依赖下次请求读取 Redis) |
|
||||||
|
| INVALIDATION_MAP 漏登记导致失效不全 | ESLint 规则 + 代码审查双重保障;新增写操作 PR 必须更新 INVALIDATION_MAP |
|
||||||
|
| `cacheFn` 包装函数 `this` 绑定丢失 | 仅包装纯函数(data-access 不使用 `this`);签名约束 `extends (...args) => Promise<unknown>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 不在本期范围
|
||||||
|
|
||||||
|
- 其他 29 个模块的 data-access / actions 迁移(下期按模块推进)
|
||||||
|
- 其他客户端组件迁移到 useQuery(下期按页面推进)
|
||||||
|
- L1+L2 两级缓存(YAGNI,先用单层 Redis 验证)
|
||||||
|
- 缓存预热 / SWR 主动刷新(YAGNI)
|
||||||
|
- 缓存命中率监控埋点(YAGNI)
|
||||||
|
- Redis pub/sub 跨实例失效广播(YAGNI,单实例内 tag 失效已足够)
|
||||||
|
- 现有 `react.cache` 全量替换(保留作为请求级 memoization 内层)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 后续工作(下期预告)
|
||||||
|
|
||||||
|
1. 按模块推进其他 29 个模块的 data-access / actions 迁移
|
||||||
|
2. 按页面推进客户端组件迁移到 useQuery
|
||||||
|
3. 在 100+ 组件中替换 `toast.success/error` 为 `notify.success/error`(V5 状态管理专项已建)
|
||||||
|
4. 评估缓存命中率监控(Prometheus + Grafana)
|
||||||
|
5. 评估引入 Redis pub/sub 实现跨实例实时失效广播(如跨实例失效延迟不可接受)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:与 rate-limit 模块的复用关系
|
||||||
|
|
||||||
|
| 资产 | rate-limit 现状 | cache 复用方式 |
|
||||||
|
|------|----------------|---------------|
|
||||||
|
| `RATE_LIMIT_DRIVER` env | 已实现 | 新增 `CACHE_DRIVER` env,独立控制 |
|
||||||
|
| `@upstash/redis` 动态 import | `redis-limiter.ts` 内部 `getRedisClient()` | 抽出至 `redis-client.ts`,rate-limit + cache 共用 |
|
||||||
|
| `@upstash/ratelimit` 动态 import | `redis-limiter.ts` 内部 | 不复用,cache 不需要 ratelimit |
|
||||||
|
| `upstash-modules.d.ts` | rate-limit 目录内 | 提升至 `shared/lib/upstash-modules.d.ts`,rate-limit 与 cache 均从提升后的位置 import;旧路径保留 re-export shim 避免破坏现有 import |
|
||||||
|
| fail-open 策略 | catch → 兜底返回 allow | catch → 直查 DB(透传 producer) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 B:queryKey 命名约定
|
||||||
|
|
||||||
|
格式:`[module, resource, ...args]`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
queryKeys.classes.all // ["classes"]
|
||||||
|
queryKeys.classes.lists() // ["classes", "list"]
|
||||||
|
queryKeys.classes.list(filters) // ["classes", "list", filters]
|
||||||
|
queryKeys.classes.detail(id) // ["classes", "detail", id]
|
||||||
|
queryKeys.classes.students(classId) // ["classes", "students", classId]
|
||||||
|
queryKeys.classes.schedule(classId) // ["classes", "schedule", classId]
|
||||||
|
queryKeys.classes.stats(classId) // ["classes", "stats", classId]
|
||||||
|
queryKeys.classes.invitations(classId) // ["classes", "invitations", classId]
|
||||||
|
```
|
||||||
|
|
||||||
|
**约定**:
|
||||||
|
- 第一段:模块名(与 `src/modules/[module]/` 一致)
|
||||||
|
- 第二段:资源名(复数:list / detail / students / schedule / stats)
|
||||||
|
- 后续段:唯一标识(id / classId)或过滤条件对象
|
||||||
|
- 失效时使用前缀匹配:`invalidateQueries({ queryKey: ["classes", "detail"] })` 失效所有详情
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# 组件化重构专项设计文档
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|---|
|
||||||
|
| 文档版本 | v1 |
|
||||||
|
| 创建日期 | 2026-07-06 |
|
||||||
|
| 作者 | Trae 协作生成 |
|
||||||
|
| 状态 | 待用户审查 |
|
||||||
|
| 范围 | 巨型文件拆分(9 个)+ 重复组件收敛(49 个)+ 共享底座补全 |
|
||||||
|
| 策略 | 混合:先收敛底座,再按模块垂直闭环(3 批风险分级) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与现状
|
||||||
|
|
||||||
|
### 1.1 巨型文件清单(9 个,>400 行)
|
||||||
|
|
||||||
|
基于 2026-07-06 实际扫描:
|
||||||
|
|
||||||
|
| # | 路径 | 行数 | 模块 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | `src/modules/textbooks/components/textbook-reader.tsx` | >450 | textbooks |
|
||||||
|
| 2 | `src/modules/lesson-preparation/components/lesson-plan-editor.tsx` | >450 | lesson-preparation |
|
||||||
|
| 3 | `src/modules/grades/components/batch-grade-entry.tsx` | >450 | grades |
|
||||||
|
| 4 | `src/modules/grades/components/grade-record-list.tsx` | >450 | grades |
|
||||||
|
| 5 | `src/modules/homework/components/homework-take-view.tsx` | 428 | homework |
|
||||||
|
| 6 | `src/modules/homework/components/homework-grading-view.tsx` | >450 | homework |
|
||||||
|
| 7 | `src/modules/exams/components/exam-assembly.tsx` | >450 | exams |
|
||||||
|
| 8 | `src/modules/ai/components/ai-chat-panel.tsx` | 417 | ai |
|
||||||
|
| 9 | `src/modules/textbooks/components/knowledge-graph-inner.tsx` | 411 | textbooks |
|
||||||
|
|
||||||
|
**已瘦身的 5 个文件**(不在本次范围内):exam-rich-form.tsx (384)、message-list.tsx (396)、selection-toolbar.tsx (366)、error-book-detail-dialog.tsx (336)、admin-users-view.tsx (332)。
|
||||||
|
|
||||||
|
### 1.2 重复组件清单(49 个)
|
||||||
|
|
||||||
|
| 类型 | 数量 | 示例路径 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `*-filters.tsx` | 20 | `grades/components/grade-filters.tsx` 等 |
|
||||||
|
| `*-stats-cards.tsx` / `*-stats-card.tsx` | 8 | `attendance/components/attendance-stats-cards.tsx` 等 |
|
||||||
|
| `*-skeleton.tsx` | 8 | `ai/components/ai-skeleton.tsx` 等 |
|
||||||
|
| `*-error-boundary.tsx` | 8 | `audit/components/audit-error-boundary.tsx` 等 |
|
||||||
|
| `*-dashboard.tsx` | 5 | `dashboard/components/admin-dashboard/admin-dashboard.tsx` 等 |
|
||||||
|
|
||||||
|
**已知冲突**:
|
||||||
|
- `textbooks/components/section-error-boundary.tsx` 与 `shared/components/section-error-boundary.tsx` 同名
|
||||||
|
- `attendance/components/attendance-stats-card.tsx`(单数)与 `attendance-stats-cards.tsx`(复数)并存
|
||||||
|
|
||||||
|
### 1.3 共享底座现状(关键发现)
|
||||||
|
|
||||||
|
| 底座 | 状态 | 路径 | 当前接口 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| `FilterBar` | 已存在 | `shared/components/ui/filter-bar.tsx` (124 行) | children-based 组合式:`FilterBar` + `FilterSearchInput` + `FilterResetButton` |
|
||||||
|
| `StatCard` | 已存在 | `shared/components/ui/stat-card.tsx` (95 行) | 单卡片,无 grid 容器 |
|
||||||
|
| `Skeleton` | 已存在 | `shared/components/ui/skeleton.tsx` (15 行) | 仅原语,无 variant |
|
||||||
|
| `ErrorBoundary` | **缺失** | — | 有 3 个分散替代件:`section-error-boundary.tsx` (148)、`route-error.tsx` (44)、`widget-boundary.tsx` (155) |
|
||||||
|
|
||||||
|
### 1.4 架构文档缺口
|
||||||
|
|
||||||
|
`docs/architecture/005_architecture_data.json` 的 `lastUpdate` 仅记录缓存策略重构(2026-07-05),**未记录** `filter-bar`/`stat-card`/`widget-boundary` 等底座组件的存在,违反项目规则"新增组件必须同步 004/005"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 2.1 目标
|
||||||
|
|
||||||
|
1. **补全底座**:新增 `ui/error-boundary.tsx` 基础组件 + 收敛 3 个分散替代件为 preset
|
||||||
|
2. **扩展底座**:新增 `StatsGrid` 容器(基于 `StatCard`)、`SkeletonCard` variant(基于 `Skeleton`)
|
||||||
|
3. **拆分巨型文件**:9 个 >400 行组件全部拆为容器+子组件,目标 ≤500 行
|
||||||
|
4. **迁移重复组件**:49 个重复组件迁移到底座,删除旧实现(不留 backwards-compat shim)
|
||||||
|
5. **同步架构文档**:004 / 005 / known-issues.md 全量更新
|
||||||
|
|
||||||
|
### 2.2 非目标(YAGNI)
|
||||||
|
|
||||||
|
- `shared/db/schema.ts` (1111 行) 拆分 — 属于解耦专项
|
||||||
|
- `exams/actions.ts` (633 行) 拆分 — 属于解耦专项
|
||||||
|
- 各模块 `data-access.ts` 拆分 — 属于解耦专项
|
||||||
|
- `proctoring/exam-mode-config.tsx` 死代码清理 — 单独任务
|
||||||
|
- `useDialogState` 批量接入 — V5 状态专项收尾
|
||||||
|
- 跨模块直接 DB 查询扫描 — 解耦专项遗留
|
||||||
|
- FilterBar 接口改为配置驱动 — 当前 children-based 组合式更符合 React 模式,保持不变
|
||||||
|
- 单元测试编写 — 项目无单测覆盖,本次不引入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 架构设计
|
||||||
|
|
||||||
|
### 3.1 Phase 1:底座收敛与补全
|
||||||
|
|
||||||
|
#### 3.1.1 ErrorBoundary 收敛方案
|
||||||
|
|
||||||
|
**新增** `src/shared/components/ui/error-boundary.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
fallback: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode);
|
||||||
|
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, { hasError: boolean; error: Error | null }> {
|
||||||
|
// 标准 React ErrorBoundary 实现
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收敛 3 个替代件为 preset**(保留外部 API,内部改用 `<ErrorBoundary>`):
|
||||||
|
|
||||||
|
| 现有文件 | 收敛后 | 位置 |
|
||||||
|
|---------|-------|------|
|
||||||
|
| `shared/components/section-error-boundary.tsx` | `SectionErrorBoundary` preset | 保留原路径,内部改为 `<ErrorBoundary fallback={...}>` 包装 |
|
||||||
|
| `shared/components/route-error.tsx` | `RouteErrorBoundary` preset | 保留原路径 |
|
||||||
|
| `shared/components/widget-boundary.tsx` | `WidgetBoundary` preset | 保留原路径(含 Suspense + Skeleton 三合一) |
|
||||||
|
|
||||||
|
**模块层 8 个 `*-error-boundary.tsx`**:删除,改为直接使用 `SectionErrorBoundary` preset。
|
||||||
|
|
||||||
|
#### 3.1.2 StatsGrid 新增方案
|
||||||
|
|
||||||
|
**新增** `src/shared/components/ui/stats-grid.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
trend?: { direction: 'up' | 'down' | 'flat'; delta: string };
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsGridProps {
|
||||||
|
items: StatItem[];
|
||||||
|
columns?: 2 | 3 | 4; // 默认响应式
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
内部用 `StatCard` 渲染,`isLoading` 时渲染 `StatCardSkeleton`。
|
||||||
|
|
||||||
|
#### 3.1.3 SkeletonCard variant 方案
|
||||||
|
|
||||||
|
**扩展** `src/shared/components/ui/skeleton.tsx`(15 行 → ~80 行):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface SkeletonCardProps {
|
||||||
|
variant: 'table' | 'list' | 'chart' | 'form' | 'stats-grid';
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard({ variant, rows = 5 }: SkeletonCardProps) {
|
||||||
|
// 按 variant 渲染不同骨架布局
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
// 保留原语
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.4 FilterBar 与 StatCard
|
||||||
|
|
||||||
|
- `FilterBar`:**保持现状**,不修改接口。模块层 `*-filters.tsx` 改为 `<FilterBar><FilterSearchInput .../><select .../></FilterBar>` 组合实现
|
||||||
|
- `StatCard`:**保持现状**,作为 `StatsGrid` 内部使用
|
||||||
|
|
||||||
|
#### 3.1.5 Phase 1 交付物
|
||||||
|
|
||||||
|
| 文件 | 操作 |
|
||||||
|
|------|------|
|
||||||
|
| `shared/components/ui/error-boundary.tsx` | 新增 |
|
||||||
|
| `shared/components/ui/stats-grid.tsx` | 新增 |
|
||||||
|
| `shared/components/ui/skeleton.tsx` | 扩展(新增 SkeletonCard) |
|
||||||
|
| `shared/components/ui/filter-bar.tsx` | 不修改 |
|
||||||
|
| `shared/components/ui/stat-card.tsx` | 不修改 |
|
||||||
|
| `shared/components/section-error-boundary.tsx` | 内部改用 ErrorBoundary |
|
||||||
|
| `shared/components/route-error.tsx` | 内部改用 ErrorBoundary |
|
||||||
|
| `shared/components/widget-boundary.tsx` | 内部改用 ErrorBoundary |
|
||||||
|
| `shared/components/index.ts` | 新增 re-export |
|
||||||
|
| `docs/architecture/004` / `005` | 同步底座节点 |
|
||||||
|
|
||||||
|
### 3.2 Phase 2:模块垂直闭环
|
||||||
|
|
||||||
|
#### 3.2.1 批次划分
|
||||||
|
|
||||||
|
| 批次 | 模块 | 巨型文件 | 重复组件 | 风险 |
|
||||||
|
|------|------|---------|---------|------|
|
||||||
|
| **第 1 批** | grades | `batch-grade-entry.tsx`、`grade-record-list.tsx` | `grade-stats-card`、`grade-filters`、`grade-query-filters`、`analytics-filters` | 低 |
|
||||||
|
| | attendance | — | `attendance-stats-cards`、`attendance-stats-card`(单数,重复)、`attendance-filters` | 低 |
|
||||||
|
| | error-book | — | `error-book-stats-cards`、`error-book-filters`、`analytics-stats-cards` | 低 |
|
||||||
|
| | elective | — | `elective-stats-cards`、`elective-filters` | 低 |
|
||||||
|
| **第 2 批** | homework | `homework-take-view.tsx` (428)、`homework-grading-view.tsx` | `assignment-filters` | 中 |
|
||||||
|
| | exams | `exam-assembly.tsx` | `exam-filters` | 中 |
|
||||||
|
| | textbooks | `textbook-reader.tsx`、`knowledge-graph-inner.tsx` (411) | `textbook-filters`、`section-error-boundary.tsx`(同名冲突) | 中 |
|
||||||
|
| **第 3 批** | lesson-preparation | `lesson-plan-editor.tsx` | `lesson-plan-filters`、`lesson-plan-skeleton`、`lesson-plan-error-boundary` | 高 |
|
||||||
|
| | ai | `ai-chat-panel.tsx` (417) | `ai-skeleton`、`ai-error-boundary` | 高 |
|
||||||
|
| | dashboard | — | `dashboard-loading-skeleton`、4 个角色 dashboard 子组件(仅抽象布局壳) | 高 |
|
||||||
|
|
||||||
|
#### 3.2.2 单模块闭环流程(6 步)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 模块审计 → Read 全部目标文件 + 重复组件,记录依赖与外部 API
|
||||||
|
2. 拆分巨型文件 → 拆为 容器 + 子组件(保持外部 API 不变)
|
||||||
|
3. 迁移到底座 → *-filters → FilterBar、*-stats-cards → StatsGrid、
|
||||||
|
*-skeleton → SkeletonCard、*-error-boundary → SectionErrorBoundary
|
||||||
|
4. 删除旧组件 → 直接删除,不留 backwards-compat shim
|
||||||
|
5. 验证 → tsc --noEmit + npm run lint + 手动回归
|
||||||
|
6. 文档同步 → 004 / 005 / known-issues.md
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 跨批次依赖规则
|
||||||
|
|
||||||
|
- Phase 1 完成 → 启动第 1 批
|
||||||
|
- 第 1 批完成 → 评估底座接口是否需调整(允许返回 Phase 1 调整)
|
||||||
|
- 第 2 批启动前 → 第 1 批验证通过
|
||||||
|
- 第 3 批启动前 → 第 2 批验证通过(高风险模块需底座稳定)
|
||||||
|
|
||||||
|
#### 3.2.4 dashboard 4 角色抽象范围
|
||||||
|
|
||||||
|
仅抽象**布局壳**(`<DashboardShell>` 含 PageHeader + StatsGrid 区 + 内容区),各角色业务逻辑保持独立。不强制 4 个 dashboard 使用相同内容区。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 验收标准
|
||||||
|
|
||||||
|
### 4.1 代码验收(硬性门禁)
|
||||||
|
|
||||||
|
| 检查项 | 命令 | 通过标准 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| TypeScript | `npx tsc --noEmit` | 零错误 |
|
||||||
|
| ESLint | `npm run lint` | 零新增错误(pre-existing warnings 不计入) |
|
||||||
|
| 文件行数 | Read 确认 | 9 个巨型文件拆分后 ≤500 行 |
|
||||||
|
| 重复组件删除 | Grep 旧组件名 | 全局无残留引用 |
|
||||||
|
| 外部 API 不变 | Grep 模块导出名 | 容器组件对外 API 签名不变 |
|
||||||
|
| 设计令牌合规 | Grep `#hex`、`'Inter'`、`w-[` | 零违规(豁免场景除外) |
|
||||||
|
|
||||||
|
### 4.2 行为验收(手动回归)
|
||||||
|
|
||||||
|
| 模块类型 | 验收方式 |
|
||||||
|
|---------|---------|
|
||||||
|
| 列表/筛选页 | 进入页面 → 应用筛选 → 重置 → 翻页 → 数据正确 |
|
||||||
|
| 表单页 | 打开 Dialog → 填写 → 提交 → 列表刷新 |
|
||||||
|
| 编辑器 | 进入 → 编辑内容 → 保存 → 重新加载内容存在 |
|
||||||
|
| dashboard | 4 角色分别登录 → 卡片渲染 → 数据正确 |
|
||||||
|
|
||||||
|
### 4.3 文档同步
|
||||||
|
|
||||||
|
- `004_architecture_impact_map.md`:模块章节组件清单 + shared/components 章节 + 文末"组件化重构专项"章节
|
||||||
|
- `005_architecture_data.json`:`modules.*.exports` + `sharedComponents` + `lastUpdate` + `dependencyMatrix`
|
||||||
|
- `known-issues.md`:每批新增章节,速查手册风格记录底座使用规则与拆分模式
|
||||||
|
|
||||||
|
### 4.4 Commit 规范
|
||||||
|
|
||||||
|
每批一个 squash commit:
|
||||||
|
|
||||||
|
```
|
||||||
|
refactor(grades,attendance,error-book,elective): 组件化重构第 1 批
|
||||||
|
|
||||||
|
- 新增 ErrorBoundary/StatsGrid/SkeletonCard 底座
|
||||||
|
- 拆分 grades/batch-grade-entry.tsx (XXX → YYY 行)
|
||||||
|
- 删除 12 个重复组件,迁移到底座
|
||||||
|
- 同步 004/005 架构图,known-issues 新增章节
|
||||||
|
|
||||||
|
验收:tsc + lint 零错误,手动回归 4 模块页面通过
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| 底座接口设计不准 → 返工 | 第 1 批低风险模块验证后允许调整底座 |
|
||||||
|
| 巨型文件拆分破坏外部 API | 容器保持外部 API 不变,仅内部拆子组件 |
|
||||||
|
| 删除旧组件后引用未清理 | tsc + lint + grep 三重校验 |
|
||||||
|
| textbooks 同名 `section-error-boundary.tsx` 冲突 | 模块层版本删除,改用 shared 层 |
|
||||||
|
| 富文本/编辑器拆分难度高 | textbooks 按 `003_ui_refactoring_plan.md` 已有方案执行 |
|
||||||
|
| dashboard 4 角色抽象范围蔓延 | 仅抽象布局壳,业务逻辑保持独立 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 失败回滚策略
|
||||||
|
|
||||||
|
- 模块闭环中暴露底座接口问题 → 停止该模块,回 Phase 1 调整底座,已完成模块不回滚
|
||||||
|
- 模块闭环中暴露业务逻辑问题(非组件化引起)→ 不在本次范围,记录到 known-issues,组件化继续推进
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 完成定义(Definition of Done)
|
||||||
|
|
||||||
|
- [ ] Phase 1 完成:ErrorBoundary/StatsGrid/SkeletonCard 落地,3 个替代件收敛为 preset
|
||||||
|
- [ ] 第 1 批完成:grades/attendance/error-book/elective 闭环
|
||||||
|
- [ ] 第 2 批完成:homework/exams/textbooks 闭环
|
||||||
|
- [ ] 第 3 批完成:lesson-preparation/ai/dashboard 闭环
|
||||||
|
- [ ] 9 个巨型文件全部 ≤500 行
|
||||||
|
- [ ] 49 个重复组件全部删除并迁移到底座
|
||||||
|
- [ ] 004 / 005 / known-issues 同步更新
|
||||||
|
- [ ] 3 个批次 commit 全部提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 附录:完整重复组件清单
|
||||||
|
|
||||||
|
### 8.1 `*-filters.tsx`(20 个)
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/components/question/question-bank-filters.tsx
|
||||||
|
modules/grades/components/grade-query-filters.tsx
|
||||||
|
modules/grades/components/grade-filters.tsx
|
||||||
|
modules/grades/components/analytics-filters.tsx
|
||||||
|
modules/classes/components/students-filters.tsx
|
||||||
|
modules/classes/components/schedule-filters.tsx
|
||||||
|
modules/attendance/components/attendance-filters.tsx
|
||||||
|
modules/error-book/components/error-book-filters.tsx
|
||||||
|
modules/exams/components/exam-filters.tsx
|
||||||
|
modules/audit/components/data-change-log-filters.tsx
|
||||||
|
modules/audit/components/login-log-filters.tsx
|
||||||
|
modules/audit/components/audit-log-filters.tsx
|
||||||
|
modules/lesson-preparation/components/lesson-plan-filters.tsx
|
||||||
|
modules/homework/components/assignment-filters.tsx
|
||||||
|
modules/questions/components/question-filters.tsx
|
||||||
|
modules/student/components/course-filters.tsx
|
||||||
|
modules/student/components/student-schedule-filters.tsx
|
||||||
|
modules/school/components/grade-insights-filters.tsx
|
||||||
|
modules/textbooks/components/textbook-filters.tsx
|
||||||
|
modules/elective/components/elective-filters.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 `*-stats-cards.tsx` / `*-stats-card.tsx`(8 个)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/error-book/components/analytics-stats-cards.tsx
|
||||||
|
modules/error-book/components/error-book-stats-cards.tsx
|
||||||
|
modules/attendance/components/attendance-stats-cards.tsx
|
||||||
|
modules/attendance/components/attendance-stats-card.tsx ← 单数,与复数重复
|
||||||
|
modules/elective/components/elective-stats-cards.tsx
|
||||||
|
modules/adaptive-practice/components/practice-overview-stats-cards.tsx
|
||||||
|
modules/adaptive-practice/components/practice-stats-cards.tsx
|
||||||
|
modules/grades/components/grade-stats-card.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 `*-skeleton.tsx`(8 个)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/messaging/components/message-list-skeleton.tsx
|
||||||
|
modules/announcements/components/announcement-list-skeleton.tsx
|
||||||
|
modules/audit/components/audit-log-table-skeleton.tsx
|
||||||
|
modules/classes/components/class-skeleton.tsx
|
||||||
|
modules/ai/components/ai-skeleton.tsx
|
||||||
|
modules/school/components/school-skeleton.tsx
|
||||||
|
modules/dashboard/components/dashboard-loading-skeleton.tsx
|
||||||
|
modules/lesson-preparation/components/lesson-plan-skeleton.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 ErrorBoundary 相关文件(10 个)
|
||||||
|
|
||||||
|
**shared 层 preset(3 个,保留并内部收敛到 `<ErrorBoundary>`)**
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/components/section-error-boundary.tsx
|
||||||
|
shared/components/route-error.tsx
|
||||||
|
shared/components/widget-boundary.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
**模块层待删除(7 个,改为直接使用 `SectionErrorBoundary` preset)**
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/audit/components/audit-error-boundary.tsx
|
||||||
|
modules/school/components/school-error-boundary.tsx
|
||||||
|
modules/classes/components/class-error-boundary.tsx
|
||||||
|
modules/textbooks/components/section-error-boundary.tsx ← 与 shared 同名,删除
|
||||||
|
modules/settings/components/settings-section-error-boundary.tsx
|
||||||
|
modules/ai/components/ai-error-boundary.tsx
|
||||||
|
modules/lesson-preparation/components/lesson-plan-error-boundary.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 `*-dashboard.tsx`(5 个)
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/ai/components/ai-usage-dashboard.tsx
|
||||||
|
modules/dashboard/components/parent-dashboard/parent-dashboard.tsx
|
||||||
|
modules/dashboard/components/admin-dashboard/admin-dashboard.tsx
|
||||||
|
modules/proctoring/components/proctoring-dashboard.tsx
|
||||||
|
modules/exams/components/exam-analytics-dashboard.tsx
|
||||||
|
```
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
# 文档体系重设计
|
||||||
|
|
||||||
|
> 日期:2026-07-07
|
||||||
|
> 状态:待用户审阅
|
||||||
|
> 范围:全局规范文档(`project_rules.md` / `coding-standards.md` / `004` / `005` / `known-issues.md`)+ 新增架构元数据库 + 模块工作手册
|
||||||
|
> 唯一源原则:004 为架构设计意图唯一源;arch.db 为代码结构唯一源;known-issues.md 为经验唯一源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计目标
|
||||||
|
|
||||||
|
### 1.1 核心诉求
|
||||||
|
|
||||||
|
1. **审核并修正现有规范文档**的合规性、遗漏、技术错误
|
||||||
|
2. **重新设计文档体系结构**,消除职责重叠,让每份文档只回答一类问题
|
||||||
|
3. **建立架构元数据库**(arch.db),让 AI 快速查询模块/函数/调用链/依赖关系,无需扫描整个代码库
|
||||||
|
4. **建立 AI 自我演进机制**,让 AI 越工作越了解项目,后来者可接力而非重新梳理
|
||||||
|
|
||||||
|
### 1.2 设计原则
|
||||||
|
|
||||||
|
| 原则 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| 单一来源 | 每类信息只有一个权威文档,其他文档引用而非重复 |
|
||||||
|
| 职责分离 | 每份文档只回答一类问题(What / Why / How / When) |
|
||||||
|
| 贴近代码 | 模块工作手册放在 `src/modules/[module]/README.md`,与代码同生命周期 |
|
||||||
|
| 自动优于手动 | 代码结构由扫描器自动生成(arch.db),减少人工维护 |
|
||||||
|
| 信任但验证 | AI 使用经验前必须审核,文档变更后重新审核 |
|
||||||
|
|
||||||
|
### 1.3 不做事项(YAGNI)
|
||||||
|
|
||||||
|
- 不建 Monorepo(项目明确为单应用 + 模块化)
|
||||||
|
- 不引入 Storybook(当前无需求)
|
||||||
|
- 不建独立文档站点(GitHub/Gitea 直接渲染 Markdown 足够)
|
||||||
|
- 不做实时热更新架构库(CI + AI 工作前手动触发足够)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、文档体系拓扑
|
||||||
|
|
||||||
|
### 2.1 整体结构
|
||||||
|
|
||||||
|
```
|
||||||
|
项目根/
|
||||||
|
├─ .trae/rules/project_rules.md # 全局硬性规则(强制约束)
|
||||||
|
├─ docs/
|
||||||
|
│ ├─ standards/coding-standards.md # 编码规范(How to write code)
|
||||||
|
│ ├─ architecture/
|
||||||
|
│ │ ├─ 004_architecture_impact_map.md # 架构设计意图(Why - 瘦身后约 500 行)
|
||||||
|
│ │ ├─ 006_k12_feature_checklist.md # 功能模块清单(保留)
|
||||||
|
│ │ ├─ 007_gap_audit_report.md # 差距审计(保留)
|
||||||
|
│ │ ├─ 008_module_role_mapping.md # 模块角色映射(保留)
|
||||||
|
│ │ ├─ roadmap/ # 【新增】长远规划
|
||||||
|
│ │ │ ├─ README.md # 路线图索引
|
||||||
|
│ │ │ ├─ tech-debt.md # 技术债清单(从 004 第三部分迁入)
|
||||||
|
│ │ │ ├─ decoupling.md # 解耦路线图(从 audit/01 迁入)
|
||||||
|
│ │ │ └─ pending-features.md # 待开发功能(从 004 "未完成项"迁入)
|
||||||
|
│ │ └─ audit/archive/ # 【新增】历史审查报告归档(只读)
|
||||||
|
│ │ ├─ 005_architecture_data.json # 005 废弃后归档于此
|
||||||
|
│ │ └─ (现有 60+ 份 audit 报告迁入)
|
||||||
|
│ └─ troubleshooting/
|
||||||
|
│ └─ known-issues.md # 经验库(精简为索引式,无代码示例)
|
||||||
|
├─ src/modules/[module]/
|
||||||
|
│ └─ README.md # 【新增】模块工作手册(每模块一份)
|
||||||
|
└─ scripts/arch-scan/ # 【新增】架构扫描器
|
||||||
|
├─ scanner.ts # ts-morph 扫描器
|
||||||
|
├─ schema.ts # SQLite schema 定义
|
||||||
|
├─ query.ts # 查询函数
|
||||||
|
├─ cli.ts # CLI 入口
|
||||||
|
└─ arch.db # 生成的 SQLite(git 提交)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 文档职责边界
|
||||||
|
|
||||||
|
| 文档 | 类型 | 回答的问题 | 谁维护 | 内容禁区 |
|
||||||
|
|------|------|-----------|--------|---------|
|
||||||
|
| `arch.db` | 自动生成 | What(代码结构是什么) | 扫描器 | 无人为语义 |
|
||||||
|
| `004` 架构图 | 人类可读 | Why(架构为什么这样设计) | 人/AI | 不含代码结构、不含规划、不含教程 |
|
||||||
|
| `project_rules.md` | 硬性规则 | Must(必须遵守什么) | 人 | 不含详细规范、不含架构描述 |
|
||||||
|
| `coding-standards.md` | 编码规范 | How to write(怎么写代码) | 人 | 不含架构事实、不含经验 |
|
||||||
|
| `known-issues.md` | 经验库 | Experience(场景→技术、工作经验) | AI 思考后更新 | 不含代码示例、不含错误示范 |
|
||||||
|
| `modules/[m]/README.md` | 模块上下文 | How to work(怎么上手模块) | 人/AI | 不重复 arch.db 的代码结构、不含经验 |
|
||||||
|
| `roadmap/*` | 规划 | When(未来做什么) | 人/AI | 不含架构事实、不含经验 |
|
||||||
|
| `audit/archive/*` | 历史参考 | Past(过去发现了什么) | 只读归档 | 不再更新 |
|
||||||
|
|
||||||
|
### 2.3 三类信息源的互补关系
|
||||||
|
|
||||||
|
```
|
||||||
|
arch.db (What) ── AI 查询代码结构、调用关系、依赖
|
||||||
|
↓ 基于代码生成
|
||||||
|
004 架构图 (Why) ── 人理解架构设计意图
|
||||||
|
↓ 解释决策
|
||||||
|
模块手册 (How to work) ── 人/AI 上手模块的工作流程
|
||||||
|
↓ 记录经验
|
||||||
|
known-issues (Experience) ── 遇到问题时查技术方向
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 废弃文档
|
||||||
|
|
||||||
|
| 文档 | 处理 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| `005_architecture_data.json` | 废弃,归档到 `audit/archive/` | arch.db 自动生成比手维护 JSON 准确 |
|
||||||
|
| `docs/architecture/audit/01_decoupling_roadmap.md` | 迁移到 `roadmap/decoupling.md` | 属于规划而非审查报告 |
|
||||||
|
| `docs/architecture/audit/` 下 60+ 份审查报告 | 迁移到 `audit/archive/`,只读归档 | 历史参考,不再更新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、arch.db 架构元数据库
|
||||||
|
|
||||||
|
### 3.1 设计目标
|
||||||
|
|
||||||
|
让 AI 无需扫描整个代码库即可精准查询:
|
||||||
|
- 某模块的所有导出函数
|
||||||
|
- 某函数被谁调用(逆向追踪,递归)
|
||||||
|
- 某函数调用了什么(正向追踪,递归)
|
||||||
|
- 某模块依赖哪些模块(递归)
|
||||||
|
- 哪些地方用了某技术(如 cacheFn)
|
||||||
|
- 架构违规检测(超长文件、Server Action 缺权限校验等)
|
||||||
|
- 完整调用链(从 UI 到 DB)
|
||||||
|
|
||||||
|
### 3.2 技术选型
|
||||||
|
|
||||||
|
| 决策 | 选择 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 存储 | SQLite | 嵌入式、零配置、.db 文件可 git 提交、AI 可直接 sqlite3 查询 |
|
||||||
|
| 扫描器 | ts-morph | TypeScript AST 操作库,支持类型推导,能提取调用关系 |
|
||||||
|
| 扫描粒度 | 全调用图 | 模块 + 函数 + 类型 + 函数间调用关系,覆盖 UE 引用查看器所有用法 |
|
||||||
|
| 触发时机 | CI 自动 + 本地手动 + AI 工作前强制 | 三重保障架构库与代码同步 |
|
||||||
|
| 数据源 | 自动扫描代码 | 零人工维护,与代码同步 |
|
||||||
|
|
||||||
|
### 3.3 SQLite Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ============ 核心实体 ============
|
||||||
|
|
||||||
|
-- 1. 模块(src/modules/* 下的目录)
|
||||||
|
CREATE TABLE modules (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
layer TEXT NOT NULL, -- "modules" | "shared" | "app" | "root"
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. 文件
|
||||||
|
CREATE TABLE files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
module_id INTEGER REFERENCES modules(id),
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL, -- "actions" | "data-access" | "schema" | "types" | "component" | "hook" | "lib" | "config" | "route" | "page" | "layout"
|
||||||
|
lines INTEGER,
|
||||||
|
is_server INTEGER DEFAULT 0,
|
||||||
|
is_client INTEGER DEFAULT 0,
|
||||||
|
has_server_only INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 导出符号(函数、类、类型、常量、组件)
|
||||||
|
CREATE TABLE symbols (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- "function" | "class" | "type" | "interface" | "const" | "component"
|
||||||
|
is_exported INTEGER DEFAULT 0,
|
||||||
|
is_async INTEGER DEFAULT 0,
|
||||||
|
is_server_action INTEGER DEFAULT 0,
|
||||||
|
signature TEXT,
|
||||||
|
start_line INTEGER,
|
||||||
|
end_line INTEGER,
|
||||||
|
UNIQUE(file_id, name, start_line)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============ 关系 ============
|
||||||
|
|
||||||
|
-- 4. 调用关系(符号间调用)
|
||||||
|
CREATE TABLE calls (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
caller_id INTEGER NOT NULL REFERENCES symbols(id),
|
||||||
|
callee_id INTEGER REFERENCES symbols(id),
|
||||||
|
callee_external TEXT, -- 项目外调用(如 "fetch", "console.log")
|
||||||
|
call_line INTEGER,
|
||||||
|
count INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. 文件级导入
|
||||||
|
CREATE TABLE file_imports (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_file_id INTEGER NOT NULL REFERENCES files(id),
|
||||||
|
imported_file_id INTEGER REFERENCES files(id),
|
||||||
|
import_path TEXT NOT NULL,
|
||||||
|
is_type_only INTEGER DEFAULT 0,
|
||||||
|
imported_names TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. 模块间依赖(聚合视图)
|
||||||
|
CREATE TABLE module_deps (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_module_id INTEGER NOT NULL REFERENCES modules(id),
|
||||||
|
target_module_id INTEGER NOT NULL REFERENCES modules(id),
|
||||||
|
dep_type TEXT NOT NULL, -- "import" | "data-access-call" | "action-call"
|
||||||
|
UNIQUE(source_module_id, target_module_id, dep_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============ 业务元数据 ============
|
||||||
|
|
||||||
|
-- 7. 技术标签
|
||||||
|
CREATE TABLE tech_tags (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
category TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. 符号-技术标签关联
|
||||||
|
CREATE TABLE symbol_tech_tags (
|
||||||
|
symbol_id INTEGER NOT NULL REFERENCES symbols(id),
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tech_tags(id),
|
||||||
|
PRIMARY KEY(symbol_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. 权限点
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 10. 路由
|
||||||
|
CREATE TABLE routes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL, -- "page" | "api" | "layout"
|
||||||
|
file_id INTEGER REFERENCES files(id),
|
||||||
|
min_permission TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. 数据库表
|
||||||
|
CREATE TABLE db_tables (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
module_id INTEGER REFERENCES modules(id),
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============ 扫描元数据 ============
|
||||||
|
|
||||||
|
-- 12. 扫描元数据
|
||||||
|
CREATE TABLE scan_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 关键索引
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_symbols_file ON symbols(file_id);
|
||||||
|
CREATE INDEX idx_symbols_name ON symbols(name);
|
||||||
|
CREATE INDEX idx_calls_caller ON calls(caller_id);
|
||||||
|
CREATE INDEX idx_calls_callee ON calls(callee_id);
|
||||||
|
CREATE INDEX idx_file_imports_source ON file_imports(source_file_id);
|
||||||
|
CREATE INDEX idx_file_imports_target ON file_imports(imported_file_id);
|
||||||
|
CREATE INDEX idx_symbol_tech_tags_tag ON symbol_tech_tags(tag_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 技术标签自动识别规则
|
||||||
|
|
||||||
|
扫描器通过启发式规则自动打标签:
|
||||||
|
|
||||||
|
| 标签 | 识别规则 | 类别 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `cacheFn` | 文件内出现 `cacheFn(` 调用 | cache |
|
||||||
|
| `zustand` | import 自 `zustand` | state |
|
||||||
|
| `useOptimistic` | 文件内出现 `useOptimistic` 调用 | state |
|
||||||
|
| `react-hook-form` | import 自 `react-hook-form` | form |
|
||||||
|
| `TanStack Query` | import 自 `@tanstack/react-query` | state |
|
||||||
|
| `Server Action` | 文件顶部 `"use server"` 或函数级 `"use server"` | server |
|
||||||
|
| `Tiptap` | import 自 `@tiptap/*` | ui |
|
||||||
|
| `Drizzle` | import 自 `drizzle-orm` | db |
|
||||||
|
| `nuqs` | import 自 `nuqs` | state |
|
||||||
|
| `recharts` | import 自 `recharts` | ui |
|
||||||
|
|
||||||
|
### 3.6 递归查询能力(6 类)
|
||||||
|
|
||||||
|
#### 查询 1:逆向递归(谁调用了 X?递归到入口)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE upstream(caller_id, caller_name, caller_path, depth, path_chain) AS (
|
||||||
|
SELECT c.caller_id, s.name, f.path, 0, s.name
|
||||||
|
FROM calls c
|
||||||
|
JOIN symbols s ON c.caller_id = s.id
|
||||||
|
JOIN files f ON s.file_id = f.id
|
||||||
|
WHERE c.callee_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
|
||||||
|
UNION
|
||||||
|
SELECT c.caller_id, s.name, f.path, u.depth + 1, u.path_chain || ' → ' || s.name
|
||||||
|
FROM upstream u
|
||||||
|
JOIN calls c ON c.callee_id = u.caller_id
|
||||||
|
JOIN symbols s ON c.caller_id = s.id
|
||||||
|
JOIN files f ON s.file_id = f.id
|
||||||
|
WHERE u.depth < 10
|
||||||
|
AND u.caller_id NOT IN (SELECT caller_id FROM upstream)
|
||||||
|
)
|
||||||
|
SELECT caller_name, caller_path, depth, path_chain FROM upstream
|
||||||
|
ORDER BY depth, caller_path;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查询 2:正向递归(X 调用了什么?递归到叶子)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE downstream(callee_id, callee_name, callee_path, depth, path_chain) AS (
|
||||||
|
SELECT c.callee_id, s.name, f.path, 0, ?
|
||||||
|
FROM calls c
|
||||||
|
JOIN symbols s ON c.callee_id = s.id
|
||||||
|
JOIN files f ON s.file_id = f.id
|
||||||
|
WHERE c.caller_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
|
||||||
|
UNION
|
||||||
|
SELECT c.callee_id, s.name, f.path, d.depth + 1, d.path_chain || ' → ' || s.name
|
||||||
|
FROM downstream d
|
||||||
|
JOIN calls c ON c.caller_id = d.callee_id
|
||||||
|
JOIN symbols s ON c.callee_id = s.id
|
||||||
|
JOIN files f ON s.file_id = f.id
|
||||||
|
WHERE d.depth < 10
|
||||||
|
AND d.callee_id NOT IN (SELECT callee_id FROM downstream)
|
||||||
|
)
|
||||||
|
SELECT callee_name, callee_path, depth, path_chain FROM downstream
|
||||||
|
ORDER BY depth, callee_path;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查询 3:模块级逆向(谁依赖了模块 X?递归到根)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE mod_upstream(source_module, target_module, depth, path_chain) AS (
|
||||||
|
SELECT m.name, m2.name, 0, m.name || ' ← ' || m2.name
|
||||||
|
FROM module_deps md
|
||||||
|
JOIN modules m ON md.target_module_id = m.id
|
||||||
|
JOIN modules m2 ON md.source_module_id = m2.id
|
||||||
|
WHERE m.name = ?
|
||||||
|
UNION
|
||||||
|
SELECT mu.source_module, m.name, mu.depth + 1, mu.path_chain || ' ← ' || m.name
|
||||||
|
FROM mod_upstream mu
|
||||||
|
JOIN module_deps md ON md.target_module_id = (
|
||||||
|
SELECT id FROM modules WHERE name = mu.source_module
|
||||||
|
)
|
||||||
|
JOIN modules m ON md.source_module_id = m.id
|
||||||
|
WHERE mu.depth < 10
|
||||||
|
)
|
||||||
|
SELECT DISTINCT source_module, target_module, depth, path_chain
|
||||||
|
FROM mod_upstream ORDER BY depth;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查询 4:模块级正向(模块 X 依赖了哪些模块?递归到叶子)
|
||||||
|
|
||||||
|
类似查询 3,方向反转(从 source_module_id 出发递归 target_module_id)。实施时补全完整 SQL。
|
||||||
|
|
||||||
|
#### 查询 5:双向依赖检测(模块 A 和 B 之间是否有循环依赖)
|
||||||
|
|
||||||
|
同时执行查询 3(A→B)和查询 4(B→A),若两方向都存在路径,则存在循环依赖。实施时封装为单一查询函数。
|
||||||
|
|
||||||
|
#### 查询 6:完整调用链(UI → Server Action → data-access → DB)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE trace(symbol_id, symbol_name, file_path, kind, depth, path_chain) AS (
|
||||||
|
-- 入口:UI 事件处理函数(handle*, onSubmit*, onClick*)
|
||||||
|
SELECT s.id, s.name, f.path, f.kind, 0, s.name
|
||||||
|
FROM symbols s JOIN files f ON s.file_id = f.id
|
||||||
|
WHERE s.name LIKE 'handle%' OR s.name LIKE 'onSubmit%' OR s.name LIKE 'onClick%'
|
||||||
|
UNION
|
||||||
|
SELECT cs.id, cs.name, cf.path, cf.kind, t.depth + 1, t.path_chain || ' → ' || cs.name
|
||||||
|
FROM trace t
|
||||||
|
JOIN calls c ON c.caller_id = t.symbol_id
|
||||||
|
JOIN symbols cs ON c.callee_id = cs.id
|
||||||
|
JOIN files cf ON cs.file_id = cf.id
|
||||||
|
WHERE t.depth < 15
|
||||||
|
AND t.symbol_id NOT IN (SELECT symbol_id FROM trace)
|
||||||
|
)
|
||||||
|
SELECT * FROM trace
|
||||||
|
WHERE kind IN ('actions', 'data-access')
|
||||||
|
ORDER BY depth, path_chain;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 CLI 接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 更新架构库
|
||||||
|
npm run arch:scan
|
||||||
|
|
||||||
|
# 2. 查询命令
|
||||||
|
npm run arch:query ref <symbolName> [--forward] [--depth=10] # 符号引用(默认逆向)
|
||||||
|
npm run arch:query module <moduleName> [--reverse] [--depth=10] # 模块依赖
|
||||||
|
npm run arch:query tech <techTag> # 技术使用
|
||||||
|
npm run arch:query path <moduleA> <moduleB> # 模块间路径
|
||||||
|
npm run arch:query violations # 架构违规
|
||||||
|
npm run arch:query trace <entrySymbol> # 完整调用链
|
||||||
|
npm run arch:query sql "<SQL>" # 自由 SQL
|
||||||
|
npm run arch:query repl # 交互式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.8 输出格式
|
||||||
|
|
||||||
|
默认输出**树形可视化**(模仿 UE 引用查看器),可选 `--json` 输出机器可读格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm run arch:query ref createExam
|
||||||
|
|
||||||
|
▼ createExam (src/modules/exams/data-access.ts#L42)
|
||||||
|
│
|
||||||
|
├─▼ createExamAction (src/modules/exams/actions.ts#L18) [Server Action]
|
||||||
|
│ │
|
||||||
|
│ └─▼ handleCreateExam (src/modules/exams/components/exam-form.tsx#L67) [Client]
|
||||||
|
│ │
|
||||||
|
│ └── <Form onSubmit> (src/modules/exams/components/exam-form.tsx#L120)
|
||||||
|
│
|
||||||
|
└─▼ importExams (src/modules/exams/import-export.ts#L234)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、AI 自我演进机制
|
||||||
|
|
||||||
|
### 4.1 模块 README.md 标准结构
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [模块名] 模块工作手册
|
||||||
|
|
||||||
|
> 经验查 known-issues.md,代码结构查 arch.db,本文件只记工作流程。
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
一句话描述本模块做什么。
|
||||||
|
|
||||||
|
## 核心工作流程
|
||||||
|
1. 新增考试: ...
|
||||||
|
2. 修改成绩计算: ...
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
- [不可违反的约束]
|
||||||
|
- [依赖关系,从 arch.db 提取]
|
||||||
|
|
||||||
|
## 架构决策(为什么这样设计)
|
||||||
|
- **为什么用 X 而不用 Y**: [决策理由]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 known-issues.md 结构(唯一经验库)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 项目经验库
|
||||||
|
|
||||||
|
> AI 工作前必读,使用经验前必审核。AI 发现更好办法时更新本文件。
|
||||||
|
> 最后审核: 2026-07-07 14:00 (commit: abc1234)
|
||||||
|
|
||||||
|
## 全局经验
|
||||||
|
|
||||||
|
### 缓存策略
|
||||||
|
| 场景 | 技术方向 | 模块 | 备注 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| 服务端数据缓存 | cacheFn + Redis | 全局 | 详见 arch.db tech_tags |
|
||||||
|
| 客户端数据缓存 | TanStack Query | 全局 | 禁止 useEffect+fetch |
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
| 场景 | 技术方向 | 模块 | 备注 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| URL 状态 | nuqs | 全局 | 5 层状态模型 L1 |
|
||||||
|
| 表单状态 | react-hook-form + zodResolver | 全局 | L5 层 |
|
||||||
|
|
||||||
|
## 模块经验: exams
|
||||||
|
|
||||||
|
### 考试创建流程
|
||||||
|
| 场景 | 技术方向 | 备注 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 考试数据缓存 | cacheFn 包裹 createExamRaw | 修改后须失效 importExams 缓存 |
|
||||||
|
| AI 题目解析 | 动态 import + webpackIgnore | 可选依赖 ollama |
|
||||||
|
|
||||||
|
## 模块经验: grades
|
||||||
|
...
|
||||||
|
|
||||||
|
## 工作经验日志(按时间倒序,定期提炼到上述分区)
|
||||||
|
|
||||||
|
### 2026-07-07 重构 createExam 调用链
|
||||||
|
- **模块**: exams
|
||||||
|
- **做了什么**: 拆分 createExam 为 createExamRaw + createExam(含缓存)
|
||||||
|
- **学到什么**: data-access 层已有 cacheFn,actions 层无需再缓存
|
||||||
|
- **下次注意**: 修改 createExam 必须同步更新 importExams 的缓存失效
|
||||||
|
- **审核状态**: 待审核
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 known-issues.md 精简规则
|
||||||
|
|
||||||
|
| 内容类型 | 处理 |
|
||||||
|
|---------|------|
|
||||||
|
| 代码示例(多行代码块) | 删除,改为"技术方向"描述 |
|
||||||
|
| 错误示范 | 删除,只保留"正确做法" |
|
||||||
|
| 重复的架构规则 | 删除,引用 004/project_rules |
|
||||||
|
| "场景 → 技术"映射 | 保留,按模块分区 |
|
||||||
|
| 工作经验日志 | 保留(追加区) |
|
||||||
|
|
||||||
|
### 4.4 AI 工作强制流程(写入 project_rules.md)
|
||||||
|
|
||||||
|
```
|
||||||
|
AI 进入项目工作流程(强制,违反即违规):
|
||||||
|
|
||||||
|
阶段 1: 上下文加载
|
||||||
|
1.1 npm run arch:scan # 更新 arch.db
|
||||||
|
1.2 npm run arch:query module <目标模块> # 查模块依赖
|
||||||
|
1.3 npm run arch:query ref <目标函数> --forward # 查调用链
|
||||||
|
1.4 阅读 src/modules/[模块]/README.md # 读模块工作流程
|
||||||
|
1.5 查 known-issues.md "模块经验: <模块>" 分区 # 读相关经验
|
||||||
|
1.5.1 审核相关经验(检查代码是否仍匹配)
|
||||||
|
1.5.2 若文档自上次审核后已变更 → 重新审核并标记
|
||||||
|
1.5.3 审核通过 → 使用;失败 → 标记失效,不使用
|
||||||
|
|
||||||
|
阶段 2: 执行工作
|
||||||
|
2.1 按规划执行
|
||||||
|
2.2 修改代码后立即运行 arch:scan
|
||||||
|
|
||||||
|
阶段 3: 经验沉淀(强制,不可跳过)
|
||||||
|
3.1 在 known-issues.md "工作经验日志" 区追加一条记录:
|
||||||
|
- 做了什么
|
||||||
|
- 学到什么
|
||||||
|
- 下次注意事项
|
||||||
|
- 审核状态: 待审核
|
||||||
|
3.2 若发现新的"场景→技术"映射 → 提炼到对应模块分区
|
||||||
|
3.3 若发现新的架构决策 → 更新 004
|
||||||
|
3.4 若代码结构变化 → arch:scan 确认 arch.db 已更新
|
||||||
|
|
||||||
|
阶段 4: 提交后审核(人工)
|
||||||
|
4.1 人工审查"待审核"日志条目
|
||||||
|
4.2 通过 → 标记"已审核 (commit, 审核人)"
|
||||||
|
4.3 失败 → 标记"审核失败,原因:..."
|
||||||
|
4.4 定期(如每两周)将成熟日志提炼到分区表格
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 信任但验证机制
|
||||||
|
|
||||||
|
```
|
||||||
|
AI 读取 known-issues.md 经验
|
||||||
|
↓
|
||||||
|
检查该条经验的"审核状态":
|
||||||
|
├─ 已审核 → 检查代码是否仍匹配
|
||||||
|
│ ├─ 匹配 → 使用经验
|
||||||
|
│ └─ 不匹配 → 标记"待重新审核",不使用
|
||||||
|
├─ 待审核 → 标记"AI 使用前审核",验证后使用
|
||||||
|
└─ 审核失败 → 不使用,记录原因
|
||||||
|
↓
|
||||||
|
使用经验工作时,若发现经验有误 → 标记"审核失败,原因:..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 防 known-issues.md 膨胀
|
||||||
|
|
||||||
|
- **工作经验日志区上限 50 条**——超过则人工提炼最早的到分区表格,删除原日志
|
||||||
|
- **分区表格无上限**——但每条保持单行索引式
|
||||||
|
- **精简目标**:从 1317 行降至约 300 行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、004 瘦身方案
|
||||||
|
|
||||||
|
### 5.1 现状问题
|
||||||
|
|
||||||
|
004 当前 4227 行,远超架构文档应有体量。主要问题:
|
||||||
|
|
||||||
|
| 问题 | 表现 | 行数估算 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 混入工作日志 | "1.1.1 M7 移动端 PWA 支持(2026-07-01 新增)"等 7 个变更日志章节 | ~400 行 |
|
||||||
|
| 混入规划/待办 | "未完成项(待后续专项)"、各模块的 P0/P1/P2 修复标记 | ~600 行 |
|
||||||
|
| 混入实现细节 | 函数签名索引、文件行数表格、组件清单 | ~1500 行 |
|
||||||
|
| 模块清单冗长 | 27 个模块每个都用大段文字描述,含"V4 P2-4 已修复"等历史 | ~1500 行 |
|
||||||
|
| 真正的架构内容 | 分层图、依赖关系图、数据流向图、核心原则 | ~227 行 |
|
||||||
|
|
||||||
|
### 5.2 瘦身后目标结构(约 500 行)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Next_Edu 架构影响地图
|
||||||
|
|
||||||
|
> 唯一源:项目架构事实。代码结构查 arch.db,经验查 known-issues.md,规划查 roadmap/。
|
||||||
|
|
||||||
|
## 1. 分层架构
|
||||||
|
- 三层架构图(app → modules → shared)
|
||||||
|
- 分层规则(4 条核心约束)
|
||||||
|
- 根模块说明(auth.ts, proxy.ts)
|
||||||
|
|
||||||
|
## 2. 模块清单
|
||||||
|
(表格形式,每模块一行,详情查 arch.db 和模块 README)
|
||||||
|
|
||||||
|
| 模块 | 职责 | 核心依赖 | 被依赖 | README |
|
||||||
|
|------|------|---------|--------|--------|
|
||||||
|
| exams | 考试管理 | grades, classes, questions | dashboard | [README](../../src/modules/exams/README.md) |
|
||||||
|
| ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
|
## 3. 模块依赖关系图
|
||||||
|
- 核心业务模块依赖图
|
||||||
|
- 扩展模块依赖图
|
||||||
|
- (循环依赖检测见 arch.db 查询)
|
||||||
|
|
||||||
|
## 4. 数据流向(核心场景)
|
||||||
|
- 考试流程数据流
|
||||||
|
- 学生提交作业数据流
|
||||||
|
- 仪表盘聚合数据流
|
||||||
|
|
||||||
|
## 5. 核心架构原则
|
||||||
|
- 三层架构单向依赖
|
||||||
|
- 模块间通过 data-access 通信
|
||||||
|
- Server Action 必须权限校验
|
||||||
|
- 设计令牌分层(Primitive → Semantic → Tailwind)
|
||||||
|
|
||||||
|
## 6. 设计令牌体系
|
||||||
|
- 文件分布(src/app/styles/tokens/)
|
||||||
|
- 令牌分层规则
|
||||||
|
- 强制约束(禁止硬编码颜色/字体/字号)
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
- [arch.db 查询](../../scripts/arch-scan/) - 代码结构
|
||||||
|
- [known-issues.md](../troubleshooting/known-issues.md) - 经验库
|
||||||
|
- [roadmap/](./roadmap/) - 规划
|
||||||
|
- [模块 README](../../src/modules/) - 模块工作流程
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 迁移映射
|
||||||
|
|
||||||
|
| 004 现有内容 | 去向 | 理由 |
|
||||||
|
|------------|------|------|
|
||||||
|
| 1.1.1-1.1.7 变更日志章节 | 删除(git 历史已记录) | 工作日志不属于架构事实 |
|
||||||
|
| "Phase X.X 新增"标记 | 删除 | 同上 |
|
||||||
|
| "P0-X 已修复"标记 | 删除(保留事实,删除修复历史) | 修复历史属于 git log |
|
||||||
|
| "未完成项(待后续专项)" | 迁移到 `roadmap/tech-debt.md` | 属于规划 |
|
||||||
|
| 各模块的"V1/V2/V3/V4"版本描述 | 删除,只保留当前状态 | 版本演进属于 git log |
|
||||||
|
| 函数签名索引(附录 C) | 删除(查 arch.db) | 代码结构属于 arch.db |
|
||||||
|
| 模块间依赖矩阵(附录 A) | 删除(查 arch.db `module_deps`) | 同上 |
|
||||||
|
| 关键参数影响链(附录 B) | 保留(架构决策) | 属于架构意图 |
|
||||||
|
| 第三部分"已知架构问题和技术债" | 迁移到 `roadmap/tech-debt.md` | 属于规划 |
|
||||||
|
| 模块清单(第二部分) | 大幅精简为表格 | 详情查 arch.db + README |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、规范文档修正要点
|
||||||
|
|
||||||
|
### 6.1 已发现的跨文档冲突
|
||||||
|
|
||||||
|
| 冲突项 | project_rules.md | coding-standards.md | 004 | 修正方向 |
|
||||||
|
|--------|-----------------|---------------------|-----|---------|
|
||||||
|
| 设计令牌位置 | `src/app/styles/tokens/` | `globals.css` | `src/app/styles/tokens/` | 统一为 `src/app/styles/tokens/`(以 004 为准) |
|
||||||
|
| 架构文档清单 | 仅列 004-008 + audit/01 | 无 | 自身 | project_rules.md 补全 001/002/003/008 |
|
||||||
|
| 002 编号冲突 | 无 | 无 | 无 | `002_rbac_refactoring.md` 与 `002_role_based_routing.md` 编号冲突,需重命名 |
|
||||||
|
| 缓存策略描述 | 无 | "用 unstable_cache" | 已迁移到 cacheFn | coding-standards.md 更新为 cacheFn |
|
||||||
|
|
||||||
|
### 6.2 project_rules.md 修正
|
||||||
|
|
||||||
|
1. **架构文档清单补全**:加入 001/002/003/008
|
||||||
|
2. **新增 AI 工作流程规则**:写入第四节"AI 工作强制流程"
|
||||||
|
3. **新增 arch.db 规则**:AI 工作前必须 `npm run arch:scan`
|
||||||
|
4. **令牌位置统一**:与 004 一致,明确为 `src/app/styles/tokens/`
|
||||||
|
|
||||||
|
### 6.3 coding-standards.md 修正
|
||||||
|
|
||||||
|
1. **令牌位置统一**:从 `globals.css` 改为 `src/app/styles/tokens/`
|
||||||
|
2. **缓存策略更新**:从 `unstable_cache` 改为 `cacheFn`
|
||||||
|
3. **删除过时内容**:tsconfig "当前差异"部分(已升级则删除,未升级则列入 roadmap)
|
||||||
|
4. **状态管理章节更新**:加入 5 层状态模型(L1 URL / L2 Server / L3 Client Business / L4 Global UI / L5 Form)
|
||||||
|
5. **ESLint 配置章节更新**:反映已实现的 `no-restricted-syntax`、`design-tokens/no-hardcoded-fonts` 等规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施阶段划分
|
||||||
|
|
||||||
|
### 7.1 三阶段流水线
|
||||||
|
|
||||||
|
| 阶段 | 目标 | 产出 | 验收标准 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 阶段 1: 同步实际 | 把 4 份文档对齐到代码现状 | 实际状态基线报告 | 4 份文档与代码零冲突 |
|
||||||
|
| 阶段 2: 审核正确性 | 基于阶段 1 基线,按 4 维度审核文档 | 问题清单 + 修正建议 | 文档内部一致、无技术错误、无遗漏、对齐大仓最佳实践 |
|
||||||
|
| 阶段 3: 反向修正代码 | 基于阶段 2 审核后文档,修正代码偏差 | 代码修正 PR | 代码与文档零冲突 |
|
||||||
|
|
||||||
|
### 7.2 阶段 1 任务分解
|
||||||
|
|
||||||
|
1. 扫描代码实际状态(令牌位置、模块结构、tsconfig、Husky/lint-staged 是否配置等)
|
||||||
|
2. 对照 4 份文档找出不一致项
|
||||||
|
3. 修正文档使其与代码一致
|
||||||
|
4. 产出"实际状态基线报告"
|
||||||
|
|
||||||
|
### 7.3 阶段 2 任务分解
|
||||||
|
|
||||||
|
按 4 维度审核:
|
||||||
|
1. **合规性**:文档内部一致性、跨文档一致性
|
||||||
|
2. **遗漏**:对照大仓最佳实践找缺失规则(包边界、依赖方向、共享工具下沉)
|
||||||
|
3. **技术错误**:tsconfig 目标版本、令牌位置、ESLint 规则等具体错误
|
||||||
|
4. **大仓规范对照**:提取适用于单应用模块化的部分
|
||||||
|
|
||||||
|
### 7.4 阶段 3 任务分解
|
||||||
|
|
||||||
|
1. 识别代码与审核后文档的偏差
|
||||||
|
2. 修正代码(如 tsconfig 升级、令牌位置迁移等)
|
||||||
|
3. 验证 `npm run lint` 和 `npx tsc --noEmit` 零错误
|
||||||
|
|
||||||
|
### 7.5 独立项目:arch.db 扫描器
|
||||||
|
|
||||||
|
arch.db 扫描器作为独立项目,可与三阶段并行推进:
|
||||||
|
|
||||||
|
1. 实现 ts-morph 扫描器(scanner.ts)
|
||||||
|
2. 实现 SQLite schema(schema.ts)
|
||||||
|
3. 实现查询函数(query.ts)
|
||||||
|
4. 实现 CLI(cli.ts)
|
||||||
|
5. 添加 `npm run arch:scan` 和 `npm run arch:query` 脚本
|
||||||
|
6. 配置 CI 自动运行
|
||||||
|
7. 写入 project_rules.md 作为 AI 工作前置规则
|
||||||
|
|
||||||
|
### 7.6 独立项目:模块 README 创建
|
||||||
|
|
||||||
|
约 27 个模块各创建一份 README.md(实施时以 arch.db 扫描结果为准),可分批推进:
|
||||||
|
|
||||||
|
1. 标杆模块先做(textbooks、grades 已在 004 标记为"标杆模块")
|
||||||
|
2. 核心业务模块(exams、homework、questions)
|
||||||
|
3. 教学管理模块(classes、school、scheduling、attendance)
|
||||||
|
4. 用户沟通模块(users、messaging、notifications、parent)
|
||||||
|
5. 扩展功能模块(elective、proctoring、diagnostic、dashboard)
|
||||||
|
6. 其他模块(announcements、files、settings、auth、layout、student、lesson-preparation、standards、course-plans、audit、rbac、onboarding)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、验收标准
|
||||||
|
|
||||||
|
### 8.1 文档体系验收
|
||||||
|
|
||||||
|
- [ ] 4 份全局规范文档(project_rules.md / coding-standards.md / 004 / known-issues.md)内部无矛盾
|
||||||
|
- [ ] 4 份文档与代码零冲突
|
||||||
|
- [ ] 004 瘦身至约 500 行,不含规划/工作日志/代码结构
|
||||||
|
- [ ] 005 归档到 audit/archive/
|
||||||
|
- [ ] known-issues.md 精简至约 300 行,无代码示例
|
||||||
|
- [ ] roadmap/ 目录建立,包含 tech-debt.md / decoupling.md / pending-features.md
|
||||||
|
- [ ] audit/archive/ 目录建立,60+ 份审查报告归档
|
||||||
|
|
||||||
|
### 8.2 arch.db 验收
|
||||||
|
|
||||||
|
- [ ] `npm run arch:scan` 能成功扫描全项目并生成 arch.db
|
||||||
|
- [ ] `npm run arch:query ref <symbol>` 能递归查询符号引用
|
||||||
|
- [ ] `npm run arch:query module <module>` 能递归查询模块依赖
|
||||||
|
- [ ] `npm run arch:query tech <tag>` 能查询技术使用
|
||||||
|
- [ ] `npm run arch:query violations` 能检测架构违规
|
||||||
|
- [ ] arch.db 与代码零偏差(扫描器在干净代码上运行无错误)
|
||||||
|
|
||||||
|
### 8.3 模块 README 验收
|
||||||
|
|
||||||
|
- [ ] 27 个模块各有 README.md
|
||||||
|
- [ ] 每个 README 含:模块职责、核心工作流程、关键约束、架构决策
|
||||||
|
- [ ] 每个 README 不含经验(查 known-issues.md)、不含代码结构(查 arch.db)
|
||||||
|
|
||||||
|
### 8.4 AI 工作流程验收
|
||||||
|
|
||||||
|
- [ ] project_rules.md 写入 AI 工作强制流程
|
||||||
|
- [ ] AI 工作前运行 `npm run arch:scan` 成为硬性规则
|
||||||
|
- [ ] known-issues.md 含审核状态字段
|
||||||
|
- [ ] 模块 README 含审核标记
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|------|------|------|
|
||||||
|
| ts-morph 扫描大型项目慢 | CI 时间增加 | 扫描器增量扫描(仅变更文件),全量扫描仅 CI 触发 |
|
||||||
|
| arch.db 二进制文件 git diff 不友好 | Code review 难 | 配合导出 SQL 文本文件,diff 看 SQL,应用看 .db |
|
||||||
|
| 模块 README 维护成本 | AI/人遗忘更新 | project_rules.md 强制 AI 工作后更新;CI 检查 README 格式 |
|
||||||
|
| known-issues.md 日志区膨胀 | 文件过大 | 50 条上限,定期提炼到分区表格 |
|
||||||
|
| 60+ 份 audit 报告归档后信息丢失 | 历史经验丢失 | 归档前提取有价值内容到模块 README 和 known-issues.md |
|
||||||
|
| AI 不遵守工作流程 | 文档体系失效 | project_rules.md 写为硬性规则,CI 检查 arch.db 是否更新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、未决事项
|
||||||
|
|
||||||
|
本 spec 已涵盖所有用户确认的决策。以下事项在实施阶段可能需要进一步决策:
|
||||||
|
|
||||||
|
1. **ts-morph 扫描器性能**:若全量扫描超过 30 秒,需考虑增量扫描策略
|
||||||
|
2. **arch.db 大小**:若超过 10MB,需考虑是否排除部分表(如 calls 表可能很大)
|
||||||
|
3. **模块 README 模板**:实施时可能需要根据实际模块调整模板
|
||||||
|
4. **技术标签体系扩展**:初始 10 个标签可能不够,实施时根据需要扩展
|
||||||
721
docs/superpowers/specs/2026-07-07-logging-refactor-design.md
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
# 日志系统重构设计文档
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|---|
|
||||||
|
| 文档版本 | v1 |
|
||||||
|
| 创建日期 | 2026-07-07 |
|
||||||
|
| 作者 | Trae 协作生成 |
|
||||||
|
| 状态 | 待用户审查 |
|
||||||
|
| 范围 | pino logger 抽象 + Request ID 贯穿 + 静默失败修复 + error.tsx 上报 + track-event 去重 |
|
||||||
|
| 策略 | 应用层 SDK 改造,不部署外部可观测性服务(OTel / Sentry / Prometheus 等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与现状
|
||||||
|
|
||||||
|
### 1.1 现状调研摘要
|
||||||
|
|
||||||
|
基于 2026-07-07 全项目调研:
|
||||||
|
|
||||||
|
| 维度 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 业务审计日志 | ✅ 已成熟 | audit / login / change logger + 3 张 DB 表 + audit 模块管理后台 |
|
||||||
|
| 应用层结构化日志 | ❌ 未实现 | 88 处散乱 `console.*`,无级别、无 JSON、无 request ID |
|
||||||
|
| Request Correlation | ❌ 未实现 | proxy.ts 中间件零日志、不注入请求 ID |
|
||||||
|
| 错误边界上报 | ❌ 全部丢弃 | 130 个 `error.tsx` 接收 `error` prop 后直接丢弃,前端运行时错误对开发者不可见 |
|
||||||
|
| 静默失败 | ⚠️ 严重 | 三个 audit-logger 的 catch 块完全吞没错误,运维无法感知审计写入失败 |
|
||||||
|
| 统一错误处理 | ⚠️ 部分 | `handleActionError` / `handleApiError` 仅 `console.error`,前缀混乱(`[ExamAction]` / `[ActionError]` / `[ApiError]` / 无前缀混用) |
|
||||||
|
| track-event | ⚠️ 重复 | 4 个 no-op stub(shared / rbac / course-plans / questions 各一份) |
|
||||||
|
| 架构图覆盖 | ❌ 未覆盖 | 004 / 005 完全未记录可观测性基础设施 |
|
||||||
|
|
||||||
|
### 1.2 console.* 分布
|
||||||
|
|
||||||
|
| 类型 | 次数 | 文件数 | 主要位置 |
|
||||||
|
|------|------|--------|----------|
|
||||||
|
| `console.log` | 2 | 1 | `questions/utils/parse-content.ts` |
|
||||||
|
| `console.error` | 67 | 30 | data-access 的 catch 块、`action-utils.ts`、`api-response.ts`、各 Server Action |
|
||||||
|
| `console.warn` | 5 | 5 | `api/web-vitals/route.ts`、redis/store 等 |
|
||||||
|
| `console.info` | 10 | 6 | `track-event.ts`、`api/web-vitals/route.ts`、notifications channels |
|
||||||
|
| `console.debug` | 4 | 3 | 各模块 `track-event.ts`(仅 dev) |
|
||||||
|
|
||||||
|
### 1.3 关键约束
|
||||||
|
|
||||||
|
- **proxy.ts 在 Edge Runtime 运行**([proxy.ts:64](file:///e:/Desktop/CICD/src/proxy.ts#L64) 注释明确说明),`AsyncLocalStorage` 不可用(依赖 `node:async_hooks`,Edge 不支持)
|
||||||
|
- 其余部分(RSC、Server Action、Route Handler、data-access)运行在 Node.js runtime,`AsyncLocalStorage` 完全支持
|
||||||
|
- 项目自部署(`next.config.ts` 中 `output: "standalone"`),运维通过 `docker logs` 查看日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
### 2.1 目标
|
||||||
|
|
||||||
|
1. **统一 logger 抽象**:pino 替换 88 处散乱 `console.*`,提供 `info/error/warn/debug` + `createModuleLogger(module)` 子 logger
|
||||||
|
2. **级别控制**:`LOG_LEVEL` 环境变量控制输出阈值(debug/info/warn/error),Zod 校验
|
||||||
|
3. **结构化输出**:生产环境 JSON(含 timestamp/level/module/requestId/msg),开发环境 `pino-pretty` 彩色文本
|
||||||
|
4. **Request ID 贯穿**:proxy.ts 生成 ID 并注入请求头,Node.js runtime 通过 `AsyncLocalStorage` 贯穿到 data-access / audit-logger
|
||||||
|
5. **静默失败告警**:三个 audit-logger 的 catch 块从 silent 改为 `logger.warn`
|
||||||
|
6. **统一错误处理接入**:`handleActionError` / `handleApiError` / `safeActionCall` 接入 logger,前缀通过 `module` 字段规范化
|
||||||
|
7. **track-event 去重合并**:4 个 no-op stub 合并为 1 个,统一通过 `createModuleLogger("track")`
|
||||||
|
8. **error.tsx 错误上报**:130 个 error.tsx 通过 `useErrorReport` Hook 上报到 `/api/client-error`,含节流防风暴
|
||||||
|
9. **架构图同步**:004 / 005 / known-issues.md 全量更新
|
||||||
|
|
||||||
|
### 2.2 非目标(YAGNI)
|
||||||
|
|
||||||
|
- OpenTelemetry / 链路追踪(trace)
|
||||||
|
- Prometheus / `/metrics` 端点
|
||||||
|
- Sentry / Bugsnag / Datadog 等 SaaS 错误监控
|
||||||
|
- Web Vitals 后端持久化(保留现有 `console.warn`)
|
||||||
|
- 数据库查询日志 / 慢查询日志
|
||||||
|
- 日志文件轮转(`docker logs` 已足够)
|
||||||
|
- next-auth events 回调改造(与本次重构解耦)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 架构设计
|
||||||
|
|
||||||
|
### 3.1 整体数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client] ──HTTP──> [proxy.ts (Edge Runtime)]
|
||||||
|
│ requestId = crypto.randomUUID() // Web Crypto API
|
||||||
|
│ NextResponse.next({ request: { headers } })
|
||||||
|
│ 注入 x-request-id 到下游请求头
|
||||||
|
▼
|
||||||
|
[RSC / Server Action / Route Handler (Node.js Runtime)]
|
||||||
|
│ withRequestContext(fn):
|
||||||
|
│ 1. headers().get("x-request-id") 读取
|
||||||
|
│ 2. requestContextStorage.run({ requestId }, fn)
|
||||||
|
▼
|
||||||
|
[data-access / audit-logger / 业务逻辑 (Node.js Runtime)]
|
||||||
|
│ logger.info({...}, "msg") 调用
|
||||||
|
│ pino mixin 自动从 getRequestContext() 取 requestId
|
||||||
|
▼
|
||||||
|
[stdout: {"level":"info","time":...,"requestId":"abc-123","module":"audit","msg":"..."}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 新增文件清单
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/lib/
|
||||||
|
├─ logger.ts # pino 实例 + createModuleLogger 工厂
|
||||||
|
├─ request-context.ts # AsyncLocalStorage(仅 Node.js runtime)
|
||||||
|
└─ with-request-context.ts # Server Action / Route Handler 入口包装
|
||||||
|
|
||||||
|
src/shared/hooks/
|
||||||
|
└─ use-error-report.ts # error.tsx 公共上报 Hook(含节流)
|
||||||
|
|
||||||
|
src/app/api/client-error/
|
||||||
|
└─ route.ts # 客户端错误接收端点
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动类型 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `package.json` | 新增依赖 | `pino`、`pino-pretty`(dev) |
|
||||||
|
| `src/env.mjs` | 新增字段 | `LOG_LEVEL`(默认 `info`,Zod enum) |
|
||||||
|
| `src/proxy.ts` | 修改 | 生成 requestId 并通过 `NextResponse.next` 注入请求头 |
|
||||||
|
| `src/shared/lib/action-utils.ts` | 修改 | `handleActionError` / `safeActionCall` 用 logger |
|
||||||
|
| `src/shared/lib/api-response.ts` | 修改 | `handleApiError` 用 logger |
|
||||||
|
| `src/shared/lib/audit-logger.ts` | 修改 | catch 块从 silent 改为 `logger.warn` |
|
||||||
|
| `src/shared/lib/change-logger.ts` | 修改 | 同上 |
|
||||||
|
| `src/shared/lib/login-logger.ts` | 修改 | 同上 |
|
||||||
|
| `src/shared/lib/track-event.ts` | 修改 | 改为 `createModuleLogger("track")`,删除 no-op 输出 |
|
||||||
|
| `src/modules/rbac/lib/track.ts` | 删除 | 引用方改为从 `@/shared/lib/track-event` 导入 |
|
||||||
|
| `src/modules/course-plans/lib/track-event.ts` | 删除 | 同上 |
|
||||||
|
| `src/modules/questions/utils/track-event.ts` | 删除 | 同上 |
|
||||||
|
| 88 处 `console.*` 调用点 | 修改 | 替换为 `logger.*` 或 `createModuleLogger(module)` |
|
||||||
|
| 130 个 `error.tsx` | 修改 | 在 useEffect 中调用 `useErrorReport(error)` |
|
||||||
|
| `next.config.ts` | 修改 | 在现有 `serverExternalPackages` 数组中添加 `"pino"`(当前已有 `mysql2`/`tencentcloud-sdk-nodejs`/`exceljs`) |
|
||||||
|
| `.eslintrc` / `eslint.config.mjs` | 修改 | 新增 `no-console` 规则,仅允许 `logger.ts` 中使用 console |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 核心模块设计
|
||||||
|
|
||||||
|
### 4.1 `src/shared/lib/logger.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import pino, { type Logger } from "pino"
|
||||||
|
import { env } from "@/env.mjs"
|
||||||
|
import { getRequestContext } from "./request-context"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 logger 实例。
|
||||||
|
*
|
||||||
|
* - 生产环境:JSON 输出到 stdout(docker logs 友好)
|
||||||
|
* - 开发环境:pino-pretty 彩色文本
|
||||||
|
* - 自动从 AsyncLocalStorage 混入 requestId / userId(若存在)
|
||||||
|
*/
|
||||||
|
export const logger = pino({
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
base: { service: "cicd-app" },
|
||||||
|
formatters: {
|
||||||
|
level: (label) => ({ level: label }),
|
||||||
|
},
|
||||||
|
mixin: () => getRequestContext(),
|
||||||
|
...(env.NODE_ENV === "development" && {
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { colorize: true, translateTime: "SYS:standard" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模块级子 logger,自动绑定 module 字段。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const log = createModuleLogger("audit")
|
||||||
|
* log.info({ userId }, "User action logged")
|
||||||
|
* // 输出: {"level":"info","module":"audit","msg":"User action logged", ...}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createModuleLogger(module: string): Logger {
|
||||||
|
return logger.child({ module })
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Logger }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `src/shared/lib/request-context.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求上下文,贯穿整个请求生命周期。
|
||||||
|
*
|
||||||
|
* 仅在 Node.js Runtime 中可用(proxy.ts 是 Edge Runtime,不导入此模块)。
|
||||||
|
* 通过 withRequestContext 高阶函数注入。
|
||||||
|
*/
|
||||||
|
export interface RequestContext {
|
||||||
|
requestId: string
|
||||||
|
userId?: string
|
||||||
|
module?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestContextStorage = new AsyncLocalStorage<RequestContext>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前请求上下文(若存在)。
|
||||||
|
*
|
||||||
|
* - 在 withRequestContext 包装的调用栈内:返回完整上下文
|
||||||
|
* - 在调用栈外(如顶层模块初始化、定时任务):返回空对象
|
||||||
|
*
|
||||||
|
* pino logger 的 mixin 配置会自动调用此函数混入 requestId。
|
||||||
|
*/
|
||||||
|
export function getRequestContext(): Partial<RequestContext> {
|
||||||
|
return requestContextStorage.getStore() ?? {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `src/shared/lib/with-request-context.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { headers } from "next/headers"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { requestContextStorage, type RequestContext } from "./request-context"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 包装 Server Action / Route Handler,注入 requestId 到 AsyncLocalStorage。
|
||||||
|
*
|
||||||
|
* 工作流程:
|
||||||
|
* 1. 通过 `headers()` 读取 proxy.ts 注入的 `x-request-id`
|
||||||
|
* 2. 若请求头无此字段(如直接调用的内部函数),生成新 UUID
|
||||||
|
* 3. 通过 `requestContextStorage.run()` 注入到 AsyncLocalStorage
|
||||||
|
* 4. 在调用栈内的所有 logger 调用自动获得 requestId
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* export const createUserAction = withRequestContext(
|
||||||
|
* async (state: ActionState<User>, input: CreateUserInput) => {
|
||||||
|
* // 此处 logger.info 会自动带 requestId
|
||||||
|
* return handleAction(...)
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withRequestContext<TArgs extends unknown[], TResult>(
|
||||||
|
fn: (...args: TArgs) => Promise<TResult>
|
||||||
|
): (...args: TArgs) => Promise<TResult> {
|
||||||
|
return async (...args: TArgs) => {
|
||||||
|
const headersList = await headers()
|
||||||
|
const requestId =
|
||||||
|
headersList.get("x-request-id") ?? randomUUID()
|
||||||
|
const ctx: RequestContext = { requestId }
|
||||||
|
return requestContextStorage.run(ctx, () => fn(...args))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 `src/proxy.ts` 改造
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { getToken } from "next-auth/jwt"
|
||||||
|
// ... 原有 imports
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// 生成或复用 requestId(Web Crypto API,Edge 兼容)
|
||||||
|
const requestId =
|
||||||
|
request.headers.get("x-request-id") ?? crypto.randomUUID()
|
||||||
|
|
||||||
|
// 跳过静态资源和登录页
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/_next") ||
|
||||||
|
pathname.startsWith("/api/auth") ||
|
||||||
|
pathname === "/login" ||
|
||||||
|
pathname === "/register" ||
|
||||||
|
pathname === "/favicon.ico"
|
||||||
|
) {
|
||||||
|
return NextResponse.next({
|
||||||
|
request: { headers: injectRequestId(request, requestId) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 原有 token / onboarding / 权限检查逻辑
|
||||||
|
// 所有 NextResponse.next() / NextResponse.redirect() 调用保留,
|
||||||
|
// 但 NextResponse.next() 调用统一传入 request.headers
|
||||||
|
|
||||||
|
const response = NextResponse.next({
|
||||||
|
request: { headers: injectRequestId(request, requestId) },
|
||||||
|
})
|
||||||
|
response.headers.set("x-request-id", requestId)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含 x-request-id 的新 Headers 对象。
|
||||||
|
* 通过 NextResponse.next({ request: { headers } }) 注入到下游 RSC 请求。
|
||||||
|
*/
|
||||||
|
function injectRequestId(request: NextRequest, requestId: string): Headers {
|
||||||
|
const headers = new Headers(request.headers)
|
||||||
|
headers.set("x-request-id", requestId)
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **说明**:proxy.ts 不导入 `request-context.ts`,避免在 Edge Runtime 中加载 `node:async_hooks` 导致构建错误。
|
||||||
|
|
||||||
|
### 4.5 `src/shared/hooks/use-error-report.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
interface ClientErrorPayload {
|
||||||
|
message: string
|
||||||
|
stack?: string
|
||||||
|
digest?: string
|
||||||
|
url: string
|
||||||
|
userAgent: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端错误上报 Hook。
|
||||||
|
*
|
||||||
|
* 用于 error.tsx 接收 error prop 后上报到 /api/client-error。
|
||||||
|
*
|
||||||
|
* 节流策略:
|
||||||
|
* - 同一 digest(或 message)在 sessionStorage 中标记,避免 React 重渲染或快速刷新时多次上报
|
||||||
|
* - 上报失败时静默忽略,避免无限循环
|
||||||
|
*/
|
||||||
|
export function useErrorReport(error: Error & { digest?: string }): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!error) return
|
||||||
|
|
||||||
|
const digest = error.digest ?? error.message
|
||||||
|
const storageKey = `error-reported:${digest}`
|
||||||
|
if (sessionStorage.getItem(storageKey)) return
|
||||||
|
sessionStorage.setItem(storageKey, "1")
|
||||||
|
|
||||||
|
const payload: ClientErrorPayload = {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
digest: error.digest,
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch("/api/client-error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
keepalive: true, // 即使页面卸载也尝试发送
|
||||||
|
}).catch(() => {
|
||||||
|
// 上报失败时不再上报,避免无限循环
|
||||||
|
})
|
||||||
|
}, [error])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 `src/app/api/client-error/route.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { createModuleLogger } from "@/shared/lib/logger"
|
||||||
|
import { withRequestContext } from "@/shared/lib/with-request-context"
|
||||||
|
|
||||||
|
const log = createModuleLogger("client-error")
|
||||||
|
|
||||||
|
interface ClientErrorPayload {
|
||||||
|
message: string
|
||||||
|
stack?: string
|
||||||
|
digest?: string
|
||||||
|
url: string
|
||||||
|
userAgent: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收客户端 error.tsx 上报的错误。
|
||||||
|
*
|
||||||
|
* 注意:客户端错误的 requestId 与原始请求不同(来自 /api/client-error 的 HTTP 请求),
|
||||||
|
* 但 digest 字段可用于关联原始错误。
|
||||||
|
*/
|
||||||
|
export const POST = withRequestContext(async (request: Request) => {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as ClientErrorPayload
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
clientMessage: body.message,
|
||||||
|
stack: body.stack,
|
||||||
|
digest: body.digest,
|
||||||
|
url: body.url,
|
||||||
|
userAgent: body.userAgent,
|
||||||
|
clientTimestamp: body.timestamp,
|
||||||
|
},
|
||||||
|
"Client error reported"
|
||||||
|
)
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ err: error }, "Failed to parse client error payload")
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 `src/shared/lib/audit-logger.ts` 改造(静默失败 → 告警)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createModuleLogger } from "@/shared/lib/logger"
|
||||||
|
|
||||||
|
const log = createModuleLogger("audit-logger")
|
||||||
|
|
||||||
|
export async function logAudit(params: LogAuditParams): Promise<void> {
|
||||||
|
try {
|
||||||
|
// ... 原有写入逻辑
|
||||||
|
} catch (error) {
|
||||||
|
// 旧:catch { /* Silently fail */ }
|
||||||
|
// 新:记录到 logger,运维可感知
|
||||||
|
log.warn(
|
||||||
|
{ err: error, action: params.action, module: params.module },
|
||||||
|
"Audit log write failed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.8 `src/shared/lib/action-utils.ts` 改造
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createModuleLogger } from "@/shared/lib/logger"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||||||
|
|
||||||
|
const log = createModuleLogger("action")
|
||||||
|
|
||||||
|
export function handleActionError(e: unknown): ActionState<never> {
|
||||||
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: e.message }
|
||||||
|
}
|
||||||
|
if (e instanceof BusinessError) {
|
||||||
|
return { success: false, message: e.message, errorCode: e.code }
|
||||||
|
}
|
||||||
|
if (e instanceof Error) {
|
||||||
|
// 旧:console.error("[ActionError]", e.name, e.message, e.stack)
|
||||||
|
log.error({ err: e }, "Action failed")
|
||||||
|
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
|
||||||
|
}
|
||||||
|
log.error({ err: e }, "Unknown action error")
|
||||||
|
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeActionCall<T>(
|
||||||
|
action: () => Promise<ActionState<T>>,
|
||||||
|
options?: {
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
onFinally?: () => void
|
||||||
|
}
|
||||||
|
): Promise<ActionState<T> | null> {
|
||||||
|
try {
|
||||||
|
return await action()
|
||||||
|
} catch (e) {
|
||||||
|
options?.onError?.(e)
|
||||||
|
// 旧:console.error("[SafeActionCall]", e)
|
||||||
|
log.error({ err: e }, "Safe action call threw")
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
options?.onFinally?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.9 `src/shared/lib/track-event.ts` 改造
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createModuleLogger } from "@/shared/lib/logger"
|
||||||
|
|
||||||
|
const log = createModuleLogger("track")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务埋点接口。
|
||||||
|
*
|
||||||
|
* 不再是 no-op stub,通过 logger.info 输出结构化事件,
|
||||||
|
* 后续可扩展为接入外部 analytics 服务。
|
||||||
|
*/
|
||||||
|
export function trackEvent(
|
||||||
|
name: string,
|
||||||
|
props?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
log.info({ event: name, ...props }, "track event")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackExamEvent(
|
||||||
|
name: string,
|
||||||
|
props?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
trackEvent(`exam.${name}`, props)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackAuthEvent(
|
||||||
|
name: string,
|
||||||
|
props?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
trackEvent(`auth.${name}`, props)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.10 `src/env.mjs` 改造
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 在 server schema 中添加:
|
||||||
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.11 ESLint 规则
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.mjs
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"no-console": ["error", { allow: [], allowWithImplicit: false }]
|
||||||
|
},
|
||||||
|
// logger.ts 豁免(pino 内部需用 console)
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["src/shared/lib/logger.ts"],
|
||||||
|
rules: { "no-console": "off" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 改造范围与策略
|
||||||
|
|
||||||
|
### 5.1 console.* 替换映射
|
||||||
|
|
||||||
|
| 类型 | 数量 | 替换为 | 调用方式 |
|
||||||
|
|------|------|--------|----------|
|
||||||
|
| `console.log` | 2 | `log.debug` | `const log = createModuleLogger("questions")` |
|
||||||
|
| `console.error` | 67 | `log.error` | 按文件所属模块创建子 logger |
|
||||||
|
| `console.warn` | 5 | `log.warn` | 同上 |
|
||||||
|
| `console.info` | 10 | `log.info` | 同上 |
|
||||||
|
| `console.debug` | 4 | `log.debug` | 同上 |
|
||||||
|
|
||||||
|
### 5.2 模块前缀规范化
|
||||||
|
|
||||||
|
当前散乱前缀 → 统一通过 `module` 字段:
|
||||||
|
|
||||||
|
| 当前前缀 | 新 module 字段 |
|
||||||
|
|---------|----------------|
|
||||||
|
| `[ExamAction]` | `exams` |
|
||||||
|
| `[ActionError]` | `action` |
|
||||||
|
| `[ApiError]` | `api` |
|
||||||
|
| `[AuditLogger]` | `audit-logger` |
|
||||||
|
| `[SafeActionCall]` | `action` |
|
||||||
|
| `[Files]` / `[files]` | `files` |
|
||||||
|
| 无前缀 | 按文件所属模块 |
|
||||||
|
|
||||||
|
### 5.3 Server Action 包装策略
|
||||||
|
|
||||||
|
由于 Server Action 通过 `"use server"` 自动成为 RPC,不能简单用 `withRequestContext` 包装导出函数(会丢失 Next.js 类型推断)。且 `handleActionError` 是同步函数,无法 `await headers()` 读取请求头。
|
||||||
|
|
||||||
|
**最终策略**:在每个 Server Action 模块的入口点调用 `withRequestContext` 包装:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/modules/audit/actions.ts
|
||||||
|
"use server"
|
||||||
|
import { withRequestContext } from "@/shared/lib/with-request-context"
|
||||||
|
import { createModuleLogger } from "@/shared/lib/logger"
|
||||||
|
|
||||||
|
const log = createModuleLogger("audit")
|
||||||
|
|
||||||
|
export const createAuditLogAction = withRequestContext(
|
||||||
|
async (state: ActionState<void>, input: CreateAuditInput) => {
|
||||||
|
// 此处 logger 自动带 requestId
|
||||||
|
log.info({ input }, "Creating audit log")
|
||||||
|
return handleAction(...)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**data-access 层无需显式包装**:因为 data-access 总是从 Server Action 调用,AsyncLocalStorage 上下文会自动贯穿到调用栈下游。data-access 中的 logger 调用会自动获得 requestId。
|
||||||
|
|
||||||
|
**实施时验证项**:
|
||||||
|
- Next.js 16 是否允许高阶函数包装 Server Action(保留 NextServerAction 标记)
|
||||||
|
- 若不允许,回退方案:在每个 Server Action 函数体首行调用 `await initRequestContext()`,该函数内部读取 headers 并写入 AsyncLocalStorage
|
||||||
|
|
||||||
|
### 5.4 error.tsx 改造模式
|
||||||
|
|
||||||
|
130 个 error.tsx 统一改为:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||||||
|
// ... 原有 imports
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useErrorReport(error)
|
||||||
|
// ... 原有 UI 渲染
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 error.tsx 仅增加 2 行(import + 调用 Hook)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 测试策略
|
||||||
|
|
||||||
|
### 6.1 单元测试
|
||||||
|
|
||||||
|
| 测试文件 | 覆盖内容 |
|
||||||
|
|---------|---------|
|
||||||
|
| `__tests__/logger.test.ts` | level 控制、JSON 输出格式、mixin 注入 requestId、createModuleLogger |
|
||||||
|
| `__tests__/request-context.test.ts` | AsyncLocalStorage 读写、嵌套调用、空上下文 |
|
||||||
|
| `__tests__/use-error-report.test.tsx` | 节流(sessionStorage 标记)、fetch 调用、错误处理 |
|
||||||
|
|
||||||
|
### 6.2 集成测试
|
||||||
|
|
||||||
|
- 启动开发服务器,发起请求,验证日志包含 requestId
|
||||||
|
- 触发 Server Action 错误,验证日志包含 module + requestId + error stack
|
||||||
|
- 触发 client error.tsx,验证 `/api/client-error` 收到请求并记录日志
|
||||||
|
|
||||||
|
### 6.3 回归验证
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` 零错误
|
||||||
|
- `npm run lint` 零错误(含新增 `no-console` 规则)
|
||||||
|
- 现有 vitest 测试套件全部通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 风险与权衡
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|------|------|------|
|
||||||
|
| pino 在 Next.js bundling 中可能有问题 | 高 | 已配置 `serverExternalPackages`,pino 仅服务端导入;开发期 `pino-pretty` 通过 transport 配置启用 |
|
||||||
|
| `AsyncLocalStorage` 在 Server Action 中可能不工作 | 中 | Server Action 运行在 Node.js runtime,完全支持;实施时先做最小验证 |
|
||||||
|
| Server Action 高阶函数包装可能丢失 Next.js 类型 | 中 | 实施时验证,必要时回退为显式 `headers()` 调用 |
|
||||||
|
| 88 处 console.* 替换可能遗漏 | 低 | 通过 ESLint `no-console` 规则强制,仅允许 `logger.ts` 中使用 |
|
||||||
|
| 130 个 error.tsx 改造量大 | 中 | 提取公共 `useErrorReport` Hook,每个 error.tsx 仅加 2 行 |
|
||||||
|
| track-event 合并可能破坏调用方 | 低 | 删除前 grep 所有引用,统一改为从 `@/shared/lib/track-event` 导入 |
|
||||||
|
| 客户端错误风暴(无限循环上报) | 中 | sessionStorage 节流 + fetch 失败静默 + `keepalive` 选项 |
|
||||||
|
| proxy.ts 改造可能影响 Edge Runtime 构建 | 中 | 不导入任何 Node.js 模块,仅用 Web Crypto API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 架构图同步(强制)
|
||||||
|
|
||||||
|
按项目规则"改码必同步图",重构后必须更新:
|
||||||
|
|
||||||
|
### 8.1 `docs/architecture/004_architecture_impact_map.md`
|
||||||
|
新增章节:
|
||||||
|
- `shared/lib/logger.ts` — pino 实例 + createModuleLogger
|
||||||
|
- `shared/lib/request-context.ts` — AsyncLocalStorage 请求上下文
|
||||||
|
- `shared/lib/with-request-context.ts` — Server Action / Route Handler 入口包装
|
||||||
|
- `shared/hooks/use-error-report.ts` — 客户端错误上报 Hook
|
||||||
|
- `app/api/client-error/route.ts` — 客户端错误接收端点
|
||||||
|
|
||||||
|
修改章节:
|
||||||
|
- `proxy.ts` — 增加 requestId 注入逻辑
|
||||||
|
- `shared/lib/audit-logger.ts` / `change-logger.ts` / `login-logger.ts` — 静默失败改为 logger.warn
|
||||||
|
- `shared/lib/track-event.ts` — 实现层从 no-op 改为 logger.info
|
||||||
|
- `shared/lib/action-utils.ts` / `api-response.ts` — 接入 logger
|
||||||
|
|
||||||
|
删除记录:
|
||||||
|
- `modules/rbac/lib/track.ts`
|
||||||
|
- `modules/course-plans/lib/track-event.ts`
|
||||||
|
- `modules/questions/utils/track-event.ts`
|
||||||
|
|
||||||
|
### 8.2 `docs/architecture/005_architecture_data.json`
|
||||||
|
- `modules.shared.lib.exports` 新增 `logger` / `createModuleLogger` / `requestContextStorage` / `getRequestContext` / `withRequestContext`
|
||||||
|
- `modules.shared.hooks.exports` 新增 `useErrorReport`
|
||||||
|
- `modules.app.api.client-error.exports` 新增 `POST`
|
||||||
|
- `dependencyMatrix` 更新:proxy.ts → request-context(仅注入请求头,不导入)
|
||||||
|
- 删除 `modules.rbac.lib.track`、`modules.course-plans.lib.track-event`、`modules.questions.utils.track-event`
|
||||||
|
|
||||||
|
### 8.3 `docs/troubleshooting/known-issues.md`
|
||||||
|
新增规则条目:
|
||||||
|
- pino 集成:`serverExternalPackages` 配置 + `pino-pretty` 仅 dev
|
||||||
|
- Edge Runtime 限制:proxy.ts 不能导入 `node:async_hooks`
|
||||||
|
- Server Action 包装:`withRequestContext` 高阶函数使用模式
|
||||||
|
- error.tsx 错误上报:`useErrorReport` Hook + 节流策略
|
||||||
|
- ESLint `no-console` 规则与豁免
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 实施顺序建议
|
||||||
|
|
||||||
|
1. **Phase 1:基础底座**
|
||||||
|
- 装 pino / pino-pretty
|
||||||
|
- 创建 `logger.ts` / `request-context.ts` / `with-request-context.ts`
|
||||||
|
- 在 `env.mjs` 添加 LOG_LEVEL
|
||||||
|
- 单元测试
|
||||||
|
|
||||||
|
2. **Phase 2:核心接入**
|
||||||
|
- proxy.ts 注入 requestId
|
||||||
|
- `action-utils.ts` / `api-response.ts` 接入 logger
|
||||||
|
- 三个 audit-logger 静默失败 → logger.warn
|
||||||
|
- track-event 去重合并
|
||||||
|
|
||||||
|
3. **Phase 3:批量替换**
|
||||||
|
- 88 处 console.* 替换为 logger.*
|
||||||
|
- 前缀规范化
|
||||||
|
- ESLint `no-console` 规则启用
|
||||||
|
|
||||||
|
4. **Phase 4:error.tsx 上报**
|
||||||
|
- 创建 `use-error-report` Hook
|
||||||
|
- 创建 `/api/client-error` Route Handler
|
||||||
|
- 130 个 error.tsx 接入
|
||||||
|
|
||||||
|
5. **Phase 5:架构同步**
|
||||||
|
- 更新 004 / 005 / known-issues.md
|
||||||
|
- 验证 tsc / lint / 测试通过
|
||||||
30
drizzle/0001_questions_fulltext_search.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE `lesson_plan_schedules` (
|
||||||
|
`id` varchar(128) NOT NULL,
|
||||||
|
`plan_id` varchar(128) NOT NULL,
|
||||||
|
`class_id` varchar(128) NOT NULL,
|
||||||
|
`scheduled_date` date NOT NULL,
|
||||||
|
`period` int NOT NULL,
|
||||||
|
`class_schedule_id` varchar(128),
|
||||||
|
`duration_min` int NOT NULL DEFAULT 40,
|
||||||
|
`created_by` varchar(128) NOT NULL,
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `lesson_plan_schedules_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `questions` ADD `content_text` text GENERATED ALWAYS AS (CAST(content AS CHAR)) STORED;--> statement-breakpoint
|
||||||
|
ALTER TABLE `lesson_plan_schedules` ADD CONSTRAINT `lesson_plan_schedules_plan_id_lesson_plans_id_fk` FOREIGN KEY (`plan_id`) REFERENCES `lesson_plans`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `lesson_plan_schedules` ADD CONSTRAINT `lesson_plan_schedules_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `lesson_plan_schedules` ADD CONSTRAINT `lesson_plan_schedules_created_by_users_id_fk` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `lpsc_plan_idx` ON `lesson_plan_schedules` (`plan_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `lpsc_class_date_idx` ON `lesson_plan_schedules` (`class_id`,`scheduled_date`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `lpsc_plan_date_idx` ON `lesson_plan_schedules` (`plan_id`,`scheduled_date`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `exam_submissions_exam_status_idx` ON `exam_submissions` (`exam_id`,`status`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `exam_submissions_submitted_at_idx` ON `exam_submissions` (`submitted_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `exams_status_created_idx` ON `exams` (`status`,`created_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `exams_creator_idx` ON `exams` (`creator_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `questions_type_difficulty_idx` ON `questions` (`type`,`difficulty`);--> statement-breakpoint
|
||||||
|
-- P3-6: questions.content_text FULLTEXT 索引(drizzle-kit 无法生成 FULLTEXT 声明,需手动追加)
|
||||||
|
-- 要求:InnoDB 引擎 + utf8mb4 字符集(MySQL 5.7+)
|
||||||
|
-- 用于 searchQuestions 的 MATCH(content_text) AGAINST(? IN BOOLEAN MODE) 检索
|
||||||
|
CREATE FULLTEXT INDEX `questions_content_text_ft_idx` ON `questions` (`content_text`);
|
||||||
11740
drizzle/meta/0001_snapshot.json
Normal file
@@ -8,6 +8,13 @@
|
|||||||
"when": 1783064688766,
|
"when": 1783064688766,
|
||||||
"tag": "0000_aberrant_deathstrike",
|
"tag": "0000_aberrant_deathstrike",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1783234384304,
|
||||||
|
"tag": "0001_questions_fulltext_search",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
70
eslint-rules/no-hardcoded-design-tokens.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// eslint-rules/no-hardcoded-design-tokens.js
|
||||||
|
// 检测 TSX/TS 中硬编码的字体家族字面量(Inter/Fraunces/JetBrains Mono)
|
||||||
|
// 白名单:src/app/styles/tokens/primitive.css(令牌定义)、email-channel.ts、manifest.ts
|
||||||
|
// 使用单词边界匹配,避免误判 Interval/Interactive/Interrupt 等含 Inter 子串的标识符
|
||||||
|
|
||||||
|
const FORBIDDEN_FONTS = ['Inter', 'Fraunces', 'JetBrains Mono'];
|
||||||
|
const WHITELIST_FILES = [
|
||||||
|
'eslint-rules/no-hardcoded-design-tokens.js',
|
||||||
|
'src/app/styles/tokens/primitive.css',
|
||||||
|
'src/modules/notifications/channels/email-channel.ts',
|
||||||
|
'src/app/manifest.ts',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 为每个字体名构建单词边界正则:\bInter\b 匹配 "Inter" 但不匹配 "Interval"
|
||||||
|
const FORBIDDEN_PATTERNS = FORBIDDEN_FONTS.map((font) => ({
|
||||||
|
font,
|
||||||
|
regex: new RegExp(`\\b${font}\\b`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: '禁止硬编码字体家族字面量,使用 var(--font-family-sans/serif/mono)',
|
||||||
|
category: 'Best Practices',
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
forbiddenFont:
|
||||||
|
'禁止硬编码字体家族 "{{font}}",使用 var(--font-family-sans/serif/mono) 或 var(--font-family-mono)',
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const filename = context.getFilename().replace(/\\/g, '/');
|
||||||
|
if (WHITELIST_FILES.some((f) => filename.endsWith(f))) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Literal(node) {
|
||||||
|
if (typeof node.value !== 'string') return;
|
||||||
|
for (const { font, regex } of FORBIDDEN_PATTERNS) {
|
||||||
|
if (regex.test(node.value)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'forbiddenFont',
|
||||||
|
data: { font },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TemplateElement(node) {
|
||||||
|
const raw = node.value.raw;
|
||||||
|
for (const { font, regex } of FORBIDDEN_PATTERNS) {
|
||||||
|
if (regex.test(raw)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'forbiddenFont',
|
||||||
|
data: { font },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
@@ -16,6 +20,19 @@ const eslintConfig = defineConfig([
|
|||||||
caughtErrorsIgnorePattern: "^_",
|
caughtErrorsIgnorePattern: "^_",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// 禁止硬编码 hex 颜色字面量(白名单:tokens 定义文件、邮件、manifest)
|
||||||
|
// 白名单文件内的 #hex 需在所在行上方加 // eslint-disable-next-line no-restricted-syntax 注释豁免
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
|
||||||
|
message:
|
||||||
|
"禁止硬编码 hex 颜色,使用设计令牌 hsl(var(--*)) 或 Tailwind 类 bg-*",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 禁止 console.* 调用,统一使用 createModuleLogger("module-name")
|
||||||
|
// 豁免场景:scripts/、tests/、客户端 .tsx(留待客户端错误上报机制处理)、被客户端导入的 .ts
|
||||||
|
"no-console": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -34,6 +51,84 @@ const eslintConfig = defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 自定义规则:检测硬编码字体家族字面量
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
"design-tokens": {
|
||||||
|
rules: {
|
||||||
|
"no-hardcoded-fonts": await import(
|
||||||
|
pathToFileURL(join(__dirname, "eslint-rules/no-hardcoded-design-tokens.js")).href
|
||||||
|
).then((m) => m.default ?? m),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"design-tokens/no-hardcoded-fonts": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 缓存策略规则:禁止在 actions / route.ts 中直接调用 revalidatePath/revalidateTag
|
||||||
|
{
|
||||||
|
files: ["src/modules/**/actions*.ts", "src/app/api/**/route.ts", "src/i18n/actions*.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.name='revalidatePath']",
|
||||||
|
message:
|
||||||
|
"使用 invalidateFor() 替代直接 revalidatePath(),参见 docs/architecture/004 缓存章节",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.name='revalidateTag']",
|
||||||
|
message:
|
||||||
|
"使用 invalidateFor() 替代直接 revalidateTag(),参见 docs/architecture/004 缓存章节",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 豁免:shared/lib/cache/ 内部允许调用 revalidatePath/revalidateTag(invalidate.ts 内部实现)
|
||||||
|
{
|
||||||
|
files: ["src/shared/lib/cache/**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-syntax": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 豁免:scripts/ 和 tests/ 允许 console(脚本/测试场景不接入 logger)
|
||||||
|
{
|
||||||
|
files: ["scripts/**/*.{js,mjs,ts}", "tests/**/*.{ts,tsx,js}"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 暂时豁免:客户端 .tsx 文件中的 console 调用,留待客户端错误上报机制(Task 12-14)处理
|
||||||
|
// 客户端组件不能导入服务端 pino logger,需通过 useErrorReport Hook 上报
|
||||||
|
{
|
||||||
|
files: ["src/**/*.tsx"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 豁免:客户端 hooks(.ts)被客户端组件导入,不能使用服务端 logger
|
||||||
|
// 留待 Task 12-14 客户端错误上报机制处理
|
||||||
|
{
|
||||||
|
files: ["src/**/hooks/**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 豁免:components 目录中的 .ts utils(如 exam-preview-utils.ts)被客户端 hooks 导入
|
||||||
|
{
|
||||||
|
files: ["src/**/components/**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 豁免:被客户端组件导入的 .ts 文件不能使用服务端 logger(如 query-client.ts 被 providers.tsx 导入)
|
||||||
|
{
|
||||||
|
files: ["src/shared/lib/query-client.ts"],
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
@@ -48,6 +143,8 @@ const eslintConfig = defineConfig([
|
|||||||
"tests/webapp/debug_drizzle.js",
|
"tests/webapp/debug_drizzle.js",
|
||||||
// Migration/maintenance scripts using CommonJS require()
|
// Migration/maintenance scripts using CommonJS require()
|
||||||
"scripts/**/*.js",
|
"scripts/**/*.js",
|
||||||
|
// 已删除文件的归档目录,不参与 lint
|
||||||
|
"deletes/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
35
lighthouserc.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"url": [
|
||||||
|
"http://localhost:3000/login"
|
||||||
|
],
|
||||||
|
"numberOfRuns": 3,
|
||||||
|
"settings": {
|
||||||
|
"preset": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["warn", { "minScore": 0.85 }],
|
||||||
|
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
||||||
|
"categories:best-practices": ["warn", { "minScore": 0.85 }],
|
||||||
|
"categories:seo": ["warn", { "minScore": 0.85 }],
|
||||||
|
"largest-contentful-paint": ["error", { "maxNumericValue": 3000 }],
|
||||||
|
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
|
||||||
|
"interactive": ["warn", { "maxNumericValue": 3000 }],
|
||||||
|
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
|
||||||
|
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "filesystem",
|
||||||
|
"outputDir": "lighthouse-results"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
lint-phase2b.log
Normal file
@@ -5,9 +5,44 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
// mysql2 是 Node.js 服务端驱动,依赖 net/tls 等 Node 内置模块,
|
// 服务端 Node.js 原生模块,不应被 Turbopack 打包到 server bundle。
|
||||||
// 不应被 Turbopack 打包到 server bundle 中,标记为外部包在运行时 require。
|
// - mysql2: Node.js MySQL 驱动,依赖 net/tls
|
||||||
serverExternalPackages: ["mysql2"],
|
// - tencentcloud-sdk-nodejs: 腾讯云 SDK,体积 ~2-3 MB,仅在短信/对象存储等服务端调用
|
||||||
|
// - exceljs: Excel 生成库,体积 ~250-300 KB,仅在导出报表时使用
|
||||||
|
// - pino: 结构化日志库,依赖 node:async_hooks 与 transport worker 线程
|
||||||
|
serverExternalPackages: [
|
||||||
|
"mysql2",
|
||||||
|
"tencentcloud-sdk-nodejs",
|
||||||
|
"exceljs",
|
||||||
|
"pino",
|
||||||
|
],
|
||||||
|
experimental: {
|
||||||
|
// 启用 optimizePackageImports 让 Turbopack 对 barrel 导出做精确 tree-shaking,
|
||||||
|
// 避免整个库被打入首屏 chunk。覆盖项目中最常用的重型库:
|
||||||
|
// - lucide-react: 图标库,barrel 导出 1000+ 图标
|
||||||
|
// - recharts: 图表库,barrel 导出 20+ 组件
|
||||||
|
// - @xyflow/react: ReactFlow,barrel 导出 30+ 模块
|
||||||
|
// - @tiptap/*: 富文本编辑器,多个 barrel 包
|
||||||
|
// - @radix-ui/*: 无障碍 UI 原语,barrel 导出
|
||||||
|
// - date-fns: 日期工具,barrel 导出 200+ 函数
|
||||||
|
optimizePackageImports: [
|
||||||
|
"lucide-react",
|
||||||
|
"recharts",
|
||||||
|
"@xyflow/react",
|
||||||
|
"@tiptap/react",
|
||||||
|
"@tiptap/starter-kit",
|
||||||
|
"@tiptap/extension-placeholder",
|
||||||
|
"@tiptap/extension-image",
|
||||||
|
"tiptap-markdown",
|
||||||
|
"@radix-ui/react-dialog",
|
||||||
|
"@radix-ui/react-dropdown-menu",
|
||||||
|
"@radix-ui/react-popover",
|
||||||
|
"@radix-ui/react-select",
|
||||||
|
"@radix-ui/react-tabs",
|
||||||
|
"@radix-ui/react-tooltip",
|
||||||
|
"date-fns",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
379
package-lock.json
generated
@@ -37,6 +37,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.5",
|
||||||
"@tiptap/extension-image": "^3.27.1",
|
"@tiptap/extension-image": "^3.27.1",
|
||||||
"@tiptap/extension-placeholder": "^3.15.3",
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
"@tiptap/pm": "^3.15.3",
|
"@tiptap/pm": "^3.15.3",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"p-queue": "^9.1.0",
|
"p-queue": "^9.1.0",
|
||||||
|
"pino": "^9.14.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tanstack/react-query-devtools": "^5.101.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -97,6 +100,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.0.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"pino-pretty": "^11.3.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -3300,6 +3304,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinojs/redact": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -6535,9 +6545,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.12",
|
"version": "5.101.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
|
||||||
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
|
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/query-devtools": {
|
||||||
|
"version": "5.101.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz",
|
||||||
|
"integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6545,12 +6566,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.90.12",
|
"version": "5.101.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
|
||||||
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
|
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.12"
|
"@tanstack/query-core": "5.101.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6560,6 +6581,24 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-query-devtools": {
|
||||||
|
"version": "5.101.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz",
|
||||||
|
"integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-devtools": "5.101.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-table": {
|
"node_modules/@tanstack/react-table": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
@@ -6580,6 +6619,23 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.5.tgz",
|
||||||
|
"integrity": "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.17.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/table-core": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.3",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
@@ -6593,6 +6649,16 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.17.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.3.tgz",
|
||||||
|
"integrity": "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -8169,6 +8235,19 @@
|
|||||||
"d3-zoom": "^3.0.0"
|
"d3-zoom": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accessor-fn": {
|
"node_modules/accessor-fn": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
||||||
@@ -8575,6 +8654,15 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -9105,6 +9193,13 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "2.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -9553,6 +9648,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dateformat": {
|
||||||
|
"version": "4.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||||
|
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.21",
|
"version": "1.11.21",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||||
@@ -10715,12 +10820,32 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exceljs": {
|
"node_modules/exceljs": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
||||||
@@ -10769,6 +10894,13 @@
|
|||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-copy": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-csv": {
|
"node_modules/fast-csv": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||||
@@ -10842,6 +10974,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-safe-stringify": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
@@ -11426,6 +11565,13 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/help-me": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -12284,6 +12430,16 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/joycon": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -14692,6 +14848,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -14978,6 +15143,111 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "9.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||||
|
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^2.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty": {
|
||||||
|
"version": "11.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.3.0.tgz",
|
||||||
|
"integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"colorette": "^2.0.7",
|
||||||
|
"dateformat": "^4.6.3",
|
||||||
|
"fast-copy": "^3.0.2",
|
||||||
|
"fast-safe-stringify": "^2.1.1",
|
||||||
|
"help-me": "^5.0.0",
|
||||||
|
"joycon": "^3.1.1",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^2.0.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"readable-stream": "^4.0.0",
|
||||||
|
"secure-json-parse": "^2.4.0",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"strip-json-comments": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino-pretty": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty/node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
@@ -15237,12 +15507,38 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -15414,6 +15710,17 @@
|
|||||||
"prosemirror-transform": "^1.1.0"
|
"prosemirror-transform": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -15471,6 +15778,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.1",
|
"version": "19.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||||
@@ -15709,6 +16022,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||||
@@ -16142,6 +16464,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -16176,6 +16507,13 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/secure-json-parse": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -16422,6 +16760,15 @@
|
|||||||
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
|
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
@@ -16472,6 +16819,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sqlstring": {
|
"node_modules/sqlstring": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
@@ -16898,6 +17254,15 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.5",
|
||||||
"@tiptap/extension-image": "^3.27.1",
|
"@tiptap/extension-image": "^3.27.1",
|
||||||
"@tiptap/extension-placeholder": "^3.15.3",
|
"@tiptap/extension-placeholder": "^3.15.3",
|
||||||
"@tiptap/pm": "^3.15.3",
|
"@tiptap/pm": "^3.15.3",
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"p-queue": "^9.1.0",
|
"p-queue": "^9.1.0",
|
||||||
|
"pino": "^9.14.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tanstack/react-query-devtools": "^5.101.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -129,6 +132,7 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "16.0.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"pino-pretty": "^11.3.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
BIN
public/uploads/2026-06/ulthqxd89p6d4ikjv7m7mf3l.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/2026-06/v86mvtnjoejz3l189hrf01ty.png
Normal file
|
After Width: | Height: | Size: 706 KiB |
BIN
public/uploads/2026-06/zofz1jxqbx3z7te0mbknw9j9.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
screenshots/01-login.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/02-form-filled.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/03-after-login.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -22,7 +22,7 @@ async function main() {
|
|||||||
console.log("\n=== class_enrollments 结构 ===");
|
console.log("\n=== class_enrollments 结构 ===");
|
||||||
const [cols] = await conn.query(`SHOW COLUMNS FROM \`class_enrollments\``);
|
const [cols] = await conn.query(`SHOW COLUMNS FROM \`class_enrollments\``);
|
||||||
console.table(cols);
|
console.table(cols);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.log("class_enrollments 表不存在");
|
console.log("class_enrollments 表不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,9 @@ import {
|
|||||||
examQuestions,
|
examQuestions,
|
||||||
subjects,
|
subjects,
|
||||||
grades,
|
grades,
|
||||||
schools,
|
|
||||||
users,
|
|
||||||
usersToRoles,
|
|
||||||
roles,
|
|
||||||
} from "../src/shared/db/schema";
|
} from "../src/shared/db/schema";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { sql, eq, and } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
// ============ 类型定义 ============
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||||||
|
|
||||||
export default function AuthError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function AuthError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
useErrorReport(error)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4 text-center">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
import { QuestionCascadeFilter } from "./question-cascade-filter"
|
import { QuestionCascadeFilter } from "@/modules/questions/components/question-cascade-filter"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题库筛选栏。
|
* 题库筛选栏。
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
import { SUBJECTS, GRADES } from "../constants"
|
import { SUBJECTS, GRADES } from "@/modules/textbooks/constants"
|
||||||
|
|
||||||
export function TextbookFilters() {
|
export function TextbookFilters() {
|
||||||
const t = useTranslations("textbooks")
|
const t = useTranslations("textbooks")
|
||||||
@@ -4,9 +4,11 @@ import { AlertCircle } from "lucide-react"
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||||||
|
|
||||||
export default function EditAnnouncementError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function EditAnnouncementError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
const t = useTranslations("announcements")
|
const t = useTranslations("announcements")
|
||||||
|
useErrorReport(error)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { AlertCircle } from "lucide-react"
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||||||
|
|
||||||
export default function AdminAnnouncementsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function AdminAnnouncementsError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
const t = useTranslations("announcements")
|
const t = useTranslations("announcements")
|
||||||
|
useErrorReport(error)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-skeleton"
|
import { SkeletonCard } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
export default function AdminAnnouncementsLoading() {
|
export default function AdminAnnouncementsLoading() {
|
||||||
return <AnnouncementListSkeleton showCreateButton />
|
return <SkeletonCard variant="list" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
@@ -12,19 +13,26 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
||||||
|
import {
|
||||||
|
ATTENDANCE_STATUS_OPTIONS,
|
||||||
|
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||||
|
} from "@/shared/constants/attendance-status"
|
||||||
|
|
||||||
import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants"
|
interface AdminAttendanceFiltersProps {
|
||||||
|
classes: Array<{ id: string; name: string }>
|
||||||
type Option = { id: string; name: string }
|
|
||||||
|
|
||||||
interface AttendanceFiltersProps {
|
|
||||||
classes: Option[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
/**
|
||||||
|
* 管理端考勤筛选器(基于共享底座 FilterBar + Select)。
|
||||||
|
*
|
||||||
|
* 组件化重构:从 modules/attendance/components/attendance-filters.tsx 内联至调用方目录,
|
||||||
|
* 直接复用 shared 底座组件,消除模块级 wrapper。
|
||||||
|
*/
|
||||||
|
export function AdminAttendanceFilters({ classes }: AdminAttendanceFiltersProps): JSX.Element {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const t = useTranslations("attendance")
|
|
||||||
|
|
||||||
const updateParam = useCallback(
|
const updateParam = useCallback(
|
||||||
(key: string, value: string) => {
|
(key: string, value: string) => {
|
||||||
@@ -42,13 +50,22 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
const classId = searchParams.get("classId") ?? "all"
|
const classId = searchParams.get("classId") ?? "all"
|
||||||
const status = searchParams.get("status") ?? "all"
|
const status = searchParams.get("status") ?? "all"
|
||||||
const date = searchParams.get("date") ?? ""
|
const date = searchParams.get("date") ?? ""
|
||||||
|
const hasFilters = classId !== "all" || status !== "all" || date !== ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
|
<FilterBar
|
||||||
|
layout="wrap"
|
||||||
|
hasFilters={hasFilters}
|
||||||
|
onReset={() => {
|
||||||
|
router.push("/admin/attendance")
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">{t("filters.class")}</Label>
|
<Label htmlFor="filter-class" className="text-xs">
|
||||||
|
{t("filters.class")}
|
||||||
|
</Label>
|
||||||
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
|
||||||
<SelectTrigger className="h-9" aria-label={t("filters.class")}>
|
<SelectTrigger id="filter-class" className="h-9" aria-label={t("filters.class")}>
|
||||||
<SelectValue placeholder={t("filters.allClasses")} />
|
<SelectValue placeholder={t("filters.allClasses")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -63,9 +80,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">{t("filters.status")}</Label>
|
<Label htmlFor="filter-status" className="text-xs">
|
||||||
|
{t("filters.status")}
|
||||||
|
</Label>
|
||||||
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||||
<SelectTrigger className="h-9" aria-label={t("filters.status")}>
|
<SelectTrigger id="filter-status" className="h-9" aria-label={t("filters.status")}>
|
||||||
<SelectValue placeholder={t("filters.allStatuses")} />
|
<SelectValue placeholder={t("filters.allStatuses")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -80,8 +99,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">{t("filters.date")}</Label>
|
<Label htmlFor="filter-date" className="text-xs">
|
||||||
|
{t("filters.date")}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="filter-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => updateParam("date", e.target.value)}
|
onChange={(e) => updateParam("date", e.target.value)}
|
||||||
@@ -89,6 +111,6 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
|
|||||||
aria-label={t("filters.date")}
|
aria-label={t("filters.date")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FilterBar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||||
|
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||||||
|
|
||||||
export default function AdminAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function AdminAttendanceError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
useErrorReport(error)
|
||||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { BarChart3, ClipboardList } from "lucide-react"
|
import { BarChart3, CheckCircle2, ClipboardList, Clock, FileText, LogOut, School, Users, XCircle } from "lucide-react"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { StatsGrid } from "@/shared/components/ui/stats-grid"
|
||||||
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||||
@@ -13,8 +14,7 @@ import { getGrades } from "@/modules/school/data-access"
|
|||||||
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
|
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
|
||||||
import { getClassComparison } from "@/modules/attendance/data-access-stats"
|
import { getClassComparison } from "@/modules/attendance/data-access-stats"
|
||||||
import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation"
|
import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation"
|
||||||
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
|
import { AdminAttendanceFilters } from "./admin-attendance-filters"
|
||||||
import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards"
|
|
||||||
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||||
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
|
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
|
||||||
import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card"
|
import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card"
|
||||||
@@ -82,6 +82,21 @@ export default async function AdminAttendancePage({
|
|||||||
|
|
||||||
const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
|
const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
|
||||||
|
|
||||||
|
const statsItems = [
|
||||||
|
{ label: t("stats.totalRecords"), value: stats.total, icon: FileText, color: "text-blue-500" },
|
||||||
|
{ label: t("stats.present"), value: stats.present, icon: CheckCircle2, color: "text-green-500" },
|
||||||
|
{ label: t("stats.absent"), value: stats.absent, icon: XCircle, color: "text-red-500" },
|
||||||
|
{ label: t("stats.late"), value: stats.late, icon: Clock, color: "text-yellow-500" },
|
||||||
|
{ label: t("stats.earlyLeave"), value: stats.earlyLeave, icon: LogOut, color: "text-orange-500" },
|
||||||
|
{ label: t("stats.schoolActivity"), value: stats.schoolActivity, icon: School, color: "text-cyan-500" },
|
||||||
|
{
|
||||||
|
label: t("stats.attendanceRate"),
|
||||||
|
value: `${stats.presentRate.toFixed(1)}%`,
|
||||||
|
icon: Users,
|
||||||
|
color: "text-primary",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -100,8 +115,8 @@ export default async function AdminAttendancePage({
|
|||||||
return (
|
return (
|
||||||
<AttendancePageLayout
|
<AttendancePageLayout
|
||||||
header={header}
|
header={header}
|
||||||
stats={<AttendanceStatsCards stats={stats} />}
|
stats={<StatsGrid items={statsItems} columns={4} />}
|
||||||
filters={<AttendanceFilters classes={classOptions} />}
|
filters={<AdminAttendanceFilters classes={classOptions} />}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{result.items.length === 0 && !classId && !status && !date ? (
|
{result.items.length === 0 && !classId && !status && !date ? (
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import { AlertCircle } from "lucide-react"
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { useErrorReport } from "@/shared/hooks/use-error-report"
|
||||||
|
|
||||||
export default function DataChangeLogsError({
|
export default function DataChangeLogsError({
|
||||||
|
error,
|
||||||
reset,
|
reset,
|
||||||
}: {
|
}: {
|
||||||
error: Error & { digest?: string }
|
error: Error & { digest?: string }
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("audit")
|
const t = useTranslations("audit")
|
||||||
|
useErrorReport(error)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||