feat: 联动系统化待办负责人和责任人 (#697)
## 实际改动 - 提醒筛选项“无提醒”改为“已完成”,保持原有已完成筛选逻辑。 - 总负责人和责任人建立双向关联:选择总负责人后只显示对应责任人,选择责任人时自动勾选对应总负责人。 - 编辑弹窗将原生联想输入改为统一的勾选面板,并使用同一套负责人关联逻辑。 - 责任人关联同时支持固定映射和现有数据推导,惠兰等未在固定清单中的人员也能从历史数据正确归属。 - DESIGN.md 与机器设计规范新增跨设备控件准则,后续页面统一复用内置字体和自绘控件。 ## 验证证据 - SystemizationTodosTest 通过:23 tests,343 assertions。 - 覆盖已完成筛选标签、固定人员映射、历史数据推导和自定义责任人控件。 - 不涉及数据库迁移、金额、权限或上游数据。 ## 合并前复查 - 基于最新 origin/main(da546877)开发。 - 已自审完整 diff,未包含敏感信息或无关修改。 - 提交:fa7d2608。 Reviewed-on: #697
This commit is contained in:
parent
da546877ba
commit
c33bb7308e
@ -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" }
|
||||
],
|
||||
|
||||
15
DESIGN.md
15
DESIGN.md
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user