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,573 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import {
5
+ App, Alert, Badge, Button, Card, Col, Descriptions, Form, Input, Modal, Popconfirm, Row, Select,
6
+ Space, Table, Tag, Typography,
7
+ } from 'antd';
8
+ import { usePathname } from 'next/navigation';
9
+ import {
10
+ KeyOutlined,
11
+ MessageOutlined,
12
+ SendOutlined,
13
+ ThunderboltOutlined,
14
+ } from '@ant-design/icons';
15
+ import type { ColumnsType } from 'antd/es/table';
16
+ import FeedbackAiModal from '@/components/FeedbackAiModal';
17
+ import FeedbackReportModal from '@/components/FeedbackReportModal';
18
+ import { useAuth } from '@/components/AuthContext';
19
+ import { fmtDateTime } from '@/lib/format';
20
+ import { fs, MILITARY } from '@/lib/theme';
21
+ import { formRules } from '@/lib/formRules';
22
+
23
+ /**
24
+ * 问题反馈页(骨架内置)。
25
+ *
26
+ * ★ 为什么必须有这个模块:
27
+ * 比赛演示时评委最常问的一句是「如果有问题怎么反馈」。
28
+ * 有一个能提交、能跟踪状态、管理员能回复的反馈闭环,
29
+ * 比任何功能都更能体现"这是个能上线的系统"。
30
+ *
31
+ * 权限设计:
32
+ * - 任何登录用户都能提交(不需要额外权限码)
33
+ * - 普通用户只看自己的反馈
34
+ * - 管理员看全部,并能改状态、回复
35
+ */
36
+
37
+ const TYPE_OPTIONS = [
38
+ { label: '功能异常', value: 'bug', color: 'red' },
39
+ { label: '体验建议', value: 'suggest', color: 'blue' },
40
+ { label: '数据问题', value: 'data', color: 'orange' },
41
+ { label: '其他', value: 'other', color: 'default' },
42
+ // 账号申诉由「忘记密码 → 人工申诉」在未登录状态下提交,普通反馈弹窗不提供该类型
43
+ { label: '账号申诉', value: 'account', color: 'purple' },
44
+ ];
45
+ const SUBMIT_TYPES = TYPE_OPTIONS.filter((t) => t.value !== 'account');
46
+ const STATUS_OPTIONS = [
47
+ { label: '待处理', value: 'pending', color: 'red' },
48
+ { label: '处理中', value: 'doing', color: 'blue' },
49
+ { label: '已解决', value: 'resolved', color: 'green' },
50
+ { label: '已关闭', value: 'closed', color: 'default' },
51
+ ];
52
+
53
+ interface Row {
54
+ id: string;
55
+ type: string;
56
+ title: string;
57
+ detail: string;
58
+ pagePath: string | null;
59
+ contact: string | null;
60
+ attachments: string | null;
61
+ status: string;
62
+ reply: string | null;
63
+ username: string;
64
+ createdAt: string;
65
+ }
66
+
67
+ type Attachment = { name: string; url: string; size?: number };
68
+
69
+ /** 附件字段是 JSON 字符串;坏数据一律当没有附件,不能因此打不开详情 */
70
+ function parseAttachments(raw: string | null | undefined): Attachment[] {
71
+ if (!raw) return [];
72
+ try {
73
+ const parsed = JSON.parse(raw) as unknown;
74
+ if (!Array.isArray(parsed)) return [];
75
+ return parsed.filter(
76
+ (a): a is Attachment =>
77
+ Boolean(a) && typeof (a as Attachment).url === 'string' && typeof (a as Attachment).name === 'string'
78
+ );
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ const typeMeta = (t: string) => TYPE_OPTIONS.find((x) => x.value === t) ?? TYPE_OPTIONS[3];
85
+ const statusMeta = (s: string) => STATUS_OPTIONS.find((x) => x.value === s) ?? STATUS_OPTIONS[0];
86
+
87
+ export default function FeedbackPage() {
88
+ const { message } = App.useApp();
89
+ const { user } = useAuth();
90
+ const isSuper = Boolean(user?.isSuper);
91
+ const pathname = usePathname();
92
+ const [list, setList] = useState<Row[]>([]);
93
+ const [total, setTotal] = useState(0);
94
+ const [page, setPage] = useState(1);
95
+ const [loading, setLoading] = useState(false);
96
+ const [canManage, setCanManage] = useState(false);
97
+ const [statusFilter, setStatusFilter] = useState<string>('');
98
+ const [open, setOpen] = useState(false);
99
+ const [submitting, setSubmitting] = useState(false);
100
+ const [detail, setDetail] = useState<Row | null>(null);
101
+ const [aiTarget, setAiTarget] = useState<Row | null>(null);
102
+ const [reportOpen, setReportOpen] = useState(false);
103
+ const [approving, setApproving] = useState(false);
104
+ const [form] = Form.useForm();
105
+
106
+ const load = useCallback(
107
+ async (p = page) => {
108
+ setLoading(true);
109
+ try {
110
+ const qs = new URLSearchParams({ page: String(p), pageSize: '10' });
111
+ if (statusFilter) qs.set('status', statusFilter);
112
+ const r = await fetch(`/api/feedback?${qs.toString()}`);
113
+ const j = (await r.json()) as {
114
+ code: number;
115
+ data?: { list: Row[]; total: number; canManage: boolean };
116
+ message?: string;
117
+ };
118
+ // 注意:脚手架 ok() 的成功码是 0
119
+ if (j.code === 0 && j.data) {
120
+ setList(j.data.list);
121
+ setTotal(j.data.total);
122
+ setCanManage(j.data.canManage);
123
+ } else {
124
+ message.error(j.message || '加载失败');
125
+ }
126
+ } catch (e) {
127
+ message.error(`加载失败:${String(e)}`);
128
+ } finally {
129
+ setLoading(false);
130
+ }
131
+ },
132
+ [page, statusFilter, message]
133
+ );
134
+
135
+ useEffect(() => {
136
+ void load(page);
137
+ }, [load, page]);
138
+
139
+ /**
140
+ * 单条深链:待办/消息里的 link 是 `/feedback?id=xxx`
141
+ * (管理员那条还会带 `&status=pending`,进去就是待处理那一屏)。
142
+ *
143
+ * 用 window.location 读参数而不是 useSearchParams —— 页面已 'use client',
144
+ * 但 useSearchParams 会让整页在构建期要求 Suspense 边界,没必要。
145
+ * 只认第一次:详情里改完状态会 load 重排列表,此时 URL 里的 id 还在,
146
+ * 不拦一下就会把刚关掉的弹窗又弹回来。
147
+ */
148
+ const linkedOnce = useRef(false);
149
+ const openById = useCallback(
150
+ async (id: string) => {
151
+ try {
152
+ const r = await fetch(`/api/feedback?id=${encodeURIComponent(id)}`);
153
+ const j = (await r.json()) as {
154
+ code: number;
155
+ data?: { list: Row[]; canManage?: boolean };
156
+ message?: string;
157
+ };
158
+ const row = j.code === 0 ? j.data?.list?.[0] : undefined;
159
+ if (!row) {
160
+ message.error(j.message || '反馈不存在或已被删除');
161
+ return;
162
+ }
163
+ if (typeof j.data?.canManage === 'boolean') setCanManage(j.data.canManage);
164
+ setDetail(row);
165
+ } catch (e) {
166
+ message.error(`加载失败:${String(e)}`);
167
+ }
168
+ },
169
+ [message]
170
+ );
171
+
172
+ useEffect(() => {
173
+ if (linkedOnce.current) return;
174
+ linkedOnce.current = true;
175
+ const sp = new URLSearchParams(window.location.search);
176
+ const status = sp.get('status');
177
+ // 管理员从待办点进来带 status=pending:先把列表筛成待处理,
178
+ // 关掉详情看到的就是他要处理的那一屏,而不是全部历史。
179
+ // 注意这里只能 setState,真正的请求交给上面 load 的依赖变化触发。
180
+ if (status && STATUS_OPTIONS.some((s) => s.value === status)) {
181
+ setStatusFilter(status);
182
+ setPage(1);
183
+ }
184
+ const id = sp.get('id');
185
+ if (id) void openById(id);
186
+ }, [openById]);
187
+
188
+ const submit = async (v: { type: string; title: string; detail: string; contact?: string }) => {
189
+ setSubmitting(true);
190
+ try {
191
+ const r = await fetch('/api/feedback', {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify({ ...v, pagePath: pathname }),
195
+ });
196
+ const j = (await r.json()) as { code: number; message?: string };
197
+ if (j.code === 0) {
198
+ message.success('反馈已提交,感谢你的反馈');
199
+ setOpen(false);
200
+ form.resetFields();
201
+ setPage(1);
202
+ await load(1);
203
+ } else {
204
+ message.error(j.message || '提交失败');
205
+ }
206
+ } catch (e) {
207
+ message.error(`提交失败:${String(e)}`);
208
+ } finally {
209
+ setSubmitting(false);
210
+ }
211
+ };
212
+
213
+ const updateFeedback = async (id: string, patch: { status?: string; reply?: string }) => {
214
+ try {
215
+ const r = await fetch('/api/feedback', {
216
+ method: 'PATCH',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ id, ...patch }),
219
+ });
220
+ const j = (await r.json()) as { code: number; message?: string };
221
+ if (j.code === 0) {
222
+ message.success('已更新');
223
+ await load(page);
224
+ setDetail(null);
225
+ } else {
226
+ message.error(j.message || '更新失败');
227
+ }
228
+ } catch (e) {
229
+ message.error(`更新失败:${String(e)}`);
230
+ }
231
+ };
232
+
233
+ /** 超管通过账号申诉:密码重置为 123456,并标记首次登录改密 */
234
+ const approveRecovery = async (id: string) => {
235
+ setApproving(true);
236
+ try {
237
+ const r = await fetch('/api/feedback/approve', {
238
+ method: 'POST',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ id }),
241
+ });
242
+ const j = (await r.json()) as { code: number; message?: string };
243
+ if (j.code === 0) {
244
+ message.success('已通过申诉,密码已重置为 123456');
245
+ await load(page);
246
+ setDetail(null);
247
+ } else {
248
+ message.error(j.message || '处理失败');
249
+ }
250
+ } catch (e) {
251
+ message.error(`处理失败:${String(e)}`);
252
+ } finally {
253
+ setApproving(false);
254
+ }
255
+ };
256
+
257
+ const detailAttachments = detail ? parseAttachments(detail.attachments) : [];
258
+
259
+ const columns: ColumnsType<Row> = [
260
+ {
261
+ title: '类型',
262
+ dataIndex: 'type',
263
+ width: 96,
264
+ render: (v: string) => {
265
+ const m = typeMeta(v);
266
+ return <Tag color={m.color}>{m.label}</Tag>;
267
+ },
268
+ },
269
+ { title: '标题', dataIndex: 'title', ellipsis: true },
270
+ {
271
+ title: '状态',
272
+ dataIndex: 'status',
273
+ width: 96,
274
+ render: (v: string) => {
275
+ const m = statusMeta(v);
276
+ return <Badge status={v === 'resolved' ? 'success' : v === 'pending' ? 'error' : v === 'doing' ? 'processing' : 'default'} text={m.label} />;
277
+ },
278
+ },
279
+ ...(canManage ? [{ title: '提交人', dataIndex: 'username', width: 110 }] : []),
280
+ {
281
+ title: '提交时间',
282
+ dataIndex: 'createdAt',
283
+ width: 160,
284
+ render: (v: string) => fmtDateTime(v),
285
+ },
286
+ {
287
+ title: '操作',
288
+ width: 176,
289
+ render: (_: unknown, r: Row) => (
290
+ <Space size={0}>
291
+ <Button type="link" size="small" onClick={() => setDetail(r)}>
292
+ 查看
293
+ </Button>
294
+ {/* 智能处理会按白名单执行"账号/员工"类操作,没有处理权限的普通用户点了
295
+ 只会得到"没有权限"。按钮就该只给能处理的人看,别制造假失败。 */}
296
+ {canManage && (
297
+ <Button
298
+ type="link"
299
+ size="small"
300
+ icon={<ThunderboltOutlined />}
301
+ onClick={() => setAiTarget(r)}
302
+ style={{ color: MILITARY.primary, fontWeight: 600 }}
303
+ >
304
+ 智能处理
305
+ </Button>
306
+ )}
307
+ </Space>
308
+ ),
309
+ },
310
+ ];
311
+
312
+ return (
313
+ <div style={{ padding: 16 }}>
314
+ <div style={{ marginBottom: 12 }}>
315
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
316
+ <span style={{ width: 6, height: 6, background: MILITARY.accent, display: 'inline-block' }} />
317
+ <span style={{ fontSize: fs(10.5), letterSpacing: 3, color: MILITARY.textMuted, fontWeight: 600 }}>
318
+ FEEDBACK
319
+ </span>
320
+ </div>
321
+ <div style={{ fontSize: fs(20), fontWeight: 700, color: MILITARY.text, marginTop: 4 }}>
322
+ <MessageOutlined style={{ color: MILITARY.primary, marginRight: 8, fontSize: fs(18) }} />
323
+ 问题反馈
324
+ </div>
325
+ <div style={{ fontSize: fs(12.5), color: MILITARY.textMuted, marginTop: 4 }}>
326
+ 使用中遇到问题或有改进建议,都可以在这里提交。管理员会跟进处理并在下方回复。
327
+ </div>
328
+ </div>
329
+
330
+ <Card
331
+ title={canManage ? '全部反馈' : '我的反馈'}
332
+ extra={
333
+ <Space wrap>
334
+ {isSuper && (
335
+ <Button
336
+ icon={<ThunderboltOutlined />}
337
+ onClick={() => setReportOpen(true)}
338
+ style={{
339
+ borderColor: MILITARY.accent,
340
+ color: MILITARY.primary,
341
+ background: MILITARY.accentSoft,
342
+ fontWeight: 600,
343
+ }}
344
+ >
345
+ 智能生成报告
346
+ </Button>
347
+ )}
348
+ <Select
349
+ allowClear
350
+ size="small"
351
+ style={{ width: 120 }}
352
+ placeholder="全部状态"
353
+ value={statusFilter || undefined}
354
+ onChange={(v) => {
355
+ setStatusFilter(v ?? '');
356
+ setPage(1);
357
+ }}
358
+ options={STATUS_OPTIONS.map((s) => ({ label: s.label, value: s.value }))}
359
+ />
360
+ <Button type="primary" icon={<SendOutlined />} onClick={() => setOpen(true)}>
361
+ 提交反馈
362
+ </Button>
363
+ </Space>
364
+ }
365
+ >
366
+ <Table
367
+ rowKey="id"
368
+ size="small"
369
+ loading={loading}
370
+ columns={columns}
371
+ dataSource={list}
372
+ pagination={{
373
+ current: page,
374
+ pageSize: 10,
375
+ total,
376
+ onChange: setPage,
377
+ showTotal: (t) => `共 ${t} 条`,
378
+ }}
379
+ locale={{ emptyText: '还没有反馈,遇到问题随时告诉我们' }}
380
+ />
381
+ </Card>
382
+
383
+ {/* 提交弹窗 */}
384
+ <Modal
385
+ title="提交反馈"
386
+ open={open}
387
+ onCancel={() => setOpen(false)}
388
+ onOk={() => form.submit()}
389
+ okText="提交"
390
+ cancelText="取消"
391
+ confirmLoading={submitting}
392
+ >
393
+ <Form form={form} layout="vertical" initialValues={{ type: 'bug' }} onFinish={submit}>
394
+ <Form.Item name="type" label="反馈类型" rules={[{ required: true }]}>
395
+ <Select options={SUBMIT_TYPES.map((t) => ({ label: t.label, value: t.value }))} />
396
+ </Form.Item>
397
+ <Form.Item
398
+ name="title"
399
+ label="标题"
400
+ rules={[{ required: true, message: '请简单概括问题(至少 2 个字)' }]}
401
+ >
402
+ <Input maxLength={80} showCount placeholder="一句话说明问题,例如:点击保存后列表没有更新" />
403
+ </Form.Item>
404
+ <Form.Item
405
+ name="detail"
406
+ label="详细描述"
407
+ rules={[{ required: true, message: '请把问题描述清楚(至少 5 个字)' }]}
408
+ >
409
+ <Input.TextArea
410
+ rows={5}
411
+ maxLength={2000}
412
+ showCount
413
+ placeholder="可以写:在哪个页面、做了什么操作、期望看到什么、实际看到什么"
414
+ />
415
+ </Form.Item>
416
+ <Form.Item
417
+ name="contact"
418
+ label="联系方式(可选)"
419
+ rules={formRules('contact', { label: '联系方式' })}
420
+ >
421
+ <Input maxLength={254} placeholder="手机或邮箱,方便管理员回访" />
422
+ </Form.Item>
423
+ <Alert
424
+ type="info"
425
+ showIcon
426
+ title="提交时会自动记录当前页面路径,便于定位问题。"
427
+ />
428
+ </Form>
429
+ </Modal>
430
+
431
+ {/* 详情 / 处理弹窗 */}
432
+ <Modal
433
+ title="反馈详情"
434
+ open={!!detail}
435
+ onCancel={() => setDetail(null)}
436
+ footer={null}
437
+ width={640}
438
+ >
439
+ {detail && (
440
+ <>
441
+ <Descriptions column={1} size="small" bordered>
442
+ <Descriptions.Item label="类型">
443
+ <Tag color={typeMeta(detail.type).color}>{typeMeta(detail.type).label}</Tag>
444
+ </Descriptions.Item>
445
+ <Descriptions.Item label="标题">{detail.title}</Descriptions.Item>
446
+ <Descriptions.Item label="状态">
447
+ <Tag color={statusMeta(detail.status).color}>{statusMeta(detail.status).label}</Tag>
448
+ </Descriptions.Item>
449
+ <Descriptions.Item label="提交人">{detail.username}</Descriptions.Item>
450
+ <Descriptions.Item label="提交时间">{fmtDateTime(detail.createdAt)}</Descriptions.Item>
451
+ {detail.pagePath && (
452
+ <Descriptions.Item label="所在页面">
453
+ <Typography.Text code>{detail.pagePath}</Typography.Text>
454
+ </Descriptions.Item>
455
+ )}
456
+ {detail.contact && (
457
+ <Descriptions.Item label="联系方式">{detail.contact}</Descriptions.Item>
458
+ )}
459
+ <Descriptions.Item label="详细描述">
460
+ <div style={{ whiteSpace: 'pre-wrap' }}>{detail.detail}</div>
461
+ </Descriptions.Item>
462
+ {detailAttachments.length > 0 && (
463
+ <Descriptions.Item label="证明材料">
464
+ <Space direction="vertical" size={2}>
465
+ {detailAttachments.map((a) => (
466
+ <a key={a.url} href={a.url} target="_blank" rel="noreferrer">
467
+ {a.name}
468
+ </a>
469
+ ))}
470
+ </Space>
471
+ </Descriptions.Item>
472
+ )}
473
+ {detail.reply && (
474
+ <Descriptions.Item label="管理员回复">
475
+ <div style={{ whiteSpace: 'pre-wrap' }}>{detail.reply}</div>
476
+ </Descriptions.Item>
477
+ )}
478
+ </Descriptions>
479
+
480
+ {canManage && (
481
+ <Card size="small" title="处理" style={{ marginTop: 16 }}>
482
+ {isSuper &&
483
+ detail.type === 'account' &&
484
+ detail.status !== 'resolved' &&
485
+ detail.status !== 'closed' && (
486
+ <Alert
487
+ type="warning"
488
+ showIcon
489
+ style={{ marginBottom: 12 }}
490
+ title="通过申诉会将密码重置为 123456"
491
+ description="用户下次登录会被强制要求修改密码。请先核对账号信息与证明材料。"
492
+ />
493
+ )}
494
+ <Row gutter={12}>
495
+ {isSuper &&
496
+ detail.type === 'account' &&
497
+ detail.status !== 'resolved' &&
498
+ detail.status !== 'closed' && (
499
+ <Col span={24}>
500
+ <Popconfirm
501
+ title="确认通过该账号申诉?"
502
+ description="通过后密码将重置为 123456,用户首次登录需要修改密码。"
503
+ okText="通过并重置"
504
+ cancelText="取消"
505
+ onConfirm={() => void approveRecovery(detail.id)}
506
+ >
507
+ <Button
508
+ type="primary"
509
+ danger
510
+ icon={<KeyOutlined />}
511
+ loading={approving}
512
+ style={{ marginBottom: 12 }}
513
+ >
514
+ 通过申诉并重置密码
515
+ </Button>
516
+ </Popconfirm>
517
+ </Col>
518
+ )}
519
+ <Col span={24}>
520
+ <Space wrap style={{ marginBottom: 12 }}>
521
+ {STATUS_OPTIONS.map((s) => (
522
+ <Button
523
+ key={s.value}
524
+ size="small"
525
+ type={detail.status === s.value ? 'primary' : 'default'}
526
+ onClick={() => void updateFeedback(detail.id, { status: s.value })}
527
+ >
528
+ {s.label}
529
+ </Button>
530
+ ))}
531
+ </Space>
532
+ </Col>
533
+ <Col span={24}>
534
+ <Input.TextArea
535
+ rows={3}
536
+ placeholder="给用户的回复(可选)"
537
+ defaultValue={detail.reply ?? ''}
538
+ id="fb-reply"
539
+ />
540
+ <div style={{ marginTop: 8, textAlign: 'right' }}>
541
+ <Button
542
+ type="primary"
543
+ onClick={() => {
544
+ const el = document.getElementById('fb-reply') as HTMLTextAreaElement | null;
545
+ void updateFeedback(detail.id, { reply: el?.value ?? '' });
546
+ }}
547
+ >
548
+ 保存回复
549
+ </Button>
550
+ </div>
551
+ </Col>
552
+ </Row>
553
+ </Card>
554
+ )}
555
+ </>
556
+ )}
557
+ </Modal>
558
+
559
+ {/* 单条反馈智能处理:模型识别意图 + 服务端校验执行 + 自动写回复 */}
560
+ <FeedbackAiModal
561
+ open={!!aiTarget}
562
+ feedback={aiTarget ? { id: aiTarget.id, title: aiTarget.title } : null}
563
+ onClose={() => setAiTarget(null)}
564
+ onApplied={() => void load(page)}
565
+ />
566
+
567
+ {/* 智能分析报告(仅超管) */}
568
+ {isSuper && (
569
+ <FeedbackReportModal open={reportOpen} onClose={() => setReportOpen(false)} />
570
+ )}
571
+ </div>
572
+ );
573
+ }