diff --git a/openspec/changes/update-october-batch-one-admin-integration/design.md b/openspec/changes/update-october-batch-one-admin-integration/design.md new file mode 100644 index 0000000..f0d4def --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/design.md @@ -0,0 +1,113 @@ +# 十月第 1 批后台适配设计 + +## Context + +本仓库为 Vue 3/TypeScript 后台,HTTP 服务使用 `BaseService` 与 Axios,页面已有业务权限、状态映射、详情组件、异步导入导出任务。用户明确排除所有 C 端接口和页面。后台本人改密虽然使用 `/api/auth/password`,仍属于本次后台适配。 + +本设计依据十月第 1 批说明、用户最新确认和当前代码。用户确认不提供最新 OpenAPI,不以该文件作为实施前置条件;新密码禁止特殊符号的确认优先于原说明。实施已获批准,代码与本地模拟页面验证详见实施记录;没有调用真实后台写接口或验证后端运行行为。 + +### 代码证据与修改路径 + +| 依据/代码证据 | 当前差异 | 修改路径 | +| --- | --- | --- | +| M3;`src/types/api/order.ts`、`src/api/modules/order.ts` | 详情与列表共用 `Order`,缺三项支付信息与支付记录 | `Order` 补公共字段,另建 `OrderDetail` 与支付记录类型,详情服务改用详情类型 | +| M8;`employeeCollectionDisplay.ts`、核销申请列表/详情 | 状态仅 0~3,可重提仅状态 2;提交状态只读名称 | 补终态 4、状态值映射与恢复/重提判断,覆盖账单关联申请 | +| M14;退款/充值/提现类型与状态映射 | 缺退款 7、充值 7、提现 5;旧重提及补发条件不认识终态 | 各业务独立扩展类型、筛选、标签和可执行操作 | +| M14;后台代理注册查询类型/详情 | 筛选仅 0~2,恢复操作仅看 `recoverable` | 补状态 3,并在业务终态下优先隐藏恢复 | +| M1;账号、客户账号、店铺、企业表单及本人改密弹窗 | 仍使用 8~32 或 6~20;旧密码也有长度规则 | 新密码使用专用校验,旧密码只保留必要输入校验 | +| M1;`AccountService.updateAccountPassword` | 独立改密发送 `{ password }`,说明要求 `{ new_password }` | 按第 1 批说明修正独立改密请求,不误改其他接口字段 | +| M12;换货创建/确认完成、`apiError.ts` | 无 1202 定制反馈;错误归一化不暴露业务 data,换货 catch 可能输出错误对象 | 在换货操作定向提取 code/msg/message/data.phone,只在提示中展示号码,不记录完整错误对象 | +| M16;预警规则与记录类型/页面 | 规则依赖已删除的 `real_data_mb`;记录未定义三项 MB 字段 | 只移除规则响应引用,补记录快照并明确 MB 单位 | +| M2;业务员导入页 | 仍写「业务员登录账号」且未注明只填手机号 | 同步说明及已有前端模板,保留逐行失败明细 | +| M23;设备导入任务页 | IMEI 被列为可选,已支持 `failed_items` | 改说明,复用失败明细,不改 Excel 模板结构 | + +## Goals / Non-goals + +- Goals:让后台显示、筛选、请求字段和可执行操作符合本批次说明;保留既有权限、表单内容、财务快照和导入导出流程。 +- Non-goals:C 端适配、后台接口实现、新审批机制、新页面架构、自动解绑手机号、前端重算预警或余额、替换设备导入模板、引入测试框架。 + +## Decisions + +### 1. 订单详情类型与支付状态隔离 + +公共 `Order` 接收三项字符串字段,列表与创建响应仍使用公共类型;详情增加独立 `payments` 数组。支付记录状态 0~3 与订单 `PaymentStatus` 的 1~4 含义不同,使用独立枚举/映射,不能复用。 + +支付记录使用后端顺序,不自行筛掉失败或待支付记录;金额用已有分转元格式化,`paid_at`、`expire_at` 空值显示 `-`。渠道码映射 `wechat` 为微信直连 v3、`wechat_v2` 为微信直连 v2、`fuiou` 为富友;支付方式微信/支付宝与技术渠道分开展示,未知非空渠道保留原值。公共字段为空串显示 `-`,不能据此虚构支付信息。 + +### 2. 业务终态优先于恢复标志 + +六个业务各自定义终态,禁止跨业务共用数值。恢复入口使用「不是本业务审批提交失败终态 + 后端允许恢复 + 原权限允许」共同判断;即使陈旧响应仍返回 `recoverable=true`,终态也不得显示恢复按钮。 + +「提交结果未知」不能只凭提交状态转换成业务失败,保留原后端恢复资格判断,不扩大恢复权限。终态下隐藏恢复/补发和不适用的审批操作,查看详情、凭证等只读操作保留。 + +重新发起复用既有表单和文档规定的接口:核销/退款/提现使用原业务重提接口,线下充值创建新单,提现资料提交新版本。代理注册仅提示申请人重新扫码,后台不调用 `/api/c/v1`、不补建后台代注册接口。涉及预占/冻结金额时重新加载账单或佣金概览,前端不乐观加减财务数据。 + +核销审批提交状态使用 `approval_submission_status` 的 0 待提交、1 提交中、2 已提交、3 提交失败、4 结果未知。名称字段存在时可使用后端名称,但字段为空时需按真实数值回退,0 不能因 falsy 判断显示成缺失。 + +### 3. 新密码校验专用化 + +新增/复用新登录密码专用工具,避免直接改通用 `validatePassword` 或 `validateStrongPassword` 影响非登录密码。按用户最新确认,长度 8~12,仅允许 ASCII 英文字母和数字,并必须同时包含大写字母、小写字母和数字;所有特殊符号、空白、中文、emoji 和其他非英文字母数字字符均拒绝。不对输入静默 `trim` 后放行。 + +实现判定为 `^[A-Za-z0-9]{8,12}$` 且同时满足 `[A-Z]`、`[a-z]`、`[0-9]` 三个包含条件。例如 `Abc12345` 可接受,`abcd1234` 因缺少大写拒绝,`Abc1234!` 即使包含大写、小写和数字也因特殊符号拒绝。 + +新密码和确认密码统一长度限制、规则说明与一致性校验;本人改密 `old_password` 不设新规则的 `maxlength` 或类别限制,旧登录表单不收紧。账号编辑目前没有密码输入则不新增;若既有调用传递可选 `password`,仅非空新值需要校验,未修改密码时不发送空串。 + +后台七个契约: + +| 场景 | 接口 | 新密码字段 | +| --- | --- | --- | +| 本人修改 | `PUT /api/auth/password` | `new_password` | +| 创建账号(含店铺/企业下的客户账号) | `POST /api/admin/accounts` | `password` | +| 修改账号 | `PUT /api/admin/accounts/{id}` | 可选 `password` | +| 独立修改账号密码 | `PUT /api/admin/accounts/{id}/password` | `new_password` | +| 创建店铺初始账号 | `POST /api/admin/shops` | `init_password` | +| 创建企业 | `POST /api/admin/enterprises` | `password` | +| 修改企业密码 | `PUT /api/admin/enterprises/{id}/password` | `password` | + +错误消息兼容项目既有 `msg` 与说明中的 `message`,优先展示后端提供的可读字段级原因;不改变 401 等既有统一处理,不重复弹错、不输出密码值。 + +### 4. 换货 1202 定向错误处理 + +创建换货和确认完成同时覆盖 HTTP 200 的非零业务码与 Axios HTTP 错误响应,不假定只有一种承载形式。在受影响的两个服务调用范围关闭重复默认提示,局部共享处理器处理 1202,其余错误仍按既有统一错误分类/文案展示。 + +1202 提示读取业务 `data.phone`,有号码则显示「新资产已绑定手机号 {phone},请先到『手机号资产关联』解除后再提交」,无号码时显示不含号码的完整指引,不输出 `undefined`。只以文本插值渲染,不把后端内容拼成 HTML。 + +具备关联管理页面查看权限时可提供跳转;无权限时提示联系有权限人员,不扩大解绑权限。禁止自动调用 DELETE、批量解绑或 CSV 解绑。响应内完整号码不写日志或持久化;换货页现有打印完整错误对象的受影响分支也应修正,防止号码间接泄漏。 + +创建失败保留资产/原因等字段;完成失败重新加载换货单,保持后端「已发货待确认」事实,不乐观标记完成。解除后由用户再次提交。取消只关闭提示,不当作解绑或换货成功。 + +### 5. 预警只展示快照,不重算阈值 + +删除 `PackageTrafficAlertRuleItem.real_data_mb` 与规则行/详情/列设置依赖。来自 `/api/admin/packages` 的套餐选项 `real_data_mb` 保留,套餐选择与规则参数校验不变。 + +记录列表/详情接收后端 `threshold_mb`、`used_mb`、`limit_mb`,用 MB 显示两位小数;0 显示 `0.00`,缺失/null 显示 `-`。标签分别为阈值流量、已用流量(含加油包)、总流量(含加油包)。不使用当前套餐额度重新计算历史 `threshold_mb`,不把新字段作为新的请求入参或筛选项。 + +### 6. 导入说明与后端结果保持一致 + +CSV 只同步新表头和手机号说明,不为旧表头做客户端转换、不将用户名转为手机号。发现内置模板时同步,否则仅改当前说明,任务明细保留原后端原因。 + +设备导入保持原下载文件与列序,将说明中的 IMEI 移到必填列;只把空值规则交给后端逐行处理,不为单行缺失拦截整个文件,不添加格式或去重限制。 + +## Risks / Trade-offs + +- 无最新 OpenAPI:按用户确认,以第 1 批说明实施,不要求补文件;联调发现真实接口与说明不符时先反馈,不自行猜测或声称已通过联调。 +- 密码口径覆盖原说明:用户明确禁止特殊符号,前端不得按原「四类至少三类」放行含符号的新密码;如后端实际校验与该确认冲突,先反馈用户。 +- 恢复旧规范与新业务终态冲突:文档注明差异,实施时优先按本批次终态处理,结果未知继续守原恢复规则。 +- 改错密码/流量范围:专用新密码工具和独立规则/套餐类型限制修改范围,回归旧密码、操作密码、WiFi 密码、套餐额度。 +- 换货错误提示重复或泄漏号码:两个请求定向接管提示,共享处理器保留其他错误行为,删除受影响分支的完整错误输出。 +- 财务状态不同步:重提和失败后重新拉取后端数据,不修改本地冻结金额来模拟后端效果。 +- 仓库未配置可直接运行的单测脚本:先检查现有测试方式,以针对性逻辑检查、类型检查、编码检查和人工联调为验收,不自动引入新框架。 + +## Migration plan + +提案获批后,依据第 1 批说明和用户最新确认,依任务清单按公共类型/规则、各页面、联调顺序实施;前端与对应测试后端一起验收。无数据库迁移、权限迁移、历史导出重生成或自动数据修复。 + +回退只回退本次业务代码,不触及原有提案和用户改动;后端若仍保留新校验及终态,旧前端将重新出现展示与提交问题,因此上线/回退需确认前后端契约匹配。 + +## 已确认事项 + +1. 没有最新 `admin-openapi.yaml`,不要求提供,以第 1 批说明实施。 +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 new file mode 100644 index 0000000..cd116e3 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/implementation.md @@ -0,0 +1,53 @@ +# 后台实施记录 + +后台代码已按第 1 批对接说明及用户确认适配,仅后台,不修改 C 端。真实后端联调尚未完成,未部署、提交或调用真实后台写接口。 + +## 结果与证据 + +| 证据 | 已确认结果 | 实现路径 | +| --- | --- | --- | +| 规则测试与本地订单详情 | 支付摘要、独立支付单状态、失败记录、空时间与金额展示正常 | `src/types/api/order.ts`、订单列表/详情、`orderPayment.ts` | +| 规则测试与模拟核销/代理注册详情 | 终态优先于陈旧恢复标志,核销可重提、代理注册提示重新扫码 | 核销展示工具、退款常量、注册状态辅助函数及相应页面 | +| 规则测试与模拟改密弹窗 | 新密码禁止符号,旧密码不受新长度限制;375px 下错误说明不重叠 | `loginPassword.ts`、后台密码表单、本人改密弹窗与双语文案 | +| 规则测试与模拟换货确认完成 | 1202 从 `data.phone` 展示号码,提示可取消,单据仍待确认,不自动解绑 | `exchangeError.ts`、换货页面与服务 | +| 数值测试与模拟预警规则/详情 | 规则额度移除,MB 快照两位小数,零和缺失区分,套餐商品额度保留 | 预警类型、规则页、记录列表/详情及 `trafficAlert.ts` | +| 模板和源码回归检查 | 新四列表头与手机号说明,设备 IMEI 必填说明、原 Excel 模板和行级失败反馈保持 | `shopBusinessOwnerImport.ts`、导入页面 | + +ui-ux-pro-max 用于检查标签含义、错误反馈和长文案换行;保留现有 Element Plus 样式,未引入新 UI 依赖或改版。docs-generator 用于区分实施结果、可复现检查和未完成的真实联调,任务清单没有将后端验收提前标为完成。 + +## 复现本地验证 + +在项目根目录执行: + +```powershell +# 63 项规则及已有回归测试;加载入口兼容 pnpm 的 esbuild 隔离布局。 +node --import ./scripts/tests/register-esbuild.mjs --test --test-reporter=spec scripts/tests/*.test.mjs +npx --no-install vue-tsc --noEmit + +# 生产构建输出到临时目录,关闭自动声明生成并校验原声明内容未被覆盖。 +node scripts/tests/verifyBuild.mjs +openspec validate update-october-batch-one-admin-integration --strict --no-interactive +``` + +受影响文件 ESLint、Prettier 和 UTF-8 检查通过。生产构建存在原有搜索表单常量赋值、审计/认证循环分块和大 chunk 警告,未扩大本次修改去重构这些模块。 + +### 本地页面检查 + +```powershell +node scripts/tests/verifyBuild.mjs --serve +``` + +访问 `http://127.0.0.1:5190`。该预览拦截全部 `/api/` 请求且禁用真实代理,测试数据为模拟值;登录和写请求不会到真实后台。模拟账号为超级管理员,仅用于本地验收,输入满足登录页既有规则的任意测试账号密码即可。 + +已检查订单详情、核销失败详情、代理注册失败详情、预警详情/规则页、改密弹窗和换货完成 1202 提示;页面检查未产生 JS 错误。改密弹窗额外检查 375px 视口。 + +模板下载从统一 UTF-8 BOM 文本生成新 CSV,原 GBK 静态文件不再被此页面引用,没有改写用户已有模板资产。设备 Excel 模板没有修改。`src/types/components.d.ts` 与实施前内容 SHA-256 一致;已有开发服务器的自动生成曾补入本次新 Alert 声明,已改显式组件导入并只撤回该自动新增行,用户原差异保留。 + +## 待真实联调 + +1. 六类审批失败终态真实数据、权限矩阵、未知结果恢复、核销预占释放和提现重新冻结。 +2. 真实支付摘要与多次支付历史、换货解除关联后成功重试及其他错误响应。 +3. CSV 表头/手机号错误、逐行部分成功,以及设备 IMEI 空白/全部失败。 +4. 新建与历史订单、IoT 卡、设备、预警导出文件列。 + +这些项目继续保留在任务清单,用户验收及部署后再归档。 diff --git a/openspec/changes/update-october-batch-one-admin-integration/proposal.md b/openspec/changes/update-october-batch-one-admin-integration/proposal.md new file mode 100644 index 0000000..d19d67f --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/proposal.md @@ -0,0 +1,88 @@ +# Change: 十月第 1 批后台前端对接修正 + +## Why + +依据 [第 1 批前端对接说明](../../../docs/产品迭代10月份修改/第1批-8-9月迭代前端对接说明.md),后台现有代码仍使用旧密码校验、旧审批业务状态和旧预警规则字段,缺少订单在线支付记录与换货手机号冲突反馈。后端已部署测试环境,后台需要同步契约与交互,避免失败单据继续显示审批恢复操作、导入提示误导以及已删除字段显示异常。 + +用户已于 2026-10-13 批准实施。后台代码已完成适配,本地验证与真实后端联调状态分开记录在 [实施记录](./implementation.md)。用户已确认:只管后台,所有 `/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 密码不套用新密码规则。 +- M12:创建换货与确认完成捕获错误码 `1202`,从错误响应 `data.phone` 读取手机号,在弹窗中引导到已有「手机号资产关联」入口;不自动解绑、不新增扫码/退款前置拦截,失败时保留表单及原单状态。 +- M16:**BREAKING**:删除预警规则响应类型、规则列表及规则详情展示中对 `real_data_mb` 的依赖;预警列表和详情补齐 `used_mb`、`limit_mb` 及新增 `threshold_mb`,以 MB 展示触发时快照,说明包含加油包。套餐接口和套餐选项中的 `real_data_mb` 保留。 +- M2:**BREAKING**:店铺业务员批量换绑说明及前端内置模板(如有)改用固定表头 `店铺编码,操作类型,平台登录账号,备注`,注明换绑时只填写平台用户登录手机号、清空时该列留空;不兼容旧表头,保留逐行失败原因。 +- M23:设备导入文案将 IMEI 列从可选改为必填,不改变现有模板列名、列序、请求结构,也不新增 IMEI 长度、格式或重复校验;失败详情继续展示后端行号和原因。 +- M13:保留退款状态 3 的重新发起入口;若存在引导在企微重新提交的文案则移除,统一指向后台退款重提流程。 +- M4~M6 及 M16 导出补列:仅安排新建/历史导出任务下载回归,文件列由后端生成,不增加前端导出模板或修改历史文件。 + +## Scope + +| 项目 | 后台纳入内容 | 不纳入内容 | +| --- | --- | --- | +| M1 | 本人改密、账号创建/修改/独立改密、店铺初始密码、企业创建/改密,共七个接口 | 扫码注册代理页面的密码校验 | +| M3 | `/api/admin/orders` 列表/创建响应与详情支付记录 | C 端订单请求、支付流程 | +| M8、M14 | 核销、退款、代理注册审核、线下充值、佣金提现、提现资料资格 | C 端注册申请及其他 `/api/c/v1` 调用 | +| M12、M13 | 换货冲突提示、退款重提引导及回归 | 后端手机号解绑规则、企微回调实现 | +| M16 | 预警规则与记录的展示契约、导出回归 | 预警计算、套餐商品额度调整 | +| M2、M23 | 导入说明/已有模板表头及任务失败原因 | 后端 CSV/Excel 解析器实现 | +| F1、M9 | 无 | H5 手机号绑定、登录响应、资产临期提醒、通知中心 | + +### 审批失败状态与后台处理 + +| 后台业务 | 新业务状态 | 失败后的后台处理 | +| --- | --- | --- | +| 核销申请 | `4` | 有修改权限时,通过原 `PUT /api/admin/employee-collection-applications/{id}` 重新提交;账单重新读取可核销金额 | +| 退款 | `7` | 有重提权限时,通过原 `POST /api/admin/refunds/{id}/resubmit` 重新发起 | +| 代理注册审核 | `3` | 隐藏恢复审批,提示「请申请人重新扫码申请」;后台不代申请人发起 C 端注册请求 | +| 线下充值 | `7` | 复用现有线下新建表单,通过 `POST /api/admin/agent-recharges` 新建单,不给原单补发审批 | +| 佣金提现 | `5` | 在有操作资格的店铺提现入口,通过原 `PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}` 重新提交 | +| 提现资料资格 | `4` | 复用已有资料提交表单,通过原 `POST /api/admin/shops/{shop_id}/withdrawal-qualifications` 提交新版本 | + +### 不新增的能力 + +- 不修改任何 `/api/c/v1` 服务封装或页面,包括本仓库已有的公开扫码注册页。 +- 不新增后端接口、权限编码、审批实例或财务冻结/释放逻辑。 +- 不因密码接口清单存在而补建目前没有的账号改密页面;覆盖既有页面和服务契约,新增入口须另行确认。 +- 不修改 `src/types/components.d.ts` 的用户已有改动。 + +## Impact + +- Affected specs: `order-management`、`admin-authentication`、`employee-collection`、`refund-management`、`agent-distribution-registration`、`agent-recharge`、`commission-management`、`exchange-management`、`package-traffic-alert`、`shop-management`、`device-batch-import`。 +- Affected code: + - `src/types/api/{order,account,employeeCollection,refund,agentDistributionRegistration,agentRecharge,commission,packageTrafficAlert}.ts` 及相关类型导出。 + - `src/api/modules/{order,account,exchange}.ts`;其余审批相关服务仅按既有接口复用,不扩展 C 端请求。 + - `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/`。 + - `src/views/commission-management/{withdrawal-approval,my-commission}/index.vue` 及使用相同提现状态的其他后台视图。 + - `src/views/asset-management/exchange-management/{index,detail}.vue`。 + - `src/views/package-management/traffic-alert-rules/index.vue`、`src/views/package-management/traffic-alerts/{index,detail}.vue`。 + - `src/views/shop-management/business-owner-import/index.vue`、`src/views/asset-management/task-management/device-task/index.vue`。 + - `src/config/constants/{refund,commission}.ts`、新密码专用校验工具、`src/utils/business/apiError.ts`、必要的中英文文案。 +- 不引入新依赖;复用现有 Vue、Element Plus、权限、表单、错误处理和异步任务机制。 + +### 与现有提案的关系 + +当前 `openspec/specs/` 无已发布能力规范,相关功能仅存在于历史未归档提案与业务代码中,因此本次沿用既有 capability 名称,以 `ADDED Requirements` 记录本批次的独立适配约束,不伪造 `MODIFIED` 基线。 + +- 订单:承接 `update-order-management-response-semantics`,保持操作者/销售店铺/佣金语义。 +- 核销:承接 `update-employee-collection-approval-recovery`、`update-writeoff-application-and-superadmin-commission`,不回退抽屉和账单筛选优化。 +- 退款:承接 `update-refund-management`、`add-refund-approval-recovery`。旧提案允许显式提交失败恢复原审批的描述,在新业务终态 `7` 下不再适用;保留结果未知的恢复路径。 +- 代理注册:承接 `add-agent-registration-approval-management`,仅扩展后台审核状态和反馈。 +- 充值:承接 `update-agent-self-recharge-and-offline-approval`,保留线下凭证、OCR、收款方式等已有字段。 +- 佣金:承接 `add-commission-management`,保留既有店铺范围和超管选店逻辑。 +- 换货:承接 `update-exchange-refund-interception-feedback`、`update-exchange-migration-and-shop-import`,`1202` 为独立定制反馈,非 `1202` 错误继续按原逻辑展示。 +- 预警:承接 `add-package-traffic-alert`,其旧「规则展示当前真流量额度」描述被本批次规则响应契约取代。 +- 导入:承接 `update-exchange-migration-and-shop-import`、`update-device-batch-import-contract`,保留任务字段与设备模板结构。 + +## 已确认的实施依据 + +1. 用户确认没有最新 `admin-openapi.yaml`,本次不要求提供,也不将其作为实施前置条件;接口适配以第 1 批对接说明为依据,仓库中的八月 OpenAPI 不作为十月最新版。 +2. 用户确认密码不允许特殊符号,因此新密码只接受 ASCII 英文字母和数字,长度 8~12 位,必须同时包含大写、小写和数字;这一确认优先于原说明的「四类至少三类」规则。 +3. 只实施后台,所有 `/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 new file mode 100644 index 0000000..2df73ae --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/admin-authentication/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: 后台新登录密码统一校验 + +后台既有设置新登录密码的表单 SHALL 按用户最新确认使用专用统一校验:长度 8~12 位,仅允许 ASCII 英文字母和数字,必须同时包含大写字母、小写字母和数字。特殊符号、空白、中文、emoji 和其他非英文字母数字字符 MUST 拒绝;用户确认 MUST 优先于原对接说明的四类字符规则。 + +#### Scenario: 有效新密码 + +- **WHEN** 用户在后台本人改密、账号创建、店铺初始账号、企业创建或企业改密入口输入 `Abc12345` +- **THEN** 新密码校验 MUST 通过 +- **AND** 新密码/确认密码输入限制及说明 MUST 为 8~12 位,仅英文字母和数字,且大写、小写和数字三类均必需 +- **AND** 需要确认密码的表单 MUST 验证其与新密码一致 + +#### Scenario: 无效新密码 + +- **WHEN** 用户输入 7 位、13 位、缺少大写/小写/数字任一类的密码,或包含特殊符号、空白、中文、emoji 等非法字符的密码 +- **THEN** 前端 MUST 阻止提交并展示具体规则提示 +- **AND** MUST NOT 静默 trim 密码后当作合法值提交 + +#### Scenario: 字符类别齐全但含特殊符号 + +- **WHEN** 用户输入 `Abc1234!`,长度符合且已包含大写、小写和数字 +- **THEN** 前端 MUST 因 `!` 是禁止的特殊符号拒绝该密码 +- **AND** MUST 提示只允许英文字母和数字,不能按原四类字符规则放行 + +#### Scenario: 修改账号未设置新密码 + +- **WHEN** 既有账号修改操作未提供可选 `password` +- **THEN** 页面 MUST NOT 因缺少密码阻止其他字段修改 +- **AND** MUST NOT 提交空密码覆盖原值 +- **AND** 本批次 MUST NOT 仅为接口清单补建当前没有的账号密码编辑入口 + +### Requirement: 后台密码接口字段与旧密码兼容 + +后台密码服务 SHALL 保持各接口规定的请求字段;本人改密和账号独立改密的新密码使用 `new_password`,账号创建/可选修改及企业创建/改密使用 `password`,店铺初始密码使用 `init_password`。后端字段级错误 MUST 以既有 `msg` 或提供的 `message` 展示,不替换为笼统提示,不记录密码。 + +#### Scenario: 独立修改账号密码 + +- **WHEN** 既有服务调用 `PUT /api/admin/accounts/{id}/password` +- **THEN** 请求体 MUST 按本批次说明使用 `new_password`,而不是当前旧封装的 `password` +- **AND** 本批次 MUST NOT 将其他账号接口的 `password` 改名 + +#### Scenario: 使用旧密码登录和本人改密 + +- **WHEN** 用户使用仍被后端接受的旧密码登录或填写本人改密 `old_password` +- **THEN** 前端 MUST NOT 对这些旧密码输入套用新的 8~12 位/三类/禁止特殊符号限制 +- **AND** 本人改密成功后的清理表单、清理登录态和返回登录页行为 MUST 保留 + +#### Scenario: 非登录密码与 C 端保持不变 + +- **WHEN** 实施本批次密码适配 +- **THEN** 操作密码、WiFi 密码和通用校验器其他调用行为 MUST 保持不变 +- **AND** 本仓库公开扫码注册页面和所有 `/api/c/v1` 服务 MUST 不修改 + +#### Scenario: 展示后端字段级原因 + +- **WHEN** 密码相关请求返回包含具体校验原因的 `msg` 或 `message` +- **THEN** 页面 MUST 展示该原因且不重复弹错 +- **AND** 请求、日志和错误反馈 MUST NOT 输出输入的密码值 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/agent-distribution-registration/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/agent-distribution-registration/spec.md new file mode 100644 index 0000000..848d8ae --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/agent-distribution-registration/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: 后台代理注册审批提交失败反馈 + +后台代理注册审核 SHALL 支持业务状态 `3`「审批提交失败」的筛选和失败标签,终态下隐藏恢复审批,并提示申请人从原扫码入口重新申请。后台 MUST NOT 为重新申请调用任何 `/api/c/v1` 接口。 + +#### Scenario: 审核失败申请 + +- **WHEN** 列表或详情收到 `status=3` 的注册申请 +- **THEN** 页面 MUST 展示「审批提交失败」,查询类型和筛选 MUST 支持值 3 +- **AND** MUST 隐藏恢复审批,即使陈旧响应 `recoverable=true` +- **AND** MUST 提示「请申请人重新扫码申请」,保留详情只读功能 +- **AND** MUST NOT 新增后台代注册提交或修改公开注册页面 + +#### Scenario: 非终态未知结果仍可恢复 + +- **WHEN** 非失败终态申请的提交结果未知,后端允许恢复且用户有原恢复权限 +- **THEN** 页面 MUST 保留原恢复审批行为并在操作后刷新详情 +- **AND** MUST NOT 把提交状态数值直接当作注册业务状态 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/agent-recharge/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/agent-recharge/spec.md new file mode 100644 index 0000000..52394a1 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/agent-recharge/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: 线下充值审批失败重新新建 + +后台线下充值 SHALL 识别业务状态 `7` 为「审批提交失败」终态,支持状态筛选和失败标签;有原创建资格的用户 SHALL 通过既有线下新建表单重新发起,使用 `POST /api/admin/agent-recharges` 新建单,而不是给原单恢复/补发审批。 + +#### Scenario: 失败单只读与终态操作 + +- **WHEN** 线下充值列表或详情展示 `status=7` 的单据 +- **THEN** 页面 MUST 展示「审批提交失败」,筛选 MUST 支持值 7 +- **AND** MUST 隐藏原单补发审批、恢复审批及仅适用于待处理单的确认/拒绝操作 +- **AND** 原权限允许的查看详情和查看凭证 MUST 保留 + +#### Scenario: 重新发起线下充值 + +- **WHEN** 具备现有线下创建资格的用户为失败单选择重新发起 +- **THEN** 页面 MUST 打开既有线下创建表单并沿用原字段校验 +- **AND** 提交 MUST 使用 `POST /api/admin/agent-recharges`,不得携带原单 ID 调用虚构重提接口 +- **AND** 新建成功后 MUST 刷新列表,不乐观将旧单状态改成成功 + +#### Scenario: 保留线上充值与原权限 + +- **WHEN** 实施终态适配 +- **THEN** 在线支付、可用方式、线下收款方式/流水号/OCR/凭证等原行为 MUST 保持不变 +- **AND** 前端 MUST NOT 为重新发起扩大原角色、店铺或创建权限 +- **AND** MUST NOT 修改 C 端接口或将未知结果自动视为失败 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/commission-management/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/commission-management/spec.md new file mode 100644 index 0000000..a8e7d58 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/commission-management/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: 佣金提现审批提交失败状态 + +后台提现审核、店铺提现记录和相关详情 SHALL 支持业务状态 `5`「审批提交失败」,各原有提现状态筛选 MUST 包含值 5;具备既有店铺操作资格的用户 SHALL 通过原提现重提接口重新提交,不复用失败审批或本地模拟冻结金额。 + +#### Scenario: 失败提现展示与操作 + +- **WHEN** 提现列表或详情收到 `status=5` +- **THEN** 页面 MUST 展示失败标签并支持筛选值 5 +- **AND** MUST NOT 为该终态展示待审核专有审批或恢复旧审批操作 +- **AND** 可操作的店铺提现入口 MUST 支持「重新提交」,调用 `PUT /api/admin/shops/{shop_id}/withdrawal-requests/{id}` +- **AND** 既有金额、收款信息、附件校验及店铺权限范围 MUST 保留 + +#### Scenario: 刷新已释放的冻结金额 + +- **WHEN** 失败状态已由后端释放冻结金额,或用户成功重新提交提现 +- **THEN** 页面 MUST 刷新后端佣金概览和提现记录 +- **AND** MUST NOT 前端自行增减冻结金额或推断新的冻结结果 + +### Requirement: 提现资料审批失败提交新版本 + +后台提现资料 SHALL 识别业务状态 `4` 为「审批提交失败」,展示失败标签并支持以值 4 筛选。原权限允许的用户 SHALL 使用已有资料表单提交新版本,不对失败实例恢复审批。 + +#### Scenario: 失败资料展示和重提 + +- **WHEN** 店铺提现资料返回 `status=4` +- **THEN** 页面 MUST 展示「审批提交失败」,资料状态筛选 MUST 支持值 4 +- **AND** 有原资料提交资格时 MUST 允许复用原表单,通过 `POST /api/admin/shops/{shop_id}/withdrawal-qualifications` 提交新版本 +- **AND** 成功后 MUST 刷新版本列表,保留旧版本历史,不乐观复用旧审批 +- **AND** 原店铺隔离、附件校验和作废权限 MUST 保持不变 + +#### Scenario: 状态数值不跨业务共用 + +- **WHEN** 页面同时处理提现状态 5、资料状态 4 和佣金明细状态 +- **THEN** 前端 MUST 使用各自业务映射 +- **AND** MUST NOT 将佣金明细的状态 5「回溯」改成审批提交失败 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/device-batch-import/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/device-batch-import/spec.md new file mode 100644 index 0000000..4fa2861 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/device-batch-import/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: 设备导入说明标注 IMEI 必填 + +后台设备导入说明 SHALL 将独立 IMEI 列标注为必填,不再列作可选;现有 Excel 模板列名、列数、列序和上传结构 MUST 不变,前端 MUST NOT 增加文档以外的 IMEI 格式、长度或重复限制。 + +#### Scenario: 查看导入说明和模板 + +- **WHEN** 用户打开设备导入弹窗 +- **THEN** 说明 MUST 明确 IMEI 必填,且不能因为首列允许 IMEI 作为设备标识就误认为独立 IMEI 列可省略 +- **AND** 原模板下载及列结构 MUST 保持不变 +- **AND** 其他已约定的设备标识、SN 和卡 ICCID 规则 MUST 保留 + +### Requirement: 保留设备导入行级失败反馈 + +设备导入任务详情 SHALL 继续按后端 `failed_items` 展示行号、设备标识和原因,IMEI 空值/空白导致的失败 MUST 不影响后端已成功导入的其他行。 + +#### Scenario: 混合成功与 IMEI 空值失败 + +- **WHEN** 任务详情返回某行原因「设备IMEI(imei)不能为空」和其他成功行 +- **THEN** 页面 MUST 展示该行号和原始失败原因,保留任务成功/失败计数 +- **AND** 前端 MUST NOT 因单行为空在提交前拒绝整个文件 + +#### Scenario: 全部失败 + +- **WHEN** 整个文件所有行失败,后端任务状态为失败 +- **THEN** 页面 MUST 展示后端失败状态及行级明细,不当作上传成功等于导入成功 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/employee-collection/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/employee-collection/spec.md new file mode 100644 index 0000000..5dcf18c --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/employee-collection/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: 核销审批提交失败终态与重提 + +核销申请 SHALL 识别业务状态 `4` 为「审批提交失败」终态,申请列表、详情及账单中的关联申请状态 MUST 一致。该状态不得恢复原审批,有原修改权限的用户 SHALL 通过既有修改表单重新提交申请。 + +#### Scenario: 显式失败申请 + +- **WHEN** 核销申请 `status=4`,或账单分摊记录 `application_status=4` +- **THEN** 页面 MUST 以失败标签展示「审批提交失败」,申请筛选 MUST 包含值 4 +- **AND** MUST 隐藏恢复审批,即使陈旧数据仍返回 `recoverable=true` +- **AND** 有原修改权限时 MUST 允许通过 `PUT /api/admin/employee-collection-applications/{id}` 重新提交 +- **AND** MUST 保留原表单、账单分摊与权限范围 + +#### Scenario: 金额释放与重提结果 + +- **WHEN** 核销申请失败后用户刷新账单,或通过既有接口重新提交申请 +- **THEN** 页面 MUST 从后端重新读取预占和可核销金额,不本地模拟释放金额 +- **AND** 成功重提后 MUST 刷新申请与关联账单 + +### Requirement: 核销真实审批提交状态 + +核销列表和详情 SHALL 根据真实 `approval_submission_status` 展示 `0 待提交 / 1 提交中 / 2 已提交 / 3 提交失败 / 4 结果未知`;后端名称为空时 MUST 按数值回退,不仅依赖名称字段。 + +#### Scenario: 无审批上下文 + +- **WHEN** 申请返回 `approval_submission_status=0` +- **THEN** 列表和详情 MUST 展示「待提交」,不得因 0 为 falsy 而展示为缺失 + +#### Scenario: 提交结果未知 + +- **WHEN** 提交状态为 4,业务单并非失败终态,且后端返回 `recoverable=true` +- **THEN** 有原恢复权限的用户 MUST 仍能看到恢复审批入口 +- **AND** 前端 MUST NOT 将结果未知自行当作审批提交失败或直接生成新审批 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/exchange-management/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/exchange-management/spec.md new file mode 100644 index 0000000..e34b0ec --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/exchange-management/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: 换货新资产手机号绑定冲突提示 + +后台创建换货和确认完成 SHALL 在错误码 `1202` 时读取错误业务响应的 `data.phone`,用一次文本弹窗提示解除关联的后台入口;处理 MUST 覆盖正常 HTTP 承载的业务失败和 HTTP 错误响应,不自动解绑、预查扫码记录或扩大权限。 + +#### Scenario: 1202 返回手机号 + +- **WHEN** `POST /api/admin/exchanges` 或 `POST /api/admin/exchanges/{id}/complete` 返回 `code=1202` 且含 `data.phone` +- **THEN** 页面 MUST 提示「新资产已绑定手机号 {phone},请先到『手机号资产关联』解除后再提交」 +- **AND** 默认错误提示与该弹窗 MUST NOT 重复展示 +- **AND** 手机号 MUST 只作为文本渲染,不写日志或持久化 +- **AND** 前端 MUST NOT 自动调用单条、批量或 CSV 解绑接口 + +#### Scenario: 号码缺失或无管理权限 + +- **WHEN** 1202 响应不含有效号码,或用户没有关联管理入口权限 +- **THEN** 页面 MUST 显示不含 `undefined` 的解除指引,无号码时不自行猜测 +- **AND** 无管理权限时 MUST 提示联系有权限人员,不暴露越权操作按钮 + +### Requirement: 换货冲突后保持数据并允许手动重试 + +1202 失败后后台 SHALL 保留当前创建表单或后端原完成状态,解除关联后允许用户手动再次提交。非 1202 错误 MUST 保留原统一反馈及业务判断。 + +#### Scenario: 创建失败保留表单 + +- **WHEN** 创建换货因 1202 被拒绝 +- **THEN** 当前新旧资产、原因、换货类型和其他表单值 MUST 保留 +- **AND** loading MUST 结束,取消提示 MUST NOT 被当作成功 +- **AND** 用户解除关联后 MUST 可手动再次提交原接口 + +#### Scenario: 确认完成被拒绝 + +- **WHEN** 确认完成接口因 1202 被拒绝 +- **THEN** 页面 MUST 刷新原换货单,并保持后端「已发货待确认」状态 +- **AND** MUST NOT 乐观标记换货完成或清空当前单据 +- **AND** 用户解除关联后 MUST 可手动重新确认 + +#### Scenario: 其他错误及原流程不变 + +- **WHEN** 换货接口返回非 1202 错误,包括退款拦截、权限失败、迁移失败或网络错误 +- **THEN** 页面 MUST 继续按原业务文案及统一错误分类反馈,不重复弹错 +- **AND** MUST NOT 新增依据扫码历史或退款状态的前置拒绝逻辑 +- **AND** 当前换货迁移字段、角色限制和接口成功行为 MUST 保持不变 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/order-management/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/order-management/spec.md new file mode 100644 index 0000000..0bfa554 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/order-management/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: 后台订单在线支付摘要 + +后台订单公共类型 SHALL 接收 `third_party_trade_no`、`payment_channel`、`payment_merchant_name` 三个字符串字段;订单列表和详情 MUST 分别展示交易流水号、支付渠道和收款商户,不自行推断或补造后端未提供的值。 + +#### Scenario: 查看个人客户已支付在线订单 + +- **WHEN** 后台订单列表或详情收到个人客户微信/支付宝已支付订单的支付摘要 +- **THEN** 页面 MUST 展示后端返回的三个字段 +- **AND** `wechat`、`wechat_v2`、`fuiou` MUST 分别展示为微信直连 v3、微信直连 v2、富友 +- **AND** 未知非空渠道 MUST 保留原值,不错误映射成其他渠道 +- **AND** 已退款订单返回的原支付信息 MUST 继续展示 + +#### Scenario: 查看无在线支付摘要的订单 + +- **WHEN** 代理、钱包、线下或未支付订单的三个字段为空串 +- **THEN** 页面 MUST 统一展示 `-`,不得显示 `undefined` 或虚构渠道/商户 +- **AND** 列表和创建响应类型 MUST NOT 要求存在详情专有的 `payments` + +### Requirement: 后台订单详情支付记录 + +后台订单详情 SHALL 使用独立于列表/创建响应的详情类型,接收并展示 `payments` 数组。支付单状态 MUST 使用独立映射 `0 待支付 / 1 已支付 / 2 已失败 / 3 已退款`,不得复用订单支付状态枚举。 + +#### Scenario: 展示多个支付尝试 + +- **WHEN** `GET /api/admin/orders/{id}` 返回多条支付记录 +- **THEN** 页面 MUST 按后端返回的创建时间倒序展示,不删除待支付或失败记录 +- **AND** MUST 展示支付单号、支付方式、渠道、商户、交易流水号、金额、状态、创建/支付/过期时间 +- **AND** 金额 MUST 从分转换为元,`paid_at`、`expire_at` 的 null 值 MUST 展示 `-` + +#### Scenario: 无支付记录 + +- **WHEN** 订单详情返回 `payments=[]` +- **THEN** 页面 MUST 显示支付记录空态,不报错或生成占位支付单 +- **AND** 页面 MUST NOT 为加载支付记录调用 C 端接口 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/package-traffic-alert/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/package-traffic-alert/spec.md new file mode 100644 index 0000000..18c1bda --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/package-traffic-alert/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: 预警规则不依赖已删除额度字段 + +后台预警规则 SHALL 按本批次响应字段展示规则,响应类型、列表、详情和列选项 MUST NOT 依赖 `real_data_mb`;套餐商品接口和套餐选项的同名额度字段 MUST 保留。 + +#### Scenario: 规则响应不含额度 + +- **WHEN** 后台加载规则列表或详情且后端不再返回 `real_data_mb` +- **THEN** 页面 MUST 不展示规则的套餐流量额度列及详情字段 +- **AND** MUST 正常展示套餐、阈值百分比、启用状态、备注和更新时间 +- **AND** MUST NOT 因删除字段显示 `undefined` 或产生类型错误 + +#### Scenario: 套餐商品额度保留 + +- **WHEN** 规则表单从 `/api/admin/packages` 加载套餐候选 +- **THEN** 套餐类型及选项中 `real_data_mb` 的原用途 MUST 保留 +- **AND** 原规则创建/修改参数与阈值百分比校验 MUST 不变 + +### Requirement: 预警记录展示主套餐与加油包流量快照 + +后台预警列表和详情 SHALL 接收并展示后端 `threshold_mb`、`used_mb`、`limit_mb`,三者单位为 MB;阈值流量 MUST 使用触发时快照,不以当前套餐额度重新计算,已用和总量说明 MUST 标注包含加油包。 + +#### Scenario: 展示预警流量 + +- **WHEN** 预警列表或详情返回三项 MB 数值 +- **THEN** 页面 MUST 展示「阈值流量(MB)」「已用流量(MB,含加油包)」「总流量(MB,含加油包)」或同等明确标签 +- **AND** MUST 以两位小数展示,不混用规则百分比或 GB 数值 +- **AND** MUST NOT 使用当前套餐商品或前端计算覆盖 `threshold_mb` + +#### Scenario: 零与缺失值 + +- **WHEN** 流量值为 0、null 或缺失 +- **THEN** 0 MUST 展示 `0.00`,null/缺失 MUST 展示 `-` +- **AND** MUST NOT 把 0 显示为缺失或将缺失值当作 0 + +#### Scenario: 导出仍由后端生成 + +- **WHEN** 用户创建预警导出任务或下载历史文件 +- **THEN** 前端 MUST 复用原导出任务流程,不新增前端列模板或重写历史文件 +- **AND** 本批次验收 MUST 检查新任务最后一列阈值流量及历史任务原文件不变 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/refund-management/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/refund-management/spec.md new file mode 100644 index 0000000..d030393 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/refund-management/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: 退款审批提交失败状态与后台重提 + +退款后台 SHALL 识别业务状态 `7` 为「审批提交失败」终态,列表与详情 MUST 支持该状态展示和筛选。有原重提权限的用户 SHALL 使用原退款重提表单重新发起,不得恢复失败单的原审批。 + +#### Scenario: 显式提交失败 + +- **WHEN** 退款记录 `status=7` +- **THEN** 列表和详情 MUST 展示失败标签,状态筛选 MUST 允许值 7 +- **AND** MUST 隐藏恢复及补发审批,不因陈旧 `approval_recoverable=true` 重新显示 +- **AND** 有重提权限时 MUST 通过 `POST /api/admin/refunds/{id}/resubmit` 重新发起 +- **AND** 成功后 MUST 刷新记录,不复用旧审批实例 + +#### Scenario: 不混淆原路退款失败 + +- **WHEN** 退款记录 `status=6` +- **THEN** 页面 MUST 继续按原「原路退款失败」含义及 `anomaly_flag` 限制处理 +- **AND** MUST NOT 将该状态改成审批提交失败或放宽其原有异常限制 + +#### Scenario: 结果未知保留恢复资格 + +- **WHEN** 退款提交结果未知,业务状态不是 7,后端允许恢复且用户有原权限 +- **THEN** 页面 MUST 保留恢复审批入口 +- **AND** MUST NOT 直接把未知结果改成终态或创建重复申请 + +### Requirement: 退款企微结束后使用后台重新发起 + +后台退款页面 SHALL 对企微拒绝/撤销/删除后返回的 `status=3` 按「已拒绝」处理,允许符合原权限和规则的后台重提。页面 MUST NOT 引导用户在企微重新提交作为后台退款重提方式。 + +#### Scenario: 已拒绝退款重新发起 + +- **WHEN** 用户查看状态 3 的退款,且具备原重提权限 +- **THEN** 页面 MUST 保留后台重新发起入口并调用原 `resubmit` 接口 +- **AND** 若存在「在企微重新提交」文案,MUST 移除或改为后台重新发起指引 +- **AND** 换货资格 MUST 仍由换货接口判断,不新增退款状态前置拦截 diff --git a/openspec/changes/update-october-batch-one-admin-integration/specs/shop-management/spec.md b/openspec/changes/update-october-batch-one-admin-integration/specs/shop-management/spec.md new file mode 100644 index 0000000..e9ae149 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/specs/shop-management/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: 业务员批量换绑使用平台登录手机号表头 + +后台业务员批量换绑页面 SHALL 将说明及已有前端内置模板(如有)统一为固定表头 `店铺编码,操作类型,平台登录账号,备注`,说明「平台登录账号」仅填写平台用户登录手机号。请求路径、结构、逐行结果和已有任务信息 MUST 保持不变。 + +#### Scenario: 换绑说明和清空规则 + +- **WHEN** 用户查看导入说明或下载已有前端模板 +- **THEN** 表头 MUST 使用「平台登录账号」,不得保留「业务员登录账号」 +- **AND** MUST 说明换绑时填写平台用户登录手机号而非用户名,清空时该列留空 +- **AND** MUST NOT 自动兼容/转换旧表头或把用户名转换为手机号 + +#### Scenario: 展示逐行失败 + +- **WHEN** 后端返回表头错误或行级格式不对、手机号不存在、非平台用户、账号禁用、多账号匹配等原因 +- **THEN** 页面 MUST 原样展示后端原因 +- **AND** 混合结果 MUST 保留成功行与失败行,不因部分失败改为前端整批拒绝 +- **AND** 接口 MUST 继续使用 `POST /api/admin/shops/business-owner-imports` diff --git a/openspec/changes/update-october-batch-one-admin-integration/tasks.md b/openspec/changes/update-october-batch-one-admin-integration/tasks.md new file mode 100644 index 0000000..152f443 --- /dev/null +++ b/openspec/changes/update-october-batch-one-admin-integration/tasks.md @@ -0,0 +1,61 @@ +## 1. 实施前核对 + +- [x] 1.1 用户于 2026-10-13 批准实施,仅后台,不修改 `/api/c/v1`。 +- [x] 1.2 按说明落实账号独立改密、核销提交状态、预警字段和错误响应,不依赖最新 OpenAPI。 +- [x] 1.3 按已确认密码规则准备长度、字符类别、非法字符及旧密码边界用例。 +- [x] 1.4 核对旧提案与代码,保留权限、核销抽屉、店铺范围、原换货流程及用户改动。 + +## 2. 订单在线支付信息(M3) + +- [x] 2.1 补订单公共摘要字段、独立支付单状态与详情类型,列表/创建不要求 `payments`。 +- [x] 2.2 列表和详情展示三项支付摘要、渠道映射、未知值及空值。 +- [x] 2.3 详情支付表展示字段、金额、独立状态及空时间,保留失败记录和后端顺序。 + +## 3. 审批提交失败终态(M8、M14) + +- [x] 3.1 核销状态 4、筛选/标签、关联申请和真实提交状态;支持原表单重提,终态隐藏恢复。 +- [x] 3.2 退款状态 7、筛选/标签和原 resubmit,终态禁止恢复/补发,状态 6 异常限制保留。 +- [x] 3.3 后台代理注册状态 3、筛选/标签和重新扫码指引,终态隐藏恢复,不改 C 端。 +- [x] 3.4 线下充值状态 7、筛选/标签及操作判断,复用原创建权限/表单发起新单。 +- [x] 3.5 提现状态 5、相关后台筛选/标签/详情、原重提入口及概览刷新,不本地模拟金额。 +- [x] 3.6 资料资格状态 4、筛选/标签和原资料表单提交新版本。 +- [x] 3.7 本地代码与逻辑测试复核终态/未知结果、只读操作和权限判断;财务事实随 7.3 联调。 + +## 4. 后台新密码规则(M1) + +- [x] 4.1 新密码专用校验和说明:8~12 位,三类英文字母/数字必需,拒绝所有非法字符,不 trim。 +- [x] 4.2 本人改密新密码/确认限制及双语说明;旧密码仅必填,原成功行为保持;错误换行不重叠。 +- [x] 4.3 账号及客户账号创建接入校验,可选修改空密码不发送,不新增入口。 +- [x] 4.4 账号独立改密改为 `new_password`,其他字段不改名。 +- [x] 4.5 店铺初始密码、企业创建/改密接入校验及 maxlength。 +- [x] 4.6 兼容 `msg`/`message`,受影响密码操作检查非零业务码且不记录带密码的错误对象;其他密码与 C 端不改。 + +## 5. 换货手机号冲突反馈(M12) + +- [x] 5.1 两入口共用 1202 提取/提示,兼容业务与 HTTP 错误和无号码降级。 +- [x] 5.2 创建保留表单,完成失败重新读原单,用户解除后手动重试。 +- [x] 5.3 按已有权限指引,不自动解绑、记录或持久化号码。 +- [x] 5.4 非 1202 保持后端原因与原错误分类,不新增前置检查或重复默认提示。 + +## 6. 预警口径与导入说明(M16、M2、M23) + +- [x] 6.1 删除规则额度引用,保留套餐商品/候选额度及规则参数。 +- [x] 6.2 补三项 MB 快照、两位小数、0/空值与加油包说明,不重算历史阈值。 +- [x] 6.3 新 CSV 表头及说明;下载从统一 UTF-8 BOM 文本生成,原 GBK 文件保留,逐行结果不变。 +- [x] 6.4 IMEI 说明改必填,原 Excel 模板及上传/失败明细保持,不加格式或去重限制。 + +## 7. 回归与验收 + +- [x] 7.1 未发现引导企微重提的后台文案;本地测试确认状态 3 仍允许后台重提,真实流程随 7.3 联调。 +- [ ] 7.2 检查订单个人在线已支付/已退款信息,未支付及代理/钱包/线下空字段,详情多个支付记录的顺序、金额、状态和空时间。 +- [ ] 7.3 检查六类失败终态、状态筛选、恢复隐藏、重提入口;覆盖陈旧 recoverable=true、结果未知、无权限及刷新财务数据。 +- [x] 7.4 本地规则与改密页检查长度、三类、非法字符、可选空密码及旧密码兼容;375px 错误文案不重叠,其他密码代码未改。 +- [ ] 7.5 检查换货两入口 1202 有/无号码、无管理权限、用户取消、解除后重试和其他错误;确认日志不含号码、原完成状态不被误改。 +- [x] 7.6 本地规则/详情页与数值测试确认规则无额度、套餐字段保留、三项 MB/0/空值及加油包文案。 +- [ ] 7.7 检查 CSV 新/旧表头、用户名误填、清空行和混合成功失败;检查设备 IMEI 空白行、部分成功、全失败以及模板未改变。 +- [ ] 7.8 新建订单/IoT 卡/设备/预警导出任务检查后端新增列,下载历史任务检查旧列仍在;不修改前端导出模板或历史文件。 +- [x] 7.9 本地 63 项测试、受影响文件 lint、类型检查、生产构建、UTF-8、OpenSpec 严格校验通过;模拟页面检查通过,真实环境项单列。 +- [x] 7.10 C 端页面/服务无差异,组件声明 SHA-256 与实施前一致;回写实际完成和待联调项。 +- [ ] 7.11 完成真实测试环境联调及用户验收后归档,不以模拟页面替代后端验收。 + + 7.2、7.3、7.5、7.7 的本地逻辑与部分模拟页面已覆盖;尚未验证真实支付、冻结/预占金额、解除后重试、导入结果与导出文件。复现方式及证据见 [实施记录](./implementation.md)。 diff --git a/scripts/tests/octoberBatchOne.test.mjs b/scripts/tests/octoberBatchOne.test.mjs new file mode 100644 index 0000000..8277794 --- /dev/null +++ b/scripts/tests/octoberBatchOne.test.mjs @@ -0,0 +1,279 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const esbuild = createRequire(require.resolve('vite'))('esbuild') + +async function loadModule(entry, stubService = false) { + const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + alias: { '@': resolve('src') }, + plugins: stubService + ? [ + { + name: 'stub-base-service', + setup(build) { + build.onResolve({ filter: /BaseService$/ }, () => ({ + path: 'base-service', + namespace: 'stub' + })) + build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ + contents: ` + export class BaseService { + static calls = []; + static put(url, data, config) { this.calls.push({url, data, config}); return Promise.resolve({code:0, data:{}}); } + static update(url, data) { return this.put(url, data); } + static post(url, data, config) { this.calls.push({url, data, config}); return Promise.resolve({code:0, data:{}}); } + }` + })) + } + } + ] + : [] + }) + const module = { exports: {} } + new Function('require', 'module', 'exports', result.outputFiles[0].text)( + require, + module, + module.exports + ) + return module.exports +} + +test('new login password accepts only 8-12 ASCII letters/digits with all three classes', async () => { + const { getNewLoginPasswordError, createNewLoginPasswordRule } = await loadModule( + 'src/utils/validation/loginPassword.ts' + ) + for (const password of ['Abc12345', 'Abc123456789']) + assert.equal(getNewLoginPasswordError(password), undefined) + for (const password of [ + 'Abc1234', + 'Abc123456789X', + 'abcd1234', + 'ABCD1234', + 'AbcDefGh', + 'Abc1234!', + 'Abc1234_', + 'Abc1234 ', + ' Abc12345', + 'Abc1234\n', + 'Abc1234中', + 'Abc123😀', + 'Abc12345' + ]) { + assert.ok(getNewLoginPasswordError(password), `must reject ${JSON.stringify(password)}`) + } + 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)) +}) + +test('account password requests use the right field and omit unchanged optional password', async () => { + const { AccountService } = await loadModule('src/api/modules/account.ts', true) + AccountService.calls = [] + await AccountService.updateAccountPassword(12, 'Abc12345') + 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 assert.rejects(AccountService.updateAccountPassword(12, 'abcd1234')) + assert.equal(AccountService.calls.length, 2) +}) + +test('writeoff terminal failure blocks recovery but allows resubmission; unknown result stays recoverable', async () => { + const module = await loadModule( + 'src/views/finance/employee-collection/employeeCollectionDisplay.ts' + ) + assert.equal(module.canRecoverApplicationApproval({ status: 4, recoverable: true }), false) + assert.equal(module.canResubmitApplication({ status: 4 }), true) + assert.equal( + module.canRecoverApplicationApproval({ + status: 0, + approval_submission_status: 4, + recoverable: true + }), + true + ) + assert.equal(module.canRecoverApplicationApproval({ status: 0, recoverable: false }), false) + assert.equal(module.getApplicationStatusTagType(4), 'danger') + for (const [value, label] of [ + [0, '待提交'], + [1, '提交中'], + [2, '已提交'], + [3, '提交失败'], + [4, '结果未知'] + ]) { + assert.equal(module.getApplicationSubmissionLabel({ approval_submission_status: value }), label) + } +}) + +test('refund submission failure uses resubmit, not recovery, without changing status 6 anomaly handling', async () => { + const module = await loadModule('src/config/constants/refund.ts') + assert.equal(module.canRecoverRefundApproval({ status: 7, approval_recoverable: true }), false) + assert.equal(module.canResubmitRefund({ status: 7 }), true) + assert.equal(module.canResubmitRefund({ status: 3 }), true) + assert.equal( + module.canRecoverRefundApproval({ + status: 1, + submission_status: 4, + approval_recoverable: true + }), + true + ) + assert.equal(module.canResubmitRefund({ status: 6, anomaly_flag: 1 }), false) + assert.equal(module.canResubmitRefund({ status: 6, anomaly_flag: 0 }), true) + assert.equal(module.RefundStatusNameMap[7], '审批提交失败') +}) + +test('admin agent registration does not recover terminal failures and provides status fallback', async () => { + const module = await loadModule('src/types/api/agentDistributionRegistration.ts') + assert.equal(module.canRecoverAgentRegistration({ status: 3, recoverable: true }), false) + assert.equal(module.canRecoverAgentRegistration({ status: 0, recoverable: true }), true) + assert.equal(module.getAgentRegistrationStatusName({ status: 3 }), '审批提交失败') +}) + +test('offline recharge failure keeps read actions and starts a new order only with existing role/permission', async () => { + const { buildAgentRechargeActions } = await loadModule( + 'src/views/finance/agent-recharge/agentRechargeActions.ts' + ) + const row = { id: 8, status: 7, payment_method: 'offline', payment_voucher_key: ['voucher'] } + let restarted = false + const options = { + hasAuth: () => true, + canCreateOffline: true, + onRestart: () => { + restarted = true + }, + onViewPaymentVoucher() {}, + onConfirmPayment() {}, + onReject() {}, + onTriggerApproval() {} + } + const actions = buildAgentRechargeActions(row, options) + assert.deepEqual( + actions.map((action) => action.label), + ['支付凭证', '重新发起'] + ) + actions[1].handler() + assert.equal(restarted, true) + assert.deepEqual( + buildAgentRechargeActions(row, { ...options, canCreateOffline: false }).map( + (action) => action.label + ), + ['支付凭证'] + ) + assert.deepEqual(buildAgentRechargeActions(row, { ...options, hasAuth: () => false }), []) +}) + +test('withdrawal failure is independently typed from commission clawback and supports resubmit', async () => { + const module = await loadModule('src/config/constants/commission.ts') + assert.equal(module.WithdrawalStatusMap[5].label, '审批提交失败') + assert.equal(module.canResubmitWithdrawal(5), true) + assert.equal(module.canResubmitWithdrawal(3), true) + assert.equal(module.canResubmitWithdrawal(1), false) + assert.equal(module.getCommissionStatusLabel(5), '回溯') + assert.equal( + module.WITHDRAWAL_QUALIFICATION_STATUS_OPTIONS.find((item) => item.value === 4).label, + '审批提交失败' + ) +}) + +test('order payment record status is independent from order status and channels preserve unknown codes', async () => { + const module = await loadModule('src/utils/business/orderPayment.ts') + assert.equal(module.getOrderPaymentRecordStatusName(0), '待支付') + assert.equal(module.getOrderPaymentRecordStatusName(1), '已支付') + assert.equal(module.getOrderPaymentRecordStatusName(2), '已失败') + assert.equal(module.getOrderPaymentRecordStatusName(3), '已退款') + assert.equal(module.getOrderPaymentRecordTagType(2), 'danger') + assert.equal(module.getOrderPaymentChannelName('wechat'), '微信直连 v3') + assert.equal(module.getOrderPaymentChannelName('wechat_v2'), '微信直连 v2') + assert.equal(module.getOrderPaymentChannelName('fuiou'), '富友') + assert.equal(module.getOrderPaymentChannelName(''), '-') + assert.equal(module.getOrderPaymentChannelName('future-channel'), 'future-channel') +}) + +test('exchange phone conflicts support HTTP error and business response without inventing a missing number', async () => { + const module = await loadModule('src/utils/business/exchangeError.ts') + const failure = { code: 1202, data: { phone: '13800000000' } } + assert.match(module.getExchangePhoneConflictMessage(failure), /13800000000/) + assert.equal( + module.getExchangePhoneConflictMessage({ response: { data: failure } }), + module.getExchangePhoneConflictMessage(failure) + ) + assert.match(module.getExchangePhoneConflictMessage({ code: 1202 }), /请先到/) + assert.doesNotMatch(module.getExchangePhoneConflictMessage({ code: 1202 }), /undefined|null/) + assert.equal(module.getExchangePhoneConflictMessage({ code: 1206 }), undefined) + assert.equal( + module.getExchangeBusinessErrorMessage({ response: { data: { message: '迁移失败' } } }), + '迁移失败' + ) + const { ExchangeService } = await loadModule('src/api/modules/exchange.ts', true) + ExchangeService.calls = [] + const config = { requestOptions: { errorMessageMode: 'none' } } + await ExchangeService.completeExchange(9, config) + assert.deepEqual(ExchangeService.calls[0], { + url: '/api/admin/exchanges/9/complete', + data: {}, + config + }) +}) + +test('password validation errors accept backend message as well as msg', async () => { + const { normalizeApiError } = await loadModule('src/utils/business/apiError.ts') + for (const field of ['msg', 'message']) { + assert.equal( + normalizeApiError({ + isAxiosError: true, + response: { status: 400, data: { [field]: '新密码不合法' } } + }).message, + '新密码不合法' + ) + } +}) + +test('traffic snapshots preserve zero, missing values and MB precision', async () => { + const { formatTrafficAlertMb } = await loadModule('src/utils/business/trafficAlert.ts') + assert.equal(formatTrafficAlertMb(0), '0.00') + assert.equal(formatTrafficAlertMb(1234.567), '1234.57') + for (const value of [undefined, null, NaN]) assert.equal(formatTrafficAlertMb(value), '-') +}) + +test('CSV download has the new exact four-column header and preserves phone identifiers as text', async () => { + const { SHOP_BUSINESS_OWNER_CSV_TEMPLATE } = await loadModule( + 'src/utils/business/shopBusinessOwnerImport.ts' + ) + assert.equal(SHOP_BUSINESS_OWNER_CSV_TEMPLATE.charCodeAt(0), 0xfeff) + const [header, instruction] = SHOP_BUSINESS_OWNER_CSV_TEMPLATE.slice(1).trimEnd().split('\r\n') + assert.deepEqual(header.split(','), ['店铺编码', '操作类型', '平台登录账号', '备注']) + assert.equal(instruction.split(',').length, 4) + assert.match(instruction, /手机号/) + assert.match(instruction, /清空留空/) + assert.doesNotMatch(SHOP_BUSINESS_OWNER_CSV_TEMPLATE, /业务员登录账号/) +}) + +test('affected view contracts preserve raw payment history, package quota and device template', async () => { + const order = await readFile('src/views/order-management/order-list/detail.vue', 'utf8') + assert.match(order, /detailData\.payments \|\| \[\]/) + assert.doesNotMatch(order, /payments\.(filter|sort)/) + const rules = await readFile('src/views/package-management/traffic-alert-rules/index.vue', 'utf8') + assert.match(rules, /item\.real_data_mb/) + assert.doesNotMatch(rules, /row\.real_data_mb|prop: 'real_data_mb'/) + const devices = await readFile( + 'src/views/asset-management/task-management/device-task/index.vue', + 'utf8' + ) + assert.ok( + /独立的 IMEI\s+列/.test(devices), + 'import instructions must require the standalone IMEI column' + ) + assert.match(devices, /@\/template\/设备导入模板\.xlsx/) + assert.match(devices, /failed_items/) +}) diff --git a/scripts/tests/octoberBrowserFixtures.mjs b/scripts/tests/octoberBrowserFixtures.mjs new file mode 100644 index 0000000..b52e9c5 --- /dev/null +++ b/scripts/tests/octoberBrowserFixtures.mjs @@ -0,0 +1,173 @@ +const time = '2026-10-13T10:00:00+08:00' +const order = { + id: 1, + order_no: 'MOCK-ORDER-001', + order_type: 'single_card', + buyer_type: 'personal', + payment_status: 2, + payment_status_text: '已支付', + payment_method: 'wechat', + total_amount: 1000, + actual_paid_amount: 1000, + third_party_trade_no: 'MOCK-TRADE-001', + payment_channel: 'wechat', + payment_merchant_name: '模拟收款商户', + commission_status: 2, + commission_result: 2, + created_at: time, + updated_at: time, + paid_at: time, + items: [], + payments: [ + { + payment_no: 'MOCK-PAY-002', + payment_method: 'wechat', + payment_channel: 'wechat', + payment_merchant_name: '模拟收款商户', + third_party_trade_no: 'MOCK-TRADE-001', + amount: 1000, + status: 1, + created_at: time, + paid_at: time, + expire_at: null + }, + { + payment_no: 'MOCK-PAY-001', + payment_method: 'alipay', + payment_channel: 'fuiou', + payment_merchant_name: '模拟收款商户', + third_party_trade_no: '', + amount: 1000, + status: 2, + created_at: time, + paid_at: null, + expire_at: null + } + ] +} +const application = { + id: 1, + status: 4, + status_name: '审批提交失败', + recoverable: true, + approval_submission_status: 3, + paid_amount: 1000, + created_at: time +} +const registration = { + id: 1, + status: 3, + recoverable: true, + phone: '13800000000', + username: 'mock-user', + shop_name: '模拟店铺', + created_at: time +} +const rule = { + id: 1, + package_id: 1, + package_name: '模拟套餐', + threshold_percent: 80, + enabled: true, + enabled_name: '启用', + remark: '', + updated_at: time +} +const traffic = { + id: 1, + package_name: '模拟套餐', + threshold_percent: 80, + threshold_mb: 819.2, + used_mb: 0, + limit_mb: 1024, + triggered_at: time, + notification_status: 1, + asset_type: 'iot_card', + asset_identifier: 'MOCK-ASSET' +} +const exchange = { + id: 1, + exchange_no: 'MOCK-EXCHANGE-001', + status: 3, + status_name: '已发货待确认', + status_text: '已发货待确认', + old_asset_type: 'iot_card', + old_asset_identifier: 'MOCK-OLD-ASSET', + new_asset_identifier: 'MOCK-NEW-ASSET', + flow_type: 'shipping', + flow_type_name: '物流换货', + migration_status: 'pending', + created_at: time, + updated_at: time +} + +export const mockApiPlugin = { + name: 'october-local-mock-api', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const path = new URL(req.url, 'http://127.0.0.1').pathname + if (!path.startsWith('/api/')) return next() + if (path === '/api/auth/login' || path === '/api/auth/me') { + const user = { + id: 1, + user_type: 1, + username: '模拟管理员', + phone: '13800000000', + roles: ['R_SUPER'] + } + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end( + JSON.stringify({ + code: 0, + msg: 'mock', + data: { + access_token: 'local-mock-only', + refresh_token: '', + expires_in: 3600, + user, + permissions: ['*:*:*'], + buttons: [], + menus: [{ url: '/asset-management', name: '资产管理' }] + } + }) + ) + return + } + if (req.method !== 'GET') { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end( + JSON.stringify({ + code: 1202, + msg: '模拟请求:未调用真实后台', + data: { phone: '13800000000' } + }) + ) + return + } + const page = (items) => ({ + items, + list: items, + total: items.length, + page: 1, + size: 20, + page_size: 20 + }) + let data = page([]) + if (path === '/api/admin/orders/1') data = order + if (path === '/api/admin/exchanges') data = page([exchange]) + if (path === '/api/admin/exchanges/1') data = exchange + if (path === '/api/admin/orders') data = page([order]) + if (path === '/api/admin/employee-collection-applications/1') + data = { application, allocations: [], attempts: [] } + if (path === '/api/admin/employee-collection-applications') data = page([application]) + if (path === '/api/admin/agent-distribution-registrations/1') data = registration + if (path === '/api/admin/agent-distribution-registrations') data = page([registration]) + if (path === '/api/admin/package-traffic-alert-rules') data = page([rule]) + if (path === '/api/admin/package-traffic-alert-rules/1') data = rule + if (path === '/api/admin/package-traffic-alerts') data = page([traffic]) + if (path === '/api/admin/package-traffic-alerts/1') data = traffic + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ code: 0, msg: 'mock', data })) + }) + } +} diff --git a/scripts/tests/register-esbuild.mjs b/scripts/tests/register-esbuild.mjs new file mode 100644 index 0000000..b1c4fc4 --- /dev/null +++ b/scripts/tests/register-esbuild.mjs @@ -0,0 +1,14 @@ +import { createRequire, register } from 'node:module' +import { isMainThread } from 'node:worker_threads' +import { pathToFileURL } from 'node:url' + +// pnpm 的严格依赖布局下,旧测试从根目录无法直接导入 Vite 的 esbuild 依赖。 +const require = createRequire(import.meta.url) +const esbuildUrl = pathToFileURL(createRequire(require.resolve('vite')).resolve('esbuild')).href +if (isMainThread) register(import.meta.url, { parentURL: import.meta.url }) + +export function resolve(specifier, context, nextResolve) { + return specifier === 'esbuild' + ? { url: esbuildUrl, shortCircuit: true } + : nextResolve(specifier, context) +} diff --git a/scripts/tests/verifyBuild.mjs b/scripts/tests/verifyBuild.mjs new file mode 100644 index 0000000..69b0f94 --- /dev/null +++ b/scripts/tests/verifyBuild.mjs @@ -0,0 +1,71 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { build, createServer, loadConfigFromFile } from 'vite' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +// 与生产配置相同的构建,关闭声明文件生成,避免覆盖工作区已有的用户修改。 +const protectedPaths = [ + 'src/types/components.d.ts', + 'src/types/auto-imports.d.ts', + '.auto-import.json' +] +const snapshots = await Promise.all(protectedPaths.map((path) => readFile(path))) +const serve = process.argv.includes('--serve') +const loaded = await loadConfigFromFile({ + command: serve ? 'serve' : 'build', + mode: serve ? 'development' : 'production' +}) +const outputDir = await mkdtemp(join(tmpdir(), 'one-pipe-october-build-')) +const plugins = loaded.config.plugins + .flat(Infinity) + .filter( + (plugin) => plugin && !['unplugin-vue-components', 'unplugin-auto-import'].includes(plugin.name) + ) +plugins.push( + Components({ + deep: true, + extensions: ['vue'], + dirs: ['src/components'], + resolvers: [ElementPlusResolver()], + dts: false + }), + AutoImport({ + imports: ['vue', 'vue-router', '@vueuse/core', 'pinia'], + resolvers: [ElementPlusResolver()], + dts: false, + eslintrc: { enabled: false } + }) +) +if (serve) { + const { mockApiPlugin } = await import('./octoberBrowserFixtures.mjs') + const server = await createServer({ + ...loaded.config, + configFile: false, + mode: 'development', + plugins: [mockApiPlugin, ...plugins], + server: { host: '127.0.0.1', port: 5190, strictPort: true, proxy: {} } + }) + await server.listen() + console.log( + 'Local mock preview: http://127.0.0.1:5190 (all /api requests intercepted; no real backend writes)' + ) +} else { + await build({ + ...loaded.config, + configFile: false, + plugins, + logLevel: 'warn', + build: { ...loaded.config.build, outDir: outputDir, emptyOutDir: false } + }) +} +for (let i = 0; i < protectedPaths.length; i++) { + if (!snapshots[i].equals(await readFile(protectedPaths[i]))) + throw new Error(`Build modified protected file: ${protectedPaths[i]}`) +} +if (!serve) + console.log( + `Production build passed. Generated declaration files preserved. Output: ${outputDir}` + ) diff --git a/src/api/modules/account.ts b/src/api/modules/account.ts index 454e013..5c27628 100644 --- a/src/api/modules/account.ts +++ b/src/api/modules/account.ts @@ -3,6 +3,7 @@ */ import { BaseService } from '../BaseService' +import { getNewLoginPasswordError } from '@/utils/validation/loginPassword' import type { PlatformAccount, AccountQueryParams, @@ -48,7 +49,13 @@ export class AccountService extends BaseService { id: number, data: Partial ): Promise { - return this.update(`/api/admin/accounts/${id}`, data) + const payload = { ...data } + if (payload.password === '') delete payload.password + if (payload.password !== undefined) { + const error = getNewLoginPasswordError(payload.password) + if (error) return Promise.reject(new Error(error)) + } + return this.update(`/api/admin/accounts/${id}`, payload) } /** @@ -106,7 +113,9 @@ export class AccountService extends BaseService { * @param password 新密码 */ static updateAccountPassword(id: number, password: string): Promise { - return this.put(`/api/admin/accounts/${id}/password`, { password }) + const error = getNewLoginPasswordError(password) + if (error) return Promise.reject(new Error(error)) + return this.put(`/api/admin/accounts/${id}/password`, { new_password: password }) } /** diff --git a/src/api/modules/exchange.ts b/src/api/modules/exchange.ts index bbf370c..09f6931 100644 --- a/src/api/modules/exchange.ts +++ b/src/api/modules/exchange.ts @@ -138,8 +138,15 @@ export class ExchangeService extends BaseService { * POST /api/admin/exchanges/{id}/complete * @param id 换货单ID */ - static completeExchange(id: number): Promise> { - return this.post>(`/api/admin/exchanges/${id}/complete`, {}) + static completeExchange( + id: number, + config?: Record + ): Promise> { + return this.post>( + `/api/admin/exchanges/${id}/complete`, + {}, + config + ) } /** diff --git a/src/api/modules/order.ts b/src/api/modules/order.ts index aff9c23..cb60cd7 100644 --- a/src/api/modules/order.ts +++ b/src/api/modules/order.ts @@ -4,7 +4,7 @@ import { BaseService } from '../BaseService' import type { - Order, + OrderDetail, OrderQueryParams, OrderListResponse, CreateOrderRequest, @@ -25,8 +25,8 @@ export class OrderService extends BaseService { * 获取订单详情 * @param id 订单ID */ - static getOrderById(id: number): Promise> { - return this.getOne(`/api/admin/orders/${id}`) + static getOrderById(id: number): Promise> { + return this.getOne(`/api/admin/orders/${id}`) } /** diff --git a/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue b/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue index b3d3ebf..19b195c 100644 --- a/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue +++ b/src/components/core/layouts/art-header-bar/ChangePasswordDialog.vue @@ -20,17 +20,23 @@ v-model="formData.old_password" type="password" autocomplete="current-password" - maxlength="32" show-password :disabled="submitting" /> + @@ -40,7 +46,8 @@ v-model="formData.confirm_password" type="password" autocomplete="new-password" - maxlength="32" + maxlength="12" + :placeholder="t('passwordChange.passwordRule')" show-password :disabled="submitting" @keyup.enter="handleSubmit" @@ -61,12 +68,13 @@ + + diff --git a/src/config/constants/commission.ts b/src/config/constants/commission.ts index 5b8eb2c..ed7212a 100644 --- a/src/config/constants/commission.ts +++ b/src/config/constants/commission.ts @@ -19,7 +19,12 @@ export const WITHDRAWAL_STATUS_OPTIONS = [ { label: '待审核', value: WithdrawalStatus.PENDING, type: 'warning' as const }, { label: '已通过', value: WithdrawalStatus.APPROVED, type: 'success' as const }, { label: '已拒绝', value: WithdrawalStatus.REJECTED, type: 'danger' as const }, - { label: '已到账', value: WithdrawalStatus.COMPLETED, type: 'success' as const } + { label: '已到账', value: WithdrawalStatus.COMPLETED, type: 'success' as const }, + { + label: '审批提交失败', + value: WithdrawalStatus.APPROVAL_SUBMISSION_FAILED, + type: 'danger' as const + } ] // 支付方式选项 @@ -36,9 +41,22 @@ export const WithdrawalStatusMap = { 1: { label: '待审核', type: 'warning' as const, color: '#E6A23C' }, 2: { label: '已通过', type: 'success' as const, color: '#67C23A' }, 3: { label: '已拒绝', type: 'danger' as const, color: '#F56C6C' }, - 4: { label: '已到账', type: 'success' as const, color: '#67C23A' } + 4: { label: '已到账', type: 'success' as const, color: '#67C23A' }, + 5: { label: '审批提交失败', type: 'danger' as const, color: '#F56C6C' } } +export const WITHDRAWAL_QUALIFICATION_APPROVAL_FAILED = 4 +export const WITHDRAWAL_QUALIFICATION_STATUS_OPTIONS = [ + { label: '待审批', value: 0 }, + { label: '已通过', value: 1 }, + { label: '已驳回', value: 2 }, + { label: '已作废', value: 3 }, + { label: '审批提交失败', value: WITHDRAWAL_QUALIFICATION_APPROVAL_FAILED } +] + +export const canResubmitWithdrawal = (status: number): boolean => + status === WithdrawalStatus.REJECTED || status === WithdrawalStatus.APPROVAL_SUBMISSION_FAILED + // ========== 佣金状态映射 ========== // 佣金状态映射 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 99:待人工修正) @@ -134,10 +152,7 @@ export const COMMISSION_RECORD_STATUS_OPTIONS = [ ] // 是否回溯明细行(source 与 status 双判断,兼容未返回 source 的响应) -export function isCommissionClawbackRecord(record: { - source?: string - status?: number -}): boolean { +export function isCommissionClawbackRecord(record: { source?: string; status?: number }): boolean { return record.source === 'clawback' || record.status === CommissionStatus.CLAWBACK } diff --git a/src/config/constants/refund.ts b/src/config/constants/refund.ts index 04215b0..57cac0c 100644 --- a/src/config/constants/refund.ts +++ b/src/config/constants/refund.ts @@ -16,7 +16,8 @@ export const RefundStatusNameMap: Record = { [RefundStatus.REJECTED]: '已拒绝', [RefundStatus.RETURNED]: '已退回', [RefundStatus.REFUNDING]: '原路退款处理中', - [RefundStatus.REFUND_FAILED]: '原路退款失败' + [RefundStatus.REFUND_FAILED]: '原路退款失败', + [RefundStatus.APPROVAL_SUBMISSION_FAILED]: '审批提交失败' } /** 退款状态标签类型映射 */ @@ -27,7 +28,8 @@ export const RefundStatusTypeMap: Record { if (!item.status) return false - if (item.status === RefundStatus.REJECTED || item.status === RefundStatus.RETURNED) return true + if ( + item.status === RefundStatus.REJECTED || + item.status === RefundStatus.RETURNED || + item.status === RefundStatus.APPROVAL_SUBMISSION_FAILED + ) + return true if (item.status === RefundStatus.REFUND_FAILED) return item.anomaly_flag !== 1 return false } @@ -85,3 +92,9 @@ export const canManualApproveRefund = (item: { status?: RefundStatus approval_instance_id?: number | null }): boolean => item.status === RefundStatus.PENDING && !item.approval_instance_id + +export const canRecoverRefundApproval = (item: { + status?: RefundStatus + approval_recoverable?: boolean +}): boolean => + item.status !== RefundStatus.APPROVAL_SUBMISSION_FAILED && item.approval_recoverable === true diff --git a/src/locales/langs/en.json b/src/locales/langs/en.json index bd72ca1..a564f4a 100644 --- a/src/locales/langs/en.json +++ b/src/locales/langs/en.json @@ -26,7 +26,8 @@ "oldPasswordRequired": "Please enter your current password", "newPasswordRequired": "Please enter a new password", "confirmPasswordRequired": "Please enter the new password again", - "passwordLength": "Password must be 8-32 characters", + "passwordLength": "Password must be 8-12 characters", + "passwordRule": "8-12 characters with uppercase, lowercase and digits; no special characters", "passwordMismatch": "The new passwords do not match", "submit": "Change password", "success": "Password changed successfully", diff --git a/src/locales/langs/zh.json b/src/locales/langs/zh.json index a0d5083..aeb43f0 100644 --- a/src/locales/langs/zh.json +++ b/src/locales/langs/zh.json @@ -27,7 +27,8 @@ "oldPasswordRequired": "请输入当前密码", "newPasswordRequired": "请输入新密码", "confirmPasswordRequired": "请再次输入新密码", - "passwordLength": "密码长度需为8-32位", + "passwordLength": "密码长度需为8-12位", + "passwordRule": "8~12位,必须包含大写字母、小写字母和数字,不允许特殊符号", "passwordMismatch": "两次输入的新密码不一致", "submit": "确认修改", "success": "密码修改成功", diff --git a/src/types/api/account.ts b/src/types/api/account.ts index 00ea592..b2ff905 100644 --- a/src/types/api/account.ts +++ b/src/types/api/account.ts @@ -124,7 +124,7 @@ export interface AgentQueryParams extends PaginationParams { // 创建账号参数(匹配后端 ModelCreateAccountRequest) export interface CreatePlatformAccountParams { username: string // 用户名 (3-50字符) - password: string // 密码 (8-32字符) + password: string // 8~12位,必须包含大写、小写和数字,禁止特殊符号 phone: string // 手机号 (11位) user_type: number // 用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号) enterprise_id?: number | null // 关联企业ID(企业账号必填) diff --git a/src/types/api/agentDistributionRegistration.ts b/src/types/api/agentDistributionRegistration.ts index 945669d..034f100 100644 --- a/src/types/api/agentDistributionRegistration.ts +++ b/src/types/api/agentDistributionRegistration.ts @@ -3,7 +3,7 @@ export interface AgentDistributionRegistrationQueryParams { page?: number page_size?: number - status?: 0 | 1 | 2 + status?: 0 | 1 | 2 | 3 start_time?: string end_time?: string decided_start_time?: string @@ -54,3 +54,17 @@ export interface RecoverAgentDistributionRegistrationResponse { id: number message?: string } + +export const AGENT_REGISTRATION_APPROVAL_FAILED_STATUS = 3 + +export const canRecoverAgentRegistration = ( + item?: AgentDistributionRegistrationItem | null +): boolean => + !!item && item.status !== AGENT_REGISTRATION_APPROVAL_FAILED_STATUS && item.recoverable === true + +export const getAgentRegistrationStatusName = (item: AgentDistributionRegistrationItem): string => + item.status_name || + ({ 0: '待审批', 1: '已通过', 2: '已驳回', 3: '审批提交失败' } as Record)[ + item.status + ] || + String(item.status) diff --git a/src/types/api/agentRecharge.ts b/src/types/api/agentRecharge.ts index f358c54..c39ff81 100644 --- a/src/types/api/agentRecharge.ts +++ b/src/types/api/agentRecharge.ts @@ -9,7 +9,8 @@ export enum AgentRechargeStatus { COMPLETED = 3, // 已完成 CLOSED = 4, // 已关闭 REFUNDED = 5, // 已退款 - REJECTED = 6 // 已驳回 + REJECTED = 6, // 已驳回 + APPROVAL_SUBMISSION_FAILED = 7 // 审批提交失败(终态) } // 支付方式 diff --git a/src/types/api/commission.ts b/src/types/api/commission.ts index d91583c..7230e95 100644 --- a/src/types/api/commission.ts +++ b/src/types/api/commission.ts @@ -137,7 +137,8 @@ export enum WithdrawalStatus { PENDING = 1, // 待审核 APPROVED = 2, // 已通过 REJECTED = 3, // 已拒绝 - COMPLETED = 4 // 已到账 + COMPLETED = 4, // 已到账 + APPROVAL_SUBMISSION_FAILED = 5 // 审批提交失败(终态) } // 佣金类型 diff --git a/src/types/api/common.ts b/src/types/api/common.ts index 5131267..77ac8d1 100644 --- a/src/types/api/common.ts +++ b/src/types/api/common.ts @@ -6,6 +6,7 @@ export interface BaseResponse { code: number msg: string + message?: string data: T timestamp?: string } diff --git a/src/types/api/employeeCollection.ts b/src/types/api/employeeCollection.ts index 9dd9520..0a3e984 100644 --- a/src/types/api/employeeCollection.ts +++ b/src/types/api/employeeCollection.ts @@ -183,7 +183,7 @@ export type CloseEmployeeCollectionBillResult = EmployeeCollectionBill // ========== 核销申请 ========== -/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已关闭 */ +/** 申请状态:0 审批中 / 1 已通过 / 2 已驳回 / 3 已关闭 / 4 审批提交失败 */ export type EmployeeCollectionApplicationStatus = number /** 审批尝试记录(历史材料不被覆盖) */ diff --git a/src/types/api/order.ts b/src/types/api/order.ts index abc92c6..6962495 100644 --- a/src/types/api/order.ts +++ b/src/types/api/order.ts @@ -10,6 +10,28 @@ export enum PaymentStatus { REFUNDED = 4 // 已退款 } +// 支付单状态与订单支付状态数值不同,必须独立处理。 +export enum OrderPaymentRecordStatus { + PENDING = 0, + PAID = 1, + FAILED = 2, + REFUNDED = 3 +} + +export interface OrderPaymentRecord { + payment_no: string + payment_method: 'wechat' | 'alipay' + payment_channel: string + payment_merchant_name: string + third_party_trade_no: string + amount: number // 分 + status: OrderPaymentRecordStatus + status_name: string + created_at: string + paid_at: string | null + expire_at: string | null +} + // 订单类型 export type OrderType = 'single_card' | 'device' @@ -68,6 +90,9 @@ export interface Order { payment_status: PaymentStatus payment_status_text: string payment_method: OrderPaymentMethod + third_party_trade_no: string + payment_channel: string + payment_merchant_name: string total_amount: number // 订单总金额(分) actual_paid_amount?: number // 实付金额(分) paid_at: string | null @@ -97,6 +122,10 @@ export interface Order { updated_at: string } +export interface OrderDetail extends Order { + payments: OrderPaymentRecord[] +} + // 订单查询参数 export interface OrderQueryParams { page?: number diff --git a/src/types/api/packageTrafficAlert.ts b/src/types/api/packageTrafficAlert.ts index 08a0d89..655b17f 100644 --- a/src/types/api/packageTrafficAlert.ts +++ b/src/types/api/packageTrafficAlert.ts @@ -19,7 +19,6 @@ export interface PackageTrafficAlertRuleItem { id: number // 预警规则ID package_id: number // 套餐商品ID package_name: string // 套餐名称 - real_data_mb: number // 套餐商品当前真流量额度(MB),仅用于配置校验展示,不作为预警分母 threshold_percent: number // 真流量预警阈值百分比(1~100,允许两位小数) enabled: boolean // 是否启用 enabled_name: string // 启用状态名称(中文) @@ -90,6 +89,9 @@ export interface PackageTrafficAlertRecordItem { asset_type?: PackageTrafficAlertAssetType | null // 资产类型快照 asset_identifier?: string | null // 资产或卡标识快照 threshold_percent?: number // 触发阈值快照 + threshold_mb?: number | null // 触发时阈值流量(MB),主套餐与加油包汇总 + used_mb?: number | null // 触发时已用流量(MB),含加油包 + limit_mb?: number | null // 触发时总额度(MB),含加油包 triggered_at?: string // 触发时间 notification_status?: PackageTrafficAlertNotificationStatus // 通知投递结果 notification_status_name?: string // 通知投递结果名称 diff --git a/src/types/api/refund.ts b/src/types/api/refund.ts index 4895ed6..d8d0fee 100644 --- a/src/types/api/refund.ts +++ b/src/types/api/refund.ts @@ -9,7 +9,8 @@ export enum RefundStatus { REJECTED = 3, // 已拒绝 RETURNED = 4, // 已退回 REFUNDING = 5, // 原路退款处理中 - REFUND_FAILED = 6 // 原路退款失败 + REFUND_FAILED = 6, // 原路退款失败 + APPROVAL_SUBMISSION_FAILED = 7 // 审批提交失败(终态) } // 退款方式 diff --git a/src/types/components.d.ts b/src/types/components.d.ts index ad2f7d2..026474a 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -92,27 +92,16 @@ declare module 'vue' { CreateRefundDialog: typeof import('./../components/business/CreateRefundDialog.vue')['default'] CustomerAccountDialog: typeof import('./../components/business/CustomerAccountDialog.vue')['default'] DetailPage: typeof import('./../components/common/DetailPage.vue')['default'] - ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] - ElCalendar: typeof import('element-plus/es')['ElCalendar'] ElCard: typeof import('element-plus/es')['ElCard'] - ElCascader: typeof import('element-plus/es')['ElCascader'] - ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] - ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] - ElCol: typeof import('element-plus/es')['ElCol'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] - ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] - ElDivider: typeof import('element-plus/es')['ElDivider'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] - ElDropdown: typeof import('element-plus/es')['ElDropdown'] - ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] - ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] @@ -125,26 +114,16 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] - ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] - ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - ElResult: typeof import('element-plus/es')['ElResult'] - ElRow: typeof import('element-plus/es')['ElRow'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] - ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] - ElTable: typeof import('element-plus/es')['ElTable'] - ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] - ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElUpload: typeof import('element-plus/es')['ElUpload'] ElWatermark: typeof import('element-plus/es')['ElWatermark'] ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default'] diff --git a/src/utils/business/apiError.ts b/src/utils/business/apiError.ts index 3e3a27c..bc15b3a 100644 --- a/src/utils/business/apiError.ts +++ b/src/utils/business/apiError.ts @@ -23,7 +23,7 @@ export const normalizeApiError = (error: unknown): NormalizedApiError => { const response = getResponse(error) const status = response?.status const code = response?.data?.code - const backendMessage = response?.data?.msg + const backendMessage = response?.data?.msg || response?.data?.message const timeout = axios.isAxiosError(error) && error.code === 'ECONNABORTED' if (timeout) { diff --git a/src/utils/business/exchangeError.ts b/src/utils/business/exchangeError.ts new file mode 100644 index 0000000..6bb531d --- /dev/null +++ b/src/utils/business/exchangeError.ts @@ -0,0 +1,24 @@ +type BusinessFailure = { + code?: number | string + msg?: string + message?: string + data?: { phone?: unknown } +} + +const getBusinessFailure = (value: unknown): BusinessFailure | undefined => { + if (!value || typeof value !== 'object') return undefined + const candidate = value as { response?: { data?: BusinessFailure } } & BusinessFailure + return candidate.response?.data || candidate +} + +export const getExchangePhoneConflictMessage = (value: unknown): string | undefined => { + const failure = getBusinessFailure(value) + if (Number(failure?.code) !== 1202) return undefined + const phone = typeof failure?.data?.phone === 'string' ? failure.data.phone.trim() : '' + return `新资产已绑定手机号${phone ? ` ${phone}` : ''},请先到『手机号资产关联』解除后再提交` +} + +export const getExchangeBusinessErrorMessage = (value: unknown): string | undefined => { + const failure = getBusinessFailure(value) + return failure?.msg || failure?.message +} diff --git a/src/utils/business/orderPayment.ts b/src/utils/business/orderPayment.ts new file mode 100644 index 0000000..37aa20a --- /dev/null +++ b/src/utils/business/orderPayment.ts @@ -0,0 +1,29 @@ +import { OrderPaymentRecordStatus } from '@/types/api/order' + +const CHANNEL_NAMES: Record = { + wechat: '微信直连 v3', + wechat_v2: '微信直连 v2', + fuiou: '富友' +} + +const PAYMENT_STATUS_NAMES: Record = { + [OrderPaymentRecordStatus.PENDING]: '待支付', + [OrderPaymentRecordStatus.PAID]: '已支付', + [OrderPaymentRecordStatus.FAILED]: '已失败', + [OrderPaymentRecordStatus.REFUNDED]: '已退款' +} + +export const getOrderPaymentChannelName = (channel?: string | null): string => + channel ? CHANNEL_NAMES[channel] || channel : '-' + +export const getOrderPaymentRecordStatusName = ( + status: OrderPaymentRecordStatus, + name?: string | null +): string => name || PAYMENT_STATUS_NAMES[status] || String(status) + +export const getOrderPaymentRecordTagType = (status: OrderPaymentRecordStatus) => { + if (status === OrderPaymentRecordStatus.PAID) return 'success' + if (status === OrderPaymentRecordStatus.FAILED) return 'danger' + if (status === OrderPaymentRecordStatus.PENDING) return 'warning' + return 'info' +} diff --git a/src/utils/business/shopBusinessOwnerImport.ts b/src/utils/business/shopBusinessOwnerImport.ts new file mode 100644 index 0000000..c757d93 --- /dev/null +++ b/src/utils/business/shopBusinessOwnerImport.ts @@ -0,0 +1,3 @@ +// UTF-8 BOM 确保 Excel 打开中文 CSV 时正确识别编码。 +export const SHOP_BUSINESS_OWNER_CSV_TEMPLATE = + '\uFEFF店铺编码,操作类型,平台登录账号,备注\r\n,仅支持换绑或清空,换绑填写平台用户登录手机号(清空留空),\r\n' diff --git a/src/utils/business/trafficAlert.ts b/src/utils/business/trafficAlert.ts new file mode 100644 index 0000000..c35969d --- /dev/null +++ b/src/utils/business/trafficAlert.ts @@ -0,0 +1,2 @@ +export const formatTrafficAlertMb = (value?: number | null): string => + value == null || !Number.isFinite(value) ? '-' : value.toFixed(2) diff --git a/src/utils/validation/loginPassword.ts b/src/utils/validation/loginPassword.ts new file mode 100644 index 0000000..23632a7 --- /dev/null +++ b/src/utils/validation/loginPassword.ts @@ -0,0 +1,24 @@ +import type { FormItemRule } from 'element-plus' + +export const NEW_LOGIN_PASSWORD_HINT = '8~12位,必须包含大写字母、小写字母和数字,不允许特殊符号' + +export const getNewLoginPasswordError = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length < 8 || value.length > 12) { + return '密码长度必须为8~12位' + } + if (/[^A-Za-z0-9]/.test(value)) return '密码只允许英文字母和数字,不允许特殊符号、空格或中文' + if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value)) { + return '密码必须同时包含大写字母、小写字母和数字' + } + return undefined +} + +// 必填由调用表单决定,空值不妨碍不修改密码的账号编辑。 +export const createNewLoginPasswordRule = (message?: string): FormItemRule => ({ + validator: (_rule, value, callback) => { + if (value === '' || value === undefined || value === null) return callback() + const error = getNewLoginPasswordError(value) + callback(error ? new Error(message || error) : undefined) + }, + trigger: 'blur' +}) diff --git a/src/views/account-management/account/index.vue b/src/views/account-management/account/index.vue index 226162f..316062e 100644 --- a/src/views/account-management/account/index.vue +++ b/src/views/account-management/account/index.vue @@ -56,7 +56,8 @@ @@ -307,6 +308,10 @@