From 0d77a7d55bb65efdfb37a29512dc4395c55ccc4d Mon Sep 17 00:00:00 2001 From: luo Date: Wed, 14 Oct 2026 17:08:32 +0800 Subject: [PATCH] fix: some --- .../design.md | 24 +- .../implementation.md | 55 +++- .../proposal.md | 15 +- .../specs/admin-authentication/spec.md | 68 ++++- .../tasks.md | 22 +- .../tests/employeeCollectionActing.test.mjs | 233 ++++++++++++++++ scripts/tests/octoberBatchOne.test.mjs | 256 ++++++++++++++++- .../tests/tableHeaderDragLifecycle.test.mjs | 258 ++++++++++++++++++ src/api/modules/agentDistribution.ts | 7 +- .../art-header-bar/ChangePasswordDialog.vue | 1 + src/components/core/tables/ArtTableHeader.vue | 37 ++- src/locales/langs/en.json | 2 +- src/locales/langs/zh.json | 2 +- src/template/设备导入模板.xlsx | Bin 9018 -> 9806 bytes src/types/api/account.ts | 2 +- src/types/api/auth.ts | 1 + src/types/components.d.ts | 16 ++ src/types/store/index.ts | 1 + src/utils/business/agentRegistrationError.ts | 24 ++ .../business/employeeCollectionActing.ts | 34 +++ src/utils/validation/loginPassword.ts | 16 +- src/views/agent-registration/index.vue | 71 +++-- .../components/ApplicationFormDialog.vue | 57 ++-- 23 files changed, 1113 insertions(+), 89 deletions(-) create mode 100644 scripts/tests/employeeCollectionActing.test.mjs create mode 100644 scripts/tests/tableHeaderDragLifecycle.test.mjs create mode 100644 src/utils/business/agentRegistrationError.ts create mode 100644 src/utils/business/employeeCollectionActing.ts diff --git a/openspec/changes/update-october-batch-one-admin-integration/design.md b/openspec/changes/update-october-batch-one-admin-integration/design.md index ecf00e6..68e8879 100644 --- a/openspec/changes/update-october-batch-one-admin-integration/design.md +++ b/openspec/changes/update-october-batch-one-admin-integration/design.md @@ -2,9 +2,9 @@ ## Context -本仓库为 Vue 3/TypeScript 后台,HTTP 服务使用 `BaseService` 与 Axios,页面已有业务权限、状态映射、详情组件、异步导入导出任务。用户明确排除所有 C 端接口和页面。后台本人改密虽然使用 `/api/auth/password`,仍属于本次后台适配。 +本仓库为 Vue 3/TypeScript 后台,HTTP 服务使用 `BaseService` 与 Axios,页面已有业务权限、状态映射、详情组件、异步导入导出任务。原范围排除 C 端;2026-10-14 用户追加代理扫码注册页及同一注册接口的密码校验,其余 C 端接口和页面仍排除。后台本人改密虽然使用 `/api/auth/password`,仍属于本次后台适配。 -本设计依据十月第 1 批说明、用户最新确认和当前代码。用户确认不提供最新 OpenAPI,不以该文件作为实施前置条件;新密码禁止特殊符号的确认优先于原说明。实施已获批准,代码与本地模拟页面验证详见实施记录;没有调用真实后台写接口或验证后端运行行为。 +本设计依据十月第 1 批说明、用户最新确认和当前代码。用户确认不提供最新 OpenAPI;2026-10-14 修正为新密码允许特殊符号,恢复四类至少三类。实施已获批准,结果详见实施记录,没有调用真实后台写接口。 ### 代码证据与修改路径 @@ -24,7 +24,7 @@ ## Goals / Non-goals - Goals:让后台显示、筛选、请求字段和可执行操作符合本批次说明;保留既有权限、表单内容、财务快照和导入导出流程。 -- Non-goals:C 端适配、后台接口实现、新审批机制、新页面架构、自动解绑手机号、前端重算预警或余额、替换设备导入模板、引入测试框架。 +- Non-goals:除代理扫码注册密码规则外的 C 端适配、后台接口实现、新审批机制、新页面架构、自动解绑手机号、前端重算预警或余额、替换设备导入模板、引入测试框架。 ## Decisions @@ -46,9 +46,11 @@ ### 3. 新密码校验专用化 -新增/复用新登录密码专用工具,避免直接改通用 `validatePassword` 或 `validateStrongPassword` 影响非登录密码。按用户最新确认,长度 8~12,仅允许 ASCII 英文字母和数字,并必须同时包含大写字母、小写字母和数字;所有特殊符号、空白、中文、emoji 和其他非英文字母数字字符均拒绝。不对输入静默 `trim` 后放行。 +使用新登录密码专用工具,避免影响通用校验与其他密码。按最新修正,长度 8~12,允许英文字母、数字及所有可打印半角 ASCII 符号,四类至少三类;空白、中文、emoji、控制字符和非 ASCII 字符拒绝。不静默 trim。 -实现判定为 `^[A-Za-z0-9]{8,12}$` 且同时满足 `[A-Z]`、`[a-z]`、`[0-9]` 三个包含条件。例如 `Abc12345` 可接受,`abcd1234` 因缺少大写拒绝,`Abc1234!` 即使包含大写、小写和数字也因特殊符号拒绝。 +字符范围为 ASCII `0x21~0x7E`,按大写、小写、数字和标点四类计数,至少三类。`Admin@12345`、`ABC1234!`、`abc1234!`、`Abcdefg!` 均有效;`abcd1234`、`ABCDEF!!` 仅两类无效。 + +顶栏 `z-index:100`、侧栏 `z-index:101` 导致嵌在顶栏内的改密遮罩被侧栏压住。仅给改密弹窗加 `append-to-body`,不全局提高顶栏层级或修改所有弹窗;由 Element Plus 管理完整遮罩、焦点和关闭行为。 新密码和确认密码统一长度限制、规则说明与一致性校验;本人改密 `old_password` 不设新规则的 `maxlength` 或类别限制,旧登录表单不收紧。账号编辑目前没有密码输入则不新增;若既有调用传递可选 `password`,仅非空新值需要校验,未修改密码时不发送空串。 @@ -64,6 +66,12 @@ | 创建企业 | `POST /api/admin/enterprises` | `password` | | 修改企业密码 | `PUT /api/admin/enterprises/{id}/password` | `password` | +用户明确追加的公开代理注册使用原 `POST /api/c/v1/agent-distribution-registrations` 和 `password` 字段。表单复用 `createNewLoginPasswordRule()`,注册服务发送前复用 `getNewLoginPasswordError()`;保留其他入参和 `withToken: false`。页面将输入上限改为 12,完整规则独立显示在输入框下,并关联 `aria-describedby`,避免移动端 placeholder 截断规则。强度反馈仅按共享校验判定合格/未合格,空格和中文不能被计作有效特殊符号;长错误文案参与正常布局,不覆盖下一个表单项。 + +用户进一步追加注册失败反馈:仅 `registerAgent` 请求设 `errorMessageMode: 'none'`,页面处理非零业务响应与 HTTP 异常。1050/1001 不暴露企业微信模板配置细节,统一「注册失败, 请联系管理员」,其余错误保留具体业务原因或正常网络降级;不记录密码或完整错误对象。提交进行中阻止再次提交,失败保留表单。公共 HTTP、短信与其他服务的错误提示保持原状。 + +公共表头 Sortable 报错作为既有列设置功能的生命周期修复:改用 `useDraggable(..., { immediate: false })` 和真实 `HTMLElement` ref,仅在列设置 Popover 已打开且进入后,目标仍连接文档时启动;关闭、卸载及 KeepAlive 停用销毁实例,晚到回调不得重新启动。列排序仍通过原 `columns` model 写回,勾选状态和其他表头功能不变。 + 错误消息兼容项目既有 `msg` 与说明中的 `message`,优先展示后端提供的可读字段级原因;不改变 401 等既有统一处理,不重复弹错、不输出密码值。 ### 4. 换货 1202 定向错误处理 @@ -95,7 +103,7 @@ CSV 只同步新表头和手机号说明,不为旧表头做客户端转换、 ## Risks / Trade-offs - 无最新 OpenAPI:按用户确认,以第 1 批说明实施,不要求补文件;联调发现真实接口与说明不符时先反馈,不自行猜测或声称已通过联调。 -- 密码口径覆盖原说明:用户明确禁止特殊符号,前端不得按原「四类至少三类」放行含符号的新密码;如后端实际校验与该确认冲突,先反馈用户。 +- 密码口径以最新修正为准:允许半角特殊符号并按四类至少三类校验;如后端字符集合仍有不同约束,联调时反馈用户。 - 恢复旧规范与新业务终态冲突:文档注明差异,实施时优先按本批次终态处理,结果未知继续守原恢复规则。 - 改错密码/流量范围:专用新密码工具和独立规则/套餐类型限制修改范围,回归旧密码、操作密码、WiFi 密码、套餐额度。 - 换货错误提示重复或泄漏号码:两个请求定向接管提示,共享处理器保留其他错误行为,删除受影响分支的完整错误输出。 @@ -111,7 +119,7 @@ CSV 只同步新表头和手机号说明,不为旧表头做客户端转换、 ## 已确认事项 1. 没有最新 `admin-openapi.yaml`,不要求提供,以第 1 批说明实施。 -2. 新密码 8~12 位,仅允许英文字母和数字,且大写、小写和数字均必需,不允许特殊符号。 -3. 所有 `/api/c/v1` 和 C 端页面排除;后台代理注册失败仅展示重新扫码申请指引,不代发 C 端申请。 +2. 新密码 8~12 位,大写、小写、数字、半角特殊符号至少三类,禁止空白和中文。 +3. 用户明确追加的代理扫码注册密码规则纳入;其他 `/api/c/v1` 和 C 端页面排除。后台代理注册失败仍仅展示重新扫码申请指引,不代发 C 端申请。 当前没有待确认的需求问题。用户已批准实施,后台代码已适配;真实后端联调项继续在任务清单保留未完成状态。 diff --git a/openspec/changes/update-october-batch-one-admin-integration/implementation.md b/openspec/changes/update-october-batch-one-admin-integration/implementation.md index 8a16a77..09d196a 100644 --- a/openspec/changes/update-october-batch-one-admin-integration/implementation.md +++ b/openspec/changes/update-october-batch-one-admin-integration/implementation.md @@ -1,6 +1,6 @@ # 后台实施记录 -后台代码已按第 1 批对接说明及用户确认适配,仅后台,不修改 C 端。真实后端联调尚未完成,未部署、提交或调用真实后台写接口。 +后台代码已按第 1 批对接说明及用户确认适配。2026-10-14 用户明确追加代理扫码注册页及原注册接口的密码校验,其他 C 端仍不修改。真实后端联调尚未完成,未部署、提交或调用真实写接口。 ## 结果与证据 @@ -8,7 +8,7 @@ | --- | --- | --- | | 规则测试与本地订单详情 | 支付摘要、独立支付单状态、失败记录、空时间与金额展示正常 | `src/types/api/order.ts`、订单列表/详情、`orderPayment.ts` | | 规则测试与模拟核销/代理注册详情 | 终态优先于陈旧恢复标志,核销可重提、代理注册提示重新扫码 | 核销展示工具、退款常量、注册状态辅助函数及相应页面 | -| 规则测试与模拟改密弹窗 | 新密码禁止符号,旧密码不受新长度限制;375px 下错误说明不重叠 | `loginPassword.ts`、后台密码表单、本人改密弹窗与双语文案 | +| 规则测试与模拟改密弹窗 | 按 2026-10-14 修正允许特殊符号且四类至少三类;旧密码不受新长度限制,错误说明不重叠 | `loginPassword.ts`、后台密码表单、本人改密弹窗与双语文案 | | 规则测试与原模拟换货确认完成 | 1202 按最新规则原样展示 `msg`,有效响应手机号才提供跳转;原待确认状态保持,不自动解绑 | `exchangeError.ts`、换货页面与服务 | | 数值测试与模拟预警规则/详情 | 规则额度移除,MB 快照两位小数,零和缺失区分,套餐商品额度保留 | 预警类型、规则页、记录列表/详情及 `trafficAlert.ts` | | 模板和源码回归检查 | 新四列表头与手机号说明,设备 IMEI 必填说明、原 Excel 模板和行级失败反馈保持 | `shopBusinessOwnerImport.ts`、导入页面 | @@ -70,3 +70,54 @@ node scripts/tests/verifyBuild.mjs --serve 同为 1202 的「已完成过换货」响应 `data=null`,只弹出后端 `msg` 和「知道了」,不出现关联管理按钮;带有效 `data.phone` 的绑定冲突才保留原权限内的去解绑入口。两种弹窗正文都使用原始 `msg`,不再拼接手机号或固定指引。新增回归覆盖 HTTP/业务承载、null/空白/非字符串号码及资产历史数组不得绕过门槛,全套测试增至 67 项。 该修正的 67 项自动回归、类型检查、lint、格式、编码及严格规范校验通过。重新做浏览器检查时本地会话回到了资产首页,未完成两种新弹窗的视觉验证;未将此项标为已通过,也未操作真实换货或解绑数据。 + +## 2026-10-14:允许特殊符号与改密遮罩覆盖侧栏 + +最新密码规则恢复为 8~12 位,大写、小写、数字、半角特殊符号至少三类,禁止空白和中文等非法字符。`Admin@12345`、`ABC1234!`、`abc1234!`、`Abcdefg!` 可通过;仅两类、超长/过短、空白、中文、emoji 和控制字符拒绝。共享规则和双语文案已同步所有后台新密码表单及账号服务,不改旧密码输入、登录、C 端、操作密码或 WiFi 密码。 + +侧栏未变暗的原因是顶栏堆叠上下文(100)低于侧栏(101),改密弹窗此前在顶栏内部。加 `append-to-body` 后,DOM 检查确认实际遮罩父元素是 BODY、不在顶栏中;菜单位置命中遮罩而非菜单。1920px 和 375px 截图检查通过,整页包括侧栏一起变暗,未改变全局 z-index。 + +全套 68 项 Node 测试、类型检查、受影响文件 ESLint、该弹窗 Stylelint、格式、UTF-8 和规范校验通过。模拟页面中 `Admin@12345` 通过表单校验并发出本地 `PUT /api/auth/password` 请求,无表单规则错误。没有修改真实账号密码;工作区同时出现的设备 Excel 模板及其临时文件属于用户其他工作,未触及。 + +## 2026-10-14:全后台新密码入口复核 + +用户要求所有修改密码入口使用相同规则。全量搜索密码输入、独立校验、动态输入弹窗及密码服务调用后,确认既有后台入口均已引用 `loginPassword.ts`;共享规则修改会同时生效,不只影响顶栏弹窗。本次没有发现需要补改的后台密码表单,也不新建账号改密入口。 + +| 后台入口 | 表单文件 | 已核对字段 | +| --- | --- | --- | +| 本人修改密码 | `src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue` | `new_password`、`confirm_password` | +| 平台账号创建 | `src/views/account-management/account/index.vue` | `password` | +| 店铺/企业下客户账号创建 | `src/views/common/account-list.vue` | `password` | +| 店铺初始账号创建 | `src/views/shop-management/list/index.vue` | `init_password` | +| 企业创建及企业修改密码 | `src/views/account-management/enterprise-customer/index.vue` | 两个表单各自的 `password` | + +新增源码契约回归,逐项确认上述七个新密码输入使用共享提示、12 位输入上限、无 `.trim` 的绑定,以及共享校验与必填规则,没有叠加旧长度或字符正则。账号服务的可选密码修改与独立改密仍使用共享规则。仓库中未被这些表单调用的通用密码工具不改变;登录/旧密码、操作密码、WiFi/锁屏密码和公开扫码注册页继续排除。 + +可复现检查仍使用上面的 Node 测试命令;全套增至 69 项并通过,类型、测试文件 ESLint/格式、编码及 OpenSpec 严格校验通过。此轮为源码及自动回归核对,没有修改真实账号密码或新增权限编码。docs-generator 用于明确列出实际覆盖入口,并区分源码核对与真实后端联调。 + +## 2026-10-14:用户追加代理扫码注册密码规则 + +原扫码页按此前排除 C 端的范围保留了 8~32 位校验。用户现在明确要求该页及同一注册接口的 `password` 使用 8~12 位、四类至少三类、禁止空格和中文,本轮只将这项纳入,不扩大到其他 C 端功能。 + +| 证据 | 已确认结果 | 实现路径 | +| --- | --- | --- | +| 表单源码契约回归与共享规则测试 | 扫码页复用同一规则,输入上限 12,无 `.trim`,完整规则可见;强度反馈不把空格/中文算作有效符号 | `src/views/agent-registration/index.vue`、`loginPassword.ts` | +| 注册服务桩测试 | 有效三类组合原样发送;过短/超长/两类/空格/中文阻止请求;原注册路径、其他字段、免 Token 和短信调用不变 | `src/api/modules/agentDistribution.ts`、`scripts/tests/octoberBatchOne.test.mjs` | + +ui-ux-pro-max 影响本次提示处理:保留既有页面与 Element Plus 样式,完整规则独立显示并关联输入框,使用文字反馈而不只用颜色,长校验错误参与正常布局。docs-generator 用于明确记录这次范围例外和未完成的浏览器验收。 + +全套 71 项自动测试、类型检查、受影响文件 ESLint/Stylelint、格式、UTF-8、OpenSpec 严格校验及生产构建通过。当前 CUA 浏览器列表为空,IAB 和 Chrome 均返回不可用,未完成移动端视觉检查,不将其标为通过。没有发送真实短信、提交真实注册或改变任何权限编码。 + +## 2026-10-14:注册单次失败提示与公共表头拖拽 + +用户发现公开代理注册在 1050/1001 模板错误时,公共请求层与页面 catch 分别弹消息。只给原注册请求设置 `errorMessageMode: 'none'`,页面以共享映射处理非零响应或 HTTP 错误:1050/1001 显示「注册失败, 请联系管理员」,其他可读业务原因保留。短信、后台注册审核和公共 HTTP 不改。请求进行中阻止重复提交,失败保留输入,不日志输出密码。 + +代理注册申请列表还报告 `Sortable: el must be an HTMLElement`。仓库唯一 VueDraggable 位于公共表头 Popover 的列设置;原组件自动启动可能拿到未渲染/已离开的空 ref。修复改用 `useDraggable` 禁止自动启动,Popover 打开且进入后仅对真实、仍连接文档的 HTMLElement 初始化;关闭、卸载和 KeepAlive 停用销毁,晚到回调忽略。原列排序双向 model、勾选状态和按钮不变。 + +| 证据 | 已确认结果 | 实现路径 | +| --- | --- | --- | +| 共享错误映射及服务桩测试 | 业务/HTTP 的 1050/1001 隐藏配置细节,原注册配置关闭公共消息,短信配置不变 | `agentRegistrationError.ts`、`agentDistribution.ts` | +| 实际扫码页 setup 执行测试 | 每种模板错误只调用一次指定消息;并发重复点击不新增请求;提交失败、加载状态及表单正确保留 | `octoberBatchOne.test.mjs`、扫码页 | +| 编译后的表头 Vue 生命周期测试,拖拽控制器使用桩 | 首次挂载不自动启动;空/非元素/脱离节点不启动;开关、卸载和缓存切换正确清理;列顺序及 checked 保持 | `tableHeaderDragLifecycle.test.mjs`、`ArtTableHeader.vue` | + +ui-ux-pro-max 的生命周期建议用于把拖拽实例绑定真实节点和清理事件,不做视觉改版。docs-generator 用于区分桩/组件逻辑证据与真实浏览器验收。全套 78 项 Node 测试、类型、受影响文件 lint/stylelint、格式、编码、严格规范及生产构建通过;构建仍存在此前记录的常量赋值/循环分块/大 chunk 警告。当前无浏览器连接,未声称已重放用户浏览器的 console 堆栈,也未发送真实注册、短信或修改权限。 diff --git a/openspec/changes/update-october-batch-one-admin-integration/proposal.md b/openspec/changes/update-october-batch-one-admin-integration/proposal.md index 357d14f..da96d57 100644 --- a/openspec/changes/update-october-batch-one-admin-integration/proposal.md +++ b/openspec/changes/update-october-batch-one-admin-integration/proposal.md @@ -4,14 +4,16 @@ 依据 [第 1 批前端对接说明](../../../docs/产品迭代10月份修改/第1批-8-9月迭代前端对接说明.md),后台现有代码仍使用旧密码校验、旧审批业务状态和旧预警规则字段,缺少订单在线支付记录与换货手机号冲突反馈。后端已部署测试环境,后台需要同步契约与交互,避免失败单据继续显示审批恢复操作、导入提示误导以及已删除字段显示异常。 -用户已于 2026-10-13 批准实施。后台代码已完成适配,本地验证与真实后端联调状态分开记录在 [实施记录](./implementation.md)。用户已确认:只管后台,所有 `/api/c/v1` 接口及 C 端页面均不在范围内。 +用户已于 2026-10-13 批准实施。后台代码已完成适配,本地验证与真实后端联调状态分开记录在 [实施记录](./implementation.md)。原范围只管后台;2026-10-14 用户明确追加代理扫码注册页及同一注册接口的密码校验和失败提示,其余 `/api/c/v1` 接口及 C 端页面仍不在范围内。 ## What Changes - M3:后台订单列表和详情展示 `third_party_trade_no`、`payment_channel`、`payment_merchant_name`;详情新增支付记录表,详情类型与列表/创建类型分离,支付单状态独立于订单支付状态。 - M8、M14:六类后台业务补充各自的「审批提交失败」终态、筛选、标签和操作判断;显式失败不再恢复原审批,结果未知保留原有恢复资格与权限判断。 - M8:核销申请列表和详情使用真实的 `approval_submission_status` 展示审批提交状态,不能只依赖当前可能为空的名称字段;账单详情中的申请状态同步扩展。 -- M1:**BREAKING**:按用户最新确认,后台七个密码接口相关的既有表单、服务和类型统一到「8~12 位、只允许英文字母和数字,必须同时包含大写字母、小写字母和数字,不允许特殊符号」,包括后台 `PUT /api/auth/password`;账号独立改密服务按文档将请求字段修正为 `new_password`。旧密码登录、本人改密的旧密码输入、操作密码、WiFi 密码不套用新密码规则。 +- M1:**BREAKING**:按 2026-10-14 用户修正,后台七个密码接口相关表单、服务和类型统一到「8~12 位,大写、小写、数字、半角特殊符号至少三类,无空格或中文」,包括后台 `PUT /api/auth/password`;账号独立改密使用 `new_password`。本人改密弹窗挂载到 body,使遮罩覆盖侧栏。旧密码输入、操作密码和 WiFi 密码不套用新规则。 +- 用户追加 M1:代理扫码注册页与 `POST /api/c/v1/agent-distribution-registrations` 的 `password` 复用相同校验;同步 12 位输入上限、可见规则及强度反馈,其他注册字段、短信、接口路径和免 Token 行为不变。 +- 用户追加注册反馈:仅原公开代理注册请求关闭公共错误消息,页面将 1050/1001 映射为「注册失败, 请联系管理员」且每次失败只提示一次,其他业务错误按其具体原因展示;不修改公共 HTTP 处理或其他接口。 - M12:创建换货与确认完成捕获错误码 `1202`,从错误响应 `data.phone` 读取手机号,在弹窗中引导到已有「手机号资产关联」入口;不自动解绑、不新增扫码/退款前置拦截,失败时保留表单及原单状态。 - 用户追加 M12 快捷处理:跳转携带本次新资产标识与关联手机号,关联页面自动预填筛选并勾选匹配有效记录;复用原批量解绑权限和二次确认,结果表布尔值改为中文成功/失败,不自动执行解绑。 - M16:**BREAKING**:删除预警规则响应类型、规则列表及规则详情展示中对 `real_data_mb` 的依赖;预警列表和详情补齐 `used_mb`、`limit_mb` 及新增 `threshold_mb`,以 MB 展示触发时快照,说明包含加油包。套餐接口和套餐选项中的 `real_data_mb` 保留。 @@ -24,7 +26,7 @@ | 项目 | 后台纳入内容 | 不纳入内容 | | --- | --- | --- | -| M1 | 本人改密、账号创建/修改/独立改密、店铺初始密码、企业创建/改密,共七个接口 | 扫码注册代理页面的密码校验 | +| M1 | 后台七个密码接口;用户追加代理扫码注册页面及原注册接口的密码校验、失败提示 | 其他 C 端页面/接口与非登录密码 | | M3 | `/api/admin/orders` 列表/创建响应与详情支付记录 | C 端订单请求、支付流程 | | M8、M14 | 核销、退款、代理注册审核、线下充值、佣金提现、提现资料资格 | C 端注册申请及其他 `/api/c/v1` 调用 | | M12、M13 | 换货冲突提示、退款重提引导及回归 | 后端手机号解绑规则、企微回调实现 | @@ -45,7 +47,7 @@ ### 不新增的能力 -- 不修改任何 `/api/c/v1` 服务封装或页面,包括本仓库已有的公开扫码注册页。 +- 除用户明确追加的代理扫码注册密码规则与失败提示外,不修改其他 `/api/c/v1` 服务或页面;不改变公开注册请求路径、字段和免 Token 行为。 - 不新增后端接口、权限编码、审批实例或财务冻结/释放逻辑。 - 不因密码接口清单存在而补建目前没有的账号改密页面;覆盖既有页面和服务契约,新增入口须另行确认。 - 不修改 `src/types/components.d.ts` 的用户已有改动。 @@ -56,6 +58,7 @@ - Affected code: - `src/types/api/{order,account,employeeCollection,refund,agentDistributionRegistration,agentRecharge,commission,packageTrafficAlert}.ts` 及相关类型导出。 - `src/api/modules/{order,account,exchange}.ts`;其余审批相关服务仅按既有接口复用,不扩展 C 端请求。 + - 用户追加:`src/views/agent-registration/index.vue`、`src/api/modules/agentDistribution.ts` 的注册密码校验与类型注释。 - `src/views/order-management/order-list/{index,detail}.vue`。 - `src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue`、`src/views/account-management/{account,enterprise-customer}/index.vue`、`src/views/common/account-list.vue`、`src/views/shop-management/list/index.vue`。 - `src/views/finance/employee-collection/`、`src/views/finance/refund/`、`src/views/finance/agent-recharge/`、`src/views/shop-management/agent-distribution-registrations/`。 @@ -83,7 +86,7 @@ ## 已确认的实施依据 1. 用户确认没有最新 `admin-openapi.yaml`,本次不要求提供,也不将其作为实施前置条件;接口适配以第 1 批对接说明为依据,仓库中的八月 OpenAPI 不作为十月最新版。 -2. 用户确认密码不允许特殊符号,因此新密码只接受 ASCII 英文字母和数字,长度 8~12 位,必须同时包含大写、小写和数字;这一确认优先于原说明的「四类至少三类」规则。 -3. 只实施后台,所有 `/api/c/v1` 和 C 端页面仍排除。 +2. 用户于 2026-10-14 修正为允许特殊符号,恢复说明中的「四类至少三类」口径,长度 8~12 位、禁止空白和中文;半角可打印 ASCII 特殊符号均可用。此前禁止特殊符号的确认不再适用。 +3. 除 2026-10-14 明确追加的代理扫码注册页/同一注册接口的密码规则与失败提示外,其他 `/api/c/v1` 和 C 端页面仍排除。 以上需求疑问已消除。详见 [设计说明](./design.md)、[任务清单](./tasks.md) 与 [实施记录](./implementation.md);如联调发现实际接口与说明或用户确认不符,先向用户反馈,不自行改变口径。 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/admin-authentication/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/admin-authentication/spec.md index 2df73ae..85435ac 100644 --- a/openspec/changes/update-october-batch-one-admin-integration/specs/admin-authentication/spec.md +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/admin-authentication/spec.md @@ -2,26 +2,26 @@ ### Requirement: 后台新登录密码统一校验 -后台既有设置新登录密码的表单 SHALL 按用户最新确认使用专用统一校验:长度 8~12 位,仅允许 ASCII 英文字母和数字,必须同时包含大写字母、小写字母和数字。特殊符号、空白、中文、emoji 和其他非英文字母数字字符 MUST 拒绝;用户确认 MUST 优先于原对接说明的四类字符规则。 +后台既有设置新登录密码的表单 SHALL 按 2026-10-14 用户修正规则校验:长度 8~12 位,大写字母、小写字母、数字、半角特殊符号四类至少包含三类。可打印 ASCII 标点符号 MUST 允许;空白、中文、控制字符、emoji 等非 ASCII 字符 MUST 拒绝,不静默 trim。 #### Scenario: 有效新密码 - **WHEN** 用户在后台本人改密、账号创建、店铺初始账号、企业创建或企业改密入口输入 `Abc12345` - **THEN** 新密码校验 MUST 通过 -- **AND** 新密码/确认密码输入限制及说明 MUST 为 8~12 位,仅英文字母和数字,且大写、小写和数字三类均必需 +- **AND** 新密码/确认密码输入限制及说明 MUST 为 8~12 位,四类字符至少包含三类,允许半角特殊符号 - **AND** 需要确认密码的表单 MUST 验证其与新密码一致 #### Scenario: 无效新密码 -- **WHEN** 用户输入 7 位、13 位、缺少大写/小写/数字任一类的密码,或包含特殊符号、空白、中文、emoji 等非法字符的密码 +- **WHEN** 用户输入 7 位、13 位、只包含两类字符的密码,或包含空白、中文、emoji 等非法字符的密码 - **THEN** 前端 MUST 阻止提交并展示具体规则提示 - **AND** MUST NOT 静默 trim 密码后当作合法值提交 -#### Scenario: 字符类别齐全但含特殊符号 +#### Scenario: 含特殊符号且满足三类 -- **WHEN** 用户输入 `Abc1234!`,长度符合且已包含大写、小写和数字 -- **THEN** 前端 MUST 因 `!` 是禁止的特殊符号拒绝该密码 -- **AND** MUST 提示只允许英文字母和数字,不能按原四类字符规则放行 +- **WHEN** 用户输入 `Admin@12345`、`ABC1234!`、`abc1234!` 或 `Abcdefg!` +- **THEN** 前端 MUST 按至少三类规则放行,不额外要求大写、小写和数字三者同时存在 +- **AND** 提示 MUST 不再显示「不允许特殊符号」 #### Scenario: 修改账号未设置新密码 @@ -43,17 +43,65 @@ #### Scenario: 使用旧密码登录和本人改密 - **WHEN** 用户使用仍被后端接受的旧密码登录或填写本人改密 `old_password` -- **THEN** 前端 MUST NOT 对这些旧密码输入套用新的 8~12 位/三类/禁止特殊符号限制 +- **THEN** 前端 MUST NOT 对这些旧密码输入套用新的 8~12 位/至少三类限制 - **AND** 本人改密成功后的清理表单、清理登录态和返回登录页行为 MUST 保留 -#### Scenario: 非登录密码与 C 端保持不变 +#### Scenario: 非登录密码与其余 C 端保持不变 - **WHEN** 实施本批次密码适配 - **THEN** 操作密码、WiFi 密码和通用校验器其他调用行为 MUST 保持不变 -- **AND** 本仓库公开扫码注册页面和所有 `/api/c/v1` 服务 MUST 不修改 +- **AND** 除用户明确追加的代理扫码注册密码校验及失败提示外,其余 C 端页面和 `/api/c/v1` 服务 MUST 不修改 #### Scenario: 展示后端字段级原因 - **WHEN** 密码相关请求返回包含具体校验原因的 `msg` 或 `message` - **THEN** 页面 MUST 展示该原因且不重复弹错 - **AND** 请求、日志和错误反馈 MUST NOT 输出输入的密码值 + +### Requirement: 本人改密遮罩覆盖整个后台 + +本人修改密码弹窗 SHALL 使用 `append-to-body` 脱离顶栏堆叠上下文;遮罩 MUST 覆盖左侧菜单、顶栏和内容区,避免白色菜单压在遮罩上层,同时保留原表单、确认和关闭行为。 + +#### Scenario: 从顶栏打开改密弹窗 + +- **WHEN** 已登录用户打开本人修改密码弹窗 +- **THEN** 弹窗遮罩 MUST 挂载到 body,左侧菜单 MUST 与页面其他背景一起变暗且不可点击 +- **AND** 弹窗 MUST 继续支持 375px 视口,规则说明与错误提示不得重叠 + +### Requirement: 代理扫码注册密码复用统一规则 + +公开代理扫码注册表单及注册服务 SHALL 按用户明确追加范围复用后台新密码规则:8~12 位,大写字母、小写字母、数字、半角特殊符号至少三类,禁止空格和中文等非法字符,不静默 trim。注册路径、其他字段、短信验证码和免 Token 行为 MUST 保持不变,不扩大到其他 C 端功能。 + +#### Scenario: 输入有效注册密码 + +- **WHEN** 用户输入 `Abc12345`、`ABC1234!`、`abc1234!` 或 `Abcdefg!` +- **THEN** 表单及服务 MUST 校验通过,原样发送 `password` 至既有注册接口 +- **AND** 页面 MUST 展示完整规则、12 位输入上限和「密码符合规则」反馈 + +#### Scenario: 输入无效注册密码 + +- **WHEN** 用户输入过短、过长、仅两类字符,或带空格/中文的密码 +- **THEN** 表单及服务 MUST 阻止注册提交 +- **AND** 页面 MUST 展示具体校验原因,不能把空格/中文显示为满足规则 +- **AND** 规则说明和长错误文本 MUST 正常换行,不覆盖下一个表单项 + +### Requirement: 代理扫码注册失败仅提示一次 + +原公开代理注册请求 SHALL 关闭公共错误消息,由扫码注册页面统一处理失败;后台接口、短信及其他请求 MUST 保留原错误处理。错误码 1050/1001 MUST 显示「注册失败, 请联系管理员」,不暴露企业微信审批场景/模板配置细节。 + +#### Scenario: 企业微信模板错误阻止注册 + +- **WHEN** 注册接口以业务响应或 HTTP 错误返回 1050/1001 +- **THEN** 每次失败 MUST 只显示一次「注册失败, 请联系管理员」 +- **AND** 表单 MUST 保留供重试,不能显示提交成功或再显示「分销码不可用」 + +#### Scenario: 其他注册错误 + +- **WHEN** 注册失败不是 1050/1001,且后端提供可读 `msg` 或 `message` +- **THEN** 页面 MUST 只提示一次具体业务原因,不一律错误归因于分销码 +- **AND** 无业务消息的网络错误 MUST 使用既有安全降级提示,不记录密码 + +#### Scenario: 注册提交仍在进行 + +- **WHEN** 用户重复点击提交,且上一次注册请求尚未完成 +- **THEN** 页面 MUST 阻止并行重复提交,避免重复申请和重复失败提示 diff --git a/openspec/changes/update-october-batch-one-admin-integration/tasks.md b/openspec/changes/update-october-batch-one-admin-integration/tasks.md index d751f76..d013e09 100644 --- a/openspec/changes/update-october-batch-one-admin-integration/tasks.md +++ b/openspec/changes/update-october-batch-one-admin-integration/tasks.md @@ -23,7 +23,7 @@ ## 4. 后台新密码规则(M1) -- [x] 4.1 新密码专用校验和说明:8~12 位,三类英文字母/数字必需,拒绝所有非法字符,不 trim。 +- [x] 4.1 按 2026-10-14 修正,新密码 8~12 位,允许半角特殊符号,四类至少三类,拒绝空白/中文等字符,不 trim。 - [x] 4.2 本人改密新密码/确认限制及双语说明;旧密码仅必填,原成功行为保持;错误换行不重叠。 - [x] 4.3 账号及客户账号创建接入校验,可选修改空密码不发送,不新增入口。 - [x] 4.4 账号独立改密改为 `new_password`,其他字段不改名。 @@ -67,3 +67,23 @@ - [x] 8.3 复用原批量解绑权限、原因与二次确认,结果布尔值改中文成功/失败。 - [x] 8.4 66 项回归测试通过,本地完整跳转、实际勾选、手工取消及模拟批量结果验证通过;未操作真实资产。 - [x] 8.5 按用户修正区分共用 1202:`data=null` 只展示原始 `msg` 和「知道了」,仅带有效响应手机号才提供去解绑;不从历史数组回退。新增回归,全套 67 项通过。 + +## 9. 密码特殊符号与侧栏遮罩修正(2026-10-14) + +- [x] 9.1 专用校验恢复四类至少三类,允许所有可打印 ASCII 标点,同步中英文提示、类型注释和回归测试。 +- [x] 9.2 本人改密弹窗挂载到 body,修复左侧菜单未被遮罩覆盖,不调整全局顶栏/侧栏层级。 +- [x] 9.3 全套 68 项自动测试、类型/lint/stylelint 检查通过;本地桌面和 375px 页面确认遮罩覆盖侧栏,含符号密码发出模拟改密请求,没有修改真实密码。 +- [x] 9.4 按用户要求全量核对后台新密码入口,确认五个文件中的七个输入统一校验/说明/长度且不 trim;补源码契约回归,全套 69 项通过,记录覆盖范围,不修改其他密码或新增权限。 + +## 10. 用户追加:代理扫码注册密码(2026-10-14) + +- [x] 10.1 仅将代理扫码注册页及原注册接口的密码校验纳入,保持其他 C 端范围排除,同步规范。 +- [x] 10.2 表单和注册服务复用统一规则;输入上限 12,完整规则独立显示,强度反馈不误认空格/中文,不 trim 密码。 +- [x] 10.3 添加有效/无效密码、原字段/路径/免 Token/短信不变的回归;全套 71 项测试与类型、lint、stylelint、格式、编码、规范和生产构建检查通过。 +- [ ] 10.4 当前浏览器连接不可用,扫码页移动端视觉和真实后端注册联调待验收;未发送真实短信或注册申请。 + +## 11. 用户追加:注册错误提示与表头拖拽修复 + +- [x] 11.1 仅公开注册关闭公共消息,1050/1001 统一指定文案,其他错误按具体原因;失败保留表单,提交中阻止重复请求。 +- [x] 11.2 公共列设置拖拽延后启动,校验真实且连接文档的 DOM;关闭/卸载/缓存停用销毁,列勾选和排序 model 保持。 +- [x] 11.3 新增页面单次消息、HTTP/业务映射、表头生命周期回归,全套 78 项、类型、lint/stylelint、格式、编码、规范及生产构建通过;真实浏览器和后台验收仍待完成。 diff --git a/scripts/tests/employeeCollectionActing.test.mjs b/scripts/tests/employeeCollectionActing.test.mjs new file mode 100644 index 0000000..050993b --- /dev/null +++ b/scripts/tests/employeeCollectionActing.test.mjs @@ -0,0 +1,233 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { compileScript, parse } from '@vue/compiler-sfc' + +function evaluate(source, imports = {}) { + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', compiled)( + (name) => { + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + module, + module.exports + ) + return module.exports +} + +const acting = evaluate(readFileSync('src/utils/business/employeeCollectionActing.ts', 'utf8')) +const { descriptor } = parse( + readFileSync( + 'src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue', + 'utf8' + ) +) +const componentSource = compileScript(descriptor, { id: 'collection-acting-test' }).content + +const employee = { id: 10, name: '测试员工', user_type: 1 } +const bill = (id, name = employee.name, accountId = 99) => ({ + id, + remaining_amount: 10000, + status: 0, + debtor_account_id: accountId, + debtor_snapshot: { account_id: accountId, account_name: name } +}) + +function formHarness(props = {}, candidates = [], details = {}) { + const calls = [] + const detailCalls = [] + const cleared = [] + const user = vue.reactive({ info: { ...employee }, isSuperAdmin: true }) + const service = { + getPaymentMethods: async () => ({ + code: 0, + data: [{ id: 1, name: '银行转账', enabled: true }] + }), + getBills: async () => ({ code: 0, data: { items: candidates, total: candidates.length } }), + getApplicationById: async () => ({ + code: 0, + data: { + allocations: Object.keys(details).map((id) => ({ bill_id: Number(id), amount: 5000 })) + } + }), + getBillById: async (id) => { + detailCalls.push(id) + return { code: 0, data: { bill: details[id] } } + }, + createApplication: async (data) => { + calls.push({ method: 'create', data }) + return { code: 0 } + }, + updateApplication: async (id, data) => { + calls.push({ method: 'update', id, data }) + return { code: 0 } + } + } + const display = { + BILL_SOURCE_OPTIONS: [], + BILL_STATUS_OPTIONS: [], + canCreateApplication: () => true, + formatCollectionCurrency: String, + normalizeCollectionList: (data) => (Array.isArray(data) ? data : data.items || []), + normalizeCollectionPage: (data) => ({ list: data.items || [], total: data.total || 0 }) + } + const component = evaluate(componentSource, { + vue, + 'element-plus': { ElMessage: { success() {}, error() {}, warning() {} } }, + '@/api/modules': { + EmployeeCollectionService: service, + AgentRechargeService: {}, + ShopService: {} + }, + '@/utils/business/format': { + fenToYuan: (value) => Number(value || 0) / 100, + yuanToFen: (value) => Math.round(Number(value || 0) * 100) + }, + '@/utils/business': { + getErrorMessage: String, + toVoucherKeyList: (value) => (Array.isArray(value) ? value : value ? [value] : []) + }, + '@/store/modules/user': { useUserStore: () => user }, + '@/utils/business/employeeCollectionActing': acting, + '@/components/business/VoucherUpload.vue': { default: {} }, + '../../employeeCollectionDisplay': display + }).default + const reactiveProps = vue.reactive({ + modelValue: true, + application: null, + presetBill: null, + ...props + }) + const state = component.setup(reactiveProps, { expose() {}, emit() {} }) + state.formRef.value = { + clearValidate: (field) => cleared.push(field), + validate: async () => { + if (state.rules.value.acting_reason?.[0]?.required && !state.form.acting_reason.trim()) { + throw new Error('请填写代办原因') + } + } + } + const fillPayment = () => + Object.assign(state.form, { + payment_method_id: 1, + paid_amount: 100, + paid_at: '2026-10-14T15:00:00+08:00', + payer_name: '付款方', + external_transaction_no: 'LOCAL_TEST', + payment_voucher_keys: ['test-voucher'] + }) + return { state, user, calls, cleared, detailCalls, fillPayment } +} + +test('matching nonempty me.name makes a bill self-handled even when account IDs differ', () => { + assert.equal(acting.isEmployeeCollectionBillSelf(bill(1), employee), true) + assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, ' 测试员工 '), employee), true) + assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, '其他员工'), employee), false) + assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, '', 10), employee), true) + assert.equal( + acting.isEmployeeCollectionBillSelf(bill(1, '', null), { name: '', user_type: 1 }), + false + ) + assert.equal(acting.requiresEmployeeCollectionActingReason([bill(1), bill(2)], employee), false) + assert.equal( + acting.requiresEmployeeCollectionActingReason([bill(1), bill(2, '其他员工')], employee), + true + ) + assert.equal(acting.requiresEmployeeCollectionActingReason([undefined], employee), true) + assert.equal(acting.requiresEmployeeCollectionActingReason([], employee), true) + assert.equal( + acting.requiresEmployeeCollectionActingReason([bill(2, '其他员工')], { + ...employee, + user_type: 2 + }), + false + ) +}) + +test('creating a self-handled application has no acting reason rule and omits the field entirely', async () => { + const { state, calls, fillPayment } = formHarness({ presetBill: bill(1) }) + await state.initialize() + await vue.nextTick() + fillPayment() + assert.equal(state.isActing.value, false) + assert.equal(state.rules.value.acting_reason, undefined) + state.form.acting_reason = '不得发送的残留值' + await state.handleSubmit() + assert.equal(calls[0].method, 'create') + assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false) + assert.deepEqual(calls[0].data.allocations, [{ bill_id: 1, amount: 10000 }]) +}) + +test('acting for another employee remains required; switching back to self clears the old value and validation', async () => { + const { state, calls, cleared, fillPayment } = formHarness() + fillPayment() + state.toggleBill(bill(2, '其他员工')) + await vue.nextTick() + assert.equal(state.rules.value.acting_reason[0].required, true) + await assert.rejects(state.handleSubmit(), /代办原因/) + assert.equal(calls.length, 0) + state.form.acting_reason = '代办核销' + state.toggleBill(bill(2, '其他员工')) + state.toggleBill(bill(1)) + await vue.nextTick() + assert.equal(state.isActing.value, false) + assert.equal(state.form.acting_reason, '') + assert.ok(cleared.includes('acting_reason')) + await state.handleSubmit() + assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false) +}) + +test('resubmission loads responsibility for bills outside the current candidate page and omits a historical acting reason', async () => { + const application = { + id: 8, + acting_reason: '历史代办原因', + paid_amount: 5000, + payment_method_id: 1 + } + const { state, calls, detailCalls, fillPayment } = formHarness({ application }, [], { + 21: bill(21) + }) + await state.initialize() + await vue.nextTick() + fillPayment() + assert.deepEqual(detailCalls, [21]) + assert.equal(state.displayBills.value[0].debtor_snapshot.account_name, employee.name) + assert.equal(state.isActing.value, false) + assert.equal(state.rules.value.acting_reason, undefined) + assert.equal(state.form.acting_reason, '') + await state.handleSubmit() + assert.equal(calls[0].method, 'update') + assert.equal(calls[0].id, 8) + assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false) + assert.deepEqual(calls[0].data.allocations, [{ bill_id: 21, amount: 5000 }]) +}) + +test('resubmission reuses responsibility from the candidate page and cannot be shadowed by an incomplete allocation cache', async () => { + const { state, detailCalls } = formHarness({ application: { id: 9 } }, [bill(1)], { 1: bill(1) }) + await state.initialize() + await vue.nextTick() + assert.deepEqual(detailCalls, []) + state.selectedBillOptions.value[1] = { id: 1, remaining_amount: 10000 } + assert.equal(state.isActing.value, false) +}) + +test('mixed-responsibility resubmission still sends the required acting reason', async () => { + const { state, calls, fillPayment } = formHarness({ application: { id: 10 } }, [], { + 1: bill(1), + 2: bill(2, '其他员工') + }) + await state.initialize() + await vue.nextTick() + fillPayment() + assert.equal(state.isActing.value, true) + assert.equal(state.rules.value.acting_reason[0].required, true) + state.form.acting_reason = ' 帮员工提交核销 ' + await state.handleSubmit() + assert.equal(calls[0].data.acting_reason, '帮员工提交核销') +}) diff --git a/scripts/tests/octoberBatchOne.test.mjs b/scripts/tests/octoberBatchOne.test.mjs index 14b97e5..0df5bf9 100644 --- a/scripts/tests/octoberBatchOne.test.mjs +++ b/scripts/tests/octoberBatchOne.test.mjs @@ -3,6 +3,9 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { createRequire } from 'node:module' +import ts from 'typescript' +import * as vue from 'vue' +import { compileScript, parse } from '@vue/compiler-sfc' const require = createRequire(import.meta.url) const esbuild = createRequire(require.resolve('vite'))('esbuild') @@ -47,11 +50,21 @@ async function loadModule(entry, stubService = false) { return module.exports } -test('new login password accepts only 8-12 ASCII letters/digits with all three classes', async () => { +test('new login password allows special characters and requires at least three of four classes', async () => { const { getNewLoginPasswordError, createNewLoginPasswordRule } = await loadModule( 'src/utils/validation/loginPassword.ts' ) - for (const password of ['Abc12345', 'Abc123456789']) + for (const password of [ + 'Abc12345', + 'Abc123456789', + 'Abc1234!', + 'Abc1234_', + 'ABC1234!', + 'abc1234!', + 'Abcdefg!', + 'Ab1~`-=[]{}', + 'Ab1;:\\|,<>/?' + ]) assert.equal(getNewLoginPasswordError(password), undefined) for (const password of [ 'Abc1234', @@ -59,8 +72,9 @@ test('new login password accepts only 8-12 ASCII letters/digits with all three c 'abcd1234', 'ABCD1234', 'AbcDefGh', - 'Abc1234!', - 'Abc1234_', + 'ABCDEF!!', + 'abcdef!!', + '123456!!', 'Abc1234 ', ' Abc12345', 'Abc1234\n', @@ -73,7 +87,218 @@ test('new login password accepts only 8-12 ASCII letters/digits with all three c const rule = createNewLoginPasswordRule() for (const value of ['', undefined, null]) rule.validator({}, value, (error) => assert.equal(error, undefined)) - rule.validator({}, 'Abc1234!', (error) => assert.ok(error instanceof Error)) + rule.validator({}, 'Abc1234!', (error) => assert.equal(error, undefined)) + rule.validator({}, 'abcdef!!', (error) => assert.ok(error instanceof Error)) + for (const symbol of Array.from('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~')) { + assert.equal(getNewLoginPasswordError(`Ab12345${symbol}`), undefined) + } +}) + +test('admin and scan-registration new-password forms share validation, hints and the 12-character input limit', async () => { + const forms = [ + { + path: 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue', + models: ['formData.new_password', 'formData.confirm_password'], + fields: ['new_password', 'confirm_password'], + hint: "t('passwordChange.passwordRule')" + }, + { + path: 'src/views/account-management/account/index.vue', + models: ['formData.password'], + fields: ['password'] + }, + { + path: 'src/views/common/account-list.vue', + models: ['accountForm.password'], + fields: ['password'] + }, + { + path: 'src/views/shop-management/list/index.vue', + models: ['formData.init_password'], + fields: ['init_password'] + }, + { + path: 'src/views/account-management/enterprise-customer/index.vue', + models: ['form.password', 'passwordForm.password'], + fields: ['password', 'password'] + }, + { + path: 'src/views/agent-registration/index.vue', + models: ['form.password'], + fields: ['password'], + hint: null + } + ] + for (const { path, models, fields, hint = 'NEW_LOGIN_PASSWORD_HINT' } of forms) { + const source = await readFile(path, 'utf8') + assert.match(source, /from '@\/utils\/validation\/loginPassword'/, path) + const inputs = [...source.matchAll(//g)].map(([input]) => input) + for (const model of models) { + // Exact v-model also rejects a silent .trim modifier on password inputs. + const matches = inputs.filter((input) => input.includes(`v-model="${model}"`)) + assert.equal(matches.length, 1, `${path}: ${model}`) + assert.match(matches[0], /type="password"/, `${path}: ${model}`) + assert.match(matches[0], /maxlength="12"/, `${path}: ${model}`) + if (hint) { + assert.ok(matches[0].includes(`:placeholder="${hint}"`), `${path}: ${model} hint`) + } else { + assert.match(matches[0], /aria-describedby="registration-password-rule"/, path) + assert.match(source, /id="registration-password-rule"[\s\S]*?NEW_LOGIN_PASSWORD_HINT/, path) + } + } + const rules = [ + ...source.matchAll( + /\b(password|new_password|confirm_password|init_password):\s*\[([\s\S]*?)\n\s*\]/g + ) + ] + assert.deepEqual(rules.map(([, field]) => field).sort(), [...fields].sort(), path) + for (const [, field, rule] of rules) { + assert.match(rule, /required: true/, `${path}: ${field}`) + assert.match(rule, /createNewLoginPasswordRule\(/, `${path}: ${field}`) + assert.doesNotMatch(rule, /\b(?:min|max|pattern):/, `${path}: ${field} stale rule`) + } + } +}) + +test('scan registration validates password and locally handles errors without changing public endpoint, payload or SMS behavior', async () => { + const { AgentDistributionService } = await loadModule( + 'src/api/modules/agentDistribution.ts', + true + ) + AgentDistributionService.calls = [] + const params = { + distribution_code: 'local-test', + phone: '13800000000', + code: '123456', + shop_name: '本地测试店铺', + shop_code: 'LOCAL_TEST', + username: 'local_test' + } + for (const password of ['Abc12345', 'Abc123456789', 'ABC1234!', 'abc1234!', 'Abcdefg!']) { + await AgentDistributionService.registerAgent({ ...params, password }) + assert.deepEqual(AgentDistributionService.calls.at(-1), { + url: '/api/c/v1/agent-distribution-registrations', + data: { ...params, password }, + config: { requestOptions: { withToken: false, errorMessageMode: 'none' } } + }) + } + for (const password of ['', 'Abc1234', 'Abc123456789X', 'abcd1234', 'Abc1234 ', 'Abc1234中']) { + await assert.rejects(AgentDistributionService.registerAgent({ ...params, password })) + } + assert.equal(AgentDistributionService.calls.length, 5) + await AgentDistributionService.sendCode({ phone: params.phone, scene: 'bind_phone' }) + assert.deepEqual(AgentDistributionService.calls.at(-1), { + url: '/api/c/v1/auth/send-code', + data: { phone: params.phone, scene: 'bind_phone' }, + config: { requestOptions: { withToken: false } } + }) +}) + +test('scan registration masks template errors in both business and HTTP responses and uses only local feedback', async () => { + const { getAgentRegistrationFailureMessage } = await loadModule( + 'src/utils/business/agentRegistrationError.ts' + ) + for (const code of [1050, 1001, '1050', '1001']) { + const failure = { code, data: null, msg: '企业微信审批模板无效或不可访问' } + for (const value of [ + failure, + { ...failure, data: { detail: 'internal template settings' } }, + { isAxiosError: true, response: { status: 400, data: failure } }, + { isAxiosError: true, response: { status: 500, data: failure } }, + { data: failure } + ]) { + assert.equal(getAgentRegistrationFailureMessage(value), '注册失败, 请联系管理员') + } + } + assert.equal(getAgentRegistrationFailureMessage({ code: 1203, msg: '验证码错误' }), '验证码错误') + assert.equal( + getAgentRegistrationFailureMessage({ code: 1204, message: '分销码不可用' }), + '分销码不可用' + ) + assert.equal( + getAgentRegistrationFailureMessage({ isAxiosError: true, code: 'ECONNABORTED' }), + '请求超时,结果可能未知,请刷新确认' + ) + for (const value of [null, undefined, new Error('network')]) { + assert.equal(typeof getAgentRegistrationFailureMessage(value), 'string') + } + const page = await readFile('src/views/agent-registration/index.vue', 'utf8') + assert.match(page, /ElMessage.error\(getAgentRegistrationFailureMessage\(res\)\)/) + assert.match( + page, + /catch \(error\)\s*\{\s*ElMessage.error\(getAgentRegistrationFailureMessage\(error\)\)/ + ) + assert.doesNotMatch(page, /ElMessage.error\('分销码不可用'\)/) + assert.match(page, /if \(!valid \|\| submitting.value\) return/) +}) + +test('scan registration emits exactly one masked message per template failure and retains the form for retry', async () => { + const { getAgentRegistrationFailureMessage } = await loadModule( + 'src/utils/business/agentRegistrationError.ts' + ) + const passwordValidation = await loadModule('src/utils/validation/loginPassword.ts') + const { descriptor } = parse(await readFile('src/views/agent-registration/index.vue', 'utf8')) + const script = compileScript(descriptor, { id: 'scan-registration-error-test' }).content + const compiled = ts.transpileModule(script, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + for (const code of [1050, 1001]) { + for (const rejected of [false, true]) { + const messages = [] + const failure = { code, data: null, msg: '企业微信审批模板无效或不可访问' } + const imports = { + vue: { ...vue, onBeforeUnmount() {} }, + 'vue-router': { useRoute: () => ({ query: {} }) }, + 'element-plus': { ElMessage: { error: (message) => messages.push(message) } }, + '@/api/modules/agentDistribution': { + AgentDistributionService: { + registerAgent: async () => { + if (rejected) throw { isAxiosError: true, response: { status: 400, data: failure } } + return failure + } + } + }, + '@/utils/constants/regionData': { regionData: [] }, + '@/utils/validation/loginPassword': passwordValidation, + '@/utils/business/agentRegistrationError': { getAgentRegistrationFailureMessage } + } + const module = { exports: {} } + new Function('require', 'module', 'exports', compiled)( + (name) => { + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + module, + module.exports + ) + const state = module.exports.default.setup({}, { expose() {} }) + Object.assign(state.form, { distribution_code: 'local-test', password: 'Abc1234!' }) + let submission + state.formRef.value = { + validate: (callback) => { + submission = callback(true) + } + } + state.handleSubmit() + state.handleSubmit() + await submission + assert.deepEqual(messages, ['注册失败, 请联系管理员']) + assert.equal(state.submitted.value, false) + assert.equal(state.submitting.value, false) + assert.equal(state.form.distribution_code, 'local-test') + assert.equal(state.form.password, 'Abc1234!') + } + } +}) + +test('scan registration password feedback uses the shared rule rather than treating spaces or Chinese as symbols', async () => { + const page = await readFile('src/views/agent-registration/index.vue', 'utf8') + assert.match(page, /return getNewLoginPasswordError\(pwd\) \? 1 : 3/) + assert.match(page, /密码符合规则/) + assert.match(page, /aria-live="polite"/) + assert.match(page, /password-form-item :deep\(\.el-form-item__error\)[\s\S]*?position: static/) + assert.doesNotMatch(page, /8-32|maxlength="32"/) + assert.match(page, /password: form.password,/) }) test('account password requests use the right field and omit unchanged optional password', async () => { @@ -83,9 +308,26 @@ test('account password requests use the right field and omit unchanged optional assert.deepEqual(AccountService.calls[0].data, { new_password: 'Abc12345' }) await AccountService.updateAccount(12, { username: 'operator', password: '' }) assert.deepEqual(AccountService.calls[1].data, { username: 'operator' }) - await assert.rejects(AccountService.updateAccount(12, { password: 'Abc1234!' })) + await AccountService.updateAccount(12, { password: 'Abc1234!' }) + assert.deepEqual(AccountService.calls[2].data, { password: 'Abc1234!' }) + await AccountService.updateAccountPassword(12, 'ABC1234!') + assert.deepEqual(AccountService.calls[3].data, { new_password: 'ABC1234!' }) + await assert.rejects(AccountService.updateAccount(12, { password: 'abcdef!!' })) await assert.rejects(AccountService.updateAccountPassword(12, 'abcd1234')) - assert.equal(AccountService.calls.length, 2) + assert.equal(AccountService.calls.length, 4) +}) + +test('change-password dialog is appended to body so its overlay covers the sidebar stacking context', async () => { + const dialog = await readFile( + 'src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue', + 'utf8' + ) + assert.match(dialog, /append-to-body/) + assert.match(dialog, /createNewLoginPasswordRule\(t\('passwordChange.passwordRule'\)\)/) + for (const language of ['zh', 'en']) { + const locale = JSON.parse(await readFile(`src/locales/langs/${language}.json`, 'utf8')) + assert.doesNotMatch(locale.passwordChange.passwordRule, /不允许特殊符号|no special characters/) + } }) test('writeoff terminal failure blocks recovery but allows resubmission; unknown result stays recoverable', async () => { diff --git a/scripts/tests/tableHeaderDragLifecycle.test.mjs b/scripts/tests/tableHeaderDragLifecycle.test.mjs new file mode 100644 index 0000000..d42393b --- /dev/null +++ b/scripts/tests/tableHeaderDragLifecycle.test.mjs @@ -0,0 +1,258 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import * as vue from 'vue' +import { compileScript, parse } from '@vue/compiler-sfc' + +const { descriptor } = parse(readFileSync('src/components/core/tables/ArtTableHeader.vue', 'utf8')) +const componentSource = compileScript(descriptor, { + id: 'table-header-drag-test', + inlineTemplate: true +}).content + +class HostElement { + constructor(tag, text = '') { + this.tag = tag + this.text = text + this.props = {} + this.children = [] + this.parentNode = null + } + + get isConnected() { + return this.tag === 'root' || Boolean(this.parentNode?.isConnected) + } + + removeChild(child) { + const index = this.children.indexOf(child) + assert.notEqual(index, -1) + this.children.splice(index, 1) + child.parentNode = null + } +} + +function evaluate(source, imports) { + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } + }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', compiled)( + (name) => { + assert.ok(imports[name], `Unexpected dependency: ${name}`) + return imports[name] + }, + module, + module.exports + ) + return module.exports +} + +function mountHeader(t) { + const previousGlobals = { HTMLElement: globalThis.HTMLElement, document: globalThis.document } + globalThis.HTMLElement = HostElement + globalThis.document = { addEventListener() {}, removeEventListener() {} } + const controls = [] + const store = vue.reactive({ + tableSize: 'default', + isZebra: false, + isBorder: false, + isHeaderBackground: false + }) + const slotComponent = (name) => + vue.defineComponent({ + name, + setup: + (_, { slots, attrs }) => + () => + vue.h(name, attrs, [slots.reference?.(), slots.default?.(), slots.dropdown?.()]) + }) + const elements = Object.fromEntries( + ['ElPopover', 'ElCheckbox', 'ElDropdown', 'ElDropdownMenu', 'ElDropdownItem'].map((name) => [ + name, + slotComponent(name) + ]) + ) + const component = evaluate(componentSource, { + vue, + pinia: { storeToRefs: (value) => vue.toRefs(value) }, + 'element-plus': elements, + 'vue-i18n': { useI18n: () => ({ t: (key) => key }) }, + '@/enums/formEnum': { TableSizeEnum: { SMALL: 'small', DEFAULT: 'default', LARGE: 'large' } }, + '@/store/modules/table': { useTableStore: () => store }, + 'vue-draggable-plus': { + useDraggable: (target, model, options) => { + const control = { + target, + model, + options, + starts: [], + active: false, + destroys: 0, + start(element = target.value) { + assert.ok( + element instanceof HostElement && element.isConnected, + 'Sortable needs a connected native element' + ) + this.starts.push(element) + this.active = true + }, + destroy() { + this.destroys += 1 + this.active = false + } + } + controls.push(control) + vue.onMounted(() => { + if (options.immediate !== false) control.start() + }) + return control + } + } + }).default + const renderer = vue.createRenderer({ + createElement: (tag) => new HostElement(tag), + createText: (text) => new HostElement('#text', text), + createComment: (text) => new HostElement('#comment', text), + setText: (node, text) => { + node.text = text + }, + setElementText: (node, text) => { + node.text = text + node.children = [] + }, + parentNode: (node) => node.parentNode, + nextSibling: (node) => { + const siblings = node.parentNode?.children || [] + return siblings[siblings.indexOf(node) + 1] || null + }, + patchProp: (node, key, _, value) => { + node.props[key] = value + }, + insert: (node, parent, anchor) => { + node.parentNode?.removeChild(node) + const index = anchor ? parent.children.indexOf(anchor) : parent.children.length + assert.notEqual(index, -1) + parent.children.splice(index, 0, node) + node.parentNode = parent + }, + remove: (node) => node.parentNode?.removeChild(node) + }) + const columns = vue.ref([ + { prop: 'phone', label: '手机号', checked: true }, + { prop: 'shop_name', label: '店铺', checked: false } + ]) + const active = vue.ref(true) + const root = new HostElement('root') + const app = renderer.createApp({ + render: () => + vue.h(vue.KeepAlive, null, { + default: () => + active.value + ? vue.h(component, { + columns: columns.value, + 'onUpdate:columns': (value) => { + columns.value = value + } + }) + : vue.h('placeholder') + }) + }) + for (const [name, element] of Object.entries(elements)) app.component(name, element) + app.mount(root) + let mounted = true + const unmount = () => { + if (mounted) { + app.unmount() + mounted = false + } + } + t.after(() => { + unmount() + for (const [key, value] of Object.entries(previousGlobals)) { + if (value === undefined) delete globalThis[key] + else globalThis[key] = value + } + }) + const find = (node, tag) => + node.tag === tag ? node : node.children.map((child) => find(child, tag)).find(Boolean) + return { control: controls[0], columns, active, unmount, popover: find(root, 'ElPopover') } +} + +const openPopover = (popover) => { + popover.props['onUpdate:visible'](true) + popover.props.onAfterEnter() +} + +test('table header does not auto-start Sortable until the column popover has entered', async (t) => { + const { control, popover } = mountHeader(t) + await vue.nextTick() + assert.equal(control.options.immediate, false) + assert.equal(control.starts.length, 0) + popover.props.onAfterEnter() + assert.equal(control.starts.length, 0, 'a stale enter without opening is ignored') + openPopover(popover) + assert.equal(control.starts.length, 1) + assert.equal(control.starts[0], control.target.value) +}) + +test('closing and reopening column settings destroys the drag instance and rejects a late enter', async (t) => { + const { control, popover } = mountHeader(t) + openPopover(popover) + popover.props.onBeforeLeave() + assert.equal(control.active, false) + assert.equal(control.destroys, 1) + popover.props.onAfterEnter() + assert.equal(control.starts.length, 1) + openPopover(popover) + assert.equal(control.starts.length, 2) +}) + +test('missing, non-element and detached column targets never start Sortable', async (t) => { + const { control, popover } = mountHeader(t) + const original = control.target.value + for (const target of [null, undefined, {}, new HostElement('detached')]) { + control.target.value = target + openPopover(popover) + assert.equal(control.starts.length, 0) + } + control.target.value = original + openPopover(popover) + assert.equal(control.starts.length, 1) +}) + +test('unmounting before or after opening safely cleans up and rejects queued popover callbacks', async (t) => { + for (const opened of [false, true]) { + const { control, popover, unmount } = mountHeader(t) + if (opened) openPopover(popover) + const count = control.starts.length + unmount() + await vue.nextTick() + assert.equal(control.active, false) + assert.ok(control.destroys > 0) + popover.props.onAfterEnter() + assert.equal(control.starts.length, count) + } +}) + +test('cached page deactivation cleans up; returning preserves column order and checked state', async (t) => { + const { control, popover, active, columns } = mountHeader(t) + openPopover(popover) + control.model.value = [...control.model.value].reverse() + await vue.nextTick() + assert.deepEqual( + columns.value.map((item) => item.prop), + ['shop_name', 'phone'] + ) + active.value = false + await vue.nextTick() + assert.equal(control.active, false) + popover.props.onAfterEnter() + assert.equal(control.starts.length, 1) + active.value = true + await vue.nextTick() + openPopover(popover) + assert.equal(control.starts.length, 2) + assert.equal(columns.value[0].checked, false) + assert.equal(columns.value[1].checked, true) +}) diff --git a/src/api/modules/agentDistribution.ts b/src/api/modules/agentDistribution.ts index 2a61179..00d52c9 100644 --- a/src/api/modules/agentDistribution.ts +++ b/src/api/modules/agentDistribution.ts @@ -3,6 +3,7 @@ */ import { BaseService } from '../BaseService' +import { getNewLoginPasswordError } from '@/utils/validation/loginPassword' import type { AgentDistributionRegistrationPageResult, AgentDistributionRegistrationQueryParams, @@ -33,7 +34,7 @@ export interface AgentDistributionRegistrationParams { distribution_code: string // 分销码 phone: string // 手机号 code: string // 短信验证码 - password: string // 密码 + password: string // 8~12 位,大小写字母、数字、半角特殊符号至少三类,无空格和中文 shop_name: string // 店铺名称 shop_code: string // 店铺编号 username: string // 用户名 @@ -71,10 +72,12 @@ export class AgentDistributionService extends BaseService { static registerAgent( params: AgentDistributionRegistrationParams ): Promise> { + const error = getNewLoginPasswordError(params.password) + if (error) return Promise.reject(new Error(error)) return this.post>( '/api/c/v1/agent-distribution-registrations', params, - { requestOptions: { withToken: false } } + { requestOptions: { withToken: false, errorMessageMode: 'none' } } ) } diff --git a/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue b/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue index 19b195c..b3476f1 100644 --- a/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue +++ b/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue @@ -1,6 +1,7 @@