feat: 联动系统化待办负责人和责任人 (#697)

## 实际改动

- 提醒筛选项“无提醒”改为“已完成”,保持原有已完成筛选逻辑。
- 总负责人和责任人建立双向关联:选择总负责人后只显示对应责任人,选择责任人时自动勾选对应总负责人。
- 编辑弹窗将原生联想输入改为统一的勾选面板,并使用同一套负责人关联逻辑。
- 责任人关联同时支持固定映射和现有数据推导,惠兰等未在固定清单中的人员也能从历史数据正确归属。
- DESIGN.md 与机器设计规范新增跨设备控件准则,后续页面统一复用内置字体和自绘控件。

## 验证证据

- SystemizationTodosTest 通过:23 tests,343 assertions。
- 覆盖已完成筛选标签、固定人员映射、历史数据推导和自定义责任人控件。
- 不涉及数据库迁移、金额、权限或上游数据。

## 合并前复查

- 基于最新 origin/main(da546877)开发。
- 已自审完整 diff,未包含敏感信息或无关修改。
- 提交:fa7d2608。

Reviewed-on: #697
This commit is contained in:
yueyang 2026-10-27 18:10:54 +08:00
parent da546877ba
commit c33bb7308e
5 changed files with 263 additions and 27 deletions

View File

@ -173,7 +173,7 @@
"refersTo": "button-primary",
"description": "浅色内容区的主要操作,使用可读深金而不是亮金。",
"html": "<button type=\"button\" class=\"ds-btn-primary\">查询</button>",
"css": ".ds-btn-primary { height: 34px; padding: 0 14px; border: 1px solid #856632; border-radius: 8px; background: #856632; color: #fff; font: 500 13px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-primary:hover { background: #6A5129; border-color: #6A5129; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-primary:active { transform: translateY(1px); } .ds-btn-primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }"
"css": ".ds-btn-primary { height: 34px; padding: 0 14px; border: 1px solid #856632; border-radius: 8px; background: #856632; color: #fff; font: 500 13px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-primary:hover { background: #6A5129; border-color: #6A5129; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-primary:active { transform: translateY(1px); } .ds-btn-primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }"
},
{
"name": "Secondary Button",
@ -181,7 +181,7 @@
"refersTo": "button-secondary",
"description": "次要操作保持白底、暖灰边界和正文墨色。",
"html": "<button type=\"button\" class=\"ds-btn-secondary\">重置</button>",
"css": ".ds-btn-secondary { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 500 13px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-secondary:hover { border-color: #A17D3E; background: #FBF8F1; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-secondary:active { background: #F7F0DE; } .ds-btn-secondary:disabled { opacity: .55; cursor: not-allowed; }"
"css": ".ds-btn-secondary { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 500 13px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-secondary:hover { border-color: #A17D3E; background: #FBF8F1; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-secondary:active { background: #F7F0DE; } .ds-btn-secondary:disabled { opacity: .55; cursor: not-allowed; }"
},
{
"name": "Text Field",
@ -189,7 +189,7 @@
"refersTo": "input-default",
"description": "紧凑、清晰、具有可见标签和深金焦点状态的业务输入框。",
"html": "<label class=\"ds-field\"><span class=\"ds-field-label\">订单号</span><input class=\"ds-field-input\" type=\"text\" placeholder=\"请输入订单号\"></label>",
"css": ".ds-field { display: inline-flex; min-width: 220px; flex-direction: column; gap: 5px; color: #171717; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-field-label { color: #595959; font-size: 12px; font-weight: 500; line-height: 16px; } .ds-field-input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 400 13px/1.5 inherit; outline: none; transition: border-color 150ms cubic-bezier(.22,1,.36,1), box-shadow 150ms cubic-bezier(.22,1,.36,1); } .ds-field-input::placeholder { color: #595959; } .ds-field-input:hover { border-color: #AFA99E; } .ds-field-input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-field-input:disabled { background: #EEE9DF; color: #595959; cursor: not-allowed; }"
"css": ".ds-field { display: inline-flex; min-width: 220px; flex-direction: column; gap: 5px; color: #171717; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-field-label { color: #595959; font-size: 12px; font-weight: 500; line-height: 16px; } .ds-field-input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 400 13px/1.5 inherit; outline: none; transition: border-color 150ms cubic-bezier(.22,1,.36,1), box-shadow 150ms cubic-bezier(.22,1,.36,1); } .ds-field-input::placeholder { color: #595959; } .ds-field-input:hover { border-color: #AFA99E; } .ds-field-input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-field-input:disabled { background: #EEE9DF; color: #595959; cursor: not-allowed; }"
},
{
"name": "Sidebar Active Item",
@ -197,7 +197,7 @@
"refersTo": "sidebar-item-active",
"description": "浅黑功能栏上的单一圆形 chevron 活动状态。",
"html": "<div class=\"ds-sidebar\"><a class=\"ds-sidebar-item is-active\" href=\"#\"><svg class=\"ds-sidebar-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 4h16v16H4zM8 9h8M8 13h8M8 17h5\"/></svg><span>店铺收款账户账单</span><span class=\"ds-active-indicator\" aria-hidden=\"true\">›</span></a></div>",
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'CaiWu CJK', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
},
{
"name": "Workspace Tabs",
@ -205,7 +205,7 @@
"refersTo": "tab-active",
"description": "白色工作区内使用深金活动文字和单一金色下划线的局部标签切换。",
"html": "<nav class=\"ds-tabs\" aria-label=\"账户类型\"><a class=\"ds-tab is-active\" aria-current=\"page\" href=\"#\">淘系支付宝账户</a><a class=\"ds-tab\" href=\"#\">淘系微信账户</a></nav>",
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'CaiWu CJK', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
},
{
"name": "Business Panel",
@ -213,21 +213,21 @@
"refersTo": "panel",
"description": "使用白色表面、暖灰边界和有限圆角的单层业务容器。",
"html": "<section class=\"ds-panel\"><h2 class=\"ds-panel-title\">账单明细</h2><p class=\"ds-panel-copy\">按账户和日期范围核对交易数据。</p></section>",
"css": ".ds-panel { padding: 16px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-panel-title { margin: 0; color: #171717; font-size: 14px; font-weight: 600; line-height: 1.25; } .ds-panel-copy { max-width: 70ch; margin: 8px 0 0; color: #595959; font-size: 13px; line-height: 1.5; } .ds-panel:focus-within { border-color: #856632; }"
"css": ".ds-panel { padding: 16px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-panel-title { margin: 0; color: #171717; font-size: 14px; font-weight: 600; line-height: 1.25; } .ds-panel-copy { max-width: 70ch; margin: 8px 0 0; color: #595959; font-size: 13px; line-height: 1.5; } .ds-panel:focus-within { border-color: #856632; }"
},
{
"name": "Status Tags",
"kind": "chip",
"description": "语义颜色只表达真实业务状态,并始终带文字。",
"html": "<div class=\"ds-tags\"><span class=\"ds-tag ds-tag-success\">已核对</span><span class=\"ds-tag ds-tag-warning\">待处理</span><span class=\"ds-tag ds-tag-danger\">异常</span></div>",
"css": ".ds-tags { display: flex; gap: 8px; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; } .ds-tag-success { background: #F1F6EE; color: #53793E; } .ds-tag-warning { background: #FEF5E7; color: #935F07; } .ds-tag-danger { background: #FAE6E6; color: #B80000; }"
"css": ".ds-tags { display: flex; gap: 8px; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; } .ds-tag-success { background: #F1F6EE; color: #53793E; } .ds-tag-warning { background: #FEF5E7; color: #935F07; } .ds-tag-danger { background: #FAE6E6; color: #B80000; }"
},
{
"name": "Finance Data Table",
"kind": "custom",
"description": "紧凑、可扫描、数字右对齐的财务数据表。支持列显示/隐藏、拖动排序、恢复默认及按用户和具体表格保存;冻结多层表头,横向滚动条始终可达。导出明确筛选范围和可见列顺序,保留精度并排除密码;允许导入的业务提供模板、预览、校验及失败反馈,只读镜像不写回。",
"html": "<table class=\"ds-table\"><thead><tr><th>订单号</th><th class=\"ds-num\">收入</th><th class=\"ds-num\">余额</th></tr></thead><tbody><tr><td>20260722001</td><td class=\"ds-num ds-income\">¥1,280.00</td><td class=\"ds-num\">¥8,460.00</td></tr></tbody></table>",
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
}
],
"narrative": {
@ -236,7 +236,7 @@
"keyCharacteristics": [
"深黑工具轨道、浅黑功能栏、浅色内容区,三层边界清楚。",
"浅金用于深色导航,深金用于浅色内容区的文字与主要操作。",
"全站使用 JetBrains Mono,标题和数据采用固定字号,不使用流体放大。",
"全站使用 JetBrains Mono 与仓库内置 CaiWu CJK 字体,标题和数据采用固定字号,不使用流体放大。",
"页面标题位于内容区右侧,保持 14px;导航使用统一的紧凑字号。",
"一、二级导航统一 12px;同一层级的图标、字号和交互状态必须一致。",
"表格、筛选器和状态反馈优先,装饰性卡片、阴影和动画受到严格限制。",
@ -250,6 +250,7 @@
{ "name": "The Fixed-Scale Rule", "body": "产品界面使用固定 rem 字号,不使用 clamp() 放大标题;桌面与移动端通过布局重排适配,不通过夸张缩放字体适配。", "section": "typography" },
{ "name": "The Stable-First-Paint Rule", "body": "关键字号、行高和容器尺寸必须由首屏 CSS 直接提供,不能等待 JavaScript 或异步样式后再缩小,禁止首次进入时出现巨大字体闪烁。", "section": "typography" },
{ "name": "The One-Family Rule", "body": "按钮、标签、导航、表格和正文都使用同一字体家族;禁止在数据界面引入装饰性展示字体。", "section": "typography" },
{ "name": "The Cross-Platform Control Rule", "body": "业务控件不得依赖 macOS、Windows 或不同浏览器的原生外观。字体使用仓库内置 Web 字体;下拉框、复选框、日期选择、联想输入使用项目自绘控件;桌面筛选区使用明确 CSS 网格。新页面必须在 Safari 与 Chromium 下验证布局、换行和控件尺寸一致。", "section": "typography" },
{ "name": "The Flat-by-Default Rule", "body": "静态业务面板默认无阴影;若已经有 1px 边框,不得再叠加 16px 以上模糊半径的宽阴影。", "section": "elevation" },
{ "name": "The No-Nested-Cards Rule", "body": "卡片只用于真实分组;卡片内部不能再套同样外观的卡片。优先使用标题、间距、分隔线和表格分组表达层级。", "section": "elevation" }
],

View File

@ -22,22 +22,22 @@ colors:
info: "#006395"
typography:
title:
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontSize: "0.875rem"
fontWeight: 600
lineHeight: 1.25
body:
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontSize: "0.8125rem"
fontWeight: 400
lineHeight: 1.5
label:
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontSize: "0.75rem"
fontWeight: 500
lineHeight: 1.333
data:
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontSize: "0.78125rem"
fontWeight: 400
lineHeight: 1.5
@ -180,9 +180,9 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
## 3. Typography
**Display Font:** JetBrains Mono (with Microsoft YaHei, PingFang SC, monospace)
**Display Font:** JetBrains Mono (with bundled CaiWu CJK, monospace)
**Body Font:** JetBrains Mono (with Microsoft YaHei, PingFang SC, monospace)
**Body Font:** JetBrains Mono (with bundled CaiWu CJK, monospace)
**Label/Mono Font:** JetBrains Mono
@ -206,6 +206,8 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
**The One-Family Rule.** 按钮、标签、导航、表格和正文都使用同一字体家族;禁止在数据界面引入装饰性展示字体。
**The Cross-Platform Control Rule.** 业务控件不得依赖 macOS、Windows 或不同浏览器的原生外观。字体使用仓库内置 Web 字体;下拉框、复选框、日期选择、联想输入使用项目自绘控件;桌面筛选区使用明确 CSS 网格。新页面必须在 Safari 与 Chromium 下验证布局、换行和控件尺寸一致。
## 4. Elevation
界面默认采用“色调分层 + 细边界”,不是依赖大阴影堆叠。工具轨道、功能栏和内容区通过背景明度形成第一层深度;内容面板通过白色表面与细边界形成第二层。阴影只用于浮层或短暂交互反馈。
@ -263,6 +265,7 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
- 标准高度 34px,8px 圆角,白底、1px 边界;标签位于字段上方,12px/500。
- 聚焦使用深金边框和半透明焦点环;占位文字至少使用辅助墨色,不允许低对比浅灰。
- 错误状态使用语义危险色、明确文字和错误图标;不能只把边框染红。
- 不使用原生 `datalist`、系统日期框或未归一的系统下拉外观承载业务交互;关联选项使用项目自绘菜单或勾选面板,避免 macOS 与 Windows 显示不同。
### Cards / Containers

View File

@ -26,7 +26,7 @@ class SystemizationTodos extends Controller
'today' => '今天到期',
'upcoming' => '剩余天数',
'pending' => '时间待定',
'none' => '无提醒',
'none' => '已完成',
];
private const OWNER_OPTIONS = ['人参', '晨曦', '常仪', '待定'];
private const COLUMN_WIDTHS_SETTING = 'business.systemization_todos.column_widths';
@ -196,6 +196,7 @@ class SystemizationTodos extends Controller
'label' => $status,
]);
}
$todoAssignees = $this->peopleOptions('assignee');
return [
'view' => 'business.pages.systemization-todos',
@ -206,7 +207,8 @@ class SystemizationTodos extends Controller
'todoCategories' => $categories,
'todoContentOptions' => $this->contentOptions(),
'todoOwners' => collect(self::OWNER_OPTIONS),
'todoAssignees' => $this->peopleOptions('assignee'),
'todoAssignees' => $todoAssignees,
'todoAssigneeOwners' => $this->assigneeOwnerMap($todoAssignees),
'todoStatuses' => SystemizationTodo::STATUSES,
'todoStatusOptions' => $statusOptions,
'todoReminderOptions' => collect(self::REMINDER_OPTIONS)
@ -898,6 +900,46 @@ class SystemizationTodos extends Controller
->values();
}
private function assigneeOwnerMap(Collection $assignees): array
{
$derived = [];
SystemizationTodo::query()
->whereNotNull('assignee')
->where('assignee', '<>', '')
->get(['owner', 'assignee'])
->each(function (SystemizationTodo $todo) use (&$derived): void {
$owners = $this->ownerNames($todo->owner);
foreach ($this->personNames($todo->assignee) as $assignee) {
foreach ($owners as $owner) {
$derived[$assignee][$owner] = true;
}
}
});
$map = [];
foreach ($assignees as $assignee) {
if (isset(self::OWNER_ALIASES[$assignee])) {
$map[$assignee] = [self::OWNER_ALIASES[$assignee]];
continue;
}
$ownerSet = $derived[$assignee] ?? [];
$map[$assignee] = array_values(array_filter(
self::OWNER_OPTIONS,
static fn (string $owner): bool => isset($ownerSet[$owner]),
));
}
return $map;
}
private function personNames(?string $value): array
{
return array_values(array_unique(array_filter(
array_map('trim', preg_split('/[、,,;;\/]+/u', trim((string) $value)) ?: []),
static fn (string $name): bool => $name !== '' && $name !== '—',
)));
}
private function normalizeOwnerFilterValues(array $owners): array
{
return collect($owners)

View File

@ -50,6 +50,7 @@
.sys-todo-filter__multi-value { font-size:.8125rem; line-height:1.35; overflow-wrap:anywhere; white-space:normal; }
.sys-todo-filter__multi-menu { position:absolute; top:calc(100% + .3rem); left:0; z-index:20; display:grid; width:max(100%,13rem); max-height:17rem; padding:.35rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fff; box-shadow:0 8px 22px rgba(23,23,23,.16); overflow:auto; }
.sys-todo-filter__multi-menu label { display:flex; flex:none; align-items:center; gap:.45rem; min-height:2rem; padding:.3rem .45rem; border-radius:.35rem; color:#333; font-size:.78rem; cursor:pointer; }
.sys-todo-filter__multi-menu label[hidden] { display:none; }
.sys-todo-filter__multi-menu label:hover { background:var(--sys-gold-soft); }
.sys-todo-filter__multi-menu input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; padding:0; accent-color:var(--sys-gold); }
.sys-todo-filter__multi-empty { padding:.65rem; color:#777; font-size:.75rem; text-align:center; }
@ -166,6 +167,7 @@
.sys-todo-new-category[hidden] { display:none; }
.sys-todo-owner-checks { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.2rem .45rem; max-height:7rem; padding:.4rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; overflow:auto; }
.sys-todo-owner-checks label { display:flex; align-items:center; gap:.35rem; min-height:1.6rem; color:#333; font-size:.76rem; cursor:pointer; }
.sys-todo-owner-checks label[hidden] { display:none; }
.sys-todo-owner-checks label:hover { color:var(--sys-gold); }
.sys-todo-owner-checks input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; min-height:0; padding:0; accent-color:var(--sys-gold); }
.sys-todo-page input[type="checkbox"] {
@ -193,6 +195,7 @@
background-size:.72rem;
}
.sys-todo-page input[type="checkbox"]:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; }
.sys-todo-assignee-note { margin:0; color:#777; font-size:.68rem; line-height:1.4; }
.sys-todo-upload-zone { display:flex; flex-wrap:wrap; align-items:center; gap:.45rem .65rem; min-height:4.5rem; padding:.7rem; border:1px dashed #cfc3ad; border-radius:.55rem; background:#fcfaf5; }
.sys-todo-upload-zone input[type="file"] { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.sys-todo-upload-button { display:inline-flex; align-items:center; justify-content:center; min-height:2rem; padding:0 .75rem; border:1px solid var(--sys-gold); border-radius:.4rem; background:var(--sys-gold); color:#fff; font-size:.78rem; cursor:pointer; }
@ -397,13 +400,14 @@
</summary>
<div class="sys-todo-filter__multi-menu">
@forelse ($todoAssignees as $assignee)
<label>
<label data-sys-todo-assignee-option data-assignee-owners="{{ implode('、', $todoAssigneeOwners[$assignee] ?? []) }}">
<input type="checkbox" name="assignee[]" value="{{ $assignee }}" @checked(in_array($assignee, $todoFilters['assignees'], true))>
<span>{{ $assignee }}</span>
</label>
@empty
<span class="sys-todo-filter__multi-empty">暂无可选责任人</span>
@endforelse
<span class="sys-todo-filter__multi-empty" data-sys-todo-filter-assignee-empty hidden>当前总负责人暂无对应责任人</span>
</div>
</details>
</div>
@ -776,7 +780,22 @@
</div>
<input type="hidden" name="owner" data-sys-todo-owner-value>
</div>
<label class="sys-todo-field sys-todo-field--3">责任人<input name="assignee" maxlength="255" list="sysTodoAssignees"></label>
<div class="sys-todo-field sys-todo-field--3">
<span>责任人</span>
<div class="sys-todo-owner-checks" data-sys-todo-assignee-options>
@forelse ($todoAssignees as $assignee)
<label data-sys-todo-assignee-option data-assignee-owners="{{ implode('、', $todoAssigneeOwners[$assignee] ?? []) }}">
<input type="checkbox" value="{{ $assignee }}">
<span>{{ $assignee }}</span>
</label>
@empty
<span class="sys-todo-filter__multi-empty">暂无可选责任人</span>
@endforelse
<span class="sys-todo-filter__multi-empty" data-sys-todo-assignee-empty hidden>当前总负责人暂无对应责任人</span>
</div>
<input type="hidden" name="assignee" data-sys-todo-assignee-value>
<p class="sys-todo-assignee-note">选择总负责人后可快速定位对应责任人;选择责任人也会自动关联总负责人。</p>
</div>
<div class="sys-todo-field sys-todo-field--3">
<label for="sysTodoStartDate">开始时间</label>
<div class="sys-todo-date-control" data-sys-todo-date-control="start">
@ -940,8 +959,6 @@
</div>
</dialog>
<datalist id="sysTodoAssignees">@foreach ($todoAssignees as $assignee)<option value="{{ $assignee }}">@endforeach</datalist>
<script>
(function () {
var dialog = document.getElementById('sysTodoDialog');
@ -995,6 +1012,11 @@
var ownerOptionInputs = Array.prototype.slice.call(
dialog.querySelectorAll('[data-sys-todo-owner-options] input[type="checkbox"]')
);
var assigneeValue = dialog.querySelector('[data-sys-todo-assignee-value]');
var assigneeOptionInputs = Array.prototype.slice.call(
dialog.querySelectorAll('[data-sys-todo-assignee-options] input[type="checkbox"]')
);
var dialogAssigneeEmpty = dialog.querySelector('[data-sys-todo-assignee-empty]');
var deleteButton = dialog.querySelector('[data-sys-todo-open-delete]');
var deleteConfirm = document.getElementById('sysTodoDeleteConfirm');
var deleteForm = deleteConfirm.querySelector('[data-sys-todo-delete-form]');
@ -1015,6 +1037,7 @@
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var contentOptions = @json($todoContentOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var assigneeOwnerMap = @json($todoAssigneeOwners, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var serverColumnWidths = @json($todoColumnWidths, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var csrfToken = @json(csrf_token());
var currentTodoId = null;
@ -1025,6 +1048,13 @@
var ownerSummary = ownerFilter.querySelector('[data-sys-todo-owner-summary]');
var assigneeFilter = document.querySelector('[data-sys-todo-assignee-filter]');
var assigneeSummary = assigneeFilter.querySelector('[data-sys-todo-assignee-summary]');
var filterOwnerInputs = Array.prototype.slice.call(
ownerFilter.querySelectorAll('input[name="owner[]"]')
);
var filterAssigneeOptions = Array.prototype.slice.call(
assigneeFilter.querySelectorAll('[data-sys-todo-assignee-option]')
);
var filterAssigneeEmpty = assigneeFilter.querySelector('[data-sys-todo-filter-assignee-empty]');
var tableWrap = document.querySelector('[data-sys-todo-table-wrap]');
var todoTable = tableWrap.querySelector('.sys-todo-table');
var todoColumns = Array.prototype.slice.call(todoTable.querySelectorAll('[data-sys-todo-col]'));
@ -1334,6 +1364,11 @@
.filter(Boolean);
}
function ownersForAssignee(name) {
var owners = assigneeOwnerMap[name];
return Array.isArray(owners) ? owners : [];
}
function updateOwnerValue() {
ownerValue.value = ownerOptionInputs
.filter(function (input) { return input.checked; })
@ -1347,6 +1382,86 @@
input.checked = selected.indexOf(input.value) !== -1;
});
updateOwnerValue();
syncDialogAssigneeOptions(false);
}
function updateAssigneeValue() {
assigneeValue.value = assigneeOptionInputs
.filter(function (input) { return input.checked; })
.map(function (input) { return input.value; })
.join('、');
}
function syncDialogAssigneeOptions(clearIncompatible) {
var selectedOwners = ownerOptionInputs
.filter(function (input) { return input.checked; })
.map(function (input) { return input.value; });
var visible = 0;
assigneeOptionInputs.forEach(function (input) {
var option = input.closest('[data-sys-todo-assignee-option]');
var owners = option ? splitPeople(option.getAttribute('data-assignee-owners')) : [];
var allowed = selectedOwners.length === 0
|| owners.some(function (owner) { return selectedOwners.indexOf(owner) !== -1; });
if (option) option.hidden = !allowed;
input.disabled = !allowed;
if (!allowed && clearIncompatible) input.checked = false;
if (allowed) visible += 1;
});
dialogAssigneeEmpty.hidden = visible !== 0;
updateAssigneeValue();
}
function setAssigneeValue(value) {
var selected = splitPeople(value);
var relatedOwners = [];
assigneeOptionInputs.forEach(function (input) {
input.checked = selected.indexOf(input.value) !== -1;
if (input.checked) {
ownersForAssignee(input.value).forEach(function (owner) {
if (relatedOwners.indexOf(owner) === -1) relatedOwners.push(owner);
});
}
});
ownerOptionInputs.forEach(function (input) {
if (relatedOwners.indexOf(input.value) !== -1) input.checked = true;
});
updateOwnerValue();
syncDialogAssigneeOptions(false);
updateAssigneeValue();
}
function syncFilterAssigneeOptions(clearIncompatible) {
var selectedOwners = filterOwnerInputs
.filter(function (input) { return input.checked; })
.map(function (input) { return input.value; });
var visible = 0;
filterAssigneeOptions.forEach(function (option) {
var input = option.querySelector('input[name="assignee[]"]');
if (!input) return;
var owners = splitPeople(option.getAttribute('data-assignee-owners'));
var allowed = selectedOwners.length === 0
|| owners.some(function (owner) { return selectedOwners.indexOf(owner) !== -1; });
option.hidden = !allowed;
input.disabled = !allowed;
if (!allowed && clearIncompatible) input.checked = false;
if (allowed) visible += 1;
});
filterAssigneeEmpty.hidden = visible !== 0;
updatePeopleFilterSummary(assigneeFilter, assigneeSummary);
}
function syncFilterOwnersFromAssignees() {
var relatedOwners = [];
assigneeFilter.querySelectorAll('input[name="assignee[]"]:checked').forEach(function (input) {
ownersForAssignee(input.value).forEach(function (owner) {
if (relatedOwners.indexOf(owner) === -1) relatedOwners.push(owner);
});
});
filterOwnerInputs.forEach(function (input) {
if (relatedOwners.indexOf(input.value) !== -1) input.checked = true;
});
updatePeopleFilterSummary(ownerFilter, ownerSummary);
syncFilterAssigneeOptions(false);
}
function setCategoryValue(value) {
@ -1588,7 +1703,7 @@
refreshSectionOptions(payload.section);
setValue('title', payload.title);
setOwnerValue(payload.owner);
setValue('assignee', payload.assignee);
setAssigneeValue(payload.assignee);
applyDateControl(
startDateControl,
payload.start_date || (!currentTodoId ? defaultStartDate : ''),
@ -1603,6 +1718,7 @@
setCategoryValue('');
refreshSectionOptions('');
setOwnerValue('');
setAssigneeValue('');
applyDateControl(startDateControl, defaultStartDate, '');
applyDateControl(dueDateControl, '', '');
}
@ -1989,18 +2105,40 @@
if (event.target.matches('[data-sys-todo-auto-grow]')) resizeTextarea(event.target);
});
ownerOptionInputs.forEach(function (input) {
input.addEventListener('change', updateOwnerValue);
});
ownerFilter.querySelectorAll('input[name="owner[]"]').forEach(function (input) {
input.addEventListener('change', function () {
updatePeopleFilterSummary(ownerFilter, ownerSummary);
updateOwnerValue();
syncDialogAssigneeOptions(true);
});
});
assigneeOptionInputs.forEach(function (input) {
input.addEventListener('change', function () {
updateAssigneeValue();
var relatedOwners = [];
assigneeOptionInputs
.filter(function (option) { return option.checked; })
.forEach(function (option) {
ownersForAssignee(option.value).forEach(function (owner) {
if (relatedOwners.indexOf(owner) === -1) relatedOwners.push(owner);
});
});
ownerOptionInputs.forEach(function (option) {
if (relatedOwners.indexOf(option.value) !== -1) option.checked = true;
});
updateOwnerValue();
syncDialogAssigneeOptions(false);
});
});
filterOwnerInputs.forEach(function (input) {
input.addEventListener('change', function () {
syncFilterAssigneeOptions(true);
});
});
assigneeFilter.querySelectorAll('input[name="assignee[]"]').forEach(function (input) {
input.addEventListener('change', function () {
updatePeopleFilterSummary(assigneeFilter, assigneeSummary);
syncFilterOwnersFromAssignees();
});
});
syncFilterAssigneeOptions(true);
document.addEventListener('click', function (event) {
if (!event.target.closest('[data-sys-todo-assignee-filter]')) {
assigneeFilter.removeAttribute('open');

View File

@ -59,6 +59,11 @@ class SystemizationTodosTest extends FeatureTestCase
->assertSee('name="due_pending"', false)
->assertSee('data-sys-todo-owner-filter', false)
->assertSee('data-sys-todo-assignee-filter', false)
->assertSee('data-assignee-owners', false)
->assertSee('data-sys-todo-assignee-options', false)
->assertSee('data-sys-todo-assignee-value', false)
->assertDontSee('id="sysTodoAssignees"', false)
->assertDontSeeText('无提醒')
->assertSee('name="owner[]"', false)
->assertSee('name="assignee[]"', false)
->assertSee('data-sys-todo-category-select', false)
@ -98,6 +103,10 @@ class SystemizationTodosTest extends FeatureTestCase
[SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED],
$pageData['todoStatuses'],
);
$this->assertSame([
'value' => 'none',
'label' => '已完成',
], $pageData['todoReminderOptions']->firstWhere('value', 'none'));
$storeResponse = $this->post(route('business.systemization-todos.store'), [
'category' => '收入',
@ -459,6 +468,49 @@ class SystemizationTodosTest extends FeatureTestCase
$this->assertSame('常仪', $todo->refresh()->owner);
}
public function test_assignee_owner_correspondence_map_includes_fixed_and_derived_people(): void
{
$this->loginAs();
$this->createTodo(
'收入',
'负责人关联',
'关联玉兔',
'2026-11-30',
1,
'常仪',
'玉兔',
);
$this->createTodo(
'收入',
'负责人关联',
'关联蕙兰',
'2026-11-30',
2,
'常仪',
'蕙兰',
);
$this->createTodo(
'收入',
'负责人关联',
'关联李斯',
'2026-11-30',
3,
'人参',
'李斯',
);
$content = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))->assertOk()->viewData('content');
$map = $content['todoAssigneeOwners'];
$this->assertSame(['常仪'], $map['玉兔']);
$this->assertSame(['常仪'], $map['蕙兰']);
$this->assertSame(['人参'], $map['李斯']);
}
public function test_can_insert_a_todo_at_a_selected_sequence_and_shift_existing_items(): void
{
$this->loginAs();