feat(ops): 操作日志留痕、字典管理、名册改删(V9 / V10)

PRD FR-506/FR-1202 的 P0 项:V9 给 sys_operation_log 补 target_type/target_id/
before_val/after_val,OperationLogService 显式留痕并统一强制脱敏;
已接入的敏感动作:名册改证件号/改名/删名册项、任务发布/暂停/恢复/截止/归档。

F-32 的教训落地:动作名由调用方显式给,不从 (原状态, 新状态) 反推——
「发布」与「恢复」都是 →收集中,反推必然把 resume 记成 PUBLISH_TASK。

字典:内置字典写在代码里(DictCatalog),库里只存客户自定义项与内置项停用开关,
两表分开才能「一键恢复默认」;表单下拉与字典共用同一份口径(BuiltinFields 加访问器)。

验证:e2e-m5-logs 44/44、e2e-m1-dict 53/53。
This commit is contained in:
2026-09-30 09:02:53 +08:00
parent 29c8e932ae
commit 7090ee7d65
23 changed files with 3237 additions and 8 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* 字典管理(《03-接口设计》§13.3)。
*
* ## 三种来源,界面上必须让用户分得清
*
* | 来源 | `builtin` | 能做什么 |
* |---|---|---|
* | 内置项(代码里,照抄官方表格) | `true` 且**没有 id** | 只能**停用 / 恢复**,不能改名、不能删 |
* | 自定义项(客户自己加的) | `false` | 可改名、可停用、可删、可排序 |
* | 国标职业(`sys_occupation_ref`) | `true` 且**只读** | 什么都不能改(那是国标) |
*
* ⚠️ 内置项**没有 id** 这件事是界面上的一个具体分叉:对它的操作只能按
* `itemCode` 走 `toggle` 接口,不能走 `PUT /dicts/items/{id}`。
* 所以列表里"编辑/删除"按钮对内 builtin 行要换成"停用/恢复"。
*
* ## 为什么内置项不能改名(这条一定要在界面上说)
*
* 因为内置选项**照抄官方表格**。有人把"四级/中级工"改成"中级"之后,
* 导出的汇总表就与官方模板对不上了 —— 而那时**没有任何地方会报错**。
* 所以要改内置项名只能改代码发布;界面上的正确做法是**追加一个自定义项**。
*
* ## 项码建后不可改
*
* 历史数据里存的是项码。改了它,过去填过的值会指向一个不存在的项,
* 而查询**不报错、只显示空白** —— 这种问题只能靠"一开始就不允许改"来避免。
* 所以修改接口的请求体里根本没有 `itemCode` 字段(不是靠界面藏起来)。
*/
import http from '@/utils/http'
export interface DictVO {
dictCode: string
dictName: string
/** ⚠️ 只算**启用中**的项 */
itemCount: number
builtin: boolean
}
export interface DictItemVO {
/** 内置项为 null(它在代码里,没有数据库行) */
id: number | null
itemCode: string
itemName: string
parentItemCode?: string | null
sortNo?: number
/** 0 停用 / 1 启用 */
status?: number
/** true = 内置项:不可改名、不可删,只能停用/恢复 */
builtin?: boolean
createTime?: string
}
export interface DictItemListVO {
dictCode: string
dictName: string
builtin: boolean
/** 这个字典是否支持层级(如职业-工种) */
hierarchical: boolean
list: DictItemVO[]
}
export function getDicts() {
return http.get<DictVO[]>('/dicts')
}
export function getDictItems(code: string) {
return http.get<DictItemListVO>(`/dicts/${code}/items`)
}
export function createDictItem(code: string, data: {
itemCode: string
itemName: string
parentItemCode?: string
sortNo?: number
}) {
return http.post<DictItemVO>(`/dicts/${code}/items`, data)
}
/** ⚠️ 请求体里**没有** itemCode —— 项码不可改是结构性的,不是界面藏起来的。 */
export function updateDictItem(id: number, data: {
itemName?: string
sortNo?: number
status?: number
}) {
return http.put<void>(`/dicts/items/${id}`, data)
}
export function deleteDictItem(id: number) {
return http.delete<void>(`/dicts/items/${id}`)
}
/** 停用 / 恢复一个**内置**项。内置项没有 id,所以按 itemCode 走。 */
export function toggleBuiltinItem(code: string, itemCode: string, enabled: boolean) {
return http.post<void>(
`/dicts/${code}/items/${encodeURIComponent(itemCode)}/toggle?enabled=${enabled}`,
{}
)
}
/** 契约 §13.3 的约束文案(错误码 → 人话),界面直接用。 */
export const DICT_ERROR_HINT: Record<number, string> = {
40036: '这一项下面还有子项,不能停用或删除。请先处理子项。',
40001: '内置字典项不能改名或删除 —— 需要改内置项请改代码发布;'
+ '如果只是想加一个值,用「新增项」追加即可。'
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 日志查询(《03-接口设计》§13.4,PRD FR-506 / FR-1202)。
*
* ## 这一屏的用法与别的列表不同
*
* 它不是"浏览"用的,是**回答合规问题**用的:
* 「这张名册项的证件号被谁从什么改成了什么」「谁在什么时候看过这个学生的完整号」。
* 所以界面上有两处特别设计:
*
* 1. **`targetId` 可以直接填**(配合 `targetType`)—— 这是最重要的查询方式。
* 靠"搜关键字"是不行的:`request_param` 全文匹配会把
* 「改了它」和「顺带提到它」混在一起,而合规结论不能建立在这种匹配上。
* 2. **变更前后值单独一列**,而不是塞在"详情"弹窗里 ——
* 看这一屏的人 90% 就是为了看这两列。
*
* ## ⚠️ 一期的已知边界(界面上要说明,不能让人以为系统坏了)
*
* - 登录日志**只有 PC 账号密码登录**;学生从微信进来目前不写这张表;
* - IP 取的是代理链第一跳,**可以伪造** —— 用于排查,不作合规证据。
* 真正的证据是"哪个账号在什么时候做了什么"。
*/
import http from '@/utils/http'
export interface OperationLog {
id: number
tenantId?: number
operatorId?: number
operatorName?: string
module?: string
action?: string
targetType?: string
targetId?: number
/** ⚠️ 脱敏值。明文不进日志表 */
beforeVal?: string
afterVal?: string
ip?: string
userAgent?: string
bizCode?: number
traceId?: string
createTime?: string
}
export interface OperationLogPage {
list: OperationLog[]
total: number
}
export interface LoginLog {
id: number
userId?: number
userName?: string
userType?: number
/** PASSWORD / WECHAT / PHONE */
loginType?: string
ip?: string
location?: string
device?: string
result?: boolean
failReason?: string
loginTime?: string
}
export interface LoginLogPage {
list: LoginLog[]
total: number
}
export function getOperationLogs(params: {
operatorId?: number
module?: string
action?: string
targetType?: string
targetId?: number
from?: string
to?: string
pageNum?: number
pageSize?: number
} = {}) {
return http.get<OperationLogPage>('/logs/operations', params)
}
export function getLoginLogs(params: {
userId?: number
userType?: number
result?: boolean
from?: string
to?: string
pageNum?: number
pageSize?: number
} = {}) {
return http.get<LoginLogPage>('/logs/logins', params)
}
/** 敏感动作 → 文案。**与后端 OperationLogService 的常量一致**。 */
export const OPERATION_ACTIONS: Array<{ value: string, label: string }> = [
{ value: 'UPDATE_IDCARD', label: '改名册证件号' },
{ value: 'UPDATE_ROSTER_NAME', label: '改名册姓名' },
{ value: 'DELETE_ROSTER', label: '删名册项' },
{ value: 'PUBLISH_TASK', label: '发布任务' },
{ value: 'PAUSE_TASK', label: '暂停任务' },
{ value: 'RESUME_TASK', label: '恢复任务' },
{ value: 'CLOSE_TASK', label: '截止任务' },
{ value: 'ARCHIVE_TASK', label: '归档任务' },
{ value: 'REVOKE_AUDIT', label: '撤回审核' },
{ value: 'EXPORT_CREATE', label: '创建导出' },
{ value: 'PROXY_UPLOAD', label: '代传材料' },
{ value: 'VIEW_IDCARD_FULL', label: '查看完整证件号' },
{ value: 'CONFIG_COMPRESS', label: '改压缩配置' },
{ value: 'JOIN_APPROVE', label: '通过加入申请' },
{ value: 'JOIN_REJECT', label: '驳回加入申请' }
]
export function actionText(v?: string) {
return OPERATION_ACTIONS.find((a) => a.value === v)?.label || v || '—'
}
export const LOGIN_TYPES: Record<string, string> = {
PASSWORD: '账号密码',
WECHAT: '微信授权',
PHONE: '手机号快捷'
}
/** 需要重点看的动作(改证件号、看明文证件号)——列表里高亮。 */
export const SENSITIVE_ACTIONS = ['UPDATE_IDCARD', 'VIEW_IDCARD_FULL', 'DELETE_ROSTER']