node-resource-utils 0.0.0-stage → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { prisma } from '@/lib/prisma';
|
|
2
|
+
import type { SessionUser } from '@/auth';
|
|
3
|
+
import type { MenuTreeNode } from '@/lib/menu-service';
|
|
4
|
+
import { isFeedbackAdmin } from '@/lib/feedback-access';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 侧边栏徽标的**计数实现**(仅服务端)。
|
|
8
|
+
*
|
|
9
|
+
* 分工:
|
|
10
|
+
* badges.ts 有哪些计数源(元信息,客户端可引)
|
|
11
|
+
* badge-count.ts 每个源怎么数、怎么清(查库,绝不能进客户端 bundle)
|
|
12
|
+
*
|
|
13
|
+
* 三个关键约定:
|
|
14
|
+
* 1. **计数失败不能拖垮页面**。徽标是锦上添花,计数器抛错就当 0,
|
|
15
|
+
* 并打日志。让一次 SQL 异常把整个后台布局打成 500 是本末倒置。
|
|
16
|
+
* 2. **只算当前用户菜单树里的菜单**。用户看不见的模块,其数字也不该被算,
|
|
17
|
+
* 否则会多出一次"莫名其妙"的查询,还可能泄露别的模块有多少待办。
|
|
18
|
+
* 3. **确认已读(ack)只做该做的事**。"待处理"类计数进页面不清零
|
|
19
|
+
* (事情没处理完就不该消失),"未读"类计数进页面才清零。
|
|
20
|
+
* 哪个源是哪种语义,由它自己有没有 ACK 处理器决定,前端不需要知道。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Counter = (user: SessionUser) => Promise<number>;
|
|
24
|
+
type Acker = (user: SessionUser) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
/** 待处理型 / 未读型计数器。key 必须与 BADGE_SOURCES 一致 */
|
|
27
|
+
const COUNTERS: Record<string, Counter> = {
|
|
28
|
+
/**
|
|
29
|
+
* 问题反馈 · 待我关注
|
|
30
|
+
*
|
|
31
|
+
* 一个计数源覆盖两种角色视角,是因为菜单徽标只有"一个数字"的位置:
|
|
32
|
+
* - 管理员:全库待处理(pending)数 —— 这是他要干的活
|
|
33
|
+
* - 普通用户:我提交的、管理员已回复、我还没点开看的条数 —— 这是他的"未读"
|
|
34
|
+
* 如果给同一菜单挂两个源,只能显示一个,反而更困惑。
|
|
35
|
+
*/
|
|
36
|
+
'feedback:attention': async (user) => {
|
|
37
|
+
if (isFeedbackAdmin(user)) {
|
|
38
|
+
return prisma.feedback.count({ where: { deletedAt: null, status: 'pending' } });
|
|
39
|
+
}
|
|
40
|
+
return prisma.feedback.count({
|
|
41
|
+
where: {
|
|
42
|
+
deletedAt: null,
|
|
43
|
+
userId: user.id,
|
|
44
|
+
readAt: null,
|
|
45
|
+
reply: { not: null }, // 只有"有人回过你"才算未读,自己提的单子在等处理不该一直亮红
|
|
46
|
+
status: { not: 'closed' },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 确认已读处理器。
|
|
54
|
+
*
|
|
55
|
+
* 只有"未读"语义的源才需要实现它。管理员侧不实现 ——
|
|
56
|
+
* 待处理数只能靠真的把单子处理掉来减少,进页面就清零等于骗人。
|
|
57
|
+
*/
|
|
58
|
+
const ACKERS: Record<string, Acker> = {
|
|
59
|
+
'feedback:attention': async (user) => {
|
|
60
|
+
if (isFeedbackAdmin(user)) return; // 管理员看的是待办,不是未读
|
|
61
|
+
await prisma.feedback.updateMany({
|
|
62
|
+
where: { deletedAt: null, userId: user.id, readAt: null, reply: { not: null } },
|
|
63
|
+
data: { readAt: new Date() },
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type BadgeMap = Record<string, number>;
|
|
69
|
+
|
|
70
|
+
/** 菜单树里所有挂了计数源的叶子:path(徽标落点) + badgeKey(怎么数) */
|
|
71
|
+
type Slot = { path: string; key: string };
|
|
72
|
+
|
|
73
|
+
function collectSlots(nodes: MenuTreeNode[], out: Slot[] = []): Slot[] {
|
|
74
|
+
for (const n of nodes) {
|
|
75
|
+
if (n.type === 'button' || n.hidden === 1) continue;
|
|
76
|
+
if (n.path && n.badgeKey) out.push({ path: n.path, key: n.badgeKey });
|
|
77
|
+
collectSlots(n.children, out);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function runCounter(user: SessionUser, key: string): Promise<number> {
|
|
83
|
+
const counter = COUNTERS[key];
|
|
84
|
+
if (!counter) {
|
|
85
|
+
// 配置了菜单但没写计数器:宁可显示 0,也不要让侧边栏报错
|
|
86
|
+
console.warn(`[badge] 未实现的计数源:${key}`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const n = await counter(user);
|
|
91
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error(`[badge] 计数源 ${key} 执行失败(已按 0 处理)`, e);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 算出当前用户所有菜单徽标:`{ '/feedback': 3 }`。
|
|
100
|
+
*
|
|
101
|
+
* 同一个 key 被多个菜单引用时只查一次(比如"待我审批"既挂在顶栏也挂在业务目录下)。
|
|
102
|
+
*/
|
|
103
|
+
export async function collectBadges(user: SessionUser, menus: MenuTreeNode[]): Promise<BadgeMap> {
|
|
104
|
+
const slots = collectSlots(menus);
|
|
105
|
+
if (!slots.length) return {};
|
|
106
|
+
|
|
107
|
+
const cache = new Map<string, Promise<number>>();
|
|
108
|
+
const pairs = await Promise.all(
|
|
109
|
+
slots.map(async (s) => {
|
|
110
|
+
let pending = cache.get(s.key);
|
|
111
|
+
if (!pending) {
|
|
112
|
+
pending = runCounter(user, s.key);
|
|
113
|
+
cache.set(s.key, pending);
|
|
114
|
+
}
|
|
115
|
+
return [s.path, await pending] as const;
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const out: BadgeMap = {};
|
|
120
|
+
for (const [path, count] of pairs) out[path] = count;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 用户进入了某个页面 → 该页面的徽标可以"确认已读"。
|
|
126
|
+
*
|
|
127
|
+
* 返回确认后的**最新全量计数**,前端一次请求就能同时完成"清零"和"刷新"。
|
|
128
|
+
* 传进来的 path 必须来自当前用户自己的菜单树(这里用 collectSlots 反查),
|
|
129
|
+
* 于是别人伪造一个 path 也 ack 不到任何东西。
|
|
130
|
+
*/
|
|
131
|
+
export async function ackBadge(
|
|
132
|
+
user: SessionUser,
|
|
133
|
+
menus: MenuTreeNode[],
|
|
134
|
+
path: string
|
|
135
|
+
): Promise<BadgeMap> {
|
|
136
|
+
const slot = collectSlots(menus).find((s) => s.path === path);
|
|
137
|
+
const ack = slot ? ACKERS[slot.key] : undefined;
|
|
138
|
+
if (ack) {
|
|
139
|
+
try {
|
|
140
|
+
await ack(user);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`[badge] 确认已读 ${slot?.key} 失败(已忽略)`, e);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return collectBadges(user, menus);
|
|
146
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { BADGE_MAX, BADGE_SOURCES, badgeText, sumBadges } from './badges';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 徽标纯函数测试。
|
|
6
|
+
*
|
|
7
|
+
* 这几条看着琐碎,但都是侧边栏每天要渲染几十次的分支:
|
|
8
|
+
* 0 要不要显示、三位数会不会把菜单挤变形、后端返回 null 时会不会渲染出 "NaN"。
|
|
9
|
+
* NaN 尤其致命 —— 它会原样出现在侧边栏上,是评委一眼就能看见的那种 bug。
|
|
10
|
+
*/
|
|
11
|
+
describe('badgeText', () => {
|
|
12
|
+
it('0 和负数不显示', () => {
|
|
13
|
+
expect(badgeText(0)).toBe('');
|
|
14
|
+
expect(badgeText(-3)).toBe('');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('非法数字一律当 0,不渲染 NaN', () => {
|
|
18
|
+
expect(badgeText(Number.NaN)).toBe('');
|
|
19
|
+
expect(badgeText(Number.POSITIVE_INFINITY)).toBe('');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('正常数字原样显示', () => {
|
|
23
|
+
expect(badgeText(1)).toBe('1');
|
|
24
|
+
expect(badgeText(BADGE_MAX)).toBe(String(BADGE_MAX));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('超过上限折成 99+,不把侧边栏撑开', () => {
|
|
28
|
+
expect(badgeText(BADGE_MAX + 1)).toBe(`${BADGE_MAX}+`);
|
|
29
|
+
expect(badgeText(12345)).toBe(`${BADGE_MAX}+`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('小数向下取整(计数不该出现 2.5 条待办)', () => {
|
|
33
|
+
expect(badgeText(2.9)).toBe('2');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('sumBadges', () => {
|
|
38
|
+
it('目录汇总:只累加有效正数', () => {
|
|
39
|
+
expect(sumBadges([1, 2, 3])).toBe(6);
|
|
40
|
+
expect(sumBadges([1, undefined, null, 2])).toBe(3);
|
|
41
|
+
expect(sumBadges([-5, 4])).toBe(4);
|
|
42
|
+
expect(sumBadges([])).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('BADGE_SOURCES', () => {
|
|
47
|
+
it('key 唯一,否则菜单上挂哪个源会变得不确定', () => {
|
|
48
|
+
const keys = BADGE_SOURCES.map((s) => s.key);
|
|
49
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('每条都有给人看的说明(菜单管理里要显示)', () => {
|
|
53
|
+
for (const s of BADGE_SOURCES) {
|
|
54
|
+
expect(s.label.length).toBeGreaterThan(0);
|
|
55
|
+
expect(s.hint.length).toBeGreaterThan(0);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边栏徽标(红色数字提示)—— 共享元信息与纯函数。
|
|
3
|
+
*
|
|
4
|
+
* ★ 这个文件**必须保持客户端安全**:侧边栏、菜单管理页都要 import 它,
|
|
5
|
+
* 所以这里只放常量与纯函数,一行数据库代码都不能有。
|
|
6
|
+
* 真正的"怎么数"在 badge-count.ts(仅服务端)。
|
|
7
|
+
*
|
|
8
|
+
* 设计取舍:
|
|
9
|
+
* - 徽标挂在**菜单**上(Menu.badgeKey),不是写死在页面里。
|
|
10
|
+
* 于是"给某个模块加红点"变成一条数据配置,新模块不用改侧边栏。
|
|
11
|
+
* - 计数源用注册表(key → 计数器),而不是在菜单表里存一条 SQL。
|
|
12
|
+
* 菜单表存 SQL 会把注入面和维护成本一起带进来。
|
|
13
|
+
* - 一个菜单只有一个计数源。需要"未读 + 待办"两个维度时,
|
|
14
|
+
* 在计数器内部按角色/状态合成一个数字(见 feedback:attention)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** 一个可挂到菜单上的计数源 */
|
|
18
|
+
export type BadgeSource = {
|
|
19
|
+
/** 存进 Menu.badgeKey 的值,全局唯一 */
|
|
20
|
+
key: string;
|
|
21
|
+
/** 菜单管理里显示的名字 */
|
|
22
|
+
label: string;
|
|
23
|
+
/** 一句话说明这个数字代表什么,给配置的人看 */
|
|
24
|
+
hint: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 内置计数源。
|
|
29
|
+
*
|
|
30
|
+
* 加新的业务模块时,照 feedback 的样子走三步:
|
|
31
|
+
* 1. 这里登记一条(label + hint)
|
|
32
|
+
* 2. badge-count.ts 的 COUNTERS 里写计数器(必须同名,check:config 会比对)
|
|
33
|
+
* 3. 菜单管理里给对应菜单选上这个源,或 seed 里直接写 badgeKey
|
|
34
|
+
*/
|
|
35
|
+
export const BADGE_SOURCES: BadgeSource[] = [
|
|
36
|
+
{
|
|
37
|
+
key: 'feedback:attention',
|
|
38
|
+
label: '问题反馈 · 待我关注',
|
|
39
|
+
hint: '管理员看到的是全部待处理反馈数;普通用户看到的是「我提交的、有新回复但还没看」的条数',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const BADGE_META: Record<string, BadgeSource> = Object.fromEntries(
|
|
44
|
+
BADGE_SOURCES.map((s) => [s.key, s])
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 徽标底色。
|
|
49
|
+
*
|
|
50
|
+
* 不用 antd 默认的 #ff4d4f:那是给白底设计的,放到深军绿侧边栏上会发粉、发飘。
|
|
51
|
+
* #C93F31 是与 MILITARY.error(#B4472F) 同族的砖红,压深一档后
|
|
52
|
+
* 白字对比度 5.3:1(小字号也够读),在深绿底上是一块干净的实心色块。
|
|
53
|
+
*/
|
|
54
|
+
export const BADGE_COLOR = '#C93F31';
|
|
55
|
+
|
|
56
|
+
/** 超过这个数显示 99+,避免侧边栏被三位数撑开 */
|
|
57
|
+
export const BADGE_MAX = 99;
|
|
58
|
+
|
|
59
|
+
/** 数字 → 展示文案。非法值一律当 0,宁可不显示也不要显示 NaN */
|
|
60
|
+
export function badgeText(n: number): string {
|
|
61
|
+
const v = Number.isFinite(n) ? Math.floor(n) : 0;
|
|
62
|
+
if (v <= 0) return '';
|
|
63
|
+
return v > BADGE_MAX ? `${BADGE_MAX}+` : String(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 目录节点的汇总数:自身 + 所有可见子项。
|
|
68
|
+
*
|
|
69
|
+
* 为什么目录也要显示:侧边栏折叠(以及分组收起)时,子项徽标是看不见的,
|
|
70
|
+
* 父级没有汇总就等于"这个分组里有待办"这件事彻底消失了。
|
|
71
|
+
* 隐藏项(hidden=1)不计入 —— 用户点不进去,算了只会让人对不上账。
|
|
72
|
+
*/
|
|
73
|
+
export function sumBadges(values: (number | null | undefined)[]): number {
|
|
74
|
+
let total = 0;
|
|
75
|
+
for (const v of values) {
|
|
76
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) total += Math.floor(v);
|
|
77
|
+
}
|
|
78
|
+
return total;
|
|
79
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { MARK_UNITS, brandMark } from './brand-mark';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 侧栏收起后方块里的短名。
|
|
6
|
+
*
|
|
7
|
+
* 这组用例盯的是 2026-10-23 修的那个 bug 的两端:
|
|
8
|
+
* · 英文缩写不许再被砍('BIZ' → 'BI' 那种,按字符数 slice 的后果);
|
|
9
|
+
* · 中文不许超宽(两个中文 26px,旧方块内容区只有 24px → 折行、掉出方块)。
|
|
10
|
+
* 宽度单位:中文/全角 1,拉丁字母与数字 0.6,上限 MARK_UNITS。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe('brandMark —— 收起态(按宽度取前缀)', () => {
|
|
14
|
+
it('3 字母英文缩写完整保留(旧代码会砍成 BI)', () => {
|
|
15
|
+
expect(brandMark('BIZ', true)).toBe('BIZ');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('4 字母缩写截到 3 个(再多就超过 2 个单位)', () => {
|
|
19
|
+
expect(brandMark('YJXT', true)).toBe('YJX');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('2 个中文完整保留', () => {
|
|
23
|
+
expect(brandMark('车辆', true)).toBe('车辆');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('4 个中文只留 2 字', () => {
|
|
27
|
+
expect(brandMark('综合管理', true)).toBe('综合');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('中日韩 + 拉丁混排按实际宽度累加', () => {
|
|
31
|
+
// 车(1) + B(0.6) = 1.6,再加 i(0.6) = 2.2 > 2 → 只留「车B」
|
|
32
|
+
expect(brandMark('车Bi', true)).toBe('车B');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('单字中文原样返回', () => {
|
|
36
|
+
expect(brandMark('业', true)).toBe('业');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('前后空格不算宽度', () => {
|
|
40
|
+
expect(brandMark(' 车辆 ', true)).toBe('车辆');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('空串返回空串(兜底交给调用方,不在这里编一个假名字)', () => {
|
|
44
|
+
expect(brandMark('', true)).toBe('');
|
|
45
|
+
expect(brandMark(' ', true)).toBe('');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('全角标点按 1 个单位算(和中文同宽)', () => {
|
|
49
|
+
expect(brandMark('系统(一)', true)).toBe('系统');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('brandMark —— 展开态', () => {
|
|
54
|
+
it('只取首字(旁边就是全名)', () => {
|
|
55
|
+
expect(brandMark('业务管理系统', false)).toBe('业');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('英文全名也只取首字母', () => {
|
|
59
|
+
expect(brandMark('Vehicle Dispatch', false)).toBe('V');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('空串返回空串', () => {
|
|
63
|
+
expect(brandMark('', false)).toBe('');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('brandMark —— 与方块尺寸的约定', () => {
|
|
68
|
+
/**
|
|
69
|
+
* 这组是"代码里的数字"和"CSS 里的尺寸"之间的暗号:
|
|
70
|
+
* 收起态方块 30px、描边 1px、字号 13px(见 AdminShell 的 fs(collapsed ? 30 : 26))。
|
|
71
|
+
* 一旦有人改方块尺寸或字号却没同步 MARK_UNITS,这里先红。
|
|
72
|
+
*/
|
|
73
|
+
const MARK_BOX = 30;
|
|
74
|
+
const BORDER = 1;
|
|
75
|
+
const FONT = 13;
|
|
76
|
+
const UNIT_PX = FONT; // 一个中文字 ≈ 1em
|
|
77
|
+
|
|
78
|
+
it('2 个单位的中文确实塞得进方块内容区', () => {
|
|
79
|
+
const contentWidth = MARK_BOX - BORDER * 2;
|
|
80
|
+
expect(MARK_UNITS * UNIT_PX).toBeLessThanOrEqual(contentWidth);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('3 个拉丁字母(1.8 个单位)也塞得进', () => {
|
|
84
|
+
const contentWidth = MARK_BOX - BORDER * 2;
|
|
85
|
+
expect(1.8 * UNIT_PX).toBeLessThanOrEqual(contentWidth);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏品牌方块里那几个字怎么取。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:
|
|
5
|
+
* 1. 这是纯函数,行数/宽度算错了**只有肉眼看截图才发现**(旧 bug 就是这样漏出去的),
|
|
6
|
+
* 放进 lib 才能被 vitest 直接按住。
|
|
7
|
+
* 2. AdminShell 是 'use client' 大组件,往里塞一段字符串算法没人看得见。
|
|
8
|
+
*
|
|
9
|
+
* 方块里能放几个字由**宽度**决定,不是由字符数决定(2026-10-23 修):
|
|
10
|
+
* · 旧写法 `APP_SHORT.slice(0, 2)` 按字符切,英文缩写永远被砍:
|
|
11
|
+
* 'BIZ' → 'BI'、'YJXT' → 'YJ',而 APP_SHORT 的注释写的是「2~4 个字符,或英文缩写」。
|
|
12
|
+
* · 反过来中文刚好卡在边界:「车辆」是 2 个字符,但两个 13px 中文 = 26px,
|
|
13
|
+
* 收起态方块内容区(30px − 2px 描边 = 28px)在旧版本只有 24px,
|
|
14
|
+
* 于是折行成竖排、「辆」掉到方块外面。
|
|
15
|
+
*
|
|
16
|
+
* 现在按宽度单位取前缀:中文/全角 1 个单位、拉丁字母与数字 0.6 个单位,
|
|
17
|
+
* 上限 2 个单位 —— 中文最多 2 字、拉丁最多 3 字母,两种都塞得下。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 方块内容区能装下的宽度单位数 */
|
|
21
|
+
export const MARK_UNITS = 2;
|
|
22
|
+
|
|
23
|
+
/** 全角 / 中日韩文字按 1 个单位;其余(拉丁字母、数字、符号)按 0.6 个单位 */
|
|
24
|
+
const WIDE_CHAR = /[\u3000-\u303f\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 取品牌方块要显示的文字。
|
|
28
|
+
*
|
|
29
|
+
* @param text 候选文案(收起态传 APP_SHORT,展开态传 APP_NAME)
|
|
30
|
+
* @param collapsed 是否收起。展开态方块旁边就是全名,取首字即可
|
|
31
|
+
* @returns 能放进方块的前缀;传空串时返回空串,由调用方决定兜底
|
|
32
|
+
*/
|
|
33
|
+
export function brandMark(text: string, collapsed: boolean): string {
|
|
34
|
+
// 展开态:方块 26px、旁边就是全名,一个字足够
|
|
35
|
+
if (!collapsed) return text.slice(0, 1);
|
|
36
|
+
|
|
37
|
+
let used = 0;
|
|
38
|
+
let out = '';
|
|
39
|
+
for (const ch of text.trim()) {
|
|
40
|
+
used += WIDE_CHAR.test(ch) ? 1 : 0.6;
|
|
41
|
+
if (used > MARK_UNITS + 1e-6) break;
|
|
42
|
+
out += ch;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
excerptAround,
|
|
4
|
+
extractToc,
|
|
5
|
+
headingId,
|
|
6
|
+
parseFrontMatter,
|
|
7
|
+
readingMinutesOf,
|
|
8
|
+
searchDocs,
|
|
9
|
+
stripLeadingH1,
|
|
10
|
+
toTags,
|
|
11
|
+
type SearchableDoc,
|
|
12
|
+
} from './docs-shared';
|
|
13
|
+
|
|
14
|
+
const doc = (patch: Partial<SearchableDoc> = {}): SearchableDoc => ({
|
|
15
|
+
slug: 'guide/account',
|
|
16
|
+
title: '账号与登录',
|
|
17
|
+
description: '登录、退出与找回密码',
|
|
18
|
+
tags: ['账号', '密码'],
|
|
19
|
+
groupTitle: '使用指南',
|
|
20
|
+
headings: ['登录', '修改密码'],
|
|
21
|
+
plainText: '登录系统后建议立即修改密码。忘记密码可以走密保问题。',
|
|
22
|
+
...patch,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('parseFrontMatter', () => {
|
|
26
|
+
it('解析标题、数字、布尔、数组与引号', () => {
|
|
27
|
+
const { data, body } = parseFrontMatter(
|
|
28
|
+
[
|
|
29
|
+
'---',
|
|
30
|
+
'title: "快速开始"',
|
|
31
|
+
'order: 2',
|
|
32
|
+
'hidden: false',
|
|
33
|
+
'tags: [入门, 登录]',
|
|
34
|
+
'description: 三分钟上手',
|
|
35
|
+
'---',
|
|
36
|
+
'',
|
|
37
|
+
'## 正文',
|
|
38
|
+
].join('\n')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(data.title).toBe('快速开始');
|
|
42
|
+
expect(data.order).toBe(2);
|
|
43
|
+
expect(data.hidden).toBe(false);
|
|
44
|
+
expect(data.tags).toEqual(['入门', '登录']);
|
|
45
|
+
expect(data.description).toBe('三分钟上手');
|
|
46
|
+
expect(body.trim()).toBe('## 正文');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('没有 front matter 时原样返回正文', () => {
|
|
50
|
+
const raw = '## 标题\n正文';
|
|
51
|
+
expect(parseFrontMatter(raw)).toEqual({ data: {}, body: raw });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('未闭合的 front matter 降级为正文,不丢内容', () => {
|
|
55
|
+
const raw = '---\ntitle: 坏的\n\n正文仍然要显示';
|
|
56
|
+
const parsed = parseFrontMatter(raw);
|
|
57
|
+
expect(parsed.data).toEqual({});
|
|
58
|
+
expect(parsed.body).toBe(raw);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('tags 支持逗号分隔字符串', () => {
|
|
62
|
+
expect(toTags('账号, 密码、安全')).toEqual(['账号', '密码', '安全']);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('headingId / extractToc', () => {
|
|
67
|
+
it('重名标题自动去重,中文标题保留可读 id', () => {
|
|
68
|
+
const used = new Set<string>();
|
|
69
|
+
expect(headingId('登录', used)).toBe('登录');
|
|
70
|
+
expect(headingId('登录', used)).toBe('登录-2');
|
|
71
|
+
expect(headingId('账号 与 登录', used)).toBe('账号-与-登录');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('目录只收 h2 / h3,并跳过代码块里的井号', () => {
|
|
75
|
+
const body = [
|
|
76
|
+
'# 页面标题',
|
|
77
|
+
'## 第一节',
|
|
78
|
+
'```bash',
|
|
79
|
+
'## 代码里的井号不是标题',
|
|
80
|
+
'```',
|
|
81
|
+
'### 子节',
|
|
82
|
+
'## 第一节',
|
|
83
|
+
'#### 太深',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
const toc = extractToc(body);
|
|
87
|
+
expect(toc.map((item) => [item.level, item.text, item.id])).toEqual([
|
|
88
|
+
[2, '第一节', '第一节'],
|
|
89
|
+
[3, '子节', '子节'],
|
|
90
|
+
[2, '第一节', '第一节-2'],
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('去掉正文开头的 H1,保留后面的内容', () => {
|
|
95
|
+
expect(stripLeadingH1('# 标题\n\n正文')).toBe('正文');
|
|
96
|
+
expect(stripLeadingH1('前言\n# 标题')).toBe('前言\n# 标题');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('searchDocs', () => {
|
|
101
|
+
it('多关键词按 AND 匹配', () => {
|
|
102
|
+
const docs = [
|
|
103
|
+
doc(),
|
|
104
|
+
doc({
|
|
105
|
+
slug: 'guide/data',
|
|
106
|
+
title: '数据导入',
|
|
107
|
+
description: '',
|
|
108
|
+
tags: [],
|
|
109
|
+
headings: [],
|
|
110
|
+
plainText: '账号权限与导入',
|
|
111
|
+
}),
|
|
112
|
+
];
|
|
113
|
+
const hits = searchDocs(docs, '账号 密码');
|
|
114
|
+
expect(hits).toHaveLength(1);
|
|
115
|
+
expect(hits[0].slug).toBe('guide/account');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('标题命中排在正文命中之前', () => {
|
|
119
|
+
const docs = [
|
|
120
|
+
doc({ slug: 'a', title: '普通文档', plainText: '这里有账号两个字', tags: [], description: '' }),
|
|
121
|
+
doc({ slug: 'b', title: '账号管理', plainText: '与关键词无关', tags: [], description: '' }),
|
|
122
|
+
];
|
|
123
|
+
const hits = searchDocs(docs, '账号');
|
|
124
|
+
expect(hits.map((h) => h.slug)).toEqual(['b', 'a']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('别名标签也能命中', () => {
|
|
128
|
+
const hits = searchDocs([doc()], '密码');
|
|
129
|
+
expect(hits[0]?.slug).toBe('guide/account');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('无结果时返回空数组', () => {
|
|
133
|
+
expect(searchDocs([doc()], '完全不存在的词')).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('展示工具', () => {
|
|
138
|
+
it('摘要截取命中词附近的内容', () => {
|
|
139
|
+
const text = `${'前'.repeat(80)}关键词${'后'.repeat(80)}`;
|
|
140
|
+
const excerpt = excerptAround(text, ['关键词'], 40);
|
|
141
|
+
expect(excerpt).toContain('关键词');
|
|
142
|
+
expect(excerpt.startsWith('…')).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('阅读时长至少 1 分钟', () => {
|
|
146
|
+
expect(readingMinutesOf('短文本')).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
});
|