node-resource-utils 0.0.0-stage → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,1621 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import type { SessionUser } from '@/auth';
|
|
4
|
+
import { AI_MODEL, AiError, aiChatWithTools } from '@/lib/ai';
|
|
5
|
+
import { audit } from '@/lib/api';
|
|
6
|
+
import { prisma } from '@/lib/prisma';
|
|
7
|
+
import { P, deptAllowed, hasPerm } from '@/lib/rbac';
|
|
8
|
+
import { isFeedbackAdmin } from '@/lib/feedback-access';
|
|
9
|
+
import { validateField } from '@/lib/validation';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 反馈智能处理 —— 把「用户提交的一段话」变成「真正办完的事」。
|
|
13
|
+
*
|
|
14
|
+
* 为什么单独一层,而不是继续往 aiTools 里塞工具:
|
|
15
|
+
* aiTools 是通用助手,写操作要用户点确认卡,适合"帮我建一条记录";
|
|
16
|
+
* 而反馈页要的是一次点击直接办完 + 写回复,交互模型完全不同。
|
|
17
|
+
* 这里只用一次模型调用产出「处理方案」,真正的落库由服务端按白名单执行,
|
|
18
|
+
* 模型既不直接写库,也改不动执行结果。
|
|
19
|
+
*
|
|
20
|
+
* 四层保护:
|
|
21
|
+
* 1. 字段白名单:模型只能填已知字段,多余键丢掉
|
|
22
|
+
* 2. 证据校验:新增/修改的值必须能在反馈原文里找到;
|
|
23
|
+
* 找不到就当"没说清楚",不执行,只回复请用户补充
|
|
24
|
+
* 3. 输入合理性校验:姓名/手机号/邮箱/身份证/日期等复用 lib/validation.ts,
|
|
25
|
+
* 格式不对同样只提示、不落库
|
|
26
|
+
* 4. 权限与数据范围:账号/员工操作复用 rbac 的权限码与部门范围判定
|
|
27
|
+
*
|
|
28
|
+
* ★ 任何异常都不往外抛:处理失败也返回一段可读的分析和提示,
|
|
29
|
+
* 反馈页因此永远不会出现 error / 404 弹窗。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type FeedbackIntent =
|
|
33
|
+
| 'reset_password'
|
|
34
|
+
| 'update_user'
|
|
35
|
+
| 'add_user'
|
|
36
|
+
| 'delete_user'
|
|
37
|
+
| 'update_employee'
|
|
38
|
+
| 'add_employee'
|
|
39
|
+
| 'delete_employee'
|
|
40
|
+
| 'none';
|
|
41
|
+
|
|
42
|
+
export type FeedbackOutcome = 'done' | 'need_info' | 'unsupported' | 'failed';
|
|
43
|
+
|
|
44
|
+
export interface AiStep {
|
|
45
|
+
label: string;
|
|
46
|
+
detail: string;
|
|
47
|
+
ok: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AiChange {
|
|
51
|
+
label: string;
|
|
52
|
+
from: string;
|
|
53
|
+
to: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FeedbackAiResult {
|
|
57
|
+
feedbackId: string;
|
|
58
|
+
intent: FeedbackIntent;
|
|
59
|
+
intentLabel: string;
|
|
60
|
+
confidence: number;
|
|
61
|
+
outcome: FeedbackOutcome;
|
|
62
|
+
outcomeLabel: string;
|
|
63
|
+
status: string;
|
|
64
|
+
statusLabel: string;
|
|
65
|
+
analysis: string;
|
|
66
|
+
reply: string;
|
|
67
|
+
steps: AiStep[];
|
|
68
|
+
changes: AiChange[];
|
|
69
|
+
model: string;
|
|
70
|
+
elapsedMs: number;
|
|
71
|
+
/** true = 本地模型不可用,改用规则兜底 */
|
|
72
|
+
fallback: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface FeedbackPlan {
|
|
76
|
+
intent: FeedbackIntent;
|
|
77
|
+
confidence: number;
|
|
78
|
+
targetUsername: string;
|
|
79
|
+
targetName: string;
|
|
80
|
+
targetCode: string;
|
|
81
|
+
fields: Record<string, string>;
|
|
82
|
+
missing: string[];
|
|
83
|
+
analysis: string;
|
|
84
|
+
reply: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface AuthorInfo {
|
|
88
|
+
id: string | null;
|
|
89
|
+
username: string;
|
|
90
|
+
name: string;
|
|
91
|
+
deptId: string | null;
|
|
92
|
+
deptName: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface TargetUser {
|
|
96
|
+
id: string;
|
|
97
|
+
username: string;
|
|
98
|
+
name: string;
|
|
99
|
+
email: string | null;
|
|
100
|
+
phone: string | null;
|
|
101
|
+
deptId: string | null;
|
|
102
|
+
deptName: string | null;
|
|
103
|
+
status: number;
|
|
104
|
+
isSuper: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface TargetEmployee {
|
|
108
|
+
id: string;
|
|
109
|
+
code: string;
|
|
110
|
+
name: string;
|
|
111
|
+
idCard: string | null;
|
|
112
|
+
gender: string;
|
|
113
|
+
deptId: string | null;
|
|
114
|
+
deptName: string | null;
|
|
115
|
+
position: string | null;
|
|
116
|
+
phone: string | null;
|
|
117
|
+
email: string | null;
|
|
118
|
+
hireDate: Date | null;
|
|
119
|
+
status: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface ExecOutcome {
|
|
123
|
+
outcome: FeedbackOutcome;
|
|
124
|
+
steps: AiStep[];
|
|
125
|
+
changes: AiChange[];
|
|
126
|
+
reply: string;
|
|
127
|
+
analysis: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const INTENT_LABEL: Record<FeedbackIntent, string> = {
|
|
131
|
+
reset_password: '重置登录密码',
|
|
132
|
+
update_user: '修改账号资料',
|
|
133
|
+
add_user: '新增系统账号',
|
|
134
|
+
delete_user: '删除系统账号',
|
|
135
|
+
update_employee: '修改员工档案',
|
|
136
|
+
add_employee: '新增员工档案',
|
|
137
|
+
delete_employee: '删除员工档案',
|
|
138
|
+
none: '非可自动办理诉求',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const OUTCOME_LABEL: Record<FeedbackOutcome, string> = {
|
|
142
|
+
done: '已自动处理',
|
|
143
|
+
need_info: '信息不全,已自动回复',
|
|
144
|
+
unsupported: '暂无法自动处理,已给出分析',
|
|
145
|
+
failed: '执行未完成,已给出建议',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
149
|
+
pending: '待处理',
|
|
150
|
+
doing: '处理中',
|
|
151
|
+
resolved: '已解决',
|
|
152
|
+
closed: '已关闭',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const INTENTS: FeedbackIntent[] = [
|
|
156
|
+
'reset_password',
|
|
157
|
+
'update_user',
|
|
158
|
+
'add_user',
|
|
159
|
+
'delete_user',
|
|
160
|
+
'update_employee',
|
|
161
|
+
'add_employee',
|
|
162
|
+
'delete_employee',
|
|
163
|
+
'none',
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const FIELD_KEYS = new Set([
|
|
167
|
+
'username',
|
|
168
|
+
'password',
|
|
169
|
+
'name',
|
|
170
|
+
'email',
|
|
171
|
+
'phone',
|
|
172
|
+
'dept',
|
|
173
|
+
'position',
|
|
174
|
+
'code',
|
|
175
|
+
'gender',
|
|
176
|
+
'hireDate',
|
|
177
|
+
'role',
|
|
178
|
+
'idCard',
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
const FIELD_LABEL: Record<string, string> = {
|
|
182
|
+
username: '用户名',
|
|
183
|
+
password: '初始密码',
|
|
184
|
+
name: '姓名',
|
|
185
|
+
email: '邮箱',
|
|
186
|
+
phone: '手机号',
|
|
187
|
+
dept: '所属部门',
|
|
188
|
+
position: '岗位',
|
|
189
|
+
code: '工号',
|
|
190
|
+
gender: '性别',
|
|
191
|
+
hireDate: '入职日期',
|
|
192
|
+
role: '角色',
|
|
193
|
+
idCard: '身份证号',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const TYPE_LABEL: Record<string, string> = {
|
|
197
|
+
bug: '功能异常',
|
|
198
|
+
suggest: '体验建议',
|
|
199
|
+
data: '数据问题',
|
|
200
|
+
other: '其他',
|
|
201
|
+
account: '账号申诉',
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** 看起来像"没填"的占位词,一律当作空值,绝不落库 */
|
|
205
|
+
const PLACEHOLDER = /^(未提供|未说明|不明确|未知|不详|暂无|待补充|待确认|null|undefined|无|—|-|\?|?)$/i;
|
|
206
|
+
|
|
207
|
+
function text(v: unknown, max = 200): string {
|
|
208
|
+
return typeof v === 'string' ? v.trim().slice(0, max) : '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isBlank(v: string | null | undefined): boolean {
|
|
212
|
+
const s = String(v ?? '').trim();
|
|
213
|
+
return !s || PLACEHOLDER.test(s);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeText(s: string): string {
|
|
217
|
+
return s.replace(/\s+/g, '').toLowerCase();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 值的"证据"是否出现在反馈原文里(防止模型编造新的邮箱、姓名、工号) */
|
|
221
|
+
function hasEvidence(key: string, value: string, source: string): boolean {
|
|
222
|
+
const v = value.trim();
|
|
223
|
+
if (!v) return false;
|
|
224
|
+
if (key === 'phone') {
|
|
225
|
+
const digits = v.replace(/\D/g, '');
|
|
226
|
+
if (digits.length >= 6) return source.replace(/\D/g, '').includes(digits);
|
|
227
|
+
}
|
|
228
|
+
return normalizeText(source).includes(normalizeText(v));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function clampConfidence(v: unknown): number {
|
|
232
|
+
const n = Number(v);
|
|
233
|
+
if (!Number.isFinite(n)) return 0;
|
|
234
|
+
return Math.max(0, Math.min(1, Math.round(n * 100) / 100));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeIntent(v: unknown): FeedbackIntent {
|
|
238
|
+
const s = String(v ?? '').trim() as FeedbackIntent;
|
|
239
|
+
return INTENTS.includes(s) ? s : 'none';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeFields(raw: unknown): Record<string, string> {
|
|
243
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
244
|
+
const out: Record<string, string> = {};
|
|
245
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
246
|
+
if (!FIELD_KEYS.has(k)) continue;
|
|
247
|
+
if (typeof v !== 'string' && typeof v !== 'number') continue;
|
|
248
|
+
// 254 与邮箱的最大长度对齐;其他字段随后还会按各自规则校验
|
|
249
|
+
const s = String(v).trim().slice(0, 254);
|
|
250
|
+
if (isBlank(s)) continue;
|
|
251
|
+
out[k] = s;
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizePlan(raw: Record<string, unknown>): FeedbackPlan {
|
|
257
|
+
return {
|
|
258
|
+
intent: normalizeIntent(raw.intent),
|
|
259
|
+
confidence: clampConfidence(raw.confidence),
|
|
260
|
+
targetUsername: text(raw.targetUsername, 64),
|
|
261
|
+
targetName: text(raw.targetName, 64),
|
|
262
|
+
targetCode: text(raw.targetCode, 64),
|
|
263
|
+
fields: normalizeFields(raw.fields),
|
|
264
|
+
missing: Array.isArray(raw.missing)
|
|
265
|
+
? raw.missing.map((m) => text(m, 30)).filter(Boolean).slice(0, 6)
|
|
266
|
+
: [],
|
|
267
|
+
analysis: text(raw.analysis, 800),
|
|
268
|
+
reply: text(raw.reply, 800),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ============================ 1. 让模型出方案 ============================ */
|
|
273
|
+
|
|
274
|
+
const PLAN_TOOL = {
|
|
275
|
+
type: 'function' as const,
|
|
276
|
+
function: {
|
|
277
|
+
name: 'submit_feedback_plan',
|
|
278
|
+
description: '提交这条反馈的处理方案。必须调用本工具,不要直接输出普通文字。',
|
|
279
|
+
parameters: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
intent: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
enum: INTENTS,
|
|
285
|
+
description: '处理意图;无法自动办理时填 none',
|
|
286
|
+
},
|
|
287
|
+
confidence: { type: 'number', description: '对判断的置信度,0~1' },
|
|
288
|
+
targetUsername: { type: 'string', description: '目标账号的用户名;未明确提及他人时留空' },
|
|
289
|
+
targetName: { type: 'string', description: '目标用户或员工的姓名;未明确提及他人时留空' },
|
|
290
|
+
targetCode: { type: 'string', description: '目标员工的工号;仅员工类操作使用' },
|
|
291
|
+
fields: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
description: '要写入的字段。只填反馈原文里明确出现的信息,没提到的一律不填。',
|
|
294
|
+
properties: {
|
|
295
|
+
username: { type: 'string', description: '新增账号的用户名' },
|
|
296
|
+
password: { type: 'string', description: '初始密码;未说明时留空(系统默认 123456)' },
|
|
297
|
+
name: { type: 'string', description: '姓名' },
|
|
298
|
+
email: { type: 'string', description: '邮箱' },
|
|
299
|
+
phone: { type: 'string', description: '手机号' },
|
|
300
|
+
dept: { type: 'string', description: '部门名称,必须与反馈原文一致' },
|
|
301
|
+
position: { type: 'string', description: '岗位' },
|
|
302
|
+
code: { type: 'string', description: '员工工号' },
|
|
303
|
+
gender: { type: 'string', enum: ['male', 'female'], description: '性别' },
|
|
304
|
+
hireDate: { type: 'string', description: '入职日期,如 2026-10-21' },
|
|
305
|
+
role: { type: 'string', description: '角色名称或角色码;未说明时留空' },
|
|
306
|
+
idCard: { type: 'string', description: '身份证号(15 位或 18 位)' },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
missing: {
|
|
310
|
+
type: 'array',
|
|
311
|
+
items: { type: 'string' },
|
|
312
|
+
description: '缺少的必要信息中文名称,例如 ["工号","所属部门"]',
|
|
313
|
+
},
|
|
314
|
+
analysis: { type: 'string', description: '给管理员看的判断依据、风险和下一步建议,1~3 句' },
|
|
315
|
+
reply: { type: 'string', description: '可直接发给用户的中文回复' },
|
|
316
|
+
},
|
|
317
|
+
required: ['intent', 'confidence', 'missing', 'analysis', 'reply'],
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const SYSTEM_PROMPT = [
|
|
323
|
+
'你是企业内部管理系统的「反馈智能处理助手」,负责阅读用户反馈并给出可执行的处理方案。',
|
|
324
|
+
'',
|
|
325
|
+
'可自动办理的意图(intent)只有:',
|
|
326
|
+
'- reset_password:忘记密码 / 密码不对 / 要重置密码;',
|
|
327
|
+
'- update_user:修改账号资料(姓名、邮箱、手机号、部门);',
|
|
328
|
+
'- add_user:新增系统登录账号;',
|
|
329
|
+
'- delete_user:删除(停用)系统账号;',
|
|
330
|
+
'- update_employee:修改员工档案(姓名、工号、电话、邮箱、身份证号、岗位、部门);',
|
|
331
|
+
'- add_employee:新增员工档案(新人入职、加人,可含身份证号);',
|
|
332
|
+
'- delete_employee:删除员工档案(离职、辞退、减人);',
|
|
333
|
+
'- none:功能异常、体验建议、数据展示问题、咨询等无法直接操作的反馈。',
|
|
334
|
+
'',
|
|
335
|
+
'判断与抽取规则:',
|
|
336
|
+
'1. 反馈已经绑定提交人账号。没有明确说处理别人时,重置密码和修改账号资料默认处理提交人本人,目标留空。',
|
|
337
|
+
'2. 只抽取反馈原文中明确出现的信息。没有出现的一律不填,绝不猜测、绝不编造。',
|
|
338
|
+
'3. 修改类必须有明确对象和明确的新值。只写“填错了/要修改”却没给出改成什么,视为信息不全。',
|
|
339
|
+
'4. 新增员工必填姓名和工号;新增账号必填用户名和姓名(密码没写时系统默认 123456,不要编造)。',
|
|
340
|
+
'5. 删除类必须能唯一确定对象(用户名、工号或姓名);对象不明确时视为信息不全。',
|
|
341
|
+
'6. 出现“新人/入职/员工/工号/岗位/离职/辞退”等词,优先走 employee;出现“注册/账号/登录/用户名/角色/权限”等词,优先走 user。',
|
|
342
|
+
'7. 信息不全时,missing 列出中文缺项,reply 礼貌地请用户补充,并且不要声称已经执行。',
|
|
343
|
+
'8. 无法自动办理时 intent 填 none:analysis 写清原因和排查建议,reply 写一段可以直接发给用户的中文回复(包含分析和下一步建议)。',
|
|
344
|
+
'9. reply 面向普通用户,礼貌、具体、简短,不出现 JSON、代码或英文术语;analysis 面向管理员。',
|
|
345
|
+
'10. 不要声称“已处理/已完成”,真正的执行结果由系统填写。',
|
|
346
|
+
].join('\n');
|
|
347
|
+
|
|
348
|
+
function buildPrompt(
|
|
349
|
+
feedback: { type: string; title: string; detail: string; pagePath: string | null },
|
|
350
|
+
author: AuthorInfo | null,
|
|
351
|
+
departments: string[],
|
|
352
|
+
now: string
|
|
353
|
+
): string {
|
|
354
|
+
return [
|
|
355
|
+
`当前时间:${now}`,
|
|
356
|
+
`反馈类型:${TYPE_LABEL[feedback.type] ?? feedback.type}`,
|
|
357
|
+
`标题:${feedback.title}`,
|
|
358
|
+
`详细描述:${feedback.detail}`,
|
|
359
|
+
feedback.pagePath ? `所在页面:${feedback.pagePath}` : '',
|
|
360
|
+
author
|
|
361
|
+
? `提交人账号:${author.username}(姓名:${author.name}${author.deptName ? `,部门:${author.deptName}` : ''})`
|
|
362
|
+
: '提交人账号:未知(账号可能已删除)',
|
|
363
|
+
'',
|
|
364
|
+
`系统内可用部门:${departments.length ? departments.join('、') : '(暂无部门数据)'}`,
|
|
365
|
+
'',
|
|
366
|
+
'请判断这条反馈能否自动办理,并调用 submit_feedback_plan 提交方案。',
|
|
367
|
+
]
|
|
368
|
+
.filter(Boolean)
|
|
369
|
+
.join('\n');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 从模型文本里抠 JSON(个别引擎不遵循工具调用时的兜底) */
|
|
373
|
+
function parseJsonObject(raw: string): Record<string, unknown> | null {
|
|
374
|
+
const s = raw.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '');
|
|
375
|
+
const start = s.indexOf('{');
|
|
376
|
+
const end = s.lastIndexOf('}');
|
|
377
|
+
if (start < 0 || end <= start) return null;
|
|
378
|
+
try {
|
|
379
|
+
const parsed = JSON.parse(s.slice(start, end + 1)) as unknown;
|
|
380
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
381
|
+
? (parsed as Record<string, unknown>)
|
|
382
|
+
: null;
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function planWithModel(
|
|
389
|
+
feedback: { type: string; title: string; detail: string; pagePath: string | null },
|
|
390
|
+
author: AuthorInfo | null,
|
|
391
|
+
departments: string[]
|
|
392
|
+
): Promise<FeedbackPlan> {
|
|
393
|
+
const now = dayjs().format('YYYY-MM-DD HH:mm');
|
|
394
|
+
const turn = await aiChatWithTools({
|
|
395
|
+
system: SYSTEM_PROMPT,
|
|
396
|
+
messages: [{ role: 'user', content: buildPrompt(feedback, author, departments, now) }],
|
|
397
|
+
tools: [PLAN_TOOL],
|
|
398
|
+
toolChoice: 'required',
|
|
399
|
+
maxTokens: 700,
|
|
400
|
+
temperature: 0.1,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const call =
|
|
404
|
+
turn.toolCalls.find((c) => c.name === 'submit_feedback_plan') ?? turn.toolCalls[0];
|
|
405
|
+
let raw: Record<string, unknown> | null = call?.args ?? null;
|
|
406
|
+
if (!raw && turn.content) raw = parseJsonObject(turn.content);
|
|
407
|
+
if (!raw) throw new AiError('模型没有返回可解析的处理方案');
|
|
408
|
+
return normalizePlan(raw);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 模型不可用时的规则兜底。
|
|
413
|
+
*
|
|
414
|
+
* 只兜底"忘记密码"这种模式极其固定、误判概率低的诉求;
|
|
415
|
+
* 其余一律给分析提示,不乱执行 —— 宁可少办,也不能办错。
|
|
416
|
+
*/
|
|
417
|
+
function fallbackPlan(feedback: { type: string; title: string; detail: string }): FeedbackPlan {
|
|
418
|
+
const source = `${feedback.title} ${feedback.detail}`;
|
|
419
|
+
const reset =
|
|
420
|
+
/(忘记|忘了|记不住|找回|重置|改回).{0,8}密码|密码.{0,8}(忘记|忘了|重置|找回|不正确)/.test(
|
|
421
|
+
source
|
|
422
|
+
);
|
|
423
|
+
if (reset) {
|
|
424
|
+
return {
|
|
425
|
+
intent: 'reset_password',
|
|
426
|
+
confidence: 0.4,
|
|
427
|
+
targetUsername: '',
|
|
428
|
+
targetName: '',
|
|
429
|
+
targetCode: '',
|
|
430
|
+
fields: {},
|
|
431
|
+
missing: [],
|
|
432
|
+
analysis: '本地模型暂时不可用,依据「忘记密码 / 重置密码」关键词判断为密码重置诉求。',
|
|
433
|
+
reply: '已识别到密码重置诉求,将按提交人账号处理。',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
intent: 'none',
|
|
438
|
+
confidence: 0.2,
|
|
439
|
+
targetUsername: '',
|
|
440
|
+
targetName: '',
|
|
441
|
+
targetCode: '',
|
|
442
|
+
fields: {},
|
|
443
|
+
missing: [],
|
|
444
|
+
analysis: '本地智能模型暂时不可用,未能完成语义分析;反馈已原样保留。',
|
|
445
|
+
reply:
|
|
446
|
+
'智能处理服务当前繁忙,这条反馈已保留。请稍后重新点击「智能处理」,如情况紧急请联系管理员。',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* ============================ 2. 解析目标对象 ============================ */
|
|
451
|
+
|
|
452
|
+
async function loadAuthor(userId: string): Promise<AuthorInfo | null> {
|
|
453
|
+
const u = await prisma.user.findFirst({
|
|
454
|
+
where: { id: userId, deletedAt: null },
|
|
455
|
+
select: {
|
|
456
|
+
id: true,
|
|
457
|
+
username: true,
|
|
458
|
+
name: true,
|
|
459
|
+
deptId: true,
|
|
460
|
+
dept: { select: { name: true } },
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
if (!u) return null;
|
|
464
|
+
return {
|
|
465
|
+
id: u.id,
|
|
466
|
+
username: u.username,
|
|
467
|
+
name: u.name,
|
|
468
|
+
deptId: u.deptId,
|
|
469
|
+
deptName: u.dept?.name ?? null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function userById(id: string): Promise<TargetUser | null> {
|
|
474
|
+
const u = await prisma.user.findFirst({
|
|
475
|
+
where: { id, deletedAt: null },
|
|
476
|
+
select: {
|
|
477
|
+
id: true,
|
|
478
|
+
username: true,
|
|
479
|
+
name: true,
|
|
480
|
+
email: true,
|
|
481
|
+
phone: true,
|
|
482
|
+
deptId: true,
|
|
483
|
+
status: true,
|
|
484
|
+
isSuper: true,
|
|
485
|
+
dept: { select: { name: true } },
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
if (!u) return null;
|
|
489
|
+
return { ...u, deptName: u.dept?.name ?? null };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type ResolveResult =
|
|
493
|
+
| { ok: true; user: TargetUser }
|
|
494
|
+
| { ok: false; reply: string; missing: string[]; step: AiStep };
|
|
495
|
+
|
|
496
|
+
async function resolveUserTarget(
|
|
497
|
+
plan: FeedbackPlan,
|
|
498
|
+
author: AuthorInfo | null,
|
|
499
|
+
allowSelf: boolean
|
|
500
|
+
): Promise<ResolveResult> {
|
|
501
|
+
if (plan.targetUsername) {
|
|
502
|
+
const u = await prisma.user.findFirst({
|
|
503
|
+
where: { username: plan.targetUsername, deletedAt: null },
|
|
504
|
+
select: {
|
|
505
|
+
id: true,
|
|
506
|
+
username: true,
|
|
507
|
+
name: true,
|
|
508
|
+
email: true,
|
|
509
|
+
phone: true,
|
|
510
|
+
deptId: true,
|
|
511
|
+
status: true,
|
|
512
|
+
isSuper: true,
|
|
513
|
+
dept: { select: { name: true } },
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
if (!u) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
missing: ['准确的用户名'],
|
|
520
|
+
step: {
|
|
521
|
+
label: '定位账号',
|
|
522
|
+
detail: `没有找到用户名「${plan.targetUsername}」`,
|
|
523
|
+
ok: false,
|
|
524
|
+
},
|
|
525
|
+
reply: `没有找到用户名为「${plan.targetUsername}」的账号,暂未执行任何变更。请核对后重新处理。`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return { ok: true, user: { ...u, deptName: u.dept?.name ?? null } };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (plan.targetName) {
|
|
532
|
+
const rows = await prisma.user.findMany({
|
|
533
|
+
where: { name: plan.targetName, deletedAt: null },
|
|
534
|
+
select: {
|
|
535
|
+
id: true,
|
|
536
|
+
username: true,
|
|
537
|
+
name: true,
|
|
538
|
+
email: true,
|
|
539
|
+
phone: true,
|
|
540
|
+
deptId: true,
|
|
541
|
+
status: true,
|
|
542
|
+
isSuper: true,
|
|
543
|
+
dept: { select: { name: true } },
|
|
544
|
+
},
|
|
545
|
+
take: 6,
|
|
546
|
+
});
|
|
547
|
+
if (rows.length === 1) {
|
|
548
|
+
const u = rows[0];
|
|
549
|
+
return { ok: true, user: { ...u, deptName: u.dept?.name ?? null } };
|
|
550
|
+
}
|
|
551
|
+
if (rows.length > 1) {
|
|
552
|
+
const names = rows.map((u) => `${u.username}(${u.name})`).join('、');
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
missing: ['准确的用户名'],
|
|
556
|
+
step: {
|
|
557
|
+
label: '定位账号',
|
|
558
|
+
detail: `姓名「${plan.targetName}」匹配到 ${rows.length} 个账号`,
|
|
559
|
+
ok: false,
|
|
560
|
+
},
|
|
561
|
+
reply: `系统中存在多个名为「${plan.targetName}」的账号:${names}。为避免改错人,暂未执行,请提供准确的用户名。`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
missing: ['准确的用户名'],
|
|
567
|
+
step: {
|
|
568
|
+
label: '定位账号',
|
|
569
|
+
detail: `没有找到姓名为「${plan.targetName}」的账号`,
|
|
570
|
+
ok: false,
|
|
571
|
+
},
|
|
572
|
+
reply: `没有找到姓名为「${plan.targetName}」的账号,暂未执行任何变更。请核对姓名或补充用户名。`,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (!allowSelf) {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
missing: ['要操作的账号(用户名或姓名)'],
|
|
580
|
+
step: { label: '定位账号', detail: '反馈中没有说明要处理哪个账号', ok: false },
|
|
581
|
+
reply: '反馈中没有说明要处理哪个账号,暂未执行任何变更。请补充用户名或姓名。',
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!author?.id) {
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
missing: ['提交人账号'],
|
|
589
|
+
step: { label: '定位账号', detail: '提交人账号已不存在', ok: false },
|
|
590
|
+
reply: '提交人账号已不存在,无法按本人处理。请联系管理员人工核实。',
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const me = await userById(author.id);
|
|
595
|
+
if (!me) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
missing: ['提交人账号'],
|
|
599
|
+
step: { label: '定位账号', detail: '提交人账号已不存在', ok: false },
|
|
600
|
+
reply: '提交人账号已不存在,无法按本人处理。请联系管理员人工核实。',
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
return { ok: true, user: me };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function resolveEmployeeTarget(
|
|
607
|
+
plan: FeedbackPlan
|
|
608
|
+
): Promise<
|
|
609
|
+
| { ok: true; employee: TargetEmployee }
|
|
610
|
+
| { ok: false; reply: string; missing: string[]; step: AiStep }
|
|
611
|
+
> {
|
|
612
|
+
const select = {
|
|
613
|
+
id: true,
|
|
614
|
+
code: true,
|
|
615
|
+
name: true,
|
|
616
|
+
idCard: true,
|
|
617
|
+
gender: true,
|
|
618
|
+
deptId: true,
|
|
619
|
+
position: true,
|
|
620
|
+
phone: true,
|
|
621
|
+
email: true,
|
|
622
|
+
hireDate: true,
|
|
623
|
+
status: true,
|
|
624
|
+
dept: { select: { name: true } },
|
|
625
|
+
} as const;
|
|
626
|
+
|
|
627
|
+
if (plan.targetCode) {
|
|
628
|
+
const e = await prisma.employee.findFirst({
|
|
629
|
+
where: { code: plan.targetCode, deletedAt: null },
|
|
630
|
+
select,
|
|
631
|
+
});
|
|
632
|
+
if (!e) {
|
|
633
|
+
return {
|
|
634
|
+
ok: false,
|
|
635
|
+
missing: ['准确的工号'],
|
|
636
|
+
step: { label: '定位员工', detail: `没有找到工号「${plan.targetCode}」`, ok: false },
|
|
637
|
+
reply: `没有找到工号为「${plan.targetCode}」的员工,暂未执行任何变更。请核对工号。`,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return { ok: true, employee: { ...e, deptName: e.dept?.name ?? null } };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (plan.targetName) {
|
|
644
|
+
const rows = await prisma.employee.findMany({
|
|
645
|
+
where: { name: plan.targetName, deletedAt: null },
|
|
646
|
+
select,
|
|
647
|
+
take: 6,
|
|
648
|
+
});
|
|
649
|
+
if (rows.length === 1) {
|
|
650
|
+
const e = rows[0];
|
|
651
|
+
return { ok: true, employee: { ...e, deptName: e.dept?.name ?? null } };
|
|
652
|
+
}
|
|
653
|
+
if (rows.length > 1) {
|
|
654
|
+
const names = rows.map((e) => `${e.code}(${e.name})`).join('、');
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
missing: ['准确的工号'],
|
|
658
|
+
step: {
|
|
659
|
+
label: '定位员工',
|
|
660
|
+
detail: `姓名「${plan.targetName}」匹配到 ${rows.length} 条员工档案`,
|
|
661
|
+
ok: false,
|
|
662
|
+
},
|
|
663
|
+
reply: `系统中存在多条姓名为「${plan.targetName}」的员工档案:${names}。为避免误删/误改,暂未执行,请提供准确的工号。`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
missing: ['准确的工号'],
|
|
669
|
+
step: {
|
|
670
|
+
label: '定位员工',
|
|
671
|
+
detail: `没有找到姓名为「${plan.targetName}」的员工`,
|
|
672
|
+
ok: false,
|
|
673
|
+
},
|
|
674
|
+
reply: `没有找到姓名为「${plan.targetName}」的员工档案,暂未执行任何变更。请核对姓名或补充工号。`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
missing: ['要操作的员工(姓名或工号)'],
|
|
681
|
+
step: { label: '定位员工', detail: '反馈中没有说明要处理哪位员工', ok: false },
|
|
682
|
+
reply: '反馈中没有说明要处理哪位员工,暂未执行任何变更。请补充姓名或工号。',
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/* ============================ 3. 校验与执行 ============================ */
|
|
687
|
+
|
|
688
|
+
function needInfo(reply: string, _missing: string[], steps: AiStep[], analysis = ''): ExecOutcome {
|
|
689
|
+
// 目标定位失败的文案本身已经说明"暂未执行",避免重复前缀
|
|
690
|
+
const prefix = /暂未|没有执行|未执行/.test(reply) ? '' : '暂未执行任何变更:';
|
|
691
|
+
return {
|
|
692
|
+
outcome: 'need_info',
|
|
693
|
+
steps,
|
|
694
|
+
changes: [],
|
|
695
|
+
reply: `【AI 智能处理】${prefix}${reply}`,
|
|
696
|
+
analysis: analysis || '信息不足,按“宁可不办、不可办错”的原则保持数据不变。',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function unsupported(reply: string, steps: AiStep[], analysis: string): ExecOutcome {
|
|
701
|
+
return {
|
|
702
|
+
outcome: 'unsupported',
|
|
703
|
+
steps,
|
|
704
|
+
changes: [],
|
|
705
|
+
reply: `【AI 智能处理】${reply}${analysis ? `\n\n处理分析:${analysis}` : ''}`,
|
|
706
|
+
analysis,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function failed(reason: string, steps: AiStep[], analysis = ''): ExecOutcome {
|
|
711
|
+
return {
|
|
712
|
+
outcome: 'failed',
|
|
713
|
+
steps,
|
|
714
|
+
changes: [],
|
|
715
|
+
reply: `【AI 智能处理】已分析这条反馈,但自动执行没有完成:${reason}。建议核对信息后重新点击「智能处理」,或联系管理员人工处理。`,
|
|
716
|
+
analysis: analysis || '执行阶段被业务规则拦截,数据没有被修改。',
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* AI 抽取出来的字段统一走输入校验底座。
|
|
722
|
+
* 不合法时返回 needInfo(不是抛异常):既守住数据质量,也保证反馈页永远有可读提示。
|
|
723
|
+
*/
|
|
724
|
+
function checkField(
|
|
725
|
+
type: string,
|
|
726
|
+
value: string,
|
|
727
|
+
label: string,
|
|
728
|
+
steps: AiStep[],
|
|
729
|
+
required = false
|
|
730
|
+
): { ok: true; value: string } | { ok: false; outcome: ExecOutcome } {
|
|
731
|
+
const result = validateField(type, value, { label, required });
|
|
732
|
+
if (result.ok) return { ok: true, value: result.value };
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
outcome: needInfo(result.message, [`正确的${label}`], [
|
|
736
|
+
...steps,
|
|
737
|
+
{ label: '输入合理性校验', detail: result.message, ok: false },
|
|
738
|
+
]),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function labelOf(key: string): string {
|
|
743
|
+
return FIELD_LABEL[key] ?? key;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function valueOr(v: string | null | undefined): string {
|
|
747
|
+
return v && String(v).trim() ? String(v) : '(空)';
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** 修改类的证据校验:反馈里没出现的值,不能当成用户的要求 */
|
|
751
|
+
function verifyFields(
|
|
752
|
+
fields: Record<string, string>,
|
|
753
|
+
keys: string[],
|
|
754
|
+
source: string,
|
|
755
|
+
missing: string[],
|
|
756
|
+
optionalKeys: string[] = []
|
|
757
|
+
): Record<string, string> {
|
|
758
|
+
const out: Record<string, string> = {};
|
|
759
|
+
for (const key of keys) {
|
|
760
|
+
const v = fields[key];
|
|
761
|
+
if (!v) continue;
|
|
762
|
+
if (hasEvidence(key, v, source)) out[key] = v;
|
|
763
|
+
else if (!optionalKeys.includes(key) && !missing.includes(labelOf(key))) {
|
|
764
|
+
missing.push(labelOf(key));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function findDeptId(name: string): Promise<string | null> {
|
|
771
|
+
if (!name) return null;
|
|
772
|
+
const d = await prisma.department.findFirst({
|
|
773
|
+
where: { name: name.trim(), status: 1 },
|
|
774
|
+
select: { id: true },
|
|
775
|
+
});
|
|
776
|
+
return d?.id ?? null;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function parseDate(v: string): Date | null {
|
|
780
|
+
if (!v) return null;
|
|
781
|
+
const d = dayjs(v);
|
|
782
|
+
return d.isValid() ? d.toDate() : null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function deptNotAllowedOutcome(): ExecOutcome {
|
|
786
|
+
return unsupported(
|
|
787
|
+
'你的数据范围不包含目标部门,暂未执行任何变更。',
|
|
788
|
+
[{ label: '数据范围校验', detail: '目标不在当前账号可管理的部门内', ok: false }],
|
|
789
|
+
'数据范围校验未通过:只能处理自己有权管理的部门。'
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function executePlan(
|
|
794
|
+
plan: FeedbackPlan,
|
|
795
|
+
feedback: { id: string; type: string; title: string; detail: string; userId: string },
|
|
796
|
+
author: AuthorInfo | null,
|
|
797
|
+
operator: SessionUser
|
|
798
|
+
): Promise<ExecOutcome> {
|
|
799
|
+
const source = `${feedback.title}\n${feedback.detail}`;
|
|
800
|
+
const steps: AiStep[] = [];
|
|
801
|
+
const changes: AiChange[] = [];
|
|
802
|
+
const departments = await prisma.department.findMany({
|
|
803
|
+
where: { status: 1 },
|
|
804
|
+
select: { name: true },
|
|
805
|
+
take: 200,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
switch (plan.intent) {
|
|
809
|
+
/* ---------------------------- 重置密码 ---------------------------- */
|
|
810
|
+
case 'reset_password': {
|
|
811
|
+
const target = await resolveUserTarget(plan, author, true);
|
|
812
|
+
if (!target.ok) return needInfo(target.reply, target.missing, [target.step]);
|
|
813
|
+
const u = target.user;
|
|
814
|
+
steps.push({ label: '定位账号', detail: `${u.username}(${u.name})`, ok: true });
|
|
815
|
+
|
|
816
|
+
if (operator.id !== u.id && !hasPerm(operator, P.userReset)) {
|
|
817
|
+
return unsupported(
|
|
818
|
+
`当前账号没有重置「${u.username}」密码的权限,暂未执行。请联系超级管理员或系统管理员处理。`,
|
|
819
|
+
steps,
|
|
820
|
+
'密码重置属于账号安全操作,需要 system:user:reset 权限。'
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (operator.id !== u.id && !deptAllowed(operator, u.deptId)) {
|
|
824
|
+
return deptNotAllowedOutcome();
|
|
825
|
+
}
|
|
826
|
+
if (u.status !== 1) {
|
|
827
|
+
return unsupported(
|
|
828
|
+
`账号「${u.username}」当前是停用状态,密码已重置也无法登录。请先由管理员启用账号。`,
|
|
829
|
+
[...steps, { label: '账号状态校验', detail: '账号已停用', ok: false }],
|
|
830
|
+
'账号状态为停用,密码重置不会恢复登录能力。'
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const password = '123456';
|
|
835
|
+
await prisma.user.update({
|
|
836
|
+
where: { id: u.id },
|
|
837
|
+
data: { password: await bcrypt.hash(password, 10), mustChangePassword: 1 },
|
|
838
|
+
});
|
|
839
|
+
steps.push({ label: '重置密码', detail: '新密码 123456,下次登录需修改', ok: true });
|
|
840
|
+
|
|
841
|
+
const self = operator.id === u.id;
|
|
842
|
+
const reply = self
|
|
843
|
+
? '已重置你的登录密码,新密码为 123456。为保障账号安全,请在下次登录后按提示立即修改密码。'
|
|
844
|
+
: `已按反馈重置账号「${u.username}」的登录密码,新密码为 123456。该用户下次登录时需先修改密码。`;
|
|
845
|
+
return {
|
|
846
|
+
outcome: 'done',
|
|
847
|
+
steps,
|
|
848
|
+
changes: [{ label: '登录密码', from: '(原密码)', to: '123456' }],
|
|
849
|
+
reply,
|
|
850
|
+
analysis: plan.analysis || '密码重置已完成,并已标记下次登录强制改密。',
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/* ---------------------------- 修改账号资料 ---------------------------- */
|
|
855
|
+
case 'update_user': {
|
|
856
|
+
const target = await resolveUserTarget(plan, author, true);
|
|
857
|
+
if (!target.ok) return needInfo(target.reply, target.missing, [target.step]);
|
|
858
|
+
const u = target.user;
|
|
859
|
+
steps.push({ label: '定位账号', detail: `${u.username}(${u.name})`, ok: true });
|
|
860
|
+
|
|
861
|
+
const self = operator.id === u.id;
|
|
862
|
+
if (!self && !hasPerm(operator, P.userEdit)) {
|
|
863
|
+
return unsupported(
|
|
864
|
+
`当前账号没有修改「${u.username}」资料的权限,暂未执行。请联系系统管理员处理。`,
|
|
865
|
+
steps,
|
|
866
|
+
'修改他人账号资料需要 system:user:edit 权限。'
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
if (!self && !deptAllowed(operator, u.deptId)) return deptNotAllowedOutcome();
|
|
870
|
+
|
|
871
|
+
// 部门变更只允许有编辑权限的人做,普通用户不能自己调部门
|
|
872
|
+
const allowedKeys = self && !hasPerm(operator, P.userEdit)
|
|
873
|
+
? ['name', 'email', 'phone']
|
|
874
|
+
: ['name', 'email', 'phone', 'dept'];
|
|
875
|
+
const missing: string[] = [];
|
|
876
|
+
const fields = verifyFields(plan.fields, allowedKeys, source, missing);
|
|
877
|
+
// 修改类只要有一项没说明白,就整体不执行,避免"改一半"
|
|
878
|
+
if (missing.length) {
|
|
879
|
+
return needInfo(
|
|
880
|
+
`反馈里没有明确写出这些字段的新值:${[...new Set(missing)].join('、')}。为免改错,暂未执行任何变更,请补充后重新处理。`,
|
|
881
|
+
missing,
|
|
882
|
+
steps
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (plan.fields.dept && !allowedKeys.includes('dept')) {
|
|
887
|
+
return unsupported(
|
|
888
|
+
'所属部门不能由本人修改,请由管理员在用户管理中调整。',
|
|
889
|
+
steps,
|
|
890
|
+
'部门变更涉及数据范围与权限,属于管理操作。'
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
if (!Object.keys(fields).length) {
|
|
894
|
+
return needInfo(
|
|
895
|
+
'请说明要修改的具体字段和正确的新值(例如:姓名改为“张三”、邮箱改为“xx@example.com”)。',
|
|
896
|
+
missing.length ? missing : ['要修改的内容和正确的新值'],
|
|
897
|
+
steps
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const data: Record<string, unknown> = {};
|
|
902
|
+
const deptName = fields.dept ?? '';
|
|
903
|
+
|
|
904
|
+
if (fields.name) {
|
|
905
|
+
const checked = checkField('name', fields.name, '姓名', steps, true);
|
|
906
|
+
if (!checked.ok) return checked.outcome;
|
|
907
|
+
if (checked.value !== u.name) {
|
|
908
|
+
data.name = checked.value;
|
|
909
|
+
changes.push({ label: '姓名', from: valueOr(u.name), to: checked.value });
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (fields.email) {
|
|
913
|
+
const checked = checkField('email', fields.email, '邮箱', steps, true);
|
|
914
|
+
if (!checked.ok) return checked.outcome;
|
|
915
|
+
if (checked.value.toLowerCase() !== (u.email ?? '').toLowerCase()) {
|
|
916
|
+
data.email = checked.value;
|
|
917
|
+
changes.push({ label: '邮箱', from: valueOr(u.email), to: checked.value });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (fields.phone) {
|
|
921
|
+
const checked = checkField('phone', fields.phone, '手机号', steps, true);
|
|
922
|
+
if (!checked.ok) return checked.outcome;
|
|
923
|
+
if (checked.value !== (u.phone ?? '')) {
|
|
924
|
+
data.phone = checked.value;
|
|
925
|
+
changes.push({ label: '手机号', from: valueOr(u.phone), to: checked.value });
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (deptName) {
|
|
929
|
+
const deptId = await findDeptId(deptName);
|
|
930
|
+
if (!deptId) {
|
|
931
|
+
return needInfo(
|
|
932
|
+
`系统中没有找到部门「${deptName}」。可选部门:${departments.map((d) => d.name).join('、')}。`,
|
|
933
|
+
['正确的所属部门'],
|
|
934
|
+
steps
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
if (deptId !== u.deptId) {
|
|
938
|
+
data.deptId = deptId;
|
|
939
|
+
changes.push({ label: '所属部门', from: valueOr(u.deptName), to: deptName });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (!Object.keys(data).length) {
|
|
944
|
+
return {
|
|
945
|
+
outcome: 'done',
|
|
946
|
+
steps: [...steps, { label: '核对资料', detail: '反馈内容与现有资料一致,无需修改', ok: true }],
|
|
947
|
+
changes: [],
|
|
948
|
+
reply: '已核对你的账号资料,反馈中的内容与现有资料一致,无需修改。',
|
|
949
|
+
analysis: '字段值与现状一致,未产生数据库写入。',
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
await prisma.user.update({ where: { id: u.id }, data });
|
|
954
|
+
steps.push({
|
|
955
|
+
label: '更新账号资料',
|
|
956
|
+
detail: changes.map((c) => `${c.label} ${c.from} → ${c.to}`).join(';'),
|
|
957
|
+
ok: true,
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
outcome: 'done',
|
|
962
|
+
steps,
|
|
963
|
+
changes,
|
|
964
|
+
reply: `已按反馈修改账号「${u.username}」的资料:${changes
|
|
965
|
+
.map((c) => `${c.label}由「${c.from}」改为「${c.to}」`)
|
|
966
|
+
.join(';')}。`,
|
|
967
|
+
analysis: plan.analysis || '账号资料已按反馈更新完成。',
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/* ---------------------------- 新增账号 ---------------------------- */
|
|
972
|
+
case 'add_user': {
|
|
973
|
+
if (!hasPerm(operator, P.userAdd)) {
|
|
974
|
+
return unsupported(
|
|
975
|
+
'当前账号没有新增系统用户的权限,暂未创建任何账号。请由系统管理员在用户管理中新增。',
|
|
976
|
+
[{ label: '权限校验', detail: '缺少 system:user:add 权限', ok: false }],
|
|
977
|
+
'新增登录账号涉及账号与权限分配,只能由有用户管理权限的人执行。'
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const missing: string[] = [];
|
|
982
|
+
const fields = verifyFields(
|
|
983
|
+
plan.fields,
|
|
984
|
+
['username', 'password', 'name', 'email', 'phone', 'dept', 'role'],
|
|
985
|
+
source,
|
|
986
|
+
missing,
|
|
987
|
+
['password', 'email', 'phone', 'dept', 'role'] // 选填:原文没写就忽略,不阻塞创建
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
let username = fields.username ?? '';
|
|
991
|
+
let name = fields.name ?? '';
|
|
992
|
+
if (!username && !missing.includes('用户名')) missing.unshift('用户名');
|
|
993
|
+
if (!name && !missing.includes('姓名')) missing.unshift('姓名');
|
|
994
|
+
if (missing.length) {
|
|
995
|
+
return needInfo(
|
|
996
|
+
`新增账号还缺少:${[...new Set(missing)].join('、')}。请补充后重新点击「智能处理」。`,
|
|
997
|
+
[...new Set(missing)],
|
|
998
|
+
steps
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const usernameCheck = checkField('username', username, '用户名', steps, true);
|
|
1003
|
+
if (!usernameCheck.ok) return usernameCheck.outcome;
|
|
1004
|
+
username = usernameCheck.value;
|
|
1005
|
+
|
|
1006
|
+
const nameCheck = checkField('name', name, '姓名', steps, true);
|
|
1007
|
+
if (!nameCheck.ok) return nameCheck.outcome;
|
|
1008
|
+
name = nameCheck.value;
|
|
1009
|
+
|
|
1010
|
+
let password = '123456';
|
|
1011
|
+
if (fields.password) {
|
|
1012
|
+
const passwordCheck = checkField('password', fields.password, '初始密码', steps, true);
|
|
1013
|
+
if (!passwordCheck.ok) return passwordCheck.outcome;
|
|
1014
|
+
password = passwordCheck.value;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const existed = await prisma.user.findUnique({ where: { username } });
|
|
1018
|
+
if (existed) {
|
|
1019
|
+
return failed(
|
|
1020
|
+
`用户名「${username}」已被占用(含已删除账号)`,
|
|
1021
|
+
[...steps, { label: '用户名查重', detail: `${username} 已存在`, ok: false }],
|
|
1022
|
+
'用户名必须全局唯一,请更换一个用户名后重试。'
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const data: Record<string, unknown> = {
|
|
1027
|
+
username,
|
|
1028
|
+
name,
|
|
1029
|
+
password: await bcrypt.hash(password, 10),
|
|
1030
|
+
mustChangePassword: 1,
|
|
1031
|
+
};
|
|
1032
|
+
if (fields.email) {
|
|
1033
|
+
const checked = checkField('email', fields.email, '邮箱', steps, true);
|
|
1034
|
+
if (!checked.ok) return checked.outcome;
|
|
1035
|
+
data.email = checked.value;
|
|
1036
|
+
}
|
|
1037
|
+
if (fields.phone) {
|
|
1038
|
+
const checked = checkField('phone', fields.phone, '手机号', steps, true);
|
|
1039
|
+
if (!checked.ok) return checked.outcome;
|
|
1040
|
+
data.phone = checked.value;
|
|
1041
|
+
}
|
|
1042
|
+
if (fields.dept) {
|
|
1043
|
+
const deptId = await findDeptId(fields.dept);
|
|
1044
|
+
if (!deptId) {
|
|
1045
|
+
return needInfo(
|
|
1046
|
+
`系统中没有找到部门「${fields.dept}」。可选部门:${departments
|
|
1047
|
+
.map((d) => d.name)
|
|
1048
|
+
.join('、')}。`,
|
|
1049
|
+
['正确的所属部门'],
|
|
1050
|
+
steps
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
if (!deptAllowed(operator, deptId)) return deptNotAllowedOutcome();
|
|
1054
|
+
data.deptId = deptId;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// 角色:默认普通员工;指定管理员角色时只有超管能这么做
|
|
1058
|
+
let roleId: string | null = null;
|
|
1059
|
+
let roleName = '普通员工';
|
|
1060
|
+
if (fields.role) {
|
|
1061
|
+
const role = await prisma.role.findFirst({
|
|
1062
|
+
where: {
|
|
1063
|
+
status: 1,
|
|
1064
|
+
OR: [{ code: fields.role }, { name: fields.role }],
|
|
1065
|
+
},
|
|
1066
|
+
select: { id: true, code: true, name: true },
|
|
1067
|
+
});
|
|
1068
|
+
if (role) {
|
|
1069
|
+
if (role.code === 'admin' && !operator.isSuper) {
|
|
1070
|
+
return unsupported(
|
|
1071
|
+
'当前账号不能通过智能处理创建管理员账号,暂未创建。请联系超级管理员。',
|
|
1072
|
+
[...steps, { label: '角色校验', detail: '禁止分配管理员角色', ok: false }],
|
|
1073
|
+
'管理员账号创建属于高权限操作,只允许超级管理员执行。'
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
roleId = role.id;
|
|
1077
|
+
roleName = role.name;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (!roleId) {
|
|
1081
|
+
const staff = await prisma.role.findFirst({
|
|
1082
|
+
where: { code: 'staff', status: 1 },
|
|
1083
|
+
select: { id: true, name: true },
|
|
1084
|
+
});
|
|
1085
|
+
if (staff) {
|
|
1086
|
+
roleId = staff.id;
|
|
1087
|
+
roleName = staff.name;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (roleId) data.roles = { create: [{ roleId }] };
|
|
1091
|
+
|
|
1092
|
+
const created = await prisma.user.create({
|
|
1093
|
+
data: data as Parameters<typeof prisma.user.create>[0]['data'],
|
|
1094
|
+
});
|
|
1095
|
+
const pwd = password;
|
|
1096
|
+
steps.push({ label: '创建账号', detail: `${username}(${name})`, ok: true });
|
|
1097
|
+
if (roleId) steps.push({ label: '分配角色', detail: roleName, ok: true });
|
|
1098
|
+
|
|
1099
|
+
return {
|
|
1100
|
+
outcome: 'done',
|
|
1101
|
+
steps,
|
|
1102
|
+
changes: [
|
|
1103
|
+
{ label: '用户名', from: '(新账号)', to: username },
|
|
1104
|
+
{ label: '姓名', from: '(新账号)', to: name },
|
|
1105
|
+
...(fields.dept ? [{ label: '所属部门', from: '(新账号)', to: fields.dept }] : []),
|
|
1106
|
+
{ label: '角色', from: '(新账号)', to: roleName },
|
|
1107
|
+
],
|
|
1108
|
+
reply: `已新增系统账号「${username}」(姓名:${name}${
|
|
1109
|
+
fields.dept ? `,部门:${fields.dept}` : ''
|
|
1110
|
+
}),初始密码 ${pwd},角色为「${roleName}」。请提醒本人首次登录后修改密码。`,
|
|
1111
|
+
analysis: plan.analysis || `账号已创建(id=${created.id}),初始密码已标记为首次登录需修改。`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/* ---------------------------- 删除账号 ---------------------------- */
|
|
1116
|
+
case 'delete_user': {
|
|
1117
|
+
if (!hasPerm(operator, P.userDel)) {
|
|
1118
|
+
return unsupported(
|
|
1119
|
+
'当前账号没有删除系统用户的权限,暂未执行。请由系统管理员处理。',
|
|
1120
|
+
[{ label: '权限校验', detail: '缺少 system:user:del 权限', ok: false }],
|
|
1121
|
+
'删除账号属于高影响操作,需要 system:user:del 权限。'
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
const target = await resolveUserTarget(plan, author, false);
|
|
1125
|
+
if (!target.ok) return needInfo(target.reply, target.missing, [target.step]);
|
|
1126
|
+
const u = target.user;
|
|
1127
|
+
steps.push({ label: '定位账号', detail: `${u.username}(${u.name})`, ok: true });
|
|
1128
|
+
|
|
1129
|
+
if (u.id === operator.id) {
|
|
1130
|
+
return unsupported(
|
|
1131
|
+
'不能删除当前登录的账号,暂未执行。',
|
|
1132
|
+
[...steps, { label: '安全校验', detail: '目标是当前登录账号', ok: false }],
|
|
1133
|
+
'删除当前登录账号会让操作人立刻失去访问能力。'
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
if (u.isSuper === 1) {
|
|
1137
|
+
return unsupported(
|
|
1138
|
+
'超级管理员账号不允许删除,暂未执行。',
|
|
1139
|
+
[...steps, { label: '安全校验', detail: '目标是超级管理员', ok: false }],
|
|
1140
|
+
'超级管理员账号是系统最后的管理入口,必须保留。'
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
if (!deptAllowed(operator, u.deptId)) return deptNotAllowedOutcome();
|
|
1144
|
+
|
|
1145
|
+
await prisma.user.update({ where: { id: u.id }, data: { deletedAt: new Date() } });
|
|
1146
|
+
steps.push({ label: '删除账号', detail: '已软删除,可在审计日志追溯', ok: true });
|
|
1147
|
+
|
|
1148
|
+
return {
|
|
1149
|
+
outcome: 'done',
|
|
1150
|
+
steps,
|
|
1151
|
+
changes: [{ label: '账号状态', from: '在用', to: '已删除(软删除)' }],
|
|
1152
|
+
reply: `已删除系统账号「${u.username}」(姓名:${u.name})。该账号已停用并从用户列表中移除。`,
|
|
1153
|
+
analysis: plan.analysis || '账号已软删除,历史数据与审计记录保留。',
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/* ---------------------------- 新增员工 ---------------------------- */
|
|
1158
|
+
case 'add_employee': {
|
|
1159
|
+
if (!hasPerm(operator, P.empAdd)) {
|
|
1160
|
+
return unsupported(
|
|
1161
|
+
'当前账号没有新增员工的权限,暂未创建任何档案。请由有员工管理权限的人处理。',
|
|
1162
|
+
[{ label: '权限校验', detail: '缺少 demo:employee:add 权限', ok: false }],
|
|
1163
|
+
'新增员工档案需要员工管理的新增权限。'
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const missing: string[] = [];
|
|
1168
|
+
const fields = verifyFields(
|
|
1169
|
+
plan.fields,
|
|
1170
|
+
['code', 'name', 'idCard', 'dept', 'position', 'phone', 'email', 'hireDate'],
|
|
1171
|
+
source,
|
|
1172
|
+
missing,
|
|
1173
|
+
['idCard', 'dept', 'position', 'phone', 'email', 'hireDate'] // 选填:原文没写就忽略
|
|
1174
|
+
);
|
|
1175
|
+
let code = fields.code ?? '';
|
|
1176
|
+
let name = fields.name ?? '';
|
|
1177
|
+
if (!code && !missing.includes('工号')) missing.unshift('工号');
|
|
1178
|
+
if (!name && !missing.includes('姓名')) missing.unshift('姓名');
|
|
1179
|
+
if (missing.length) {
|
|
1180
|
+
return needInfo(
|
|
1181
|
+
`新增员工还缺少:${[...new Set(missing)].join('、')}。信息不完整,暂未创建任何档案。请补充后重新点击「智能处理」。`,
|
|
1182
|
+
[...new Set(missing)],
|
|
1183
|
+
steps
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const codeCheck = checkField('code', code, '工号', steps, true);
|
|
1188
|
+
if (!codeCheck.ok) return codeCheck.outcome;
|
|
1189
|
+
code = codeCheck.value;
|
|
1190
|
+
|
|
1191
|
+
const nameCheck = checkField('name', name, '姓名', steps, true);
|
|
1192
|
+
if (!nameCheck.ok) return nameCheck.outcome;
|
|
1193
|
+
name = nameCheck.value;
|
|
1194
|
+
|
|
1195
|
+
const existed = await prisma.employee.findUnique({ where: { code } });
|
|
1196
|
+
if (existed) {
|
|
1197
|
+
return failed(
|
|
1198
|
+
`工号「${code}」已存在`,
|
|
1199
|
+
[...steps, { label: '工号查重', detail: `${code} 已被使用`, ok: false }],
|
|
1200
|
+
'工号必须唯一,请核对后再新增。'
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const data: Record<string, unknown> = {
|
|
1205
|
+
code,
|
|
1206
|
+
name,
|
|
1207
|
+
gender: fields.gender === 'female' ? 'female' : 'male',
|
|
1208
|
+
};
|
|
1209
|
+
if (fields.position) data.position = fields.position;
|
|
1210
|
+
if (fields.idCard) {
|
|
1211
|
+
const checked = checkField('idCard', fields.idCard, '身份证号', steps);
|
|
1212
|
+
if (!checked.ok) return checked.outcome;
|
|
1213
|
+
data.idCard = checked.value;
|
|
1214
|
+
}
|
|
1215
|
+
if (fields.phone) {
|
|
1216
|
+
const checked = checkField('phone', fields.phone, '手机号', steps);
|
|
1217
|
+
if (!checked.ok) return checked.outcome;
|
|
1218
|
+
data.phone = checked.value;
|
|
1219
|
+
}
|
|
1220
|
+
if (fields.email) {
|
|
1221
|
+
const checked = checkField('email', fields.email, '邮箱', steps);
|
|
1222
|
+
if (!checked.ok) return checked.outcome;
|
|
1223
|
+
data.email = checked.value;
|
|
1224
|
+
}
|
|
1225
|
+
if (fields.hireDate) {
|
|
1226
|
+
const checked = checkField('date', fields.hireDate, '入职日期', steps);
|
|
1227
|
+
if (!checked.ok) return checked.outcome;
|
|
1228
|
+
const d = parseDate(checked.value);
|
|
1229
|
+
if (!d) return needInfo('入职日期格式无法识别。', ['正确的入职日期'], steps);
|
|
1230
|
+
data.hireDate = d;
|
|
1231
|
+
}
|
|
1232
|
+
if (fields.dept) {
|
|
1233
|
+
const deptId = await findDeptId(fields.dept);
|
|
1234
|
+
if (!deptId) {
|
|
1235
|
+
return needInfo(
|
|
1236
|
+
`系统中没有找到部门「${fields.dept}」。可选部门:${departments
|
|
1237
|
+
.map((d) => d.name)
|
|
1238
|
+
.join('、')}。`,
|
|
1239
|
+
['正确的所属部门'],
|
|
1240
|
+
steps
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
if (!deptAllowed(operator, deptId)) return deptNotAllowedOutcome();
|
|
1244
|
+
data.deptId = deptId;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const created = await prisma.employee.create({
|
|
1248
|
+
data: data as Parameters<typeof prisma.employee.create>[0]['data'],
|
|
1249
|
+
});
|
|
1250
|
+
steps.push({ label: '创建员工档案', detail: `${name}(工号 ${code})`, ok: true });
|
|
1251
|
+
|
|
1252
|
+
return {
|
|
1253
|
+
outcome: 'done',
|
|
1254
|
+
steps,
|
|
1255
|
+
changes: [
|
|
1256
|
+
{ label: '工号', from: '(新档案)', to: code },
|
|
1257
|
+
{ label: '姓名', from: '(新档案)', to: name },
|
|
1258
|
+
...(fields.dept ? [{ label: '所属部门', from: '(新档案)', to: fields.dept }] : []),
|
|
1259
|
+
...(fields.position ? [{ label: '岗位', from: '(新档案)', to: fields.position }] : []),
|
|
1260
|
+
],
|
|
1261
|
+
reply: `已新增员工「${name}」(工号 ${code}${
|
|
1262
|
+
fields.dept ? `,部门:${fields.dept}` : ''
|
|
1263
|
+
}${fields.position ? `,岗位:${fields.position}` : ''})。`,
|
|
1264
|
+
analysis: plan.analysis || `员工档案已创建(id=${created.id})。`,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/* ---------------------------- 修改员工档案 ---------------------------- */
|
|
1269
|
+
case 'update_employee': {
|
|
1270
|
+
if (!hasPerm(operator, P.empEdit)) {
|
|
1271
|
+
return unsupported(
|
|
1272
|
+
'当前账号没有修改员工档案的权限,暂未执行。请由有员工管理权限的人处理。',
|
|
1273
|
+
[{ label: '权限校验', detail: '缺少 demo:employee:edit 权限', ok: false }],
|
|
1274
|
+
'修改员工档案需要员工管理的编辑权限。'
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
const target = await resolveEmployeeTarget(plan);
|
|
1278
|
+
if (!target.ok) return needInfo(target.reply, target.missing, [target.step]);
|
|
1279
|
+
const e = target.employee;
|
|
1280
|
+
steps.push({ label: '定位员工', detail: `${e.name}(工号 ${e.code})`, ok: true });
|
|
1281
|
+
if (!deptAllowed(operator, e.deptId)) return deptNotAllowedOutcome();
|
|
1282
|
+
|
|
1283
|
+
const missing: string[] = [];
|
|
1284
|
+
const fields = verifyFields(
|
|
1285
|
+
plan.fields,
|
|
1286
|
+
['name', 'code', 'idCard', 'dept', 'position', 'phone', 'email', 'hireDate'],
|
|
1287
|
+
source,
|
|
1288
|
+
missing
|
|
1289
|
+
);
|
|
1290
|
+
// 修改类只要有一项没说明白,就整体不执行
|
|
1291
|
+
if (missing.length) {
|
|
1292
|
+
return needInfo(
|
|
1293
|
+
`反馈里没有明确写出这些字段的新值:${[...new Set(missing)].join('、')}。为免改错,暂未执行任何变更,请补充后重新处理。`,
|
|
1294
|
+
missing,
|
|
1295
|
+
steps
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
if (!Object.keys(fields).length) {
|
|
1299
|
+
return needInfo(
|
|
1300
|
+
'请说明要修改的具体字段和正确的新值(例如:手机号改为 138xxxx、岗位改为“技术主管”)。',
|
|
1301
|
+
missing.length ? missing : ['要修改的内容和正确的新值'],
|
|
1302
|
+
steps
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const data: Record<string, unknown> = {};
|
|
1307
|
+
if (fields.name) {
|
|
1308
|
+
const checked = checkField('name', fields.name, '姓名', steps, true);
|
|
1309
|
+
if (!checked.ok) return checked.outcome;
|
|
1310
|
+
if (checked.value !== e.name) {
|
|
1311
|
+
data.name = checked.value;
|
|
1312
|
+
changes.push({ label: '姓名', from: valueOr(e.name), to: checked.value });
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (fields.code) {
|
|
1316
|
+
const checked = checkField('code', fields.code, '工号', steps, true);
|
|
1317
|
+
if (!checked.ok) return checked.outcome;
|
|
1318
|
+
if (checked.value !== e.code) {
|
|
1319
|
+
const dup = await prisma.employee.findUnique({ where: { code: checked.value } });
|
|
1320
|
+
if (dup) {
|
|
1321
|
+
return failed(
|
|
1322
|
+
`工号「${checked.value}」已被占用`,
|
|
1323
|
+
[...steps, { label: '工号查重', detail: `${checked.value} 已存在`, ok: false }],
|
|
1324
|
+
'工号必须唯一,请更换后再修改。'
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
data.code = checked.value;
|
|
1328
|
+
changes.push({ label: '工号', from: valueOr(e.code), to: checked.value });
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (fields.idCard) {
|
|
1332
|
+
const checked = checkField('idCard', fields.idCard, '身份证号', steps);
|
|
1333
|
+
if (!checked.ok) return checked.outcome;
|
|
1334
|
+
if (checked.value !== (e.idCard ?? '')) {
|
|
1335
|
+
data.idCard = checked.value;
|
|
1336
|
+
changes.push({ label: '身份证号', from: valueOr(e.idCard), to: checked.value });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (fields.phone) {
|
|
1340
|
+
const checked = checkField('phone', fields.phone, '手机号', steps);
|
|
1341
|
+
if (!checked.ok) return checked.outcome;
|
|
1342
|
+
if (checked.value !== (e.phone ?? '')) {
|
|
1343
|
+
data.phone = checked.value;
|
|
1344
|
+
changes.push({ label: '手机号', from: valueOr(e.phone), to: checked.value });
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (fields.email) {
|
|
1348
|
+
const checked = checkField('email', fields.email, '邮箱', steps);
|
|
1349
|
+
if (!checked.ok) return checked.outcome;
|
|
1350
|
+
if (checked.value.toLowerCase() !== (e.email ?? '').toLowerCase()) {
|
|
1351
|
+
data.email = checked.value;
|
|
1352
|
+
changes.push({ label: '邮箱', from: valueOr(e.email), to: checked.value });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (fields.position && fields.position !== (e.position ?? '')) {
|
|
1356
|
+
data.position = fields.position;
|
|
1357
|
+
changes.push({ label: '岗位', from: valueOr(e.position), to: fields.position });
|
|
1358
|
+
}
|
|
1359
|
+
if (fields.hireDate) {
|
|
1360
|
+
const checked = checkField('date', fields.hireDate, '入职日期', steps);
|
|
1361
|
+
if (!checked.ok) return checked.outcome;
|
|
1362
|
+
const d = parseDate(checked.value);
|
|
1363
|
+
if (!d) return needInfo('入职日期格式无法识别。', ['正确的入职日期'], steps);
|
|
1364
|
+
data.hireDate = d;
|
|
1365
|
+
changes.push({
|
|
1366
|
+
label: '入职日期',
|
|
1367
|
+
from: e.hireDate ? dayjs(e.hireDate).format('YYYY-MM-DD') : '(空)',
|
|
1368
|
+
to: dayjs(d).format('YYYY-MM-DD'),
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
if (fields.dept) {
|
|
1372
|
+
const deptId = await findDeptId(fields.dept);
|
|
1373
|
+
if (!deptId) {
|
|
1374
|
+
return needInfo(
|
|
1375
|
+
`系统中没有找到部门「${fields.dept}」。可选部门:${departments
|
|
1376
|
+
.map((d) => d.name)
|
|
1377
|
+
.join('、')}。`,
|
|
1378
|
+
['正确的所属部门'],
|
|
1379
|
+
steps
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
if (!deptAllowed(operator, deptId)) return deptNotAllowedOutcome();
|
|
1383
|
+
if (deptId !== e.deptId) {
|
|
1384
|
+
data.deptId = deptId;
|
|
1385
|
+
changes.push({ label: '所属部门', from: valueOr(e.deptName), to: fields.dept });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (!Object.keys(data).length) {
|
|
1390
|
+
return {
|
|
1391
|
+
outcome: 'done',
|
|
1392
|
+
steps: [...steps, { label: '核对档案', detail: '反馈内容与现有档案一致,无需修改', ok: true }],
|
|
1393
|
+
changes: [],
|
|
1394
|
+
reply: '已核对员工档案,反馈中的内容与现有档案一致,无需修改。',
|
|
1395
|
+
analysis: '字段值与现状一致,未产生数据库写入。',
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
await prisma.employee.update({ where: { id: e.id }, data });
|
|
1400
|
+
steps.push({
|
|
1401
|
+
label: '更新员工档案',
|
|
1402
|
+
detail: changes.map((c) => `${c.label} ${c.from} → ${c.to}`).join(';'),
|
|
1403
|
+
ok: true,
|
|
1404
|
+
});
|
|
1405
|
+
return {
|
|
1406
|
+
outcome: 'done',
|
|
1407
|
+
steps,
|
|
1408
|
+
changes,
|
|
1409
|
+
reply: `已按反馈修改员工「${e.name}」(工号 ${e.code})的档案:${changes
|
|
1410
|
+
.map((c) => `${c.label}由「${c.from}」改为「${c.to}」`)
|
|
1411
|
+
.join(';')}。`,
|
|
1412
|
+
analysis: plan.analysis || '员工档案已按反馈更新完成。',
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/* ---------------------------- 删除员工档案 ---------------------------- */
|
|
1417
|
+
case 'delete_employee': {
|
|
1418
|
+
if (!hasPerm(operator, P.empDel)) {
|
|
1419
|
+
return unsupported(
|
|
1420
|
+
'当前账号没有删除员工的权限,暂未执行。请由有员工管理权限的人处理。',
|
|
1421
|
+
[{ label: '权限校验', detail: '缺少 demo:employee:del 权限', ok: false }],
|
|
1422
|
+
'删除员工档案需要员工管理的删除权限。'
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
const target = await resolveEmployeeTarget(plan);
|
|
1426
|
+
if (!target.ok) return needInfo(target.reply, target.missing, [target.step]);
|
|
1427
|
+
const e = target.employee;
|
|
1428
|
+
steps.push({ label: '定位员工', detail: `${e.name}(工号 ${e.code})`, ok: true });
|
|
1429
|
+
if (!deptAllowed(operator, e.deptId)) return deptNotAllowedOutcome();
|
|
1430
|
+
|
|
1431
|
+
await prisma.employee.update({
|
|
1432
|
+
where: { id: e.id },
|
|
1433
|
+
data: { deletedAt: new Date() },
|
|
1434
|
+
});
|
|
1435
|
+
steps.push({ label: '删除员工档案', detail: '已软删除,可在审计日志追溯', ok: true });
|
|
1436
|
+
|
|
1437
|
+
return {
|
|
1438
|
+
outcome: 'done',
|
|
1439
|
+
steps,
|
|
1440
|
+
changes: [{ label: '档案状态', from: '在职', to: '已删除(软删除)' }],
|
|
1441
|
+
reply: `已删除员工「${e.name}」(工号 ${e.code})的员工档案。`,
|
|
1442
|
+
analysis: plan.analysis || '员工档案已软删除,历史数据与审计记录保留。',
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/* ---------------------------- 无法自动办理 ---------------------------- */
|
|
1447
|
+
default: {
|
|
1448
|
+
const analysis =
|
|
1449
|
+
plan.analysis ||
|
|
1450
|
+
'这条反馈属于需要人工判断的问题,AI 无法直接修改系统数据或代码。';
|
|
1451
|
+
const reply =
|
|
1452
|
+
plan.reply ||
|
|
1453
|
+
`已收到反馈。经智能分析,这条内容需要开发或管理员进一步核实,已保留原始记录并标记为需要跟进。`;
|
|
1454
|
+
return unsupported(reply, steps, analysis);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/* ============================ 4. 对外入口 ============================ */
|
|
1460
|
+
|
|
1461
|
+
const NOWHERE = '这条反馈可能已被删除。';
|
|
1462
|
+
|
|
1463
|
+
function emptyResult(
|
|
1464
|
+
feedbackId: string,
|
|
1465
|
+
outcome: FeedbackOutcome,
|
|
1466
|
+
reply: string,
|
|
1467
|
+
analysis: string,
|
|
1468
|
+
elapsedMs: number,
|
|
1469
|
+
fallback = false
|
|
1470
|
+
): FeedbackAiResult {
|
|
1471
|
+
return {
|
|
1472
|
+
feedbackId,
|
|
1473
|
+
intent: 'none',
|
|
1474
|
+
intentLabel: INTENT_LABEL.none,
|
|
1475
|
+
confidence: 0,
|
|
1476
|
+
outcome,
|
|
1477
|
+
outcomeLabel: OUTCOME_LABEL[outcome],
|
|
1478
|
+
status: 'pending',
|
|
1479
|
+
statusLabel: STATUS_LABEL.pending,
|
|
1480
|
+
analysis,
|
|
1481
|
+
reply,
|
|
1482
|
+
steps: [],
|
|
1483
|
+
changes: [],
|
|
1484
|
+
model: AI_MODEL,
|
|
1485
|
+
elapsedMs,
|
|
1486
|
+
fallback,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* 处理一条反馈:模型出方案 → 服务端校验执行 → 写回复与状态。
|
|
1492
|
+
*
|
|
1493
|
+
* 永远返回 FeedbackAiResult,不抛异常;任何失败都变成
|
|
1494
|
+
* outcome='failed'/'unsupported' + 可读的中文分析与建议。
|
|
1495
|
+
*/
|
|
1496
|
+
export async function processFeedbackWithAi(
|
|
1497
|
+
feedbackId: string,
|
|
1498
|
+
operator: SessionUser
|
|
1499
|
+
): Promise<FeedbackAiResult> {
|
|
1500
|
+
const t0 = Date.now();
|
|
1501
|
+
|
|
1502
|
+
const feedback = await prisma.feedback.findFirst({
|
|
1503
|
+
where: { id: feedbackId, deletedAt: null },
|
|
1504
|
+
select: {
|
|
1505
|
+
id: true,
|
|
1506
|
+
type: true,
|
|
1507
|
+
title: true,
|
|
1508
|
+
detail: true,
|
|
1509
|
+
pagePath: true,
|
|
1510
|
+
status: true,
|
|
1511
|
+
userId: true,
|
|
1512
|
+
username: true,
|
|
1513
|
+
},
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
if (!feedback) {
|
|
1517
|
+
return emptyResult(
|
|
1518
|
+
feedbackId,
|
|
1519
|
+
'unsupported',
|
|
1520
|
+
`【AI 智能处理】${NOWHERE}请刷新列表后重试。`,
|
|
1521
|
+
NOWHERE,
|
|
1522
|
+
Date.now() - t0
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// 只能处理自己的反馈;他人的反馈仅管理员可处理(与反馈接口的列表口径一致)
|
|
1527
|
+
if (feedback.userId !== operator.id && !isFeedbackAdmin(operator)) {
|
|
1528
|
+
return emptyResult(
|
|
1529
|
+
feedback.id,
|
|
1530
|
+
'unsupported',
|
|
1531
|
+
'【AI 智能处理】这条反馈不属于当前账号,暂未处理。只有管理员可以处理他人的反馈。',
|
|
1532
|
+
'权限校验未通过:反馈归属与当前账号不一致。',
|
|
1533
|
+
Date.now() - t0
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const author = await loadAuthor(feedback.userId).catch(() => null);
|
|
1538
|
+
const departments = await prisma.department
|
|
1539
|
+
.findMany({ where: { status: 1 }, select: { name: true }, take: 200 })
|
|
1540
|
+
.then((rows) => rows.map((d) => d.name))
|
|
1541
|
+
.catch(() => []);
|
|
1542
|
+
|
|
1543
|
+
let plan: FeedbackPlan;
|
|
1544
|
+
let fallback = false;
|
|
1545
|
+
try {
|
|
1546
|
+
plan = await planWithModel(feedback, author, departments);
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
fallback = true;
|
|
1549
|
+
plan = fallbackPlan(feedback);
|
|
1550
|
+
if (!(e instanceof AiError)) {
|
|
1551
|
+
console.error('[feedbackAi] 模型调用异常,已使用规则兜底', e);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
let exec: ExecOutcome;
|
|
1556
|
+
try {
|
|
1557
|
+
exec = await executePlan(plan, feedback, author, operator);
|
|
1558
|
+
} catch (e) {
|
|
1559
|
+
console.error('[feedbackAi] 执行异常,已转为可读失败结果', e);
|
|
1560
|
+
exec = failed(
|
|
1561
|
+
'系统在写入时遇到业务规则冲突',
|
|
1562
|
+
[{ label: '执行处理', detail: '写入被拦截', ok: false }],
|
|
1563
|
+
'执行阶段出现异常,数据没有被修改。请联系管理员核对后重试。'
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// 写入反馈:结果回复 + 状态 + 未读标记(让提交者在侧边栏看到新回复)
|
|
1568
|
+
let status = feedback.status;
|
|
1569
|
+
try {
|
|
1570
|
+
const data: Record<string, unknown> = { reply: exec.reply, readAt: null };
|
|
1571
|
+
if (exec.outcome === 'done') {
|
|
1572
|
+
data.status = 'resolved';
|
|
1573
|
+
status = 'resolved';
|
|
1574
|
+
} else if (exec.outcome === 'need_info' || exec.outcome === 'failed') {
|
|
1575
|
+
data.status = 'doing';
|
|
1576
|
+
status = 'doing';
|
|
1577
|
+
}
|
|
1578
|
+
await prisma.feedback.update({ where: { id: feedback.id }, data });
|
|
1579
|
+
} catch (e) {
|
|
1580
|
+
console.error('[feedbackAi] 写回反馈失败', e);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
void audit({
|
|
1584
|
+
module: '问题反馈',
|
|
1585
|
+
action: 'AI 智能处理',
|
|
1586
|
+
method: 'POST',
|
|
1587
|
+
path: '/api/feedback/ai',
|
|
1588
|
+
targetId: feedback.id,
|
|
1589
|
+
detail: {
|
|
1590
|
+
intent: plan.intent,
|
|
1591
|
+
outcome: exec.outcome,
|
|
1592
|
+
confidence: plan.confidence,
|
|
1593
|
+
fallback,
|
|
1594
|
+
},
|
|
1595
|
+
userId: operator.id,
|
|
1596
|
+
username: operator.username,
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
return {
|
|
1600
|
+
feedbackId: feedback.id,
|
|
1601
|
+
intent: plan.intent,
|
|
1602
|
+
intentLabel: INTENT_LABEL[plan.intent],
|
|
1603
|
+
confidence: plan.confidence,
|
|
1604
|
+
outcome: exec.outcome,
|
|
1605
|
+
outcomeLabel: OUTCOME_LABEL[exec.outcome],
|
|
1606
|
+
status,
|
|
1607
|
+
statusLabel: STATUS_LABEL[status] ?? STATUS_LABEL.pending,
|
|
1608
|
+
analysis:
|
|
1609
|
+
exec.analysis ||
|
|
1610
|
+
plan.analysis ||
|
|
1611
|
+
(exec.outcome === 'done'
|
|
1612
|
+
? '已按反馈完成处理。'
|
|
1613
|
+
: '已完成分析,未执行数据变更。'),
|
|
1614
|
+
reply: exec.reply,
|
|
1615
|
+
steps: exec.steps,
|
|
1616
|
+
changes: exec.changes,
|
|
1617
|
+
model: AI_MODEL,
|
|
1618
|
+
elapsedMs: Date.now() - t0,
|
|
1619
|
+
fallback,
|
|
1620
|
+
};
|
|
1621
|
+
}
|