fix(projects): 针对技术负债去优化代码

This commit is contained in:
2026-06-04 21:06:05 +08:00
parent 39458386ae
commit 7cc29e0a35
7 changed files with 620 additions and 8 deletions

View File

@@ -684,10 +684,11 @@ async function confirmDeleteExecution(payload: { name: string; confirmText: stri
confirmText: payload.confirmText,
reason: payload.reason
});
if (error) return;
window.$message?.success('删除成功');
// 成功=正常删除;失败=多为打开弹层后对象被并发改状态/删除,错误文案由全局 onError 弹 Toast。
// 两种情况都关弹层 + 刷新:失败也要让用户离开已失效的弹层、看到最新数据。
deleteDialogVisible.value = false;
selectedExecution.value = null;
if (!error) window.$message?.success('删除成功');
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
await Promise.all([
reloadExecutionData(1),

View File

@@ -6,6 +6,7 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskInfoReadonly' });
@@ -53,7 +54,7 @@ const parentTaskOptions = computed(() => {
<ElFormItem label="任务类型">
<DictSelect :model-value="taskType" :dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE" disabled placeholder="--" />
</ElFormItem>
<ElFormItem label="父任务">
<ElFormItem v-if="SHOW_TASK_PARENT_FIELD" label="父任务">
<ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无">
<ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
</ElSelect>

View File

@@ -10,6 +10,8 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
type OperateMode = 'create' | 'edit';
@@ -342,7 +344,7 @@ defineExpose({
/>
</ElFormItem>
<ElFormItem label="父任务">
<ElFormItem v-if="SHOW_TASK_PARENT_FIELD" label="父任务">
<ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务">
<ElOption
v-for="item in selectableParentTasks"

View File

@@ -4,7 +4,13 @@ import type { PaginationProps } from 'element-plus';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import { useAuthStore } from '@/store/modules/auth';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDate, formatDateRange, getTaskStatusName, getTaskStatusTagType } from '../shared';
import {
SHOW_TASK_PARENT_FIELD,
formatDate,
formatDateRange,
getTaskStatusName,
getTaskStatusTagType
} from '../shared';
import { useTaskActions } from '../composables/use-task-actions';
defineOptions({ name: 'ProjectExecutionTaskTableView' });
@@ -141,7 +147,12 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn v-if="!crossExecutionMode" label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn>
<ElTableColumn v-if="!crossExecutionMode" label="父任务" min-width="140" show-overflow-tooltip>
<ElTableColumn
v-if="!crossExecutionMode && SHOW_TASK_PARENT_FIELD"
label="父任务"
min-width="140"
show-overflow-tooltip
>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn>
<ElTableColumn label="进度" width="160">

View File

@@ -696,10 +696,11 @@ async function confirmDeleteTask(payload: { name: string; confirmText: string; r
confirmText: payload.confirmText,
reason: payload.reason
});
if (error) return;
window.$message?.success('删除成功');
// 成功=正常删除;失败=多为打开弹层后对象被并发改状态/删除,错误文案由全局 onError 弹 Toast。
// 两种情况都关弹层 + 刷新列表:失败也要让用户离开已失效的弹层、看到最新数据。
deleteTaskDialogVisible.value = false;
deleteTaskTarget.value = null;
if (!error) window.$message?.success('删除成功');
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
}

View File

@@ -5,6 +5,13 @@ type ExecutionStatusCode = Api.Project.ProjectExecutionStatusCode;
type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
type ExecutionAssigneeActionType = Api.Project.ExecutionAssigneeActionType;
/**
* 是否在任务界面展示「父任务」相关露出(表格列 / 新建编辑下拉 / 详情只读字段)。
* 当前业务经执行分层后极少有子任务需求,暂统一隐藏,使任务呈扁平的一级任务列表;
* 底层父子数据与级联完成逻辑保留不动,将来恢复子任务功能改回 true 即可。
*/
export const SHOW_TASK_PARENT_FIELD = false;
export const executionAssigneeActionNameMap: Record<ExecutionAssigneeActionType, string> = {
join: '加入',
inactive: '失效',

View File

@@ -0,0 +1,589 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>用户可见错误文案规范 · cn-rdms</title>
<style>
:root {
--fg: #1f2328;
--fg-muted: #57606a;
--bg: #ffffff;
--bg-soft: #f6f8fa;
--border: #d0d7de;
--border-soft: #e7ecf2;
--accent: #0969da;
--accent-soft: #ddf4ff;
--purple: #8250df;
--purple-soft: #fbefff;
--warn: #9a6700;
--warn-soft: #fff8c5;
--danger: #cf222e;
--danger-soft: #ffebe9;
--ok: #1a7f37;
--ok-soft: #dafbe1;
--code-bg: #f6f8fa;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB',
'Source Han Sans CN', 'Noto Sans CJK SC', Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.75;
-webkit-font-smoothing: antialiased;
}
body {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
aside.sidebar {
position: sticky;
top: 0;
align-self: start;
height: 100vh;
overflow-y: auto;
background: var(--bg-soft);
border-right: 1px solid var(--border);
padding: 24px 18px;
}
aside.sidebar .sidebar-title {
font-size: 13px;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 16px;
font-weight: 600;
}
aside.sidebar nav ul {
list-style: none;
padding: 0;
margin: 0;
}
aside.sidebar nav li {
margin: 2px 0;
}
aside.sidebar nav a {
color: var(--fg);
text-decoration: none;
font-size: 13px;
display: block;
padding: 4px 10px;
border-radius: 4px;
line-height: 1.5;
}
aside.sidebar nav a:hover {
background: var(--accent-soft);
color: var(--accent);
}
.wrap {
max-width: 920px;
margin: 0 auto;
padding: 48px 32px 96px;
min-width: 0;
}
header.doc-header {
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 24px;
}
header.doc-header h1 {
margin: 0 0 8px;
font-size: 28px;
}
header.doc-header .meta {
color: var(--fg-muted);
font-size: 13px;
margin: 2px 0;
}
header.doc-header .lead {
font-size: 16px;
color: var(--fg);
margin: 12px 0 0;
}
h2 {
margin: 44px 0 12px;
padding: 10px 16px;
background: var(--accent-soft);
border-left: 5px solid var(--accent);
font-size: 21px;
border-radius: 4px;
}
h3 {
margin: 26px 0 8px;
font-size: 17px;
}
p {
margin: 8px 0;
}
ul,
ol {
margin: 8px 0;
padding-left: 24px;
}
ul li,
ol li {
margin: 4px 0;
}
code {
font-family: 'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace;
font-size: 13px;
background: var(--code-bg);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border-soft);
word-break: break-all;
}
pre {
font-family: 'JetBrains Mono', Menlo, Consolas, 'Courier New', monospace;
font-size: 13px;
background: var(--code-bg);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.55;
}
pre code {
background: transparent;
border: 0;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
font-size: 14px;
}
th,
td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
vertical-align: top;
}
th {
background: var(--bg-soft);
font-weight: 600;
}
tr:nth-child(2n) td {
background: #fafbfc;
}
.callout {
border-left: 4px solid var(--accent);
background: var(--accent-soft);
padding: 10px 14px;
border-radius: 4px;
margin: 14px 0;
}
.callout.warn {
border-left-color: var(--warn);
background: var(--warn-soft);
}
.callout.danger {
border-left-color: var(--danger);
background: var(--danger-soft);
}
.callout.ok {
border-left-color: var(--ok);
background: var(--ok-soft);
}
.callout .title {
font-weight: 600;
margin-bottom: 4px;
}
.bad {
color: var(--danger);
font-weight: 600;
}
.good {
color: var(--ok);
font-weight: 600;
}
footer.doc-footer {
margin-top: 56px;
padding-top: 16px;
border-top: 1px solid var(--border);
color: var(--fg-muted);
font-size: 13px;
}
</style>
</head>
<body>
<aside class="sidebar">
<div class="sidebar-title">目录</div>
<nav>
<ul>
<li><a href="#理念">1 · 核心理念</a></li>
<li><a href="#实现">2 · 技术实现</a></li>
<li><a href="#决策">3 · 关键设计决策</a></li>
<li><a href="#清单">4 · 新功能落地清单</a></li>
<li><a href="#缺口">5 · 信息泄漏类红线</a></li>
<li><a href="#关联">6 · 关联文档</a></li>
</ul>
</nav>
</aside>
<div class="wrap">
<header class="doc-header">
<h1>用户可见错误文案规范</h1>
<p class="meta">来源:技术负债 TD-012 · 落地日期 2026-06-03 · 文档 2026-06-04</p>
<p class="meta">
适用范围:整仓所有"状态机动作 / 状态校验失败"的业务异常,凡
<code>message</code>
会被前端直接展示者
</p>
<p class="lead">
给前端
<code>toast</code>
<code>message</code>
只放用户能看懂的中文;动作 / 状态的内部 code、堆栈等技术细节不进
<code>message</code>
,由访问日志承载。本规范是必须遵守的跨模块约定,新功能照做。
</p>
</header>
<h2 id="理念">1 · 核心理念</h2>
<p><strong>message 面向用户、诊断面向开发,两者分离。</strong></p>
<p>
前端往往直接把后端业务异常的
<code>message</code>
弹给最终用户。如果
<code>message</code>
里夹着
<code>complete</code>
/
<code>status</code>
/
<code>action</code>
这类内部术语,用户看不懂,会误以为系统异常或数据没保存。
</p>
<div class="callout danger">
<div class="title">反例TD-012 的原始案例)</div>
用户点"完成任务",第一次请求已把任务置为已完成;前端重复发了第二次
<code>complete</code>
动作,后端返回
<span class="bad">"当前任务状态不支持动作【complete】"</span>
。用户合理的预期是看到
<span class="good">"任务已完成,请勿重复提交"</span>
这类人话。
</div>
<p>
因此约定:
<strong>
用户看友好中文(
<code>message</code>
),开发排查看访问日志(
<code>infra_api_access_log</code>
里有原始 code、入参、堆栈
</strong>
两条信息流互不污染。
</p>
<h2 id="实现">2 · 技术实现</h2>
<p>
整套方案
<strong>零新表、framework 零改动</strong>
,纯在
<code>rdms-project</code>
域内:一个解析器组件 + 错误码文案模板改造 + service 接入。
</p>
<h3>
2.1 文案解析器
<code>StatusActionTextResolver</code>
</h3>
<p>
位置:
<code>rdms-project-boot · service/status/StatusActionTextResolver.java</code>
<code>@Component</code>
)。把动作 / 状态的 code 翻成中文展示名,供错误文案使用。
</p>
<table>
<thead>
<tr>
<th>方法</th>
<th>作用</th>
<th>查不到 / 空入参</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>actionName(objectType, actionCode)</code></td>
<td>动作中文名</td>
<td>
回退原
<code>actionCode</code>
,不抛错
</td>
</tr>
<tr>
<td><code>statusName(objectType, statusCode)</code></td>
<td>状态中文名</td>
<td>
回退原
<code>statusCode</code>
,不抛错
</td>
</tr>
</tbody>
</table>
<div class="callout purple">
<div class="title">权威源DB 状态机表,不在代码里硬编码映射</div>
动作名取自
<code>rdms_object_status_transition.action_name</code>
,状态名取自
<code>rdms_object_status_model.status_name</code>
。运维在状态机表里配新动作 / 新状态,文案自动生效,
<strong>不用改代码发版</strong>
</div>
<h3>2.2 错误码文案用「{}」占位中文名</h3>
<p>错误码定义时,文案就留中文名占位,由 service 在抛错前用 resolver 填入。例如:</p>
<pre><code>// 个人事项 —— 正面样板
ErrorCode PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED =
new ErrorCode(1_008_008_004, "当前个人事项为「{}」状态,不支持「{}」操作");</code></pre>
<p>
抛出前:
<code>
exception(PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED, resolver.statusName(...), resolver.actionName(...))
</code>
—— 占位填的是中文名,不是裸 code。
</p>
<h3>2.3 已接入面7 个 service</h3>
<p>状态机校验失败抛错时,先经 resolver 翻译再返回的 service</p>
<ul>
<li>
<code>ProductRequirementServiceImpl</code>
(产品需求)
</li>
<li>
<code>PersonalItemServiceImpl</code>
(个人事项)
</li>
<li>
<code>ProjectTaskServiceImpl</code>
(任务)
</li>
<li>
<code>ProductServiceImpl</code>
(产品)
</li>
<li>
<code>ProjectExecutionServiceImpl</code>
(执行)
</li>
<li>
<code>ProjectServiceImpl</code>
(项目)
</li>
<li>
<code>ProjectRequirementServiceImpl</code>
(项目需求)
</li>
</ul>
<h2 id="决策">3 · 关键设计决策</h2>
<ol>
<li>
<strong>不硬编码映射,跟 DB 状态机走。</strong>
中文名是状态机表里运维可配的数据,不复刻成 Java 常量 / Enum加新动作、新状态不需要改代码呼应仓库"字典 /
状态值不复刻成 Java 常量"的纪律)。
</li>
<li>
<strong>解析器只翻译、绝不抛错。</strong>
空入参或查不到一律回退原 code。它是文案美化层不能反过来变成新的故障点——翻译失败也要让原始业务异常正常返回。
</li>
<li>
<strong>轻量、可回滚。</strong>
纯加一个 Component + 改错误码文案模板 + service 接入,不新表、不动 framework、复用现有错误码体系因此
<strong>无演示库补丁、前端零改动</strong>
</li>
</ol>
<h2 id="清单">4 · 新功能落地清单</h2>
<div class="callout ok">
<div class="title">凡新增"状态机动作 / 状态校验"且 message 会被前端展示,照此四步</div>
</div>
<ol>
<li>
service 注入
<code>StatusActionTextResolver</code>
</li>
<li>
错误码文案写成「{}」占位中文名(参考
<code>PERSONAL_ITEM_STATUS_ACTION_NOT_ALLOWED</code>
<span class="bad">不要把 code 直接嵌进文案</span>
</li>
<li>
抛错前用
<code>actionName / statusName</code>
把 code 翻成中文名再填占位。
</li>
<li>
新对象类型在
<code>rdms_object_status_model</code>
/
<code>rdms_object_status_transition</code>
配好
<code>status_name</code>
/
<code>action_name</code>
resolver 即自动生效。
</li>
</ol>
<h2 id="缺口">5 · 信息泄漏类红线</h2>
<p>
除"状态机动作 / 状态翻中文"外,凡
<code>message</code>
会被前端直接展示,
<strong>以下技术 token 一律不得出现在 message 里</strong>
,只能进日志(
<code>log.warn</code>
/
<code>infra_api_access_log</code>
</p>
<table>
<thead>
<tr>
<th>禁止外泄</th>
<th>反例</th>
<th>正确做法</th>
</tr>
</thead>
<tbody>
<tr>
<td>数据库表名 / 列名</td>
<td>
"未在
<code>system_role</code>
找到"
</td>
<td>"…未配置,请联系管理员"</td>
</tr>
<tr>
<td>权限码 / 内部标记</td>
<td>
"操作权限【
<code>project:project:update</code>
】"、"【
<code>member</code>
】"
</td>
<td>"您没有此项操作权限,请联系管理员"</td>
</tr>
<tr>
<td>动作 / 状态 code</td>
<td>
"不支持动作【
<code>complete</code>
】"
</td>
<td>resolver 翻中文名(见 §2</td>
</tr>
<tr>
<td>类名 / 字段名 / 堆栈</td>
<td>
"
<code>NullPointerException at ...</code>
"
</td>
<td>友好提示,异常进日志</td>
</tr>
</tbody>
</table>
<div class="callout ok">
<div class="title">2026-06-04 · B 类存量整改已落地</div>
<ul>
<li>
<strong>加班申请</strong>
<code>OvertimeApplicationServiceImpl</code>
已注入 resolver文案对齐其它域「当前加班申请为「{}」状态,不支持「{}」操作」。
</li>
<li>
<strong>操作权限不足</strong>
<code>Project/ProductObjectPermissionService</code>
已去占位、权限码改
<code>log.warn</code>
;错误码文案改"您没有该项目/产品的此项操作权限,请联系管理员"。
</li>
<li>
<strong>表名外泄</strong>
<code>PRODUCT/PROJECT_INTERNAL_ROLE_NOT_CONFIGURED</code>
两条已去掉
<code>system_role</code>
</li>
</ul>
</div>
<div class="callout ok">
<div class="title">2026-06-04 · 加班申请 service 层单测已补</div>
新增
<code>OvertimeApplicationServiceImplTest</code>
(2 用例,
<code>mvn test</code>
通过):验证状态机「动作不允许 / 缺原因」抛错时,
<code>message</code>
填的是
<code>StatusActionTextResolver</code>
翻出的中文名、不外泄英文动作 / 状态 code。属 Mockito 单测(mock resolver),覆盖的是 service 层「填中文名而非裸
code」这条契约resolver 自身的 DB 翻译由
<code>StatusActionTextResolverTest</code>
覆盖。真实 DB 状态机表是否配齐
<code>status_name</code>
/
<code>action_name</code>
的端到端校验仍依赖运行时,不在单测范围。
</div>
<h2 id="关联">6 · 关联文档</h2>
<ul>
<li>
<a href="./对象状态能力落地规范.md">对象状态能力落地规范.md</a>
—— 状态机模型与流转设计,本规范的中文名权威源即来自这两张表。
</li>
<li>
<a href="../debt/技术负债台账.html">技术负债台账 · TD-012</a>
—— 本规范的需求来源条目。
</li>
<li>
技术诊断承载:
<code>infra_api_access_log</code>
(访问日志,留原始 code / 入参 / 堆栈)。
</li>
<li>
<code>CLAUDE.md · 接口语义</code>
章节留有指向本文档的红线指针。
</li>
</ul>
<footer class="doc-footer">cn-rdms · 跨模块约定 · 用户可见错误文案规范</footer>
</div>
</body>
</html>