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:
@@ -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: '内置字典项不能改名或删除 —— 需要改内置项请改代码发布;'
|
||||
+ '如果只是想加一个值,用「新增项」追加即可。'
|
||||
}
|
||||
@@ -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']
|
||||
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>字典管理</template>
|
||||
<template #extra>
|
||||
<span class="dict__hint">
|
||||
内置项照抄官方表格,**不可改名**(改名会让导出汇总表与官方模板对不上);
|
||||
要加值请用「新增项」追加
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="dict__layout">
|
||||
<!-- 左:字典列表 -->
|
||||
<div class="dict__side">
|
||||
<a-spin :loading="loadingDicts" class="dict__spin">
|
||||
<div v-for="d in dicts" :key="d.dictCode" class="dict__row"
|
||||
:class="{ 'dict__row--on': current?.dictCode === d.dictCode }"
|
||||
@click="pick(d)">
|
||||
<span class="dict__name">{{ d.dictName }}</span>
|
||||
<a-tag v-if="d.builtin" size="small" color="arcoblue">内置</a-tag>
|
||||
<span class="dict__count">{{ d.itemCount }}</span>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 右:字典项 -->
|
||||
<div class="dict__main">
|
||||
<div v-if="!current" class="dict__empty">从左边选一个字典</div>
|
||||
<template v-else>
|
||||
<div class="dict__bar">
|
||||
<span class="dict__title">
|
||||
{{ current.dictName }}
|
||||
<span class="dict__code">{{ current.dictCode }}</span>
|
||||
</span>
|
||||
<a-space>
|
||||
<a-button size="small" @click="reload">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="primary" size="small" @click="openCreate">
|
||||
新增项
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- ⚠️ 三种来源的说明必须摆在界面上:不说清的话,
|
||||
管理员会以为"内置项改不了"是权限不够 -->
|
||||
<a-alert v-if="current.builtin" type="normal" class="dict__note">
|
||||
<template #title>
|
||||
<template v-if="current.dictCode === 'OCCUPATION'">
|
||||
这一项来自**国标职业库**(`sys_occupation_ref`),只读。
|
||||
一期这张表是空的 —— 需要客户提供或采购国标职业数据后导入。
|
||||
</template>
|
||||
<template v-else>
|
||||
这是内置字典:项名不可改、不可删,只能**停用**(可一键恢复默认)。
|
||||
要加值请用「新增项」—— 追加的项可以随时改名或删除。
|
||||
</template>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-table :data="items" :loading="loadingItems" row-key="itemCode" :pagination="false"
|
||||
:scroll="{ x: 800 }">
|
||||
<template #columns>
|
||||
<a-table-column title="项码" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span :class="record.builtin ? 'dict__muted' : ''">{{ record.itemCode }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="项名" :width="260">
|
||||
<template #cell="{ record }">
|
||||
<span :class="record.status === 0 ? 'dict__off' : ''">{{ record.itemName }}</span>
|
||||
<a-tag v-if="record.builtin === true" size="small" class="dict__tag">内置</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column v-if="hierarchical" title="上级项" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span class="dict__muted">{{ record.parentItemCode || '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="排序" :width="80">
|
||||
<template #cell="{ record }">
|
||||
<span class="dict__muted">{{ record.sortNo ?? '—' }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="90">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="record.status === 0 ? 'gray' : 'green'">
|
||||
{{ record.status === 0 ? '已停用' : '启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<!--
|
||||
内置项与自定义项的可用动作**不同**,这是刻意的:
|
||||
· 内置项(没有 id)→ 只能停用/恢复(走 toggle)
|
||||
· 自定义项 → 可改名、可删
|
||||
把两套按钮混在一起,用户一定会点到"改名内置项"然后收到 40001。
|
||||
-->
|
||||
<a-space v-if="record.builtin === true">
|
||||
<a-link v-if="current.dictCode === 'OCCUPATION'" disabled>只读</a-link>
|
||||
<a-link v-else @click="toggle(record)">
|
||||
{{ record.status === 0 ? '恢复默认' : '停用' }}
|
||||
</a-link>
|
||||
</a-space>
|
||||
<a-space v-else>
|
||||
<a-link @click="openEdit(record)">编辑</a-link>
|
||||
<a-link :status="record.status === 0 ? 'success' : 'warning'"
|
||||
@click="toggleCustom(record)">
|
||||
{{ record.status === 0 ? '启用' : '停用' }}
|
||||
</a-link>
|
||||
<a-popconfirm content="删除这一项?已经用过它的历史数据不会变,但以后选不到了"
|
||||
@ok="remove(record)">
|
||||
<a-link status="danger">删除</a-link>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<a-modal v-model:visible="modalVisible" :title="editing ? '编辑字典项' : '新增字典项'"
|
||||
:ok-loading="saving" @ok="save" @cancel="modalVisible = false">
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="项码" required>
|
||||
<!-- 项码建后不可改是**结构性**的:编辑时直接禁用输入,
|
||||
并且后端接口的请求体里也没有这个字段。
|
||||
`editing !== null` 必须写全 —— 直接写 `editing` 会因为它可能是 null
|
||||
而被 vue-tsc 拦下(:disabled 只要 boolean) -->
|
||||
<a-input v-model="form.itemCode" :disabled="editing !== null"
|
||||
placeholder="如 大学本科 / TECH_SCHOOL(建后不可改)" />
|
||||
<template #extra>
|
||||
项码是历史数据里真正存的值,**建后不可改** ——
|
||||
改了它,过去填过的值会指向不存在的项,而查询不报错、只显示空白。
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item label="项名" required>
|
||||
<a-input v-model="form.itemName" placeholder="界面上给用户看的文字" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="hierarchical" label="上级项码">
|
||||
<a-input v-model="form.parentItemCode" placeholder="留空 = 顶层项" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model="form.sortNo" :min="0" :step="10" placeholder="数字越小越靠前" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DictItemVO, DictVO } from '@/apis/xt/dict'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
createDictItem,
|
||||
deleteDictItem,
|
||||
DICT_ERROR_HINT,
|
||||
getDictItems,
|
||||
getDicts,
|
||||
toggleBuiltinItem,
|
||||
updateDictItem
|
||||
} from '@/apis/xt/dict'
|
||||
|
||||
/**
|
||||
* 字典管理(《03-接口设计》§13.3)。
|
||||
*
|
||||
* ## 界面上的核心分叉:内置项 vs 自定义项
|
||||
*
|
||||
* 内置项**没有 id**(它们在代码里,没有数据库行),所以:
|
||||
* · 操作只能按 `itemCode` 走 `toggle`(停用/恢复);
|
||||
* · 没有"编辑名称"与"删除"。
|
||||
* 自定义项才有 id,可以改名、停用、删除。
|
||||
*
|
||||
* 把两套按钮混在一起,用户一定会点到"改名内置项"然后收到 40001 ——
|
||||
* 而那时他会以为"是权限不够",实际是设计如此。
|
||||
*/
|
||||
defineOptions({ name: 'DictManage' })
|
||||
|
||||
const loadingDicts = ref(false)
|
||||
const loadingItems = ref(false)
|
||||
const saving = ref(false)
|
||||
const dicts = ref<DictVO[]>([])
|
||||
const current = ref<DictVO | null>(null)
|
||||
const items = ref<DictItemVO[]>([])
|
||||
// 层级支持来自**字典项接口**(不在字典列表的 VO 上),所以单独记一个
|
||||
const hierarchical = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const editing = ref<DictItemVO | null>(null)
|
||||
|
||||
const form = ref<{
|
||||
itemCode: string
|
||||
itemName: string
|
||||
parentItemCode?: string
|
||||
sortNo?: number
|
||||
}>({ itemCode: '', itemName: '', parentItemCode: undefined, sortNo: undefined })
|
||||
|
||||
/** 错误码 → 人话。界面直接显示后端 msg 也可以,但那句话对"我该做什么"帮助有限。 */
|
||||
function hint(e: unknown) {
|
||||
const err = e as { code?: number, message?: string }
|
||||
if (err?.code && DICT_ERROR_HINT[err.code]) {
|
||||
return DICT_ERROR_HINT[err.code]
|
||||
}
|
||||
return err?.message || '操作失败'
|
||||
}
|
||||
|
||||
async function loadDicts() {
|
||||
loadingDicts.value = true
|
||||
try {
|
||||
const res = await getDicts()
|
||||
dicts.value = res.data ?? []
|
||||
} finally {
|
||||
loadingDicts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
if (!current.value) {
|
||||
return
|
||||
}
|
||||
loadingItems.value = true
|
||||
try {
|
||||
const res = await getDictItems(current.value.dictCode)
|
||||
items.value = res.data?.list ?? []
|
||||
hierarchical.value = res.data?.hierarchical === true
|
||||
} finally {
|
||||
loadingItems.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pick(d: DictVO) {
|
||||
current.value = d
|
||||
// 先清掉上一次的层级标记:否则从一个有层级的字典切到没有的字典时,
|
||||
// 「上级项」这一列会继续显示(而它对新字典毫无意义)
|
||||
hierarchical.value = false
|
||||
loadItems()
|
||||
}
|
||||
|
||||
function reload() {
|
||||
loadDicts()
|
||||
loadItems()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
form.value = { itemCode: '', itemName: '', parentItemCode: undefined, sortNo: undefined }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: DictItemVO) {
|
||||
editing.value = row
|
||||
form.value = {
|
||||
itemCode: row.itemCode,
|
||||
itemName: row.itemName,
|
||||
parentItemCode: row.parentItemCode ?? undefined,
|
||||
sortNo: row.sortNo
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!current.value) {
|
||||
return
|
||||
}
|
||||
if (!form.value.itemCode.trim() || !form.value.itemName.trim()) {
|
||||
Message.warning('项码与项名都必填')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value?.id) {
|
||||
// ⚠️ 只传项名与排序:项码不在接口契约里(结构性保证)
|
||||
await updateDictItem(editing.value.id, {
|
||||
itemName: form.value.itemName.trim(),
|
||||
sortNo: form.value.sortNo
|
||||
})
|
||||
Message.success('已保存')
|
||||
} else {
|
||||
await createDictItem(current.value.dictCode, {
|
||||
itemCode: form.value.itemCode.trim(),
|
||||
itemName: form.value.itemName.trim(),
|
||||
parentItemCode: form.value.parentItemCode?.trim() || undefined,
|
||||
sortNo: form.value.sortNo
|
||||
})
|
||||
Message.success('已新增')
|
||||
}
|
||||
modalVisible.value = false
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 内置项:停用 / 恢复默认。 */
|
||||
async function toggle(row: DictItemVO) {
|
||||
if (!current.value) {
|
||||
return
|
||||
}
|
||||
const enable = row.status === 0
|
||||
try {
|
||||
await toggleBuiltinItem(current.value.dictCode, row.itemCode, enable)
|
||||
Message.success(enable ? '已恢复默认' : '已停用')
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** 自定义项:改状态。 */
|
||||
async function toggleCustom(row: DictItemVO) {
|
||||
if (!row.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateDictItem(row.id, { status: row.status === 0 ? 1 : 0 })
|
||||
Message.success(row.status === 0 ? '已启用' : '已停用')
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: DictItemVO) {
|
||||
if (!row.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteDictItem(row.id)
|
||||
Message.success('已删除')
|
||||
reload()
|
||||
} catch (e) {
|
||||
Message.error(hint(e))
|
||||
}
|
||||
}
|
||||
|
||||
loadDicts()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dict {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__side {
|
||||
flex: none;
|
||||
width: 220px;
|
||||
border-right: 1px solid var(--color-border-1);
|
||||
}
|
||||
|
||||
&__spin {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
|
||||
&--on {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__code {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 40px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__off {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<GiPageLayout>
|
||||
<a-card :bordered="false">
|
||||
<template #title>操作日志</template>
|
||||
<template #extra>
|
||||
<span class="log__hint">
|
||||
按「对象」查是这个页面最重要的用法:填对象类型 + ID,就能看到它被谁改过
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<a-alert type="normal" class="log__tip">
|
||||
<template #title>
|
||||
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
|
||||
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
|
||||
真正的证据是「哪个账号在什么时候做了什么」。
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="g-row-tool">
|
||||
<a-space wrap>
|
||||
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
|
||||
class="log__num" :min="1" hide-button @change="search" />
|
||||
<a-select v-model="query.targetType" placeholder="对象类型" allow-clear size="small"
|
||||
class="log__sel" @change="search">
|
||||
<a-option value="ROSTER">名册项</a-option>
|
||||
<a-option value="TASK">任务</a-option>
|
||||
<a-option value="SUBMISSION">填报单</a-option>
|
||||
<a-option value="EXPORT">导出</a-option>
|
||||
<a-option value="USER">账号</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.action" placeholder="动作" allow-clear size="small"
|
||||
class="log__sel2" @change="search">
|
||||
<a-option v-for="a in OPERATION_ACTIONS" :key="a.value" :value="a.value">
|
||||
{{ a.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="query.module" placeholder="模块" allow-clear size="small"
|
||||
class="log__sel3" @change="search">
|
||||
<a-option value="ROSTER">名册</a-option>
|
||||
<a-option value="TASK">任务</a-option>
|
||||
<a-option value="AUDIT">审核</a-option>
|
||||
<a-option value="EXPORT">导出</a-option>
|
||||
<a-option value="JOIN">加入申请</a-option>
|
||||
</a-select>
|
||||
<!-- 起止日期用两个独立日期框,**不用 range 模式**:
|
||||
Arco 的 range 模式实际值是数组、但类型声明只写了单值,
|
||||
改哪边都要么 as any(掩盖问题)、要么被类型挡住。
|
||||
而且两个框各自能单独清空,核查时更好用 -->
|
||||
<a-date-picker v-model="fromDate" size="small" mode="date"
|
||||
placeholder="开始日期" class="log__date" @change="search" />
|
||||
<a-date-picker v-model="toDate" size="small" mode="date"
|
||||
placeholder="结束日期" class="log__date" @change="search" />
|
||||
<a-button size="small" @click="reset">重置</a-button>
|
||||
<a-button size="small" @click="load">
|
||||
<template #icon><IconRefresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 1200 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="时间" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<span class="log__muted">{{ fmt(record.createTime) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作人" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<div>{{ record.operatorName || '—' }}</div>
|
||||
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="动作" :width="150">
|
||||
<template #cell="{ record }">
|
||||
<a-tag size="small" :color="isSensitive(record.action) ? 'red' : 'arcoblue'">
|
||||
{{ actionText(record.action) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="对象" :width="130">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.targetType" class="log__muted">
|
||||
{{ record.targetType }} #{{ record.targetId }}
|
||||
</span>
|
||||
<span v-else class="log__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="变更前 → 变更后" :width="280">
|
||||
<template #cell="{ record }">
|
||||
<div v-if="record.beforeVal || record.afterVal" class="log__diff">
|
||||
<span class="log__before">{{ record.beforeVal || '(空)' }}</span>
|
||||
<span class="log__arrow">→</span>
|
||||
<span class="log__after">{{ record.afterVal || '(空)' }}</span>
|
||||
</div>
|
||||
<span v-else class="log__muted">—</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="来源" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div class="log__muted">{{ record.ip || '—' }}</div>
|
||||
<div class="log__ua" :title="record.userAgent || ''">{{ record.userAgent || '' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OperationLog } from '@/apis/xt/log'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import {
|
||||
actionText,
|
||||
getOperationLogs,
|
||||
OPERATION_ACTIONS,
|
||||
SENSITIVE_ACTIONS
|
||||
} from '@/apis/xt/log'
|
||||
|
||||
/**
|
||||
* 操作日志(《03-接口设计》§13.4)。
|
||||
*
|
||||
* ## 为什么把「对象 ID」放在筛选栏最左边
|
||||
*
|
||||
* 因为这是这一屏**唯一不可替代**的查询方式。合规问题问的是
|
||||
* 「这张名册项的证件号被谁从什么改成了什么」,答案只能靠
|
||||
* `target_type + target_id` 定位;而"搜关键字"会把
|
||||
* 「改了它」和「顺带提到它」混在一起 —— 那种匹配撑不起合规结论。
|
||||
*
|
||||
* ## 「变更前 → 变更后」单独一列
|
||||
*
|
||||
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
|
||||
* 而合规核查往往是几十条连着看的。
|
||||
*/
|
||||
defineOptions({ name: 'OperationLogs' })
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const fromDate = ref<string | undefined>(undefined)
|
||||
const toDate = ref<string | undefined>(undefined)
|
||||
|
||||
const query = reactive<{
|
||||
targetId?: number
|
||||
targetType?: string
|
||||
action?: string
|
||||
module?: string
|
||||
from?: string
|
||||
to?: string
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
}>({
|
||||
targetId: undefined,
|
||||
targetType: undefined,
|
||||
action: undefined,
|
||||
module: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
pageNum: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
total: total.value,
|
||||
showTotal: true,
|
||||
showPageSize: true
|
||||
}))
|
||||
|
||||
function fmt(v?: string) {
|
||||
return v ? v.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
|
||||
function isSensitive(action?: string) {
|
||||
return !!action && SENSITIVE_ACTIONS.includes(action)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOperationLogs({
|
||||
targetId: query.targetId,
|
||||
targetType: query.targetType,
|
||||
action: query.action,
|
||||
module: query.module,
|
||||
// 后端 `to` 会补到当天 23:59:59,所以这里只给日期就够
|
||||
from: fromDate.value || undefined,
|
||||
to: toDate.value || undefined,
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize
|
||||
})
|
||||
list.value = res.data?.list ?? []
|
||||
total.value = res.data?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
query.pageNum = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
query.targetId = undefined
|
||||
query.targetType = undefined
|
||||
query.action = undefined
|
||||
query.module = undefined
|
||||
fromDate.value = undefined
|
||||
toDate.value = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
query.pageNum = page
|
||||
load()
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.log {
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
&__sel {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&__sel2 {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
&__sel3 {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
&__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__diff {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__before {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
margin: 0 6px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__after {
|
||||
color: rgb(var(--red-6));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__ua {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user