node-resource-utils 0.0.0-stage → 1.1.7

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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/lib/sync.js +121 -0
  4. package/package.json +14 -4
  5. package/resources/.cache/ref.b64 +1 -0
  6. package/resources/README.md +97 -0
  7. package/resources/_.env +6 -0
  8. package/resources/_.env.example +25 -0
  9. package/resources/_.gitignore +18 -0
  10. package/resources/_.lintstagedrc.json +9 -0
  11. package/resources/_.npmrc +5 -0
  12. package/resources/_.prettierrc +7 -0
  13. package/resources/commitlint.config.mjs +1 -0
  14. package/resources/content/docs/README.md +63 -0
  15. package/resources/content/docs/faq.md +107 -0
  16. package/resources/content/docs/features/_meta.json +5 -0
  17. package/resources/content/docs/features/ai.md +71 -0
  18. package/resources/content/docs/features/appearance.md +47 -0
  19. package/resources/content/docs/features/feedback.md +79 -0
  20. package/resources/content/docs/features/files.md +74 -0
  21. package/resources/content/docs/features/graph.md +66 -0
  22. package/resources/content/docs/features/inbox.md +61 -0
  23. package/resources/content/docs/guide/_meta.json +5 -0
  24. package/resources/content/docs/guide/account.md +76 -0
  25. package/resources/content/docs/guide/data.md +87 -0
  26. package/resources/content/docs/guide/navigation.md +81 -0
  27. package/resources/content/docs/guide/permissions.md +67 -0
  28. package/resources/content/docs/guide/system.md +92 -0
  29. package/resources/content/docs/quick-start.md +70 -0
  30. package/resources/content/docs/shortcuts.md +69 -0
  31. package/resources/eslint.config.mjs +17 -0
  32. package/resources/next-env.d.ts +6 -0
  33. package/resources/next.config.ts +13 -0
  34. package/resources/postcss.config.mjs +7 -0
  35. package/resources/prisma/org.config.ts +98 -0
  36. package/resources/prisma/schema.prisma +336 -0
  37. package/resources/prisma/seed.ts +721 -0
  38. package/resources/prisma/seedSafe.ts +347 -0
  39. package/resources/prisma.config.ts +14 -0
  40. package/resources/scripts/check-config.mjs +773 -0
  41. package/resources/scripts/lib/schema-graph.mjs +410 -0
  42. package/resources/scripts/patch-next-trace.mjs +86 -0
  43. package/resources/scripts/suggest-relations.mjs +105 -0
  44. package/resources/src/app/(admin)/ai/page.tsx +469 -0
  45. package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
  46. package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
  47. package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
  48. package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
  49. package/resources/src/app/(admin)/error.tsx +96 -0
  50. package/resources/src/app/(admin)/feedback/page.tsx +573 -0
  51. package/resources/src/app/(admin)/graph/page.tsx +1224 -0
  52. package/resources/src/app/(admin)/inbox/page.tsx +31 -0
  53. package/resources/src/app/(admin)/layout.tsx +58 -0
  54. package/resources/src/app/(admin)/loading.tsx +31 -0
  55. package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
  56. package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
  57. package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
  58. package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
  59. package/resources/src/app/(admin)/system/users/page.tsx +620 -0
  60. package/resources/src/app/api/account-recovery/route.ts +132 -0
  61. package/resources/src/app/api/ai/act/route.ts +70 -0
  62. package/resources/src/app/api/ai/ask/route.ts +82 -0
  63. package/resources/src/app/api/ai/chat/route.ts +404 -0
  64. package/resources/src/app/api/ai/report/route.ts +164 -0
  65. package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
  66. package/resources/src/app/api/auth/change-password/route.ts +57 -0
  67. package/resources/src/app/api/auth/forgot/route.ts +98 -0
  68. package/resources/src/app/api/auth/register/route.ts +95 -0
  69. package/resources/src/app/api/auth/register-config/route.ts +51 -0
  70. package/resources/src/app/api/badges/route.ts +39 -0
  71. package/resources/src/app/api/depts/[id]/route.ts +145 -0
  72. package/resources/src/app/api/depts/route.ts +126 -0
  73. package/resources/src/app/api/docs/export/route.ts +87 -0
  74. package/resources/src/app/api/docs/parse/route.ts +57 -0
  75. package/resources/src/app/api/docs/search/route.ts +22 -0
  76. package/resources/src/app/api/employees/[id]/route.ts +147 -0
  77. package/resources/src/app/api/employees/export/route.ts +115 -0
  78. package/resources/src/app/api/employees/import/route.ts +205 -0
  79. package/resources/src/app/api/employees/route.ts +149 -0
  80. package/resources/src/app/api/export/route.ts +254 -0
  81. package/resources/src/app/api/favorites/route.ts +56 -0
  82. package/resources/src/app/api/feedback/ai/route.ts +31 -0
  83. package/resources/src/app/api/feedback/approve/route.ts +67 -0
  84. package/resources/src/app/api/feedback/report/route.ts +213 -0
  85. package/resources/src/app/api/feedback/route.ts +246 -0
  86. package/resources/src/app/api/files/[name]/route.ts +82 -0
  87. package/resources/src/app/api/graph/focus/route.ts +75 -0
  88. package/resources/src/app/api/graph/node/move/route.ts +48 -0
  89. package/resources/src/app/api/graph/node/route.ts +274 -0
  90. package/resources/src/app/api/graph/route.ts +272 -0
  91. package/resources/src/app/api/health/route.ts +11 -0
  92. package/resources/src/app/api/inbox/read/route.ts +36 -0
  93. package/resources/src/app/api/inbox/summary/route.ts +15 -0
  94. package/resources/src/app/api/logs/route.ts +82 -0
  95. package/resources/src/app/api/menus/[id]/route.ts +132 -0
  96. package/resources/src/app/api/menus/mine/route.ts +23 -0
  97. package/resources/src/app/api/menus/route.ts +89 -0
  98. package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
  99. package/resources/src/app/api/roles/[id]/route.ts +96 -0
  100. package/resources/src/app/api/roles/route.ts +114 -0
  101. package/resources/src/app/api/search/route.ts +22 -0
  102. package/resources/src/app/api/stats/route.ts +92 -0
  103. package/resources/src/app/api/upload/route.ts +38 -0
  104. package/resources/src/app/api/users/[id]/password/route.ts +55 -0
  105. package/resources/src/app/api/users/[id]/route.ts +145 -0
  106. package/resources/src/app/api/users/route.ts +153 -0
  107. package/resources/src/app/api/visits/route.ts +48 -0
  108. package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
  109. package/resources/src/app/change-password/page.tsx +19 -0
  110. package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
  111. package/resources/src/app/docs/layout.tsx +34 -0
  112. package/resources/src/app/docs/loading.tsx +19 -0
  113. package/resources/src/app/docs/not-found.tsx +25 -0
  114. package/resources/src/app/forgot/page.tsx +392 -0
  115. package/resources/src/app/global-error.tsx +75 -0
  116. package/resources/src/app/globals.css +235 -0
  117. package/resources/src/app/layout.tsx +44 -0
  118. package/resources/src/app/login/page.tsx +267 -0
  119. package/resources/src/app/page.tsx +14 -0
  120. package/resources/src/app/register/page.tsx +237 -0
  121. package/resources/src/auth.ts +215 -0
  122. package/resources/src/components/AiPanel.tsx +970 -0
  123. package/resources/src/components/AuthContext.tsx +82 -0
  124. package/resources/src/components/DataTable.tsx +459 -0
  125. package/resources/src/components/EChart.tsx +140 -0
  126. package/resources/src/components/FeedbackAiModal.tsx +476 -0
  127. package/resources/src/components/FeedbackReportModal.tsx +479 -0
  128. package/resources/src/components/Markdown.tsx +532 -0
  129. package/resources/src/components/OntologyGraph.tsx +1196 -0
  130. package/resources/src/components/Providers.tsx +99 -0
  131. package/resources/src/components/VisitTracker.tsx +89 -0
  132. package/resources/src/components/admin/AdminShell.tsx +749 -0
  133. package/resources/src/components/admin/CountBadge.tsx +67 -0
  134. package/resources/src/components/admin/GlobalSearch.tsx +509 -0
  135. package/resources/src/components/admin/InboxBell.tsx +78 -0
  136. package/resources/src/components/admin/VisitStats.tsx +145 -0
  137. package/resources/src/components/admin/menu-icons.tsx +56 -0
  138. package/resources/src/components/ai/AiInputForm.tsx +351 -0
  139. package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
  140. package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
  141. package/resources/src/components/docs/DocArticle.tsx +106 -0
  142. package/resources/src/components/docs/DocToc.tsx +100 -0
  143. package/resources/src/components/docs/DocsChrome.tsx +259 -0
  144. package/resources/src/components/docs/DocsHome.tsx +209 -0
  145. package/resources/src/components/docs/DocsSearch.tsx +365 -0
  146. package/resources/src/components/docs/docs.css +1458 -0
  147. package/resources/src/components/feedback/AsyncButton.tsx +108 -0
  148. package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
  149. package/resources/src/components/feedback/PressableCard.tsx +92 -0
  150. package/resources/src/components/feedback/README.md +97 -0
  151. package/resources/src/components/feedback/RouteProgress.tsx +108 -0
  152. package/resources/src/components/feedback/Skeleton.tsx +249 -0
  153. package/resources/src/components/feedback/StatCard.tsx +70 -0
  154. package/resources/src/components/feedback/TaskHint.tsx +153 -0
  155. package/resources/src/components/feedback/index.ts +93 -0
  156. package/resources/src/components/feedback/motion.css +204 -0
  157. package/resources/src/components/feedback/motion.ts +63 -0
  158. package/resources/src/components/feedback/useCountUp.ts +110 -0
  159. package/resources/src/components/feedback/usePending.ts +209 -0
  160. package/resources/src/components/feedback/useRowFlash.ts +40 -0
  161. package/resources/src/components/inbox/InboxView.tsx +409 -0
  162. package/resources/src/components/ui/button.tsx +35 -0
  163. package/resources/src/components/ui/card.tsx +37 -0
  164. package/resources/src/components/ui/input.tsx +19 -0
  165. package/resources/src/lib/ai.ts +310 -0
  166. package/resources/src/lib/aiContext.ts +357 -0
  167. package/resources/src/lib/aiForm.test.ts +191 -0
  168. package/resources/src/lib/aiForm.ts +532 -0
  169. package/resources/src/lib/aiFormTypes.ts +83 -0
  170. package/resources/src/lib/aiToken.ts +72 -0
  171. package/resources/src/lib/aiTools.ts +823 -0
  172. package/resources/src/lib/api.ts +160 -0
  173. package/resources/src/lib/appInfo.ts +128 -0
  174. package/resources/src/lib/appearance.ts +643 -0
  175. package/resources/src/lib/badge-count.ts +146 -0
  176. package/resources/src/lib/badges.test.ts +58 -0
  177. package/resources/src/lib/badges.ts +79 -0
  178. package/resources/src/lib/brand-mark.test.ts +87 -0
  179. package/resources/src/lib/brand-mark.ts +45 -0
  180. package/resources/src/lib/docs-shared.test.ts +148 -0
  181. package/resources/src/lib/docs-shared.ts +422 -0
  182. package/resources/src/lib/docs.ts +381 -0
  183. package/resources/src/lib/err-text.ts +21 -0
  184. package/resources/src/lib/excel.ts +152 -0
  185. package/resources/src/lib/favorite-path.ts +60 -0
  186. package/resources/src/lib/favorites.ts +59 -0
  187. package/resources/src/lib/features.ts +69 -0
  188. package/resources/src/lib/feedback-access.ts +17 -0
  189. package/resources/src/lib/feedbackAi.ts +1621 -0
  190. package/resources/src/lib/formRules.ts +76 -0
  191. package/resources/src/lib/format.ts +52 -0
  192. package/resources/src/lib/graph-cascade.ts +304 -0
  193. package/resources/src/lib/graph-focus.ts +301 -0
  194. package/resources/src/lib/graph-link.ts +17 -0
  195. package/resources/src/lib/graph-node-write.ts +150 -0
  196. package/resources/src/lib/graph-schema.ts +475 -0
  197. package/resources/src/lib/graph-scope.test.ts +46 -0
  198. package/resources/src/lib/graph-scope.ts +28 -0
  199. package/resources/src/lib/http.ts +157 -0
  200. package/resources/src/lib/inbox-types.ts +57 -0
  201. package/resources/src/lib/inbox.ts +55 -0
  202. package/resources/src/lib/menu-service.ts +204 -0
  203. package/resources/src/lib/notifications.ts +113 -0
  204. package/resources/src/lib/office.ts +531 -0
  205. package/resources/src/lib/ontology.ts +277 -0
  206. package/resources/src/lib/openaiCompat.ts +257 -0
  207. package/resources/src/lib/pending.ts +98 -0
  208. package/resources/src/lib/prisma.ts +30 -0
  209. package/resources/src/lib/rbac.test.ts +106 -0
  210. package/resources/src/lib/rbac.ts +185 -0
  211. package/resources/src/lib/search-data.ts +158 -0
  212. package/resources/src/lib/security-questions.ts +20 -0
  213. package/resources/src/lib/skin-theme.ts +155 -0
  214. package/resources/src/lib/system-config.ts +55 -0
  215. package/resources/src/lib/theme.ts +96 -0
  216. package/resources/src/lib/todos/feedback.ts +86 -0
  217. package/resources/src/lib/todos/index.ts +73 -0
  218. package/resources/src/lib/todos/types.ts +33 -0
  219. package/resources/src/lib/upload.ts +106 -0
  220. package/resources/src/lib/utils.test.ts +9 -0
  221. package/resources/src/lib/utils.ts +6 -0
  222. package/resources/src/lib/validation.test.ts +297 -0
  223. package/resources/src/lib/validation.ts +658 -0
  224. package/resources/src/lib/visit-stats.ts +109 -0
  225. package/resources/src/lib/visits.ts +111 -0
  226. package/resources/src/lib/zodField.ts +44 -0
  227. package/resources/tsconfig.json +41 -0
  228. package/resources/vitest.config.ts +16 -0
  229. package/resources/vitest.setup.ts +1 -0
@@ -0,0 +1,268 @@
1
+ 'use client';
2
+
3
+ import { Button, Drawer } from 'antd';
4
+ import { CheckOutlined, ReloadOutlined } from '@ant-design/icons';
5
+ import { FONT_SCALES, SKIN_LIST, fontScaleOf, type Skin } from '@/lib/appearance';
6
+ import { MILITARY, fs } from '@/lib/theme';
7
+ import { useAppearance } from './AppearanceProvider';
8
+
9
+ /**
10
+ * 外观设置面板。
11
+ *
12
+ * 三件事刻意做得不一样:
13
+ * 1. 皮肤用「缩略图 + 一句话」而不是色块 —— 用户选的是整体观感,
14
+ * 不是"主色是什么"。缩略图里带侧栏,因为三套皮肤的差别一半在侧栏。
15
+ * 2. 字号给的是可读的样例,而不是"14 / 15.4 / 16.8"这种数字 ——
16
+ * 用户只关心看起来舒服不舒服。
17
+ * 3. 点了立刻生效、立刻存,没有"保存"按钮:这是偏好,不是表单。
18
+ */
19
+
20
+ /** 皮肤缩略图:左条侧栏 + 主色块 + 两张卡,用字面色板画(不能用当前皮肤的变量) */
21
+ function SkinPreview({ skin }: { skin: Skin }) {
22
+ const p = skin.palette;
23
+ return (
24
+ <span
25
+ aria-hidden
26
+ style={{
27
+ width: 58,
28
+ height: 40,
29
+ flex: '0 0 58px',
30
+ display: 'flex',
31
+ overflow: 'hidden',
32
+ borderRadius: 6,
33
+ border: `1px solid ${p.border}`,
34
+ background: p.bg,
35
+ }}
36
+ >
37
+ <span
38
+ style={{
39
+ width: 14,
40
+ flex: '0 0 14px',
41
+ background: skin.sider.dark
42
+ ? `linear-gradient(180deg, ${p.primaryDark} 0%, ${p.primaryDeep} 100%)`
43
+ : p.card,
44
+ borderRight: `1px solid ${p.border}`,
45
+ position: 'relative',
46
+ }}
47
+ >
48
+ <span
49
+ style={{
50
+ position: 'absolute',
51
+ left: 3,
52
+ top: 6,
53
+ width: 8,
54
+ height: 3,
55
+ borderRadius: 1,
56
+ background: p.primary,
57
+ }}
58
+ />
59
+ </span>
60
+ <span style={{ flex: 1, minWidth: 0, padding: '5px 5px', display: 'flex', flexDirection: 'column', gap: 3 }}>
61
+ <span style={{ height: 4, width: '68%', borderRadius: 1, background: p.primary }} />
62
+ <span style={{ flex: 1, borderRadius: 2, background: p.card, border: `1px solid ${p.borderSoft}` }} />
63
+ <span style={{ flex: 1, borderRadius: 2, background: p.card, border: `1px solid ${p.borderSoft}` }} />
64
+ </span>
65
+ </span>
66
+ );
67
+ }
68
+
69
+ function SectionTitle({ title, hint }: { title: string; hint: string }) {
70
+ return (
71
+ <div style={{ marginBottom: 10 }}>
72
+ <div style={{ fontSize: fs(13), fontWeight: 600, color: MILITARY.text }}>{title}</div>
73
+ <div style={{ fontSize: fs(11.5), color: MILITARY.textMuted, marginTop: 3, lineHeight: 1.6 }}>{hint}</div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ export default function AppearanceDrawer() {
79
+ const { open, closePanel, settings, skin, setSkin, setFont, reset } = useAppearance();
80
+ const scale = fontScaleOf(settings.font);
81
+
82
+ return (
83
+ <Drawer
84
+ open={open}
85
+ onClose={closePanel}
86
+ // antd v6 把 width 收敛进 size(数字照旧按像素),写 width 会在控制台刷废弃告警
87
+ size={392}
88
+ placement="right"
89
+ title={
90
+ <div style={{ paddingRight: 8 }}>
91
+ <div style={{ fontSize: fs(10.5), letterSpacing: 3, color: MILITARY.textMuted, fontWeight: 600 }}>
92
+ APPEARANCE
93
+ </div>
94
+ <div style={{ fontSize: fs(16), fontWeight: 700, color: MILITARY.text, marginTop: 2 }}>外观设置</div>
95
+ </div>
96
+ }
97
+ styles={{ body: { padding: '4px 20px 20px' } }}
98
+ >
99
+ {/* ------------------------------ 皮肤 ------------------------------ */}
100
+ <div style={{ marginBottom: 22 }}>
101
+ <SectionTitle title="皮肤" hint="换的是配色、圆角与侧栏材质;功能、数据、权限都不受影响。" />
102
+ <div role="radiogroup" aria-label="皮肤" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
103
+ {SKIN_LIST.map((s) => {
104
+ const active = s.id === skin.id;
105
+ return (
106
+ <div
107
+ key={s.id}
108
+ role="radio"
109
+ aria-checked={active}
110
+ data-testid={`skin-${s.id}`}
111
+ tabIndex={0}
112
+ onClick={() => setSkin(s.id)}
113
+ onKeyDown={(e) => {
114
+ if (e.key === 'Enter' || e.key === ' ') {
115
+ e.preventDefault();
116
+ setSkin(s.id);
117
+ }
118
+ }}
119
+ className="wb-pressable"
120
+ style={{
121
+ display: 'flex',
122
+ alignItems: 'center',
123
+ gap: 12,
124
+ padding: '10px 12px',
125
+ borderRadius: 10,
126
+ cursor: 'pointer',
127
+ border: `1px solid ${active ? MILITARY.primary : MILITARY.border}`,
128
+ background: active ? MILITARY.soft : MILITARY.card,
129
+ boxShadow: active ? `inset 0 0 0 1px ${MILITARY.primary}` : 'none',
130
+ }}
131
+ >
132
+ <SkinPreview skin={s} />
133
+ <div style={{ flex: 1, minWidth: 0 }}>
134
+ <div
135
+ style={{
136
+ fontSize: fs(13.5),
137
+ fontWeight: 600,
138
+ color: active ? MILITARY.primary : MILITARY.text,
139
+ }}
140
+ >
141
+ {s.name}
142
+ </div>
143
+ <div
144
+ style={{
145
+ fontSize: fs(11.5),
146
+ color: MILITARY.textMuted,
147
+ marginTop: 2,
148
+ overflow: 'hidden',
149
+ textOverflow: 'ellipsis',
150
+ whiteSpace: 'nowrap',
151
+ }}
152
+ >
153
+ {s.blurb}
154
+ </div>
155
+ </div>
156
+ <span
157
+ style={{
158
+ width: 18,
159
+ height: 18,
160
+ flex: '0 0 18px',
161
+ borderRadius: 999,
162
+ display: 'grid',
163
+ placeItems: 'center',
164
+ fontSize: fs(10),
165
+ color: MILITARY.onPrimary,
166
+ background: active ? MILITARY.primary : 'transparent',
167
+ border: `1px solid ${active ? MILITARY.primary : MILITARY.border}`,
168
+ opacity: active ? 1 : 0.7,
169
+ }}
170
+ >
171
+ {active ? <CheckOutlined /> : null}
172
+ </span>
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+ </div>
178
+
179
+ {/* ---------------------------- 字体大小 ---------------------------- */}
180
+ <div>
181
+ <SectionTitle title="字体大小" hint="只放大文字,不改页面结构;表格、表单、菜单一起跟着变。" />
182
+ <div
183
+ style={{
184
+ display: 'grid',
185
+ gridTemplateColumns: `repeat(${FONT_SCALES.length}, 1fr)`,
186
+ gap: 6,
187
+ padding: 4,
188
+ borderRadius: 10,
189
+ background: MILITARY.soft,
190
+ border: `1px solid ${MILITARY.borderSoft}`,
191
+ }}
192
+ role="radiogroup"
193
+ aria-label="字体大小"
194
+ >
195
+ {FONT_SCALES.map((f) => {
196
+ const active = f.id === settings.font;
197
+ return (
198
+ <button
199
+ key={f.id}
200
+ type="button"
201
+ role="radio"
202
+ aria-checked={active}
203
+ data-testid={`font-${f.id}`}
204
+ onClick={() => setFont(f.id)}
205
+ style={{
206
+ appearance: 'none',
207
+ border: 'none',
208
+ cursor: 'pointer',
209
+ padding: '7px 0',
210
+ borderRadius: 7,
211
+ fontSize: fs(12.5),
212
+ fontWeight: active ? 600 : 400,
213
+ color: active ? MILITARY.onPrimary : MILITARY.textSecondary,
214
+ background: active ? MILITARY.primary : 'transparent',
215
+ transition: 'background-color .18s, color .18s',
216
+ }}
217
+ >
218
+ {f.name}
219
+ </button>
220
+ );
221
+ })}
222
+ </div>
223
+
224
+ {/* 样例:直接吃当前的 --wb-fs,用户拖之前就知道有多大 */}
225
+ <div
226
+ style={{
227
+ marginTop: 12,
228
+ padding: '14px 16px',
229
+ borderRadius: 10,
230
+ background: MILITARY.paper,
231
+ border: `1px dashed ${MILITARY.border}`,
232
+ }}
233
+ >
234
+ <div style={{ fontSize: fs(15), fontWeight: 600, color: MILITARY.text }}>设备巡检记录</div>
235
+ <div style={{ fontSize: fs(13), color: MILITARY.textSecondary, marginTop: 6, lineHeight: 1.7 }}>
236
+ 正文与表格内容 · 现在这一档是 ×{scale}
237
+ </div>
238
+ <div
239
+ data-testid="font-sample"
240
+ style={{ fontSize: fs(11.5), color: MILITARY.textMuted, marginTop: 4 }}
241
+ >
242
+ 辅助说明与时间戳
243
+ </div>
244
+ </div>
245
+ </div>
246
+
247
+ {/* ---------------------------- 底部 ---------------------------- */}
248
+ <div
249
+ style={{
250
+ marginTop: 22,
251
+ paddingTop: 14,
252
+ borderTop: `1px solid ${MILITARY.borderSoft}`,
253
+ display: 'flex',
254
+ alignItems: 'center',
255
+ justifyContent: 'space-between',
256
+ gap: 12,
257
+ }}
258
+ >
259
+ <div style={{ fontSize: fs(11), color: MILITARY.textMuted, lineHeight: 1.6 }}>
260
+ 设置保存在这台设备的浏览器里,换设备不跟随。
261
+ </div>
262
+ <Button size="small" icon={<ReloadOutlined />} onClick={reset}>
263
+ 恢复默认
264
+ </Button>
265
+ </div>
266
+ </Drawer>
267
+ );
268
+ }
@@ -0,0 +1,160 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import {
14
+ APPEARANCE_KEY,
15
+ DEFAULT_APPEARANCE,
16
+ SKINS,
17
+ applyAppearance,
18
+ readAppearance,
19
+ saveAppearance,
20
+ type AppearanceSettings,
21
+ type FontId,
22
+ type Skin,
23
+ type SkinId,
24
+ } from '@/lib/appearance';
25
+
26
+ /**
27
+ * 外观状态(皮肤 + 字号)。
28
+ *
29
+ * 为什么用一个模块级 store 而不是普通 useState:
30
+ * 首屏 HTML 是按默认外观(原版)直出的,用户本机存的可能是炫酷/大字。
31
+ * `useSyncExternalStore` 允许"服务端快照 = 默认、客户端快照 = 本机设置",
32
+ * React 会在水合完成后自己切到真值,不会报 hydration mismatch。
33
+ * 而 CSS 变量早就被 <head> 里的启动脚本设好了,所以看不到闪白。
34
+ *
35
+ * 作用域是**整站**(挂在根 Providers 上),登录页也带外观;
36
+ * 但设置存在浏览器 localStorage 里 —— 换设备不跟随,这是外观偏好,
37
+ * 不该占用账号数据(多人共用一台机器的考场尤其如此)。
38
+ */
39
+
40
+ type Listener = () => void;
41
+
42
+ let snapshot: AppearanceSettings | null = null;
43
+ const listeners = new Set<Listener>();
44
+
45
+ /** 缓存引用:useSyncExternalStore 要求未变化时返回同一个对象 */
46
+ function getSnapshot(): AppearanceSettings {
47
+ if (!snapshot) snapshot = readAppearance();
48
+ return snapshot;
49
+ }
50
+
51
+ function getServerSnapshot(): AppearanceSettings {
52
+ return DEFAULT_APPEARANCE;
53
+ }
54
+
55
+ function subscribe(cb: Listener): () => void {
56
+ listeners.add(cb);
57
+ // 同一浏览器的其他标签页改了外观 → 这里跟着变,避免两个页面观感不一致
58
+ const onStorage = (e: StorageEvent) => {
59
+ if (e.key && e.key !== APPEARANCE_KEY) return;
60
+ snapshot = readAppearance();
61
+ applyAppearance(snapshot);
62
+ cb();
63
+ };
64
+ window.addEventListener('storage', onStorage);
65
+ return () => {
66
+ listeners.delete(cb);
67
+ window.removeEventListener('storage', onStorage);
68
+ };
69
+ }
70
+
71
+ function commit(next: AppearanceSettings) {
72
+ snapshot = next;
73
+ saveAppearance(next);
74
+ applyAppearance(next);
75
+ listeners.forEach((l) => l());
76
+ }
77
+
78
+ export interface AppearanceApi {
79
+ settings: AppearanceSettings;
80
+ /** 当前皮肤定义(字面色板、材质都在里面) */
81
+ skin: Skin;
82
+ /** 面板开关 */
83
+ open: boolean;
84
+ openPanel: () => void;
85
+ closePanel: () => void;
86
+ setSkin: (id: SkinId) => void;
87
+ setFont: (id: FontId) => void;
88
+ reset: () => void;
89
+ }
90
+
91
+ const Ctx = createContext<AppearanceApi | null>(null);
92
+
93
+ export function AppearanceProvider({ children }: { children: ReactNode }) {
94
+ const settings = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
95
+ const [open, setOpen] = useState(false);
96
+
97
+ /**
98
+ * 首次挂载再对齐一次:`<head>` 里的启动脚本可能被严格模式/隐私策略拦掉,
99
+ * 这里兜一层,保证 <html data-wb-skin> 一定等于真实设置。
100
+ */
101
+ const mounted = useRef(false);
102
+ useEffect(() => {
103
+ applyAppearance(settings);
104
+ if (!mounted.current) {
105
+ mounted.current = true;
106
+ return;
107
+ }
108
+ // 切换瞬间给一个 0.18s 的颜色过渡,别让整页"啪"地换掉
109
+ const el = document.documentElement;
110
+ el.classList.add('wb-skin-anim');
111
+ const timer = window.setTimeout(() => el.classList.remove('wb-skin-anim'), 240);
112
+ return () => {
113
+ window.clearTimeout(timer);
114
+ el.classList.remove('wb-skin-anim');
115
+ };
116
+ }, [settings]);
117
+
118
+ const api = useMemo<AppearanceApi>(
119
+ () => ({
120
+ settings,
121
+ skin: SKINS[settings.skin] ?? SKINS.classic,
122
+ open,
123
+ openPanel: () => setOpen(true),
124
+ closePanel: () => setOpen(false),
125
+ setSkin: (id) => commit({ ...settings, skin: id }),
126
+ setFont: (id) => commit({ ...settings, font: id }),
127
+ reset: () => commit({ ...DEFAULT_APPEARANCE }),
128
+ }),
129
+ [settings, open]
130
+ );
131
+
132
+ return <Ctx.Provider value={api}>{children}</Ctx.Provider>;
133
+ }
134
+
135
+ /** 取外观状态。必须在 AppearanceProvider 内部调用(Providers 已包好,页面直接用) */
136
+ export function useAppearance(): AppearanceApi {
137
+ const v = useContext(Ctx);
138
+ if (!v) {
139
+ throw new Error('useAppearance 必须在 <AppearanceProvider> 内使用(根 Providers 已包)');
140
+ }
141
+ return v;
142
+ }
143
+
144
+ /**
145
+ * 图表调色板:跟随当前皮肤。
146
+ *
147
+ * ECharts / canvas 不认 CSS 变量,必须拿字面 hex —— 所以这里返回
148
+ * `SKINS[skin].palette.chart`,皮肤一换,图表重渲染就是新颜色。
149
+ * 普通 DOM 元素不要用它,直接用 MILITARY.xxx(那是 var(),会自己变)。
150
+ */
151
+ export function useChartPalette(): string[] {
152
+ const { skin } = useAppearance();
153
+ return skin.palette.chart;
154
+ }
155
+
156
+ /** 画布 / 内联 HTML 需要真实色值时用(边缘、标签、tooltip 文案着色) */
157
+ export function useSkinInk() {
158
+ const { skin } = useAppearance();
159
+ return skin.palette;
160
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import {
5
+ ArrowLeftOutlined,
6
+ ArrowRightOutlined,
7
+ ClockCircleOutlined,
8
+ EditOutlined,
9
+ } from '@ant-design/icons';
10
+ import Markdown from '@/components/Markdown';
11
+ import { docHref, type DocNavPage, type DocRecord } from '@/lib/docs-shared';
12
+ import DocToc, { ReadingProgress } from './DocToc';
13
+
14
+ /** 单篇文档:面包屑 + 标题区 + 正文 + 右侧目录 + 上下篇 */
15
+ export default function DocArticle({
16
+ doc,
17
+ prev,
18
+ next,
19
+ }: {
20
+ doc: DocRecord;
21
+ prev: DocNavPage | null;
22
+ next: DocNavPage | null;
23
+ }) {
24
+ return (
25
+ <>
26
+ <ReadingProgress />
27
+
28
+ <div className="wb-docs-article-layout">
29
+ <article className="wb-docs-article">
30
+ <nav className="wb-docs-breadcrumb" aria-label="面包屑">
31
+ <Link href="/docs">帮助中心</Link>
32
+ {doc.groupTrail.map((group) => (
33
+ <span key={group}>
34
+ <span className="wb-docs-breadcrumb-sep">/</span>
35
+ {group}
36
+ </span>
37
+ ))}
38
+ <span className="wb-docs-breadcrumb-sep">/</span>
39
+ <span className="wb-docs-breadcrumb-current">{doc.title}</span>
40
+ </nav>
41
+
42
+ <header className="wb-docs-page-head">
43
+ <h1 className="wb-docs-page-title">{doc.title}</h1>
44
+ {doc.description && <p className="wb-docs-page-desc">{doc.description}</p>}
45
+
46
+ <div className="wb-docs-page-meta">
47
+ <span className="wb-docs-page-meta-item">
48
+ <ClockCircleOutlined /> 约 {doc.readingMinutes} 分钟
49
+ </span>
50
+ {doc.updated && (
51
+ <span className="wb-docs-page-meta-item">
52
+ <EditOutlined /> 更新于 {doc.updated}
53
+ </span>
54
+ )}
55
+ {doc.tags.map((tag) => (
56
+ <span key={tag} className="wb-docs-tag">
57
+ {tag}
58
+ </span>
59
+ ))}
60
+ </div>
61
+ </header>
62
+
63
+ <div className="wb-docs-prose">
64
+ <Markdown text={doc.body} anchors internalLinks copyable />
65
+ </div>
66
+
67
+ {(prev || next) && (
68
+ <nav className="wb-docs-pager" aria-label="上一篇 / 下一篇">
69
+ {prev ? (
70
+ <Link href={docHref(prev.slug)} className="wb-docs-pager-card is-prev">
71
+ <span className="wb-docs-pager-label">
72
+ <ArrowLeftOutlined /> 上一篇
73
+ </span>
74
+ <span className="wb-docs-pager-title">{prev.title}</span>
75
+ </Link>
76
+ ) : (
77
+ <span className="wb-docs-pager-card is-empty" />
78
+ )}
79
+
80
+ {next ? (
81
+ <Link href={docHref(next.slug)} className="wb-docs-pager-card is-next">
82
+ <span className="wb-docs-pager-label">
83
+ 下一篇 <ArrowRightOutlined />
84
+ </span>
85
+ <span className="wb-docs-pager-title">{next.title}</span>
86
+ </Link>
87
+ ) : (
88
+ <span className="wb-docs-pager-card is-empty" />
89
+ )}
90
+ </nav>
91
+ )}
92
+
93
+ <footer className="wb-docs-article-foot">
94
+ <span>本文档由系统内置帮助中心维护,会随功能更新同步调整。</span>
95
+ <span>
96
+ 发现内容有误?
97
+ <Link href="/feedback"> 前往问题反馈</Link>
98
+ </span>
99
+ </footer>
100
+ </article>
101
+
102
+ <DocToc items={doc.toc} />
103
+ </div>
104
+ </>
105
+ );
106
+ }
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { DocTocItem } from '@/lib/docs-shared';
5
+
6
+ /**
7
+ * 右侧「本页目录」。
8
+ *
9
+ * 用滚动位置而不是 IntersectionObserver 决定当前小节:
10
+ * 正文里有长代码块、表格时,IntersectionObserver 的回调节流会让高亮
11
+ * 明显滞后;直接算「最后一个越过阅读线的标题」更跟手,逻辑也更好懂。
12
+ */
13
+ export default function DocToc({ items }: { items: DocTocItem[] }) {
14
+ const [active, setActive] = useState(items[0]?.id ?? '');
15
+
16
+ useEffect(() => {
17
+ if (!items.length) return;
18
+
19
+ let frame = 0;
20
+ const measure = () => {
21
+ frame = 0;
22
+ let current = items[0]?.id ?? '';
23
+ for (const item of items) {
24
+ const el = document.getElementById(item.id);
25
+ if (!el) continue;
26
+ if (el.getBoundingClientRect().top <= 96) current = item.id;
27
+ else break;
28
+ }
29
+ setActive(current);
30
+ };
31
+
32
+ const onScroll = () => {
33
+ if (frame) return;
34
+ frame = window.requestAnimationFrame(measure);
35
+ };
36
+
37
+ measure();
38
+ window.addEventListener('scroll', onScroll, { passive: true });
39
+ window.addEventListener('resize', onScroll);
40
+ return () => {
41
+ window.removeEventListener('scroll', onScroll);
42
+ window.removeEventListener('resize', onScroll);
43
+ if (frame) window.cancelAnimationFrame(frame);
44
+ };
45
+ }, [items]);
46
+
47
+ if (!items.length) return null;
48
+
49
+ return (
50
+ <aside className="wb-docs-toc" aria-label="本页目录">
51
+ <div className="wb-docs-toc-title">本页目录</div>
52
+ <div className="wb-docs-toc-list">
53
+ {items.map((item) => (
54
+ <a
55
+ key={item.id}
56
+ href={`#${item.id}`}
57
+ onClick={() => setActive(item.id)}
58
+ className={`wb-docs-toc-link level-${item.level} ${active === item.id ? 'is-active' : ''}`}
59
+ >
60
+ {item.text}
61
+ </a>
62
+ ))}
63
+ </div>
64
+ </aside>
65
+ );
66
+ }
67
+
68
+ /** 顶部阅读进度条:长文档滚到哪一目了然 */
69
+ export function ReadingProgress() {
70
+ const [progress, setProgress] = useState(0);
71
+
72
+ useEffect(() => {
73
+ let frame = 0;
74
+ const measure = () => {
75
+ frame = 0;
76
+ const doc = document.documentElement;
77
+ const max = doc.scrollHeight - doc.clientHeight;
78
+ setProgress(max > 0 ? Math.min(1, Math.max(0, doc.scrollTop / max)) : 0);
79
+ };
80
+ const onScroll = () => {
81
+ if (frame) return;
82
+ frame = window.requestAnimationFrame(measure);
83
+ };
84
+
85
+ measure();
86
+ window.addEventListener('scroll', onScroll, { passive: true });
87
+ window.addEventListener('resize', onScroll);
88
+ return () => {
89
+ window.removeEventListener('scroll', onScroll);
90
+ window.removeEventListener('resize', onScroll);
91
+ if (frame) window.cancelAnimationFrame(frame);
92
+ };
93
+ }, []);
94
+
95
+ return (
96
+ <div className="wb-docs-progress" aria-hidden="true">
97
+ <span style={{ width: `${progress * 100}%` }} />
98
+ </div>
99
+ );
100
+ }