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,67 @@
1
+ import bcrypt from 'bcryptjs';
2
+ import { NextRequest } from 'next/server';
3
+ import { BizError, CODE, audit, ok, withRoute } from '@/lib/api';
4
+ import { currentUser } from '@/auth';
5
+ import { prisma } from '@/lib/prisma';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ /**
10
+ * POST /api/feedback/approve —— 超级管理员通过「账号申诉」。
11
+ *
12
+ * 一次动作完成三件事:
13
+ * 1. 把目标账号密码重置为 123456
14
+ * 2. 标记 mustChangePassword = 1(首次登录强制改密)
15
+ * 3. 把申诉反馈置为已解决并写入回复
16
+ *
17
+ * 只有超管能调用:申诉本质是「绕过密保拿回账号」,权限必须比普通管理员更高。
18
+ */
19
+ export const POST = withRoute(async (req: NextRequest) => {
20
+ const user = await currentUser();
21
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
22
+ if (!user.isSuper) throw new BizError('只有超级管理员可以审批账号申诉', CODE.FORBIDDEN, 403);
23
+
24
+ const body = (await req.json().catch(() => ({}))) as { id?: string };
25
+ const id = String(body.id ?? '');
26
+ if (!id) throw new BizError('缺少反馈 id', CODE.BAD_REQUEST);
27
+
28
+ const feedback = await prisma.feedback.findFirst({ where: { id, deletedAt: null } });
29
+ if (!feedback) throw new BizError('反馈不存在', CODE.NOT_FOUND);
30
+ if (feedback.type !== 'account') throw new BizError('该反馈不是账号申诉', CODE.BAD_REQUEST);
31
+ if (feedback.status === 'resolved' || feedback.status === 'closed') {
32
+ throw new BizError('该申诉已结束,请先将状态改回待处理再审批', CODE.CONFLICT);
33
+ }
34
+
35
+ const target = await prisma.user.findFirst({ where: { id: feedback.userId, deletedAt: null } });
36
+ if (!target) throw new BizError('申诉对应的账号不存在或已删除', CODE.NOT_FOUND);
37
+
38
+ await prisma.user.update({
39
+ where: { id: target.id },
40
+ data: {
41
+ password: await bcrypt.hash('123456', 10),
42
+ mustChangePassword: 1,
43
+ },
44
+ });
45
+
46
+ await prisma.feedback.update({
47
+ where: { id },
48
+ data: {
49
+ status: 'resolved',
50
+ reply: '账号申诉已通过,密码已重置为 123456。请使用 123456 登录,并按提示立即修改密码。',
51
+ readAt: null,
52
+ },
53
+ });
54
+
55
+ await audit({
56
+ userId: user.id,
57
+ username: user.username,
58
+ module: '账号安全',
59
+ action: '通过找回申诉',
60
+ method: 'POST',
61
+ path: req.nextUrl.pathname,
62
+ targetId: target.id,
63
+ detail: { feedbackId: id, username: target.username, resetTo: '123456' },
64
+ });
65
+
66
+ return ok(null, '已通过申诉,密码已重置为 123456');
67
+ });
@@ -0,0 +1,213 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { ok, withRoute, audit, BizError, CODE } from '@/lib/api';
3
+ import { currentUser } from '@/auth';
4
+ import { prisma } from '@/lib/prisma';
5
+ import { aiChat, cleanAiText, AiError, AI_MODEL } from '@/lib/ai';
6
+
7
+ /**
8
+ * 智能反馈分析报告(仅超级管理员)。
9
+ *
10
+ * 为什么单独一个接口,而不是复用 /api/ai/report:
11
+ * 通用报告只做「数据汇总」,说不了「用户到底想要什么」。
12
+ * 这里先把全部反馈读出来、算好真实统计,再让模型做**归类与优先级判断**,
13
+ * 最终产出固定四段式报告:总体概况 / 呼声最高的功能 / 改进建议 / 下一步行动。
14
+ *
15
+ * 权限:只有 isSuper === true 的超级管理员能调用。
16
+ * 反馈里可能包含用户联系方式、内部页面路径等敏感信息,
17
+ * 并且这是一次消耗较大的模型调用,不开放给普通管理员。
18
+ *
19
+ * POST /api/feedback/report
20
+ * 返回 { report, stats, total, analyzed, truncated, model, generatedAt }
21
+ */
22
+
23
+ /** 单次最多送给模型分析的反馈条数(统计仍然按全量算) */
24
+ const MAX_ROWS = 300;
25
+ /** 反馈明细的字符预算,防止个别超长描述把模型上下文撑爆 */
26
+ const MAX_PAYLOAD_CHARS = 18_000;
27
+
28
+ const TYPE_LABEL: Record<string, string> = {
29
+ bug: '功能异常',
30
+ suggest: '体验建议',
31
+ data: '数据问题',
32
+ other: '其他',
33
+ };
34
+
35
+ const STATUS_LABEL: Record<string, string> = {
36
+ pending: '待处理',
37
+ doing: '处理中',
38
+ resolved: '已解决',
39
+ closed: '已关闭',
40
+ };
41
+
42
+ /** 压成一行,避免反馈里的换行把「一条一行」的明细格式打乱 */
43
+ function oneLine(s: string | null | undefined, max: number): string {
44
+ return String(s ?? '')
45
+ .replace(/\s+/g, ' ')
46
+ .trim()
47
+ .slice(0, max);
48
+ }
49
+
50
+ export const POST = withRoute(async (req: NextRequest) => {
51
+ const user = await currentUser();
52
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
53
+ if (!user.isSuper) {
54
+ throw new BizError('只有超级管理员可以生成反馈分析报告', CODE.FORBIDDEN, 403);
55
+ }
56
+
57
+ const where = { deletedAt: null };
58
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
59
+
60
+ // 统计按全量反馈算;明细只取最近 MAX_ROWS 条给模型。
61
+ // SQLite 上这些 count 都是毫秒级,并行发出去比取全量再在内存里数更稳。
62
+ const [
63
+ total,
64
+ pending,
65
+ doing,
66
+ resolved,
67
+ closed,
68
+ bug,
69
+ suggest,
70
+ dataIssue,
71
+ other,
72
+ last7,
73
+ userGroups,
74
+ rows,
75
+ ] = await Promise.all([
76
+ prisma.feedback.count({ where }),
77
+ prisma.feedback.count({ where: { ...where, status: 'pending' } }),
78
+ prisma.feedback.count({ where: { ...where, status: 'doing' } }),
79
+ prisma.feedback.count({ where: { ...where, status: 'resolved' } }),
80
+ prisma.feedback.count({ where: { ...where, status: 'closed' } }),
81
+ prisma.feedback.count({ where: { ...where, type: 'bug' } }),
82
+ prisma.feedback.count({ where: { ...where, type: 'suggest' } }),
83
+ prisma.feedback.count({ where: { ...where, type: 'data' } }),
84
+ prisma.feedback.count({ where: { ...where, type: 'other' } }),
85
+ prisma.feedback.count({ where: { ...where, createdAt: { gte: weekAgo } } }),
86
+ prisma.feedback.groupBy({ by: ['userId'], where }),
87
+ prisma.feedback.findMany({
88
+ where,
89
+ orderBy: { createdAt: 'desc' },
90
+ take: MAX_ROWS,
91
+ select: {
92
+ type: true,
93
+ title: true,
94
+ detail: true,
95
+ status: true,
96
+ pagePath: true,
97
+ reply: true,
98
+ createdAt: true,
99
+ },
100
+ }),
101
+ ]);
102
+
103
+ if (total === 0) {
104
+ throw new BizError('目前还没有反馈数据,暂时无法生成分析报告', CODE.BAD_REQUEST);
105
+ }
106
+
107
+ const stats = {
108
+ total,
109
+ pending,
110
+ doing,
111
+ resolved,
112
+ closed,
113
+ bug,
114
+ suggest,
115
+ data: dataIssue,
116
+ other,
117
+ last7,
118
+ users: userGroups.length,
119
+ };
120
+
121
+ // 按字符预算拼明细;至少保留 30 条,避免一条超长反馈就把后面全挤掉
122
+ const lines: string[] = [];
123
+ let usedChars = 0;
124
+ for (let i = 0; i < rows.length; i += 1) {
125
+ const r = rows[i];
126
+ const line =
127
+ `${i + 1}. [${TYPE_LABEL[r.type] ?? r.type}|${STATUS_LABEL[r.status] ?? r.status}|` +
128
+ `${r.createdAt.toISOString().slice(0, 10)}] ${oneLine(r.title, 80)}:${oneLine(r.detail, 160)}` +
129
+ (r.pagePath ? `(页面:${oneLine(r.pagePath, 80)})` : '') +
130
+ (r.reply ? `(管理员回复:${oneLine(r.reply, 100)})` : '');
131
+
132
+ if (usedChars + line.length > MAX_PAYLOAD_CHARS && lines.length >= 30) break;
133
+ lines.push(line);
134
+ usedChars += line.length;
135
+ }
136
+ const analyzed = lines.length;
137
+
138
+ const prompt = [
139
+ '请分析下面这组来自内部管理系统的用户反馈,写一份《用户反馈分析报告》。',
140
+ '',
141
+ '写作要求:',
142
+ '1. 只使用提供的真实反馈,不得编造用户、功能、数字或引用。样本不足时直接说明,不要硬凑。',
143
+ '2. 先通读全部反馈,把表达同一需求、同一痛点或同一类问题的条目合并归类。',
144
+ '3. 「用户呼声最高的功能」按被反馈提及的条数从多到少排序,最多 5 项。每项写清:需求名称、提及次数、典型反馈依据、价值判断。如果体验建议太少不足以形成功能排行,就基于真实痛点给出最迫切的改进方向,并注明这是从痛点反推的。',
145
+ '4. 「主要问题与改进建议」按 P0 / P1 / P2 排序。每条写清:问题表现、影响范围、具体可执行的改进动作、预期收益。改进动作要具体到能直接排期,不要只写「优化体验」。',
146
+ '5. 最后的「下一步行动建议」最多 3 条,按执行顺序排列。',
147
+ '6. 输出中文 Markdown,不要代码围栏。严格按以下结构:',
148
+ '## 一、总体概况',
149
+ '(2~3 段,引用真实数量,概括反馈结构、主要情绪和集中领域)',
150
+ '## 二、用户呼声最高的功能',
151
+ '(先给一句结论,再用表格:| 排名 | 需求 | 呼声 | 典型反馈 | 价值判断 |)',
152
+ '## 三、主要问题与改进建议',
153
+ '(表格:| 优先级 | 问题 | 改进建议 | 预期收益 |,表后补 1~2 句说明)',
154
+ '## 四、下一步行动建议',
155
+ '(有序列表,最多 3 条)',
156
+ '7. 不要使用「赋能」「闭环」「抓手」这类空泛词汇。语气像产品经理给团队做复盘:直接、具体、有依据。',
157
+ '8. 如果本次只分析了最近的一部分反馈,请在「总体概况」末尾注明样本范围。',
158
+ '',
159
+ '=== 系统统计(真实数据,可直接引用)===',
160
+ `反馈总数:${stats.total} 条;近 7 天新增:${stats.last7} 条;参与用户:${stats.users} 人`,
161
+ `类型分布:功能异常 ${stats.bug} 条,体验建议 ${stats.suggest} 条,数据问题 ${stats.data} 条,其他 ${stats.other} 条`,
162
+ `处理状态:待处理 ${stats.pending} 条,处理中 ${stats.doing} 条,已解决 ${stats.resolved} 条,已关闭 ${stats.closed} 条`,
163
+ `本次提供给模型分析:${analyzed} 条${
164
+ total > analyzed ? `(按时间倒序取最近 ${analyzed} 条,共 ${total} 条)` : `(全部 ${total} 条)`
165
+ }`,
166
+ '',
167
+ '=== 反馈明细(每条一行)===',
168
+ lines.join('\n'),
169
+ ].join('\n');
170
+
171
+ let report: string;
172
+ try {
173
+ report = cleanAiText(
174
+ await aiChat({
175
+ system:
176
+ '你是一名资深产品经理和体验分析师。你只依据给定的真实反馈做判断,' +
177
+ '善于合并同类诉求、区分事实与推测,输出结构清晰、可以直接指导排期的分析报告。',
178
+ messages: [{ role: 'user', content: prompt }],
179
+ maxTokens: 2200,
180
+ temperature: 0.3,
181
+ timeoutMs: 240_000,
182
+ })
183
+ );
184
+ } catch (e) {
185
+ if (e instanceof AiError) {
186
+ throw new BizError(
187
+ `${e.message}${e.hint ? `:${e.hint}` : ''}`,
188
+ CODE.SERVER_ERROR
189
+ );
190
+ }
191
+ throw e;
192
+ }
193
+
194
+ void audit({
195
+ module: '问题反馈',
196
+ action: '生成 AI 报告',
197
+ method: 'POST',
198
+ path: req.url,
199
+ detail: `反馈 ${stats.total} 条,分析 ${analyzed} 条`,
200
+ userId: user.id,
201
+ username: user.username,
202
+ });
203
+
204
+ return ok({
205
+ report,
206
+ stats,
207
+ total,
208
+ analyzed,
209
+ truncated: total > analyzed,
210
+ model: AI_MODEL,
211
+ generatedAt: new Date().toISOString(),
212
+ });
213
+ });
@@ -0,0 +1,246 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { ok, withRoute, audit, parseListQuery, pageResult, BizError, CODE } from '@/lib/api';
3
+ import { currentUser } from '@/auth';
4
+ import { prisma } from '@/lib/prisma';
5
+ import { isFeedbackAdmin } from '@/lib/feedback-access';
6
+ import { validateField } from '@/lib/validation';
7
+ import { notify } from '@/lib/notifications';
8
+ import type { NotificationLevel } from '@/lib/inbox-types';
9
+
10
+ /**
11
+ * 问题反馈接口(骨架内置)。
12
+ *
13
+ * 设计要点:
14
+ * - **任何登录用户都能提交**,不需要额外权限码 —— 否则反馈入口等于没有
15
+ * - 普通用户只能看到自己的反馈;管理员能看全部并改状态
16
+ * - 用软删(deletedAt),保留审计痕迹
17
+ * - 状态变更(且没写回复)时给提交人发一条站内消息,见 PATCH 里的说明
18
+ *
19
+ * GET /api/feedback?page=&pageSize=&status=&type=&q=&id=
20
+ * 普通用户自动只返回自己的;带 id 时只返回那一条(深链 /feedback?id=xxx 用)
21
+ * POST /api/feedback { type, title, detail, pagePath?, contact? }
22
+ * PATCH /api/feedback { id, status?, reply? } 仅管理员
23
+ * DELETE /api/feedback? id= 仅管理员,软删
24
+ */
25
+
26
+ const TYPES = new Set(['bug', 'suggest', 'data', 'other', 'account']);
27
+ const STATUSES = new Set(['pending', 'doing', 'resolved', 'closed']);
28
+
29
+ /**
30
+ * 状态 → 站内消息文案与级别。
31
+ *
32
+ * 只在这里出现一次:管理员改状态时用户看到的那句话必须和页面上的状态标签一致,
33
+ * 两处各写一份文案迟早出现「状态是已解决、消息写已关闭」。
34
+ */
35
+ const STATUS_NOTIFY: Record<string, { label: string; level: NotificationLevel }> = {
36
+ pending: { label: '待处理', level: 'info' },
37
+ doing: { label: '处理中', level: 'info' },
38
+ resolved: { label: '已解决', level: 'success' },
39
+ closed: { label: '已关闭', level: 'warning' },
40
+ };
41
+
42
+ /**
43
+ * 管理员判定放在 lib/feedback-access.ts:
44
+ * 侧边栏徽标要按同一套规则决定"显示全量待处理还是只显示自己的未读",
45
+ * 两处各写一份正则迟早日久失修。判定本身直接读会话里的 isSuper / roleCodes ——
46
+ * 这两项登录时已算好放进 JWT,不必回查 user 表。
47
+ */
48
+ const isAdmin = isFeedbackAdmin;
49
+
50
+ export const GET = withRoute(async (req: NextRequest) => {
51
+ const user = await currentUser();
52
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
53
+
54
+ const q = parseListQuery(req);
55
+ const url = new URL(req.url);
56
+ const status = url.searchParams.get('status') || '';
57
+ const type = url.searchParams.get('type') || '';
58
+ const keyword = (url.searchParams.get('q') || '').trim();
59
+ // 单条查询:待办/消息里的 link 是 /feedback?id=xxx,而那条记录可能不在第 1 页。
60
+ // 不带这个过滤,页面上要么打不开详情,要么得把整表拉下来自己找。
61
+ const id = (url.searchParams.get('id') || '').trim();
62
+
63
+ const admin = isAdmin(user);
64
+ const where: Record<string, unknown> = { deletedAt: null };
65
+ // 普通用户只看自己的
66
+ if (!admin) where.userId = user.id;
67
+ if (id) where.id = id;
68
+ if (status && STATUSES.has(status)) where.status = status;
69
+ if (type && TYPES.has(type)) where.type = type;
70
+ if (keyword) {
71
+ where.OR = [{ title: { contains: keyword } }, { detail: { contains: keyword } }];
72
+ }
73
+
74
+ const [list, total] = await Promise.all([
75
+ prisma.feedback.findMany({
76
+ where,
77
+ orderBy: { createdAt: 'desc' },
78
+ skip: (q.page - 1) * q.pageSize,
79
+ take: q.pageSize,
80
+ }),
81
+ prisma.feedback.count({ where }),
82
+ ]);
83
+
84
+ return ok({ ...pageResult(list, total, q), canManage: admin });
85
+ });
86
+
87
+ export const POST = withRoute(async (req: NextRequest) => {
88
+ const user = await currentUser();
89
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
90
+
91
+ const body = (await req.json().catch(() => ({}))) as {
92
+ type?: string;
93
+ title?: string;
94
+ detail?: string;
95
+ pagePath?: string;
96
+ contact?: string;
97
+ };
98
+
99
+ const title = String(body.title ?? '').trim();
100
+ const detail = String(body.detail ?? '').trim();
101
+ if (title.length < 2) throw new BizError('请填写反馈标题(至少 2 个字)', CODE.BAD_REQUEST);
102
+ if (detail.length < 5) throw new BizError('请把问题描述清楚(至少 5 个字)', CODE.BAD_REQUEST);
103
+
104
+ // 联系方式选填,但填了就要像手机号/邮箱,避免管理员拿着错误号码回访不到人
105
+ const contactCheck = validateField('contact', body.contact, { label: '联系方式' });
106
+ if (!contactCheck.ok) throw new BizError(contactCheck.message!, CODE.BAD_REQUEST);
107
+
108
+ const type = TYPES.has(String(body.type)) ? String(body.type) : 'bug';
109
+
110
+ const row = await prisma.feedback.create({
111
+ data: {
112
+ type,
113
+ title: title.slice(0, 80),
114
+ detail: detail.slice(0, 2000),
115
+ pagePath: body.pagePath ? String(body.pagePath).slice(0, 200) : null,
116
+ contact: contactCheck.value ? contactCheck.value.slice(0, 254) : null,
117
+ userId: user.id,
118
+ username: user.username,
119
+ },
120
+ });
121
+
122
+ void audit({
123
+ module: '问题反馈',
124
+ action: '提交',
125
+ method: 'POST',
126
+ path: req.url,
127
+ detail: `${type}:${title}`,
128
+ userId: user.id,
129
+ username: user.username,
130
+ });
131
+
132
+ return ok({ id: row.id });
133
+ });
134
+
135
+ export const PATCH = withRoute(async (req: NextRequest) => {
136
+ const user = await currentUser();
137
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
138
+ if (!isAdmin(user)) throw new BizError('只有管理员能处理反馈', CODE.FORBIDDEN, 403);
139
+
140
+ const body = (await req.json().catch(() => ({}))) as {
141
+ id?: string;
142
+ status?: string;
143
+ reply?: string;
144
+ };
145
+ const id = String(body.id ?? '');
146
+ if (!id) throw new BizError('缺少 id', CODE.BAD_REQUEST);
147
+
148
+ // 先读旧值:既校验记录还在(软删的不许再改),也用来判断状态是否真的变了。
149
+ // 不比对就发通知的话,管理员把状态点成当前值也会给提交人推一条「状态已更新」的假消息。
150
+ const before = await prisma.feedback.findFirst({
151
+ where: { id, deletedAt: null },
152
+ select: { id: true, userId: true, title: true, status: true },
153
+ });
154
+ if (!before) throw new BizError('反馈不存在或已被删除', CODE.NOT_FOUND);
155
+
156
+ const data: Record<string, unknown> = {};
157
+ if (body.status) {
158
+ if (!STATUSES.has(body.status)) throw new BizError('状态值不合法', CODE.BAD_REQUEST);
159
+ data.status = body.status;
160
+ }
161
+ if (body.reply !== undefined) {
162
+ data.reply = String(body.reply).slice(0, 1000);
163
+ // 有新回复 → 重置已读时间,提交者的侧边栏会重新亮红点。
164
+ // 只改状态(没写回复)不动已读:那只是流程推进,没有"新消息"给他看。
165
+ if (data.reply) data.readAt = null;
166
+ }
167
+ if (!Object.keys(data).length) throw new BizError('没有要更新的内容', CODE.BAD_REQUEST);
168
+
169
+ const row = await prisma.feedback.update({ where: { id }, data });
170
+
171
+ /*
172
+ * 待办与消息的分工,这里是示例接线的核心:
173
+ * 写了回复 → 那条回复本身就是提交人的待办(见 src/lib/todos/feedback.ts),
174
+ * 业务处理完待办才消失,此时再发消息就是同一件事提醒两次;
175
+ * 只改状态 → 待办里什么都看不到(待办只收「未读回复」),
176
+ * 所以补一条站内消息,否则用户永远不知道进度动了。
177
+ */
178
+ const statusChanged = typeof data.status === 'string' && data.status !== before.status;
179
+ const replied = typeof data.reply === 'string' && data.reply.trim().length > 0;
180
+ if (statusChanged && !replied) {
181
+ const meta = STATUS_NOTIFY[String(data.status)] ?? {
182
+ label: String(data.status),
183
+ level: 'info' as const,
184
+ };
185
+ await notify({
186
+ userId: before.userId,
187
+ title: `反馈「${before.title}」状态更新为「${meta.label}」`,
188
+ body: '管理员更新了这条反馈的处理状态,点击查看详情。',
189
+ type: 'feedback',
190
+ level: meta.level,
191
+ link: `/feedback?id=${id}`,
192
+ });
193
+ }
194
+
195
+ void audit({
196
+ module: '问题反馈',
197
+ action: '处理',
198
+ method: 'PATCH',
199
+ path: req.url,
200
+ detail: `${id} -> ${String(data.status ?? '')}`,
201
+ userId: user.id,
202
+ username: user.username,
203
+ });
204
+
205
+ return ok({ id: row.id });
206
+ });
207
+
208
+ export const DELETE = withRoute(async (req: NextRequest) => {
209
+ const user = await currentUser();
210
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
211
+ if (!isAdmin(user)) throw new BizError('只有管理员能删除反馈', CODE.FORBIDDEN, 403);
212
+
213
+ const id = new URL(req.url).searchParams.get('id') || '';
214
+ if (!id) throw new BizError('缺少 id', CODE.BAD_REQUEST);
215
+
216
+ const before = await prisma.feedback.findFirst({
217
+ where: { id, deletedAt: null },
218
+ select: { userId: true, title: true },
219
+ });
220
+ if (!before) throw new BizError('反馈不存在或已被删除', CODE.NOT_FOUND);
221
+
222
+ await prisma.feedback.update({ where: { id }, data: { deletedAt: new Date() } });
223
+
224
+ // 删掉之后,提交人那条「未读回复」待办会一起消失。不发消息他只会觉得
225
+ // 自己的反馈凭空蒸发了;这是纯告知(不需要行动),所以走消息而不是待办。
226
+ await notify({
227
+ userId: before.userId,
228
+ title: `反馈「${before.title}」已被删除`,
229
+ body: '管理员删除了这条反馈,待办中的相关提醒已同时撤下。如有疑问请联系管理员。',
230
+ type: 'feedback',
231
+ level: 'warning',
232
+ link: '/feedback',
233
+ });
234
+
235
+ void audit({
236
+ module: '问题反馈',
237
+ action: '删除',
238
+ method: 'DELETE',
239
+ path: req.url,
240
+ detail: id,
241
+ userId: user.id,
242
+ username: user.username,
243
+ });
244
+
245
+ return ok({ id });
246
+ });
@@ -0,0 +1,82 @@
1
+ import { promises as fsp } from 'fs';
2
+ import { existsSync } from 'fs';
3
+ import { NextResponse } from 'next/server';
4
+ import { BizError, CODE, withRoute } from '@/lib/api';
5
+ import { requireUser } from '@/auth';
6
+ import { resolveUploadFile, uploadOwnerOf } from '@/lib/upload';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+
10
+ const MIME: Record<string, string> = {
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg',
13
+ '.jpeg': 'image/jpeg',
14
+ '.gif': 'image/gif',
15
+ '.webp': 'image/webp',
16
+ '.svg': 'image/svg+xml',
17
+ '.bmp': 'image/bmp',
18
+ '.ico': 'image/x-icon',
19
+ '.pdf': 'application/pdf',
20
+ '.txt': 'text/plain; charset=utf-8',
21
+ '.md': 'text/markdown; charset=utf-8',
22
+ '.csv': 'text/csv; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.doc': 'application/msword',
25
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
26
+ '.xls': 'application/vnd.ms-excel',
27
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
28
+ '.ppt': 'application/vnd.ms-powerpoint',
29
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
30
+ '.zip': 'application/zip',
31
+ };
32
+
33
+ type Ctx = { params: Promise<{ name: string }> };
34
+
35
+ /**
36
+ * GET /api/files/[name] —— 读取 uploads 下的文件。
37
+ *
38
+ * 三道防线:
39
+ * 1. resolveUploadFile 拒绝含 `/`、`\`、`..` 的名字(防路径穿越)
40
+ * 2. 拼完绝对路径后再用 path.relative 校验没跑出 uploads 目录(防 URL 编码变体)
41
+ * 3. `.meta.json` 归属校验:文件按上传人隔离,别人上传的读不到
42
+ * —— 否则任何登录用户拿到文件名就能读走员工导入表这类敏感附件
43
+ */
44
+ export const GET = withRoute<Ctx>(async (_req, ctx) => {
45
+ const user = await requireUser();
46
+
47
+ const { name } = await ctx.params;
48
+
49
+ let filePath: string;
50
+ try {
51
+ // 元数据文件本身不对外提供
52
+ if (name.endsWith('.meta.json')) throw new Error('非法的文件名');
53
+ filePath = resolveUploadFile(name);
54
+ } catch {
55
+ throw new BizError('非法的文件名', CODE.BAD_REQUEST);
56
+ }
57
+
58
+ const owner = await uploadOwnerOf(name);
59
+ // 超管是「全部数据」档,可以跨人读;其他人只能读自己上传的
60
+ if (owner && owner !== user.id && !user.isSuper) {
61
+ throw new BizError('无权访问该文件', CODE.FORBIDDEN, 403);
62
+ }
63
+
64
+ if (!existsSync(filePath)) throw new BizError('文件不存在或已被删除', CODE.NOT_FOUND);
65
+
66
+ const buffer = await fsp.readFile(filePath);
67
+ const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
68
+ const contentType = MIME[ext] ?? 'application/octet-stream';
69
+
70
+ // 图片/PDF 走 inline 便于前端直接预览,其余强制下载
71
+ const inline = /^image\/|^application\/pdf|^text\//.test(contentType);
72
+ const asciiName = name.replace(/[^\x20-\x7E]/g, '_');
73
+
74
+ return new NextResponse(buffer as unknown as BodyInit, {
75
+ headers: {
76
+ 'Content-Type': contentType,
77
+ 'Content-Length': String(buffer.length),
78
+ 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(name)}`,
79
+ 'Cache-Control': 'private, max-age=3600',
80
+ },
81
+ });
82
+ });
@@ -0,0 +1,75 @@
1
+ import { ok, withRoute, BizError, CODE } from '@/lib/api';
2
+ import { currentUser } from '@/auth';
3
+ import { focusOf, entityOfModel, entityById } from '@/lib/graph-focus';
4
+ import { graphHref } from '@/lib/graph-link';
5
+ import { delegateFor } from '@/lib/graph-node-write';
6
+ import { DATA_SCOPE, deptScope, effectiveDataScope } from '@/lib/rbac';
7
+ import { fieldsOfModel } from '@/lib/graph-schema';
8
+
9
+ /**
10
+ * 业务记录 → 图谱关联 的解析接口(全站通用,不认识任何具体业务)。
11
+ *
12
+ * GET /api/graph/focus?src=FitnessRecord:clx123
13
+ * GET /api/graph/focus?model=FitnessRecord&id=clx123
14
+ * GET /api/graph/focus?entity=record&id=clx123
15
+ *
16
+ * 返回:这条记录在图谱里的关联簇(节点 / 边 / 视图 / single|multi)+
17
+ * 一个可直接跳转的 /graph 地址。
18
+ *
19
+ * ★ 业务页面只需要知道自己的 **模型名**,不用记图谱本体 id。
20
+ * 模型没有在 ontology.ts 里声明时,这里会明确说出"还没接图谱",
21
+ * 而不是让用户跳到一张空图。
22
+ */
23
+ export const dynamic = 'force-dynamic';
24
+
25
+ export const GET = withRoute(async (req) => {
26
+ const user = await currentUser();
27
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
28
+
29
+ const sp = new URL(req.url).searchParams;
30
+ const src = (sp.get('src') ?? '').trim();
31
+ const cut = src.indexOf(':');
32
+ const srcModel = cut > 0 ? src.slice(0, cut) : '';
33
+ const srcId = cut > 0 ? src.slice(cut + 1) : '';
34
+
35
+ const model = (sp.get('model') ?? '').trim() || srcModel;
36
+ const id = (sp.get('id') ?? '').trim() || srcId;
37
+ // 备用入口:直接给本体 id(图谱节点链接、手工排查时用)
38
+ const entityId = (sp.get('entity') ?? '').trim();
39
+ const byEntity = entityId ? entityById(entityId)?.model : undefined;
40
+
41
+ const modelName = model || byEntity || '';
42
+
43
+ if (!modelName || !id) {
44
+ throw new BizError('缺少参数:src=<模型名>:<记录id>,或 model + id', CODE.BAD_REQUEST);
45
+ }
46
+
47
+ // 数据范围:范围外的记录按「不存在」处理,别泄露「存在但你没权限」这件事本身
48
+ if (
49
+ effectiveDataScope(user) !== DATA_SCOPE.ALL &&
50
+ fieldsOfModel(modelName).some((f) => f.name === 'deptId')
51
+ ) {
52
+ const row = await delegateFor(modelName).findFirst?.({
53
+ where: { id, ...deptScope(user) },
54
+ });
55
+ if (!row) throw new BizError('记录不存在或已被删除', CODE.NOT_FOUND, 404);
56
+ }
57
+
58
+ const focus = await focusOf(modelName, id); if (!entityOfModel(modelName)) {
59
+ throw new BizError(
60
+ `模型 ${modelName} 还没有接入知识图谱:请在 src/lib/ontology.ts 的 entities 里声明它(model: '${modelName}'),并给它 layer 与至少一条 relations`,
61
+ CODE.NOT_FOUND,
62
+ 404
63
+ );
64
+ }
65
+ if (!focus) {
66
+ // 模型接入了图谱,但这条记录取不到(不存在 / 已被软删)
67
+ throw new BizError('记录不存在或已被删除', CODE.NOT_FOUND, 404);
68
+ }
69
+
70
+ return ok({
71
+ ...focus,
72
+ /** 业务页面直接跳这个地址;图谱页读到 src 后会再调一次本接口拿高亮信息 */
73
+ url: graphHref(focus.source.model, focus.source.dbId, focus.view.id),
74
+ });
75
+ });