diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 3f94b11..d50e3b1 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -3,7 +3,7 @@ name: 构建并部署前端到测试环境 on: push: branches: - - main + - develop - dev - test @@ -27,7 +27,7 @@ jobs: - name: 设置镜像标签 id: tag run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "${{ github.ref }}" = "refs/heads/develop" ]; then echo "tag=latest" >> $GITHUB_OUTPUT elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then echo "tag=dev" >> $GITHUB_OUTPUT @@ -51,8 +51,8 @@ jobs: docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} docker push ${{ env.IMAGE_NAME }}:${{ github.sha }} - - name: 部署到本地(仅 main 分支) - if: github.ref == 'refs/heads/main' + - name: 部署到本地(仅 develop 分支) + if: github.ref == 'refs/heads/develop' run: | # 确保部署目录存在 mkdir -p ${{ env.DEPLOY_DIR }} diff --git a/Dockerfile b/Dockerfile index 6aa31e4..9c497c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN pnpm run nd # ================================ # 阶段 2: 运行阶段 # ================================ -FROM --platform=linux/amd64 nginx:alpine +FROM --platform=linux/amd64 nginx:1.31.2-alpine # 使用阿里云镜像源加速 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index aae950b..09c4253 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,4 @@ version: '3.8' - services: web: image: registry.boss160.cn/junhong/cmp-admin-web:latest @@ -9,12 +8,19 @@ services: - '3001:80' networks: - junhong-network + + # === 以下是新增的修复内容 === + tmpfs: + - /run:rw + - /tmp:rw + healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/health'] interval: 30s timeout: 3s retries: 3 start_period: 5s + logging: driver: 'json-file' options: @@ -23,4 +29,4 @@ services: networks: junhong-network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/openspec/changes/add-admin-expiring-assets-notifications/design.md b/openspec/changes/add-admin-expiring-assets-notifications/design.md new file mode 100644 index 0000000..0b9e3e9 --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/design.md @@ -0,0 +1,35 @@ +## Context + +临期结果由后端根据资产当前套餐及排队套餐计算。管理端需要在独立列表、普通资产列表、代理首页和通知中心保持相同的临期语义。本变更只覆盖管理端,C 端资产和续费能力不纳入实现范围。 + +## Goals / Non-Goals + +- Goals: 统一消费 `estimated_final_expires_at`、`days_until_final_expiry`、`expiry_level`、`expiry_level_name` 和 `can_renew`;提供临期查询、排序、高亮、统计、通知跳转和管理端续费入口。 +- Goals: 由后端负责临期边界、预计到期结果和通知触发,前端只负责展示和路由。 +- Non-Goals: 不计算套餐接续到期时间,不修改历史订单或已购套餐,不实现 C 端入口,不展示企微临期消息。 + +## Decisions + +- Decision: 临期独立列表使用 `GET /api/admin/expiring-assets`,查询参数保持文档约定的 `asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`expires_from`、`expires_to`、`page`、`size`。 +- Decision: 临期接口负责跨分页排序,返回结果按 0-3 天优先、其余按 `estimated_final_expires_at` 升序排列;前端只保留接口顺序,不改变普通资产列表原始接口排序。 +- Decision: 颜色只按剩余天数映射:8-15 天粉红、4-7 天紫色、0-3 天红色。已过期和不可预计记录由接口排除,前端不把它们补入临期列表。 +- Decision: 续费入口由 `can_renew` 控制,具体续费动作复用现有管理端续费/充值路由和接口,不新增 C 端逻辑。 +- Decision: 通知中心继续复用现有通知接口和目标跳转协议;临期通知由后端按 15 天、7 天、3 天生成,前端按通知目标跳转到临期列表或对应资产详情。 +- Alternatives considered: 前端从普通资产列表筛选临期记录。Rejected because it无法保证全量、统一排序和后端预计最终到期结果的一致性。 + +## Risks / Trade-offs + +- 风险:代理首页当前概览接口未必已有临期计数字段。Mitigation:在现有首页/概览响应中增加 `expiring_card_count` 和 `expiring_device_count`,避免前端分页统计。 +- 风险:不同资产列表的字段结构不完全一致。Mitigation:在各自 API 类型中复用同一组临期字段语义,并通过统一展示格式处理空值和等级。 +- 风险:续费入口的现有路由可能依赖资产类型。Mitigation:由 `asset_type` 和 `asset_id` 选择对应管理端续费入口,按钮仅在 `can_renew=true` 时展示。 + +## Migration Plan + +1. 先扩展管理端 API 类型和服务,确认列表、首页统计和通知目标字段。 +2. 上线临期列表及普通列表高亮,再接入首页统计和续费入口。 +3. 最后验证通知中心过滤、已读和跳转,不修改 C 端功能。 + +## Open Questions + +- 代理首页现有概览接口的具体路径和响应字段名称,需要以后端接口实现为准确认。 +- 管理端卡/设备续费入口的现有路由是否统一,需实现时复用当前权限和路由定义。 diff --git a/openspec/changes/add-admin-expiring-assets-notifications/proposal.md b/openspec/changes/add-admin-expiring-assets-notifications/proposal.md new file mode 100644 index 0000000..ac19a2e --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/proposal.md @@ -0,0 +1,35 @@ +# Change: 增加管理端临期资产高亮、通知与续费入口 + +## Why + +管理端目前缺少统一的临期资产查询入口,运营人员需要在普通资产列表中自行识别临近到期资产,且无法从临期记录快速进入续费操作。需要基于后端统一计算的预计最终到期结果,在管理端集中展示临期资产、标记普通列表并接入站内通知。 + +## What Changes + +- 新增管理端临期资产列表,支持资产类型、关键字、店铺、套餐、剩余天数和预计到期时间筛选。 +- 临期列表按 0-3 天固定置顶,其余按预计最终到期时间升序;已过期和不可预计资产不进入列表。 +- 在卡列表、设备列表和资产相关列表中增加临期颜色高亮,但不改变普通列表原有排序。 +- 在代理首页增加临期卡数量和临期设备数量展示,并可跳转临期列表。 +- 在临期列表提供后端返回 `can_renew` 控制的续费入口,复用现有管理端续费/充值流程。 +- 接入通知中心中的 15 天、7 天、3 天临期提醒及通知跳转;管理端不展示企微临期消息。 +- 所有页面直接使用后端返回的预计最终到期字段和临期等级,不在前端重新计算最终到期时间。 + +## Impact + +- Affected specs: + - `admin-expiring-assets` +- Affected code: + - `src/api/modules` 和 `src/types/api` 中的临期资产接口及类型 + - 管理端临期资产列表页面和路由 + - 网卡列表、设备列表及资产信息展示组件 + - 代理首页统计组件 + - 通知中心跳转处理 +- API contracts: + - `GET /api/admin/expiring-assets` + - 现有代理首页/概览接口增加临期卡、设备数量字段,或提供等价的管理端统计响应 + - 复用现有通知查询、已读和目标跳转接口 +- Out of scope: + - C 端资产页临期展示 + - C 端续费按钮和 C 端续费流程 + - 企微临期消息展示或企微通知改造 + - 前端根据套餐明细自行推导预计最终到期时间 diff --git a/openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md b/openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md new file mode 100644 index 0000000..14bc997 --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/specs/admin-expiring-assets/spec.md @@ -0,0 +1,111 @@ +## ADDED Requirements + +### Requirement: Admin Expiring Asset Query + +The admin frontend SHALL provide an expiring asset list backed by `GET /api/admin/expiring-assets`. The query SHALL support `asset_type`, `keyword`, `shop_id`, `package_id`, `days_min`, `days_max`, `expires_from`, `expires_to`, `page`, and `size`. + +#### Scenario: Filter expiring assets + +- **WHEN** 管理端用户打开临期资产列表并提交筛选条件 +- **THEN** 前端 MUST send the documented query parameters to `GET /api/admin/expiring-assets` +- **AND** 页面 MUST display the paginated response items + +#### Scenario: Exclude unavailable assets + +- **WHEN** 接口返回临期列表 +- **THEN** 页面 MUST not add expired assets or assets without an estimable final expiry to the list on the client side + +### Requirement: Expiring Asset Fields + +Each expiring asset item SHALL preserve and display the backend fields `asset_type`, `asset_id`, `identifier`, `shop_name`, `package_name`, `estimated_final_expires_at`, `days_until_final_expiry`, `expiry_level`, `expiry_level_name`, and `can_renew`. The frontend MUST use backend-provided expiry results and MUST NOT calculate the estimated final expiry from package details. + +#### Scenario: Display an estimable asset + +- **WHEN** 临期接口返回资产及 `estimated_final_expires_at` +- **THEN** 页面 MUST display the asset identifier, shop, package, estimated final expiry, remaining days, and backend expiry level name + +#### Scenario: Preserve backend result + +- **WHEN** 临期接口返回 `estimated_final_expires_at` 或 `days_until_final_expiry` +- **THEN** 前端 MUST display the returned values +- **AND** 前端 MUST NOT derive or replace them from current or queued package data + +### Requirement: Expiring List Ordering And Highlighting + +The expiring asset API and standalone admin list SHALL return and preserve records with 0-3 remaining days first, then order the remaining records by `estimated_final_expires_at` ascending. The list SHALL apply pink highlighting to 8-15 days, purple highlighting to 4-7 days, and red highlighting to 0-3 days. + +#### Scenario: Order critical assets first + +- **GIVEN** the response contains assets in multiple expiry ranges +- **WHEN** 页面渲染临期列表 +- **THEN** the API response MUST place assets with 0-3 remaining days before assets with more remaining days +- **AND** the API response MUST order records in the remaining ranges by estimated final expiry ascending +- **AND** the frontend MUST preserve that cross-page ordering + +#### Scenario: Highlight by remaining days + +- **WHEN** 页面渲染临期资产 +- **THEN** 8-15 days MUST use the pink visual treatment +- **AND** 4-7 days MUST use the purple visual treatment +- **AND** 0-3 days MUST use the red visual treatment + +### Requirement: Ordinary Admin Asset List Highlighting + +The admin card and device lists SHALL apply the same expiry color treatment to returned expiring asset fields without changing their existing server or client sort order. + +#### Scenario: Highlight ordinary asset rows + +- **WHEN** 卡列表或设备列表返回临期字段 +- **THEN** 页面 MUST apply the matching expiry color treatment +- **AND** 页面 MUST preserve the list's existing ordering + +### Requirement: Admin Expiring Asset Renewal Entry + +The admin expiring asset list SHALL display a renewal action only when the backend item has `can_renew=true`. The action SHALL navigate to the existing management-side renewal or recharge flow for the asset type. + +#### Scenario: Renewable asset + +- **GIVEN** 临期资产项返回 `can_renew=true` +- **WHEN** 用户查看临期列表 +- **THEN** 页面 MUST provide the management-side renewal entry +- **AND** the entry MUST target the corresponding asset and preserve asset type context + +#### Scenario: Non-renewable asset + +- **GIVEN** 临期资产项返回 `can_renew=false` +- **WHEN** 用户查看临期列表 +- **THEN** 页面 MUST NOT display an enabled renewal action + +### Requirement: Agent Dashboard Expiry Counts + +The agent-facing admin home SHALL display the number of expiring cards and expiring devices using backend-provided summary fields and SHALL provide navigation to the standalone expiring asset list. + +#### Scenario: Display expiry counts + +- **WHEN** 代理首页概览数据返回临期卡和设备数量 +- **THEN** 页面 MUST display the expiring card count and expiring device count +- **AND** 页面 MUST not calculate the counts from a paginated asset list + +### Requirement: Admin Expiry Notifications + +The notification center SHALL display backend-generated admin expiry reminders for 15 days, 7 days, and 3 days through the existing notification API and target navigation. The admin frontend SHALL not display WeCom expiry messages. + +#### Scenario: Display expiry reminder + +- **WHEN** 通知接口返回 15 天、7 天或 3 天的临期提醒 +- **THEN** 通知中心 MUST classify it as a 临期提醒 +- **AND** 用户点击通知后 MUST navigate to the returned management-side target + +#### Scenario: Exclude WeCom expiry message + +- **WHEN** 管理端加载通知中心 +- **THEN** 页面 MUST not add or render a separate WeCom expiry message channel + +### Requirement: Admin-Only Scope + +This capability SHALL be limited to the management frontend. It SHALL NOT add or modify C-end asset expiry display, C-end renewal buttons, C-end renewal APIs, or C-end notification behavior. + +#### Scenario: Keep C-end unchanged + +- **WHEN** this change is implemented +- **THEN** C-end asset pages and renewal flows MUST remain outside the change scope diff --git a/openspec/changes/add-admin-expiring-assets-notifications/tasks.md b/openspec/changes/add-admin-expiring-assets-notifications/tasks.md new file mode 100644 index 0000000..17ef23a --- /dev/null +++ b/openspec/changes/add-admin-expiring-assets-notifications/tasks.md @@ -0,0 +1,35 @@ +## 1. API Contract + +- [x] 1.1 增加 `GET /api/admin/expiring-assets` 的查询参数、分页响应和临期资产项类型。 +- [x] 1.2 为卡列表、设备列表、资产信息和首页概览类型补充统一临期字段及临期计数字段。 +- [x] 1.3 增加临期资产 API 服务方法,并确认续费入口复用现有管理端服务和权限。 + +## 2. Expiring Asset List + +- [x] 2.1 增加或接入 `/operations/expiring-assets` 管理端路由和页面。 +- [x] 2.2 实现资产类型、关键字、店铺、套餐、剩余天数和预计到期时间筛选。 +- [x] 2.3 展示资产、店铺、当前套餐、预计最终到期、剩余天数和后端临期等级名称。 +- [x] 2.4 实现 0-3 天置顶、其余按预计到期时间升序,并排除已过期和不可预计记录。 +- [x] 2.5 按 8-15 天粉红、4-7 天紫色、0-3 天红色实现行或到期字段高亮。 +- [x] 2.6 根据 `can_renew` 展示管理端续费入口,并跳转现有续费/充值流程。 + +## 3. Existing Admin Pages + +- [x] 3.1 在网卡普通列表增加临期字段展示和颜色高亮,不改变原排序。 +- [x] 3.2 在设备普通列表增加临期字段展示和颜色高亮,不改变原排序。 +- [x] 3.3 在资产信息详情复用同一临期展示规则,禁止前端重新计算预计最终到期时间。 +- [x] 3.4 在代理首页展示临期卡数量和临期设备数量,并支持跳转临期列表。 + +## 4. Notifications + +- [x] 4.1 在通知中心复用临期分类,展示后端生成的 15 天、7 天、3 天临期提醒。 +- [x] 4.2 使用现有通知目标跳转到临期列表或对应管理端资产详情。 +- [x] 4.3 确认管理端不展示企微临期消息,不新增 C 端通知入口。 + +## 5. Verification + +- [x] 5.1 验证临期字段直接使用后端值,前端不根据套餐明细推导最终到期时间。 +- [x] 5.2 验证 0-3 天置顶、日期升序、颜色映射和普通列表原排序保持不变。 +- [x] 5.3 验证 `can_renew=false` 不展示续费入口,且各资产类型使用正确管理端入口。 +- [x] 5.4 验证通知中心临期分类、已读状态和跳转行为。 +- [x] 5.5 运行类型检查、相关 ESLint/Stylelint、构建和 `openspec validate add-admin-expiring-assets-notifications --strict`。 diff --git a/openspec/changes/add-asset-batch-realname-policy/proposal.md b/openspec/changes/add-asset-batch-realname-policy/proposal.md new file mode 100644 index 0000000..0a41325 --- /dev/null +++ b/openspec/changes/add-asset-batch-realname-policy/proposal.md @@ -0,0 +1,33 @@ +# Change: 新增资产批量实名认证策略配置 + +## Why + +运营需要一次性为多个卡或设备配置实名认证顺序。现有后台仅支持单资产设置实名认证策略,无法满足列表多选后的批量配置需求。 + +## What Changes + +- 在后台卡列表和设备列表分别增加“批量修改实名顺序”入口,基于当前勾选资产执行配置。 +- 批量配置弹框展示已选资产数量,并提供“无需实名”“先实名后购买”“先购买后实名”三种互斥策略。 +- 卡列表调用 `POST /api/admin/iot-cards/batch-update-realname-policy`;设备列表调用 `POST /api/admin/devices/batch-update-realname-policy`。 +- 批量请求传递 `asset_ids` 和 `realname_policy`;前端限制单次提交至多 500 条,并在超限时阻止提交并明确提示。 +- 设备批量配置弹框提示“实际H5流程由设备策略决定”。 +- 后端批量接口按全成全败处理;前端在失败时展示明确的后端业务错误,成功后刷新当前列表。 +- 前端不根据资产类型、卡类型或其他字段自行覆盖或推导实名认证策略。 +- 本提案不包含任何 H5 初始化、购买流程或 `effective_realname_policy` 的处理。 + +## Impact + +- Affected specs: + - `iot-card-management` + - `device-management` +- Affected code: + - `src/api/modules/asset.ts` 或对应卡、设备 API 模块 + - `src/types/api/asset.ts` 或对应卡、设备 API 类型 + - `src/views/asset-management/iot-card-management/index.vue` + - `src/views/asset-management/device-list/index.vue` +- API contracts: + - `POST /api/admin/iot-cards/batch-update-realname-policy` + - `POST /api/admin/devices/batch-update-realname-policy` +- Out of scope: + - H5 初始化返回字段和购买流程 + - 单资产实名认证策略设置接口 `PATCH /api/admin/assets/{identifier}/realname-mode` diff --git a/openspec/changes/add-asset-batch-realname-policy/specs/device-management/spec.md b/openspec/changes/add-asset-batch-realname-policy/specs/device-management/spec.md new file mode 100644 index 0000000..036cd62 --- /dev/null +++ b/openspec/changes/add-asset-batch-realname-policy/specs/device-management/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Device Batch Realname Policy Configuration + +The device management list SHALL provide a `批量修改实名顺序` action for selected devices. The action SHALL submit the selected device IDs and exactly one realname policy to `POST /api/admin/devices/batch-update-realname-policy`. + +#### Scenario: Open batch realname policy dialog for selected devices + +- **GIVEN** 用户在设备列表勾选了一台或多台设备 +- **WHEN** 用户点击“批量修改实名顺序” +- **THEN** 页面 MUST open a dialog that displays the selected device count +- **AND** 页面 MUST provide mutually exclusive options `无需实名`、`先实名后购买` 和 `先购买后实名` +- **AND** 页面 MUST display `实际H5流程由设备策略决定` 提示 + +#### Scenario: Submit selected device policy + +- **GIVEN** 用户已选择一项实名认证策略 +- **WHEN** 用户确认批量修改 +- **THEN** 系统 MUST call `POST /api/admin/devices/batch-update-realname-policy` +- **AND** 请求 MUST contain the selected device IDs as `asset_ids` +- **AND** 请求 MUST contain the selected `realname_policy` as `none`、`before_order` 或 `after_order` + +#### Scenario: Refresh devices after all-or-nothing success + +- **WHEN** 设备批量实名认证策略接口成功返回 +- **THEN** 页面 MUST close the dialog +- **AND** 页面 MUST refresh the current device list + +#### Scenario: Show failed batch update reason + +- **WHEN** 设备批量实名认证策略接口返回业务失败或请求失败 +- **THEN** 页面 MUST display the backend business reason when provided +- **AND** 页面 MUST NOT refresh the list as a partial-success result + +### Requirement: Device Batch Realname Policy Limit + +The device management list MUST limit each realname policy batch submission to 500 selected devices. + +#### Scenario: Prevent device batch submission over the limit + +- **GIVEN** 用户在设备列表选择超过 500 台设备 +- **WHEN** 用户尝试确认批量修改实名认证策略 +- **THEN** 页面 MUST prevent the request from being sent +- **AND** 页面 MUST display an explicit maximum-500-items error + +### Requirement: Device Batch Policy Is User Selected + +The device management list MUST submit the policy explicitly selected by the user and MUST NOT infer, override, or transform it from asset type, card type, or other asset fields. + +#### Scenario: Preserve selected device policy value + +- **GIVEN** 用户在批量配置弹框选择任一实名认证策略 +- **WHEN** 用户确认提交 +- **THEN** 请求中的 `realname_policy` MUST equal the selected option diff --git a/openspec/changes/add-asset-batch-realname-policy/specs/iot-card-management/spec.md b/openspec/changes/add-asset-batch-realname-policy/specs/iot-card-management/spec.md new file mode 100644 index 0000000..d960c9f --- /dev/null +++ b/openspec/changes/add-asset-batch-realname-policy/specs/iot-card-management/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: IoT Card Batch Realname Policy Configuration + +The IoT card management list SHALL provide a `批量修改实名顺序` action for selected cards. The action SHALL submit the selected card IDs and exactly one realname policy to `POST /api/admin/iot-cards/batch-update-realname-policy`. + +#### Scenario: Open batch realname policy dialog for selected cards + +- **GIVEN** 用户在卡列表勾选了一张或多张卡 +- **WHEN** 用户点击“批量修改实名顺序” +- **THEN** 页面 MUST open a dialog that displays the selected card count +- **AND** 页面 MUST provide mutually exclusive options `无需实名`、`先实名后购买` 和 `先购买后实名` + +#### Scenario: Submit selected card policy + +- **GIVEN** 用户已选择一项实名认证策略 +- **WHEN** 用户确认批量修改 +- **THEN** 系统 MUST call `POST /api/admin/iot-cards/batch-update-realname-policy` +- **AND** 请求 MUST contain the selected card IDs as `asset_ids` +- **AND** 请求 MUST contain the selected `realname_policy` as `none`、`before_order` 或 `after_order` + +#### Scenario: Refresh cards after all-or-nothing success + +- **WHEN** 卡批量实名认证策略接口成功返回 +- **THEN** 页面 MUST close the dialog +- **AND** 页面 MUST refresh the current card list + +#### Scenario: Show failed batch update reason + +- **WHEN** 卡批量实名认证策略接口返回业务失败或请求失败 +- **THEN** 页面 MUST display the backend business reason when provided +- **AND** 页面 MUST NOT refresh the list as a partial-success result + +### Requirement: IoT Card Batch Realname Policy Limit + +The IoT card management list MUST limit each realname policy batch submission to 500 selected cards. + +#### Scenario: Prevent card batch submission over the limit + +- **GIVEN** 用户在卡列表选择超过 500 张卡 +- **WHEN** 用户尝试确认批量修改实名认证策略 +- **THEN** 页面 MUST prevent the request from being sent +- **AND** 页面 MUST display an explicit maximum-500-items error + +### Requirement: IoT Card Batch Policy Is User Selected + +The IoT card management list MUST submit the policy explicitly selected by the user and MUST NOT infer, override, or transform it from asset type, card type, or other asset fields. + +#### Scenario: Preserve selected card policy value + +- **GIVEN** 用户在批量配置弹框选择任一实名认证策略 +- **WHEN** 用户确认提交 +- **THEN** 请求中的 `realname_policy` MUST equal the selected option diff --git a/openspec/changes/add-asset-batch-realname-policy/tasks.md b/openspec/changes/add-asset-batch-realname-policy/tasks.md new file mode 100644 index 0000000..cfca524 --- /dev/null +++ b/openspec/changes/add-asset-batch-realname-policy/tasks.md @@ -0,0 +1,28 @@ +## 1. API Contract + +- [x] 1.1 定义批量实名认证策略请求类型,包含 `asset_ids:int64[]` 与 `realname_policy:none|before_order|after_order`。 +- [x] 1.2 接入卡批量更新接口 `POST /api/admin/iot-cards/batch-update-realname-policy`。 +- [x] 1.3 接入设备批量更新接口 `POST /api/admin/devices/batch-update-realname-policy`。 + +## 2. IoT Card Batch Configuration + +- [x] 2.1 在卡列表多选操作区增加“批量修改实名顺序”入口。 +- [x] 2.2 弹框展示已选卡数量并提供三种互斥实名认证策略。 +- [x] 2.3 超过 500 张卡时阻止提交并显示明确提示。 +- [x] 2.4 成功后关闭弹框并刷新卡列表;失败时保留选择并展示后端业务原因。 + +## 3. Device Batch Configuration + +- [x] 3.1 在设备列表多选操作区增加“批量修改实名顺序”入口。 +- [x] 3.2 弹框展示已选设备数量并提供三种互斥实名认证策略。 +- [x] 3.3 展示“实际H5流程由设备策略决定”提示。 +- [x] 3.4 超过 500 台设备时阻止提交并显示明确提示。 +- [x] 3.5 成功后关闭弹框并刷新设备列表;失败时保留选择并展示后端业务原因。 + +## 4. Policy Integrity and Verification + +- [x] 4.1 前端不根据资产类型、卡类型或其他字段覆盖或推导提交策略。 +- [ ] 4.2 验证卡和设备三种策略均按所选值提交,且单次 500 条以内成功。 +- [ ] 4.3 验证超过 500 条、后端全成全败失败和网络失败均显示明确错误且不部分刷新列表。 +- [ ] 4.4 验证设备弹框显示 H5 流程归属提示。 +- [x] 4.5 运行相关前端校验,并执行 `openspec validate add-asset-batch-realname-policy --strict`。 diff --git a/openspec/changes/add-asset-estimated-final-expiry-display/design.md b/openspec/changes/add-asset-estimated-final-expiry-display/design.md new file mode 100644 index 0000000..56b344b --- /dev/null +++ b/openspec/changes/add-asset-estimated-final-expiry-display/design.md @@ -0,0 +1,41 @@ +## Context + +运营需要在资产层查看当前主套餐及所有排队主套餐顺序接续后的预计最终到期时间。该结果依赖后端套餐队列、激活条件与计时规则,前端只负责展示接口结果。 + +## Goals / Non-Goals + +- Goals: + - 在资产详情、IoT 卡列表和设备列表统一展示“预计套餐到期时间”。 + - 区分可精确预计与待激活后才可计算的状态。 + - 对临期资产提供基于后端剩余天数的视觉提示。 +- Non-Goals: + - 不修改单个套餐明细的“到期时间”展示或套餐队列顺序。 + - 不新增预计到期时间筛选、排序或前端日期计算。 + - 不改变套餐续费、激活或到期规则。 + +## Decisions + +- Decision: 统一使用以下五个后台响应字段:`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status`、`expiry_estimate_status_name`、`is_expiring`,不调用额外计算接口。 +- Decision: `expiry_estimate_status` 仅使用 `exact`、`waiting_activation`、`none`、`invalid_data`。除 `exact` 外,`estimated_final_expires_at` 和 `days_until_final_expiry` 必须为 `null`;已过期的 `exact` 资产允许返回负数剩余天数。 +- Decision: 仅当 `expiry_estimate_status=exact` 且存在 `estimated_final_expires_at` 时格式化展示日期;不可预计状态展示“待激活后起算”。 +- Decision: `is_expiring=true` 是临期样式的唯一触发条件,`days_until_final_expiry` 只作为剩余天数展示或样式辅助信息,前端不自行判定临期阈值。 +- Decision: 普通卡和设备列表保持既有服务端返回顺序与前端排序行为,不因临期字段重排。 +- Alternatives considered: 前端根据当前套餐到期时间、排队套餐时长和计时基准计算最终日期。未采用,因为等待激活和后端队列规则会导致结果不准确。 + +## Risks / Trade-offs + +- 后端缺少预计字段时无法显示最终日期 -> 对空值显示稳定占位,不以当前套餐日期替代。 +- 未知 `expiry_estimate_status` 可能导致错误日期展示 -> 仅 `exact` 可显示日期,其他状态显示稳定占位或后端约定的不可预计提示。 +- 列表增加时间列会占用宽度 -> 作为可配置动态列,沿用现有横向滚动与列选择能力。 + +## Migration Plan + +1. 扩展资产详情及卡、设备列表类型以保留预计最终到期字段。 +2. 将资产详情响应字段映射到页面状态,并在基础信息区域展示。 +3. 在卡和设备列表增加预计套餐到期时间列及临期样式。 +4. 验证无套餐、仅当前套餐、多个排队套餐和待激活后起算四类响应。 +5. 如需回滚,移除新增展示字段和列表列;不涉及数据迁移。 + +## Open Questions + +- 后端若返回未约定的状态值,前端不得按 `estimated_final_expires_at` 是否为空展示日期,应按不可预计状态处理。 diff --git a/openspec/changes/add-asset-estimated-final-expiry-display/proposal.md b/openspec/changes/add-asset-estimated-final-expiry-display/proposal.md new file mode 100644 index 0000000..bc9c9f4 --- /dev/null +++ b/openspec/changes/add-asset-estimated-final-expiry-display/proposal.md @@ -0,0 +1,40 @@ +# Change: 新增资产预计套餐到期时间展示 + +## Why + +当前页面只能在套餐明细中查看单个套餐的到期时间,运营无法快速了解当前主套餐与全部排队主套餐接续后的资产最终到期时间。前端也不能可靠地自行叠加套餐时长,尤其当套餐需要激活后才开始计时时。 + +## What Changes + +- 在资产详情、IoT 卡列表和设备列表增加统一的 `预计套餐到期时间` 展示。 +- 资产详情接口和资产列表响应支持统一的 5 个字段:`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status`、`expiry_estimate_status_name` 和 `is_expiring`。 +- `expiry_estimate_status` 使用 `exact`、`waiting_activation`、`none`、`invalid_data` 四种状态;非 `exact` 状态下日期和剩余天数字段必须为 `null`。 +- 当 `expiry_estimate_status=exact` 时,展示后端返回的 `estimated_final_expires_at`。 +- 当套餐尚待激活等无法预计最终日期时,展示“待激活后起算”,不得伪造日期。 +- 当 `is_expiring=true` 时,按后端返回的剩余天数使用临期颜色提示;普通资产列表不得因临期状态改变既有排序。 +- 当前套餐自身的到期时间继续仅在套餐明细中展示;前端不得叠加套餐时长计算预计最终到期时间。 +- 本次仅覆盖后台资产详情、IoT 卡列表和设备列表,不处理 C 端资产信息接口或页面。 + +## Impact + +- Affected specs: + - `asset-information` + - `iot-card-management` + - `device-management` +- Affected code: + - `src/types/api/asset.ts` + - `src/types/api/card.ts` + - `src/types/api/device.ts` + - `src/views/asset-management/asset-information/types.ts` + - `src/views/asset-management/asset-information/composables/useAssetInfo.ts` + - `src/views/asset-management/asset-information/components/BasicInfoCard.vue` + - `src/views/asset-management/iot-card-management/index.vue` + - `src/views/asset-management/device-list/index.vue` +- API contracts: + - `GET /api/admin/assets/resolve/{identifier}` + - `GET /api/admin/iot-cards/standalone` + - `GET /api/admin/devices` +- Dependencies: + - 后端在资产详情与资产列表响应中返回预计最终到期字段。 +- Out of scope: + - `GET /api/c/v1/asset/info` 及 C 端资产信息页面 diff --git a/openspec/changes/add-asset-estimated-final-expiry-display/specs/asset-information/spec.md b/openspec/changes/add-asset-estimated-final-expiry-display/specs/asset-information/spec.md new file mode 100644 index 0000000..69bf220 --- /dev/null +++ b/openspec/changes/add-asset-estimated-final-expiry-display/specs/asset-information/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: Asset Estimated Final Expiry Contract + +The admin asset information integration SHALL preserve the five backend fields `estimated_final_expires_at`, `days_until_final_expiry`, `expiry_estimate_status`, `expiry_estimate_status_name`, and `is_expiring` returned by `GET /api/admin/assets/resolve/{identifier}`. `estimated_final_expires_at` SHALL be an RFC3339 string or `null`, `days_until_final_expiry` SHALL be an integer or `null`, and `expiry_estimate_status` SHALL be one of `exact`, `waiting_activation`, `none`, or `invalid_data`. This requirement applies only to the admin frontend and excludes C-end asset information. + +#### Scenario: Preserve an exact final expiry estimate + +- **GIVEN** 用户查询后台资产详情 +- **WHEN** `GET /api/admin/assets/resolve/{identifier}` returns `expiry_estimate_status=exact` +- **THEN** 前端状态 MUST preserve `estimated_final_expires_at` as `string | null` +- **AND** 前端状态 MUST preserve `days_until_final_expiry` as `number | null` +- **AND** 前端状态 MUST preserve `is_expiring` as a boolean +- **AND** 前端状态 MUST preserve `expiry_estimate_status_name` as the backend-provided status name + +#### Scenario: Preserve null fields for non-exact estimates + +- **GIVEN** 用户查询后台资产详情 +- **WHEN** `expiry_estimate_status` is `waiting_activation`, `none`, or `invalid_data` +- **THEN** `estimated_final_expires_at` MUST be preserved as `null` +- **AND** `days_until_final_expiry` MUST be preserved as `null` +- **AND** 前端 MUST NOT calculate either field + +#### Scenario: Preserve an unavailable final expiry estimate + +- **GIVEN** 用户查询后台资产详情 +- **WHEN** 接口返回待激活或其他不可预计的 `expiry_estimate_status` +- **THEN** 前端状态 MUST preserve `expiry_estimate_status` +- **AND** 前端 MUST NOT derive `estimated_final_expires_at` from current-package or package-detail fields +- **AND** 前端 MUST NOT use `expiry_estimate_status_name` as a substitute for the status enum when choosing the display rule + +### Requirement: Asset Estimated Final Expiry Display + +The admin asset information view SHALL display one asset-level field labeled `预计套餐到期时间` for the current primary package and all queued primary packages, without replacing individual package expiry dates in package details. + +#### Scenario: Display an exact final expiry date + +- **GIVEN** 资产详情返回 `expiry_estimate_status=exact` +- **AND** `estimated_final_expires_at` has a value +- **WHEN** 页面渲染卡资产或设备资产基础信息 +- **THEN** 页面 MUST display `预计套餐到期时间` +- **AND** 页面 MUST format and display `estimated_final_expires_at` + +#### Scenario: Display activation-pending final expiry + +- **GIVEN** 资产详情返回 `expiry_estimate_status=waiting_activation` +- **WHEN** 页面渲染卡资产或设备资产基础信息 +- **THEN** `预计套餐到期时间` MUST display `待激活后起算` +- **AND** 页面 MUST NOT display a fabricated date + +#### Scenario: Handle an unknown estimate status safely + +- **GIVEN** 资产详情返回未约定的 `expiry_estimate_status` +- **WHEN** 页面渲染资产基础信息 +- **THEN** 页面 MUST treat the estimate as unavailable +- **AND** 页面 MUST NOT display `estimated_final_expires_at` as a date + +#### Scenario: Display no-package placeholder + +- **GIVEN** 资产没有当前或排队主套餐 +- **AND** `estimated_final_expires_at` is null or absent +- **WHEN** 页面渲染资产基础信息 +- **THEN** `预计套餐到期时间` MUST display a stable placeholder +- **AND** 页面 MUST NOT substitute the current package detail expiry date + +#### Scenario: Highlight backend-designated expiring asset + +- **GIVEN** 资产详情返回 `is_expiring=true` +- **WHEN** 页面渲染 `预计套餐到期时间` +- **THEN** 页面 MUST apply the expiring visual treatment using `days_until_final_expiry` +- **AND** 页面 MUST NOT derive whether the asset is expiring from a locally calculated date difference + +#### Scenario: Preserve package detail expiry semantics + +- **GIVEN** 用户查看资产详情中的套餐明细 +- **WHEN** 页面渲染单个套餐的到期时间 +- **THEN** 页面 MUST continue to display that package's own expiry field in the package detail context +- **AND** 页面 MUST NOT replace it with `estimated_final_expires_at` + +#### Scenario: Display no-package and invalid-data statuses + +- **GIVEN** 资产详情返回 `expiry_estimate_status=none` 或 `expiry_estimate_status=invalid_data` +- **WHEN** 页面渲染资产基础信息 +- **THEN** `none` MUST display a stable empty placeholder +- **AND** `invalid_data` MUST display `数据异常` +- **AND** 页面 MUST NOT display a fabricated date diff --git a/openspec/changes/add-asset-estimated-final-expiry-display/specs/device-management/spec.md b/openspec/changes/add-asset-estimated-final-expiry-display/specs/device-management/spec.md new file mode 100644 index 0000000..50a4e9a --- /dev/null +++ b/openspec/changes/add-asset-estimated-final-expiry-display/specs/device-management/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Device Estimated Final Expiry Display + +The device management list integration SHALL preserve and display the five backend device-level estimated final package expiry fields `estimated_final_expires_at`, `days_until_final_expiry`, `expiry_estimate_status`, `expiry_estimate_status_name`, and `is_expiring` returned in `data.items[]` by `GET /api/admin/devices`. The status SHALL be one of `exact`, `waiting_activation`, `none`, or `invalid_data`; non-`exact` records SHALL have `estimated_final_expires_at=null` and `days_until_final_expiry=null`. The frontend SHALL NOT traverse bound cards or calculate package continuation dates. This requirement applies only to the admin device list. + +#### Scenario: Display exact device final expiry estimate + +- **GIVEN** 设备列表接口返回 `expiry_estimate_status=exact` and `estimated_final_expires_at` +- **WHEN** 页面渲染设备列表行 +- **THEN** 页面 MUST display a column labeled `预计套餐到期时间` +- **AND** 该列 MUST format and display `estimated_final_expires_at` + +#### Scenario: Display activation-pending device final expiry + +- **GIVEN** 设备列表接口返回 `expiry_estimate_status=waiting_activation` +- **WHEN** 页面渲染设备列表行 +- **THEN** `预计套餐到期时间` MUST display `待激活后起算` +- **AND** 页面 MUST NOT traverse bound cards or display a fabricated date + +#### Scenario: Display device estimate status names for non-exact states + +- **GIVEN** 设备列表接口返回 `expiry_estimate_status=none` 或 `expiry_estimate_status=invalid_data` +- **WHEN** 页面渲染设备列表行 +- **THEN** `none` MUST display a stable empty placeholder +- **AND** `invalid_data` MUST display `数据异常` +- **AND** 页面 MUST use the status enum to choose the display rule + +#### Scenario: Preserve null fields for non-exact device estimates + +- **GIVEN** 设备列表接口返回 `expiry_estimate_status=none` 或 `expiry_estimate_status=invalid_data` +- **THEN** `estimated_final_expires_at` MUST be `null` +- **AND** `days_until_final_expiry` MUST be `null` +- **AND** 页面 MUST NOT derive either field from绑定卡或套餐数据 + +#### Scenario: Highlight expiring device without reordering + +- **GIVEN** 设备列表接口返回 `is_expiring=true` and `days_until_final_expiry` +- **WHEN** 页面渲染该设备的预计套餐到期时间 +- **THEN** 页面 MUST apply the expiring visual treatment based on the backend fields +- **AND** 页面 MUST NOT change the ordinary device list sort order because of `is_expiring` + +#### Scenario: Display no-package device placeholder + +- **GIVEN** 设备列表记录没有可预计的最终到期时间 +- **WHEN** 页面渲染预计套餐到期时间列 +- **THEN** 页面 MUST display a stable placeholder +- **AND** 页面 MUST NOT use an individual package expiry as a substitute + +#### Scenario: Preserve device status name without using it for business calculation + +- **WHEN** 设备列表接口返回 `expiry_estimate_status_name` +- **THEN** 前端类型 MUST preserve the field +- **AND** 页面 MUST NOT calculate or replace the backend status name diff --git a/openspec/changes/add-asset-estimated-final-expiry-display/specs/iot-card-management/spec.md b/openspec/changes/add-asset-estimated-final-expiry-display/specs/iot-card-management/spec.md new file mode 100644 index 0000000..8b56e7f --- /dev/null +++ b/openspec/changes/add-asset-estimated-final-expiry-display/specs/iot-card-management/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: IoT Card Estimated Final Expiry Display + +The IoT card management list integration SHALL preserve and display the five backend asset-level estimated final package expiry fields `estimated_final_expires_at`, `days_until_final_expiry`, `expiry_estimate_status`, `expiry_estimate_status_name`, and `is_expiring` returned in `data.items[]` by `GET /api/admin/iot-cards/standalone`. The status SHALL be one of `exact`, `waiting_activation`, `none`, or `invalid_data`; non-`exact` records SHALL have `estimated_final_expires_at=null` and `days_until_final_expiry=null`. The frontend SHALL NOT calculate package continuation dates. This requirement applies only to the admin IoT card list. + +#### Scenario: Display exact card final expiry estimate + +- **GIVEN** 卡列表接口返回 `expiry_estimate_status=exact` and `estimated_final_expires_at` +- **WHEN** 页面渲染卡列表行 +- **THEN** 页面 MUST display a column labeled `预计套餐到期时间` +- **AND** 该列 MUST format and display `estimated_final_expires_at` + +#### Scenario: Display activation-pending card final expiry + +- **GIVEN** 卡列表接口返回 `expiry_estimate_status=waiting_activation` +- **WHEN** 页面渲染卡列表行 +- **THEN** `预计套餐到期时间` MUST display `待激活后起算` +- **AND** 页面 MUST NOT calculate or display a fabricated date + +#### Scenario: Display card estimate status names for non-exact states + +- **GIVEN** 卡列表接口返回 `expiry_estimate_status=none` 或 `expiry_estimate_status=invalid_data` +- **WHEN** 页面渲染卡列表行 +- **THEN** `none` MUST display a stable empty placeholder +- **AND** `invalid_data` MUST display `数据异常` +- **AND** 页面 MUST use the status enum to choose the display rule + +#### Scenario: Preserve null fields for non-exact card estimates + +- **GIVEN** 卡列表接口返回 `expiry_estimate_status=none` 或 `expiry_estimate_status=invalid_data` +- **THEN** `estimated_final_expires_at` MUST be `null` +- **AND** `days_until_final_expiry` MUST be `null` +- **AND** 页面 MUST NOT derive either field from套餐数据 + +#### Scenario: Highlight expiring card without reordering + +- **GIVEN** 卡列表接口返回 `is_expiring=true` and `days_until_final_expiry` +- **WHEN** 页面渲染该卡的预计套餐到期时间 +- **THEN** 页面 MUST apply the expiring visual treatment based on the backend fields +- **AND** 页面 MUST NOT change the ordinary card list sort order because of `is_expiring` + +#### Scenario: Display no-package card placeholder + +- **GIVEN** 卡列表记录没有可预计的最终到期时间 +- **WHEN** 页面渲染预计套餐到期时间列 +- **THEN** 页面 MUST display a stable placeholder +- **AND** 页面 MUST NOT use an individual package expiry as a substitute + +#### Scenario: Preserve card status name without using it for business calculation + +- **WHEN** 卡列表接口返回 `expiry_estimate_status_name` +- **THEN** 前端类型 MUST preserve the field +- **AND** 页面 MUST NOT calculate or replace the backend status name diff --git a/openspec/changes/add-asset-estimated-final-expiry-display/tasks.md b/openspec/changes/add-asset-estimated-final-expiry-display/tasks.md new file mode 100644 index 0000000..4a41301 --- /dev/null +++ b/openspec/changes/add-asset-estimated-final-expiry-display/tasks.md @@ -0,0 +1,26 @@ +## 1. API Contracts And Types + +- [x] 1.1 扩展资产详情响应和资产信息页面状态,支持 5 个预计最终到期字段:`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status`、`expiry_estimate_status_name`、`is_expiring`。 +- [x] 1.2 扩展 IoT 卡和设备列表项类型,支持 5 个预计最终到期字段;不处理 C 端字段。 +- [x] 1.3 校验四种状态及字段约束:`exact` 可返回日期和剩余天数,其他状态的 `estimated_final_expires_at` 与 `days_until_final_expiry` 必须为 `null`。 + +## 2. Asset Information + +- [x] 2.1 将资产解析接口返回的预计最终到期字段映射到资产详情页面状态。 +- [x] 2.2 在资产详情卡和设备基础信息中展示“预计套餐到期时间”。 +- [x] 2.3 按 `expiry_estimate_status` 展示:`exact` 显示日期,`waiting_activation` 显示“待激活后起算”,`none` 显示占位,`invalid_data` 显示“数据异常”。 +- [x] 2.4 `is_expiring=true` 时按 `days_until_final_expiry` 应用临期样式,不根据日期或天数自行推导临期状态。 + +## 3. Asset Lists + +- [x] 3.1 在 IoT 卡列表新增“预计套餐到期时间”可配置列,显示接口返回的预计日期或“待激活后起算”。 +- [x] 3.2 在设备列表新增“预计套餐到期时间”可配置列,显示接口返回的预计日期或“待激活后起算”。 +- [x] 3.3 在两个列表对 `is_expiring=true` 的预计到期时间使用临期样式,且不改变现有列表排序。 + +## 4. Verification + +- [x] 4.1 验证无套餐时不伪造预计日期并显示稳定占位内容。 +- [x] 4.2 验证仅当前套餐和存在多个排队主套餐时,资产详情和两个列表均显示后端 `estimated_final_expires_at`。 +- [x] 4.3 验证等待激活等不可预计状态显示“待激活后起算”,不显示计算出的日期。 +- [x] 4.4 验证 `is_expiring=true` 时使用临期样式,且卡、设备列表顺序不变。 +- [x] 4.5 运行相关类型检查、lint 或构建验证。 diff --git a/openspec/changes/add-asset-realname-status-filter/design.md b/openspec/changes/add-asset-realname-status-filter/design.md new file mode 100644 index 0000000..593e923 --- /dev/null +++ b/openspec/changes/add-asset-realname-status-filter/design.md @@ -0,0 +1,37 @@ +## Context + +卡和设备列表需要新增统一的实名状态筛选与展示能力。设备的实名状态由设备列表接口直接返回,不能通过关联卡在前端二次计算,以避免多卡设备或卡绑定关系变化时出现不一致。 + +## Goals / Non-Goals + +- Goals: + - 支持按“全部”“已实名”“未实名”筛选卡和设备。 + - 直接展示后端返回的实名状态名称。 + - 在搜索、刷新、分页和导出查询中保留当前筛选条件。 +- Non-Goals: + - 不新增或修改实名认证流程、策略配置或状态更新操作。 + - 不在前端推导设备实名状态。 + - 不变更其他资产详情页的实名状态取值规则。 + +## Decisions + +- Decision: 使用可选数值查询参数 `real_name_status`,其中 `0` 表示未实名、`1` 表示已实名;“全部”对应不传该参数。 +- Decision: 列表展示优先使用每条记录的 `real_name_status_name`,而非根据 `real_name_status` 写死文案。 +- Decision: 设备列表把接口响应中的 `real_name_status` 与 `real_name_status_name` 作为唯一状态来源,不读取或遍历绑定卡数据。 +- Alternatives considered: 前端将 `0` 和 `1` 映射为固定文案。未采用,因为后端已提供标准显示名称,直接使用可避免展示口径分叉。 + +## Risks / Trade-offs + +- 后端未返回 `real_name_status_name` 时无法满足状态名称展示契约 -> 联调时校验列表响应字段,并将该字段设为必需的列表类型字段。 +- 卡列表实名认证筛选依赖单卡列表接口 -> 实施时确保筛选参数发往 `GET /api/admin/iot-cards/standalone`。 + +## Migration Plan + +1. 扩展卡与设备列表 API 类型和查询参数。 +2. 接入筛选控件、查询参数及状态列。 +3. 验证全部、已实名、未实名筛选以及分页和重置行为。 +4. 如需回滚,移除前端筛选控件、查询参数和状态列;不涉及数据迁移。 + +## Open Questions + +- 无。 diff --git a/openspec/changes/add-asset-realname-status-filter/proposal.md b/openspec/changes/add-asset-realname-status-filter/proposal.md new file mode 100644 index 0000000..ba6ad67 --- /dev/null +++ b/openspec/changes/add-asset-realname-status-filter/proposal.md @@ -0,0 +1,33 @@ +# Change: 新增卡和设备实名状态筛选 + +## Why + +运营人员需要在卡列表和设备列表中快速识别并筛选已实名或未实名的资产。当前页面未完整接入实名状态筛选和后端返回的实名状态名称,设备列表尤其不能依赖前端遍历绑定卡来推导状态。 + +## What Changes + +- 卡列表和设备列表的筛选区新增“实名状态”,提供“全部”“已实名”“未实名”选项。 +- 卡列表查询使用 `GET /api/admin/iot-cards/standalone`,设备列表查询使用 `GET /api/admin/devices`;选择状态后分别传递 `real_name_status=0|1`,未选择时不传该参数。 +- 卡和设备列表项的类型契约支持 `real_name_status: int` 与 `real_name_status_name: string`。 +- 两个列表表格新增“实名状态”列,直接展示接口返回的 `real_name_status_name`。 +- 设备列表直接使用设备列表接口的实名状态字段,不遍历或关联绑定卡计算设备实名状态。 +- 搜索、刷新和分页切换必须保留当前实名状态筛选;重置搜索时清空该筛选。 + +## Impact + +- Affected specs: + - `iot-card-management` + - `device-management` +- Affected code: + - `src/api/modules/card.ts` + - `src/api/modules/device.ts` + - `src/types/api/card.ts` + - `src/types/api/device.ts` + - `src/views/asset-management/iot-card-management/index.vue` + - `src/views/asset-management/device-list/index.vue` +- API contracts: + - `GET /api/admin/iot-cards/standalone?real_name_status=0|1` + - `GET /api/admin/devices?real_name_status=0|1` + - List items return `real_name_status: int` and `real_name_status_name: string` +- Dependencies: + - 后端列表接口必须支持实名状态查询并返回实名状态名称。 diff --git a/openspec/changes/add-asset-realname-status-filter/specs/device-management/spec.md b/openspec/changes/add-asset-realname-status-filter/specs/device-management/spec.md new file mode 100644 index 0000000..cc68813 --- /dev/null +++ b/openspec/changes/add-asset-realname-status-filter/specs/device-management/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Device Realname Status Query Contract + +The device management list integration SHALL support an optional numeric `real_name_status` parameter on `GET /api/admin/devices`. The list-item contract SHALL include `real_name_status` and `real_name_status_name` returned by the device list API. + +#### Scenario: Query devices by realname status + +- **GIVEN** 用户正在后台设备列表使用实名状态筛选 +- **WHEN** 用户选择“已实名”并执行搜索 +- **THEN** 系统 MUST call `GET /api/admin/devices` with `real_name_status=1` + +#### Scenario: Query unverified devices + +- **GIVEN** 用户正在后台设备列表使用实名状态筛选 +- **WHEN** 用户选择“未实名”并执行搜索 +- **THEN** 系统 MUST call `GET /api/admin/devices` with `real_name_status=0` +- **AND** 前端 MUST NOT 因为该值为 `0` 而省略此参数 + +#### Scenario: Query all devices without status restriction + +- **GIVEN** 用户未选择实名状态或选择“全部” +- **WHEN** 用户查询、刷新或切换设备列表分页 +- **THEN** 请求 MUST NOT 携带 `real_name_status` + +#### Scenario: Receive device realname status fields + +- **GIVEN** 设备列表接口返回设备记录 +- **WHEN** 前端解析列表响应 +- **THEN** 列表项类型 MUST 支持 `real_name_status: int` +- **AND** 列表项类型 MUST 支持 `real_name_status_name: string` + +### Requirement: Device Realname Status Filter And Display + +The device management page SHALL provide a `实名状态` filter with `全部`、`已实名`、`未实名` options and display the backend device realname status name in the device table. + +#### Scenario: Display device realname status filter + +- **GIVEN** 用户打开后台设备列表 +- **WHEN** 页面渲染筛选区 +- **THEN** 页面 MUST display a `实名状态` filter +- **AND** 筛选项 MUST provide `全部`、`已实名`、`未实名` options + +#### Scenario: Display backend device realname status name + +- **GIVEN** 设备列表接口返回某条记录的 `real_name_status_name` +- **WHEN** 页面渲染该设备的表格行 +- **THEN** 页面 MUST 在“实名状态”列显示该字段值 + +#### Scenario: Use the device API as the status source + +- **GIVEN** 设备列表接口返回设备的实名状态字段 +- **WHEN** 页面渲染设备实名状态 +- **THEN** 页面 MUST directly use the record's `real_name_status` and `real_name_status_name` +- **AND** 页面 MUST NOT 遍历、绑定或计算关联卡的实名状态 + +#### Scenario: Preserve device realname status while paginating + +- **GIVEN** 用户已选择“已实名”或“未实名”并获得筛选结果 +- **WHEN** 用户切换设备列表页码或每页条数 +- **THEN** 后续列表请求 MUST 保留当前的 `real_name_status` 参数 + +#### Scenario: Reset device realname status filter + +- **GIVEN** 用户已选择实名状态 +- **WHEN** 用户重置设备列表搜索条件 +- **THEN** 页面 MUST 清空实名状态筛选 +- **AND** 后续列表请求 MUST NOT 携带 `real_name_status` diff --git a/openspec/changes/add-asset-realname-status-filter/specs/iot-card-management/spec.md b/openspec/changes/add-asset-realname-status-filter/specs/iot-card-management/spec.md new file mode 100644 index 0000000..acebee8 --- /dev/null +++ b/openspec/changes/add-asset-realname-status-filter/specs/iot-card-management/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: IoT Card Realname Status Query Contract + +The IoT card management list integration SHALL query `GET /api/admin/iot-cards/standalone` and support an optional numeric `real_name_status` parameter. The list-item contract SHALL include `real_name_status` and `real_name_status_name`. + +#### Scenario: Query cards by realname status + +- **GIVEN** 用户正在后台卡列表使用实名状态筛选 +- **WHEN** 用户选择“已实名”并执行搜索 +- **THEN** 系统 MUST call `GET /api/admin/iot-cards/standalone` with `real_name_status=1` + +#### Scenario: Query unverified cards + +- **GIVEN** 用户正在后台卡列表使用实名状态筛选 +- **WHEN** 用户选择“未实名”并执行搜索 +- **THEN** 系统 MUST call `GET /api/admin/iot-cards/standalone` with `real_name_status=0` +- **AND** 前端 MUST NOT 因为该值为 `0` 而省略此参数 + +#### Scenario: Query all cards without status restriction + +- **GIVEN** 用户未选择实名状态或选择“全部” +- **WHEN** 用户查询、刷新或切换卡列表分页 +- **THEN** 请求 MUST NOT 携带 `real_name_status` + +#### Scenario: Receive card realname status fields + +- **GIVEN** 卡列表接口返回资产记录 +- **WHEN** 前端解析列表响应 +- **THEN** 列表项类型 MUST 支持 `real_name_status: int` +- **AND** 列表项类型 MUST 支持 `real_name_status_name: string` + +### Requirement: IoT Card Realname Status Filter And Display + +The IoT card management page SHALL provide a `实名状态` filter with `全部`、`已实名`、`未实名` options and display the backend realname status name in the card table. + +#### Scenario: Display card realname status filter + +- **GIVEN** 用户打开后台卡列表 +- **WHEN** 页面渲染筛选区 +- **THEN** 页面 MUST display a `实名状态` filter +- **AND** 筛选项 MUST provide `全部`、`已实名`、`未实名` options + +#### Scenario: Display backend card realname status name + +- **GIVEN** 卡列表接口返回某条记录的 `real_name_status_name` +- **WHEN** 页面渲染该卡的表格行 +- **THEN** 页面 MUST 在“实名状态”列显示该字段值 + +#### Scenario: Preserve card realname status while paginating + +- **GIVEN** 用户已选择“已实名”或“未实名”并获得筛选结果 +- **WHEN** 用户切换卡列表页码或每页条数 +- **THEN** 后续列表请求 MUST 保留当前的 `real_name_status` 参数 + +#### Scenario: Reset card realname status filter + +- **GIVEN** 用户已选择实名状态 +- **WHEN** 用户重置卡列表搜索条件 +- **THEN** 页面 MUST 清空实名状态筛选 +- **AND** 后续列表请求 MUST NOT 携带 `real_name_status` diff --git a/openspec/changes/add-asset-realname-status-filter/tasks.md b/openspec/changes/add-asset-realname-status-filter/tasks.md new file mode 100644 index 0000000..b713eb0 --- /dev/null +++ b/openspec/changes/add-asset-realname-status-filter/tasks.md @@ -0,0 +1,26 @@ +## 1. API Contracts And Types + +- [x] 1.1 扩展卡列表查询参数和列表项类型,支持可选 `real_name_status` 以及必需的 `real_name_status_name`。 +- [x] 1.2 扩展设备列表查询参数和列表项类型,支持可选 `real_name_status` 以及必需的 `real_name_status_name`。 +- [x] 1.3 将卡列表查询接入 `GET /api/admin/iot-cards/standalone` 并传递实名状态筛选参数。 + +## 2. Card List + +- [x] 2.1 在卡列表筛选区新增“实名状态”的全部、已实名、未实名选项。 +- [x] 2.2 将选中的实名状态传递给卡列表查询,并在搜索、刷新、分页、导出中保留该条件。 +- [x] 2.3 在卡列表表格新增“实名状态”列,展示接口返回的 `real_name_status_name`。 +- [x] 2.4 重置卡列表搜索时清空实名状态筛选。 + +## 3. Device List + +- [x] 3.1 在设备列表筛选区新增“实名状态”的全部、已实名、未实名选项。 +- [x] 3.2 将选中的实名状态传递给 `GET /api/admin/devices`,并在搜索、刷新、分页、导出中保留该条件。 +- [x] 3.3 在设备列表表格新增“实名状态”列,直接展示接口返回的 `real_name_status_name`,不遍历绑定卡计算状态。 +- [x] 3.4 重置设备列表搜索时清空实名状态筛选。 + +## 4. Verification + +- [ ] 4.1 验证卡列表“全部”“已实名”“未实名”分别不传、传 `1`、传 `0`,且状态列显示接口名称。 +- [ ] 4.2 验证设备列表“全部”“已实名”“未实名”分别不传、传 `1`、传 `0`,且状态列直接显示设备接口名称。 +- [ ] 4.3 验证两个列表在分页切换、刷新和导出时保留实名状态筛选,重置后清空该条件。 +- [x] 4.4 运行相关类型检查、lint 或构建验证。 diff --git a/openspec/changes/add-audit-chain-frontend-integration/design.md b/openspec/changes/add-audit-chain-frontend-integration/design.md new file mode 100644 index 0000000..4e949a4 --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/design.md @@ -0,0 +1,53 @@ +## Context + +本次变更横跨平台、代理和企业三类主体以及资产、订单、退款、钱包、通知等多个业务页面。OpenAPI 将关联关系明确建模为 `investigation_refs`、`linkage` 和稳定资源引用,并区分平台内部 ID 与代理/企业可见的业务标识。前端必须保持这些边界,不能为了补齐跳转而从名称、时间或摘要推断关联关系。 + +仓库目前没有已归档的基线 specs,但存在通知中心和旧资产操作日志的活跃变更。本提案建立独立的审计链路能力,通过明确的依赖点与它们集成,不复制或覆盖其已有要求。 + +## Goals / Non-Goals + +- Goals: 按 OpenAPI 实现 16 个只读接口的类型、查询、页面和跨视角跳转契约。 +- Goals: 为平台、代理和企业提供符合各自权限与数据投影的业务审计入口。 +- Goals: 统一分页、RFC3339 时间范围、枚举展示、在线保留窗口和错误处理。 +- Non-Goals: 不修改后端接口、数据库、日志采集或保留策略。 +- Non-Goals: 不提供恢复、重试、修改、删除、处置、封禁、任意全文搜索或导出。 +- Non-Goals: 不替换现有资产操作日志组件,也不修改 C/H5 端。 + +## Decisions + +- Decision: 新增独立审计 API 模块和共享类型,按平台审计、主体活动、资金、风险、Integration、链路时间线分组暴露查询函数;统一保留服务端响应包装和 nullable 字段。 +- Decision: 平台业务入口使用响应中的内部稳定 ID。卡、设备、店铺、企业、订单和退款分别使用对应 `resource_type` 与 `response.data.id`;钱包通过资金时间线的 `wallet_id` 进入。 +- Decision: 代理与企业入口使用响应中的稳定业务标识。卡使用 `iccid`,设备使用 `virtual_no`;代理的分配、换货、店铺和企业使用接口文档指定的业务编号。缺少稳定标识时隐藏入口。 +- Decision: 前端只转发后端返回的调查引用。事件的 `event_id`、`actor_ref`、`resource_refs`、`request_id`、`correlation_id`、`integration_refs`,以及 Integration 的 `linkage`/`resource` 是跨视角导航的唯一数据来源。 +- Decision: `request_id` 可以来自调查引用、Integration、或用户从 Access Log 明确粘贴;`correlation_id` 和 `integration_id` 只能来自接口返回的稳定字段。前端不使用 UUID 或其他方式生成这些 ID。 +- Decision: 对不存在、为空或 `fidelity=false` 的引用隐藏对应跳转,禁止按名称、时间、摘要或相邻记录猜测缺失关系。 +- Decision: 资源搜索严格使用 `resource_type + keyword` 精确查询,并把选中项的 `resource_type/resource_id` 原样传给资源时间线。`historical=true` 只作为历史快照命中提示。 +- Decision: 列表和时间线统一消费 `page`、`page_size`、`total` 并保留 `retention` 语义。时间筛选发送含时区的 RFC3339 值,`created_to` 按接口定义为不包含该时刻;页面不展示全局在线窗口提示,也不提供归档查询假入口。 +- Decision: 所有枚举筛选提交稳定 code,并优先展示后端 `*_name`。`action`、Integration `operation` 等开放编码必须直接来自响应,不能从中文文案反推。 +- Decision: 资金金额按分展示转换,权威性只认 `amount_authority.authoritative=true` 指向的业务字段;前端不得合并冲突金额或自行计算余额。 +- Decision: 代理与企业活动共用展示外壳但使用独立 API。企业页面只展示主体安全投影,不引入平台操作者、风险、内部原因或 before/after 字段。 +- Decision: 通知目标仅在 `available=true`、`target_type=integration_log` 且 `target_key` 非空时跳转,并将 `target_key` 原样作为 Integration 详情的 `integration_id`。其他情况保留通知正文并提示目标不可用。 +- Decision: 权限以最终菜单/按钮权限编码和服务端鉴权共同控制。平台、代理、企业不展示不属于其主体的审计入口,`401/403` 继续走共享认证与无权处理。 + +## Risks / Trade-offs + +- 最终菜单与按钮权限编码尚未体现在 OpenAPI 中;实施前必须确认,否则只能依赖服务端 `403`,无法做到准确的入口隐藏。 +- 审计事件和 Integration 模型字段较多;通过共享详情/时间线组件和严格类型避免各业务页重复实现,但不抽象为可以执行任意端点的动态页面。 +- 在线保留窗口外的数据不可由这些接口查询;空状态使用“当前查询无记录”的中性表达,避免将空数据误报为无历史事件。 +- 活跃通知中心变更也会修改通知导航;实施时需在其最新状态上追加 `integration_log` 规则,避免覆盖既有白名单映射。 +- 旧资产操作日志与新审计中心含义相邻但数据源不同;两个入口并存并清晰命名,避免误将旧日志能力删除。 + +## Migration Plan + +1. 确认平台、代理、企业的菜单和按钮权限编码,以及审计中心路由归属。 +2. 建立 OpenAPI 对齐的类型、API 模块和共享只读展示组件。 +3. 按平台审计、资金、风险、Integration、主体活动顺序接入页面。 +4. 在业务页面与通知导航中增加稳定引用入口。 +5. 完成角色、数据隔离、空引用、保留窗口、错误和跨视角跳转验收。 +6. 若上线异常,隐藏新增路由与入口;现有业务接口和旧操作日志不受影响。 + +## Open Questions + +- 平台审计中心、风险中心、Integration、代理资源活动和企业资源活动的最终菜单及按钮权限编码分别是什么? +- 审计中心是作为一级菜单,还是归入现有系统管理/运维菜单? +- OpenAPI 未包含 `GET /api/admin/notifications/{id}/target` 的完整响应 schema;是否继续以活跃通知中心变更定义的 `available/target_type/target_id/target_key` 为最终契约? diff --git a/openspec/changes/add-audit-chain-frontend-integration/proposal.md b/openspec/changes/add-audit-chain-frontend-integration/proposal.md new file mode 100644 index 0000000..d49df30 --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/proposal.md @@ -0,0 +1,38 @@ +# Change: 新增审计链路前端接入能力 + +## Why + +八月迭代新增了审计事件、请求与业务链路、资金调查、风险调查、外部集成交互以及代理/企业资源活动共 16 个只读接口。当前前端尚无统一的审计调查页面、接口类型和业务入口,无法可靠消费后端返回的稳定调查引用,也容易错误地由名称、时间或页面数据自行拼接链路标识。 + +## What Changes + +- 新增平台审计事件列表、详情、操作者时间线、资源精确搜索和资源时间线能力。 +- 新增请求链路与业务关联链路时间线,并只使用接口返回或用户从 Access Log 粘贴的稳定 `request_id`、`correlation_id`。 +- 新增资金调查时间线、风险总览与明细、Integration 总览/列表/详情页面。 +- 新增代理和企业资源活动入口,并按主体支持范围使用稳定业务标识和后端安全投影。 +- 在卡、设备、店铺、企业、订单、退款和钱包相关列表/详情页增加角色匹配的「审计记录」入口。 +- 接入通知目标到 Integration 详情的受控跳转:仅接受可用的 `integration_log` 目标。 +- 统一使用后端返回的名称、状态、保留窗口语义和调查引用;禁止前端猜测或生成 `request_id`、`correlation_id`、`integration_id`。 +- 所有新增能力均为只读调查能力,不增加恢复、修改、删除、处置、封禁或导出操作。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: + - `src/api/modules/` 下新增审计接口模块 + - `src/types/api/` 下新增审计接口类型 + - `src/views/` 下审计中心、风险中心、Integration 和时间线页面 + - 卡、设备、店铺、企业、订单、退款、钱包相关列表/详情页 + - 路由、菜单、权限控制及通知目标导航 +- API contracts: + - `GET /api/admin/audit/*` + - `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}` + - `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}` + - `GET /api/admin/notifications/{id}/target` +- Dependencies: + - 与活跃变更 `update-admin-notification-center-api` 的受控通知目标协议保持一致 + - 与活跃变更 `update-admin-asset-device-signal-and-audit-logs` 的旧资产操作日志展示并存;本变更不替换旧日志接口 +- Source of truth: + - `docs/产品迭代8月份/审计链路接口变更整理文档.md` + - `docs/产品迭代8月份/默认模块.openapi.json` +- Breaking changes: 无;现有业务接口路径保持不变 diff --git a/openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..75d1dbc --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,207 @@ +## ADDED Requirements + +### Requirement: Platform Audit Event Investigation + +The admin frontend SHALL provide platform audit event list and detail views using `GET /api/admin/audit/events` and `GET /api/admin/audit/events/{event_id}`. It SHALL preserve the documented event, actor, scope, resource, result, risk, request and investigation-reference fields, use stable codes for filters, and prefer backend display names. + +#### Scenario: Filter and inspect audit events + +- **WHEN** an authorized platform user filters audit events by documented time, action, category, actor, source, result, risk, scope, resource or linkage parameters +- **THEN** the frontend MUST send the documented parameter names and stable code values +- **AND** it MUST render the paginated `items`, `total`, `page`, `page_size` and `retention` response + +#### Scenario: Open an event detail + +- **WHEN** the user opens an event returned by the list or another investigation reference +- **THEN** the frontend MUST pass its `event_id` unchanged to the detail endpoint +- **AND** it MUST display backend snapshots and names without replacing historical values with current account or resource names + +### Requirement: Stable Investigation Reference Navigation + +The frontend SHALL navigate between audit views only through stable identifiers returned by the APIs. `investigation_refs.actor_ref`, `resource_refs`, `request_id`, `correlation_id`, `integration_refs`, Integration `linkage`, and stable Integration `resource` references SHALL be the authoritative navigation sources. + +#### Scenario: Navigate through a returned reference + +- **WHEN** an audit or Integration response contains a non-empty supported investigation reference +- **THEN** the frontend MUST pass the returned identifier and type unchanged to the corresponding actor, resource, request, correlation or Integration view + +#### Scenario: A reference is unavailable or unreliable + +- **WHEN** the required reference is empty, absent, unsupported, or its linkage `fidelity` indicates that a relationship is unavailable +- **THEN** the frontend MUST hide or disable the related navigation action +- **AND** it MUST NOT infer a relationship from names, timestamps, summaries, adjacent rows or resource text + +#### Scenario: Link identifiers are not generated by the frontend + +- **WHEN** the frontend needs a `correlation_id` or `integration_id` +- **THEN** it MUST use a value returned by an API +- **AND** it MUST NOT generate, concatenate or guess the identifier + +### Requirement: Actor, Resource, and Link Timelines + +The admin frontend SHALL provide actor-event, resource-event, request-link and correlation-link timelines through the documented endpoints. It SHALL support an explicitly pasted Access Log `request_id`, while all other navigation values SHALL come from returned stable references. + +#### Scenario: Inspect actor behavior + +- **WHEN** a user follows `actor_ref.kind/id` or opens an account by its stable ID +- **THEN** the frontend MUST call `/api/admin/audit/actors/{kind}/{id}/events` +- **AND** action and resource filters MUST use stable values returned by audit data + +#### Scenario: Select an exact resource result + +- **WHEN** a user searches with a supported `resource_type` and exact `keyword` and selects a result +- **THEN** the frontend MUST pass `items[].resource_type/resource_id` unchanged to the resource timeline +- **AND** it MUST identify `historical=true` as a historical-snapshot match + +#### Scenario: Query a request copied from Access Log + +- **WHEN** a platform user explicitly pastes a request ID from Access Log +- **THEN** the frontend MAY query `/api/admin/audit/requests/{request_id}/timeline` with that exact value +- **AND** it MUST NOT claim that the endpoint scans Access Log or archived object storage + +### Requirement: Retention, Pagination, and Time Semantics + +All audit list and timeline views SHALL use the documented pagination and `retention` contract. Time filters SHALL be RFC3339 timestamps with timezone information, and `created_to` SHALL be treated as an exclusive upper bound. + +#### Scenario: Respect the online retention window without a global prompt + +- **WHEN** a response contains `retention.online_from`, `archived_before` and `timezone` +- **THEN** the frontend MUST preserve the retention semantics in its data contract without displaying a global online-window banner +- **AND** it MUST NOT interpret an empty online result as proof that no historical event exists + +#### Scenario: Page through a timeline + +- **WHEN** the user changes page or page size +- **THEN** the frontend MUST use `page` and `page_size`, respect the maximum page size of 100, and preserve the active filters + +### Requirement: Finance Investigation Timeline + +The admin frontend SHALL query `GET /api/admin/audit/finance/timeline` with any documented stable finance condition, including shop, wallet, order, payment, refund, recharge, approval, third-party trade, actor or correlation identifiers. Monetary values SHALL remain integer fen in application data, and authority SHALL follow `amount_authority`. + +#### Scenario: Open finance history from a business record + +- **WHEN** a user opens finance history from an order, refund, recharge, wallet or shop with a stable backend ID +- **THEN** the frontend MUST send the matching documented query parameter +- **AND** it MUST allow the server to complete related facts instead of assembling a local timeline + +#### Scenario: Display an authoritative amount + +- **WHEN** a finance node returns an amount and `amount_authority.authoritative=true` +- **THEN** the frontend MUST treat the referenced table and field as authoritative +- **AND** it MUST only convert integer fen for presentation and MUST NOT reconcile conflicting facts locally + +### Requirement: Risk Investigation + +The admin frontend SHALL provide risk overview and event-detail views using `/api/admin/audit/risks/overview` and `/api/admin/audit/risks/events`. Filters SHALL use codes returned in overview collections, and the selected time range SHALL not exceed 31 days. + +#### Scenario: Drill down from a risk summary + +- **WHEN** a user selects a risk, result, action, source or signal represented by the overview +- **THEN** the frontend MUST use the returned stable code for the supported event-list filter +- **AND** backend `name` values MUST be used only for display + +#### Scenario: Select a range longer than 31 days + +- **WHEN** the user attempts to query more than 31 days +- **THEN** the frontend MUST prevent submission and explain the maximum range + +### Requirement: External Integration Investigation + +The admin frontend SHALL provide Integration overview, list and detail views through the documented endpoints. It SHALL preserve provider, direction, operation, raw result, derived result category, duration, state-change, trigger, resource, linkage, attempt, content-summary, fidelity and retention fields. + +#### Scenario: Apply overview filters to the list + +- **WHEN** a user selects an overview provider, direction or result +- **THEN** the frontend MUST map `providers[].code` to `provider`, `directions[].code` to `direction`, `results[].code` to `result`, and `results[].category` to `result_category` +- **AND** it MUST use `name` only as display text + +#### Scenario: Display Integration trend categories + +- **WHEN** the overview returns trend points +- **THEN** the frontend MUST preserve the five categories `processing`, `succeeded`, `indeterminate`, `failed` and `not_sent` +- **AND** it MUST use only the documented `hour` or `day` bucket + +#### Scenario: Inspect an Integration detail + +- **WHEN** a user opens an `integration_id` returned by a list, event reference or notification target +- **THEN** the frontend MUST query the detail with that value unchanged +- **AND** it MUST present the capability as read-only without recovery, modification, deletion or export actions + +### Requirement: Role-Specific Resource Activity + +The frontend SHALL use the agent and enterprise resource-activity endpoints according to the authenticated subject. Agent activity SHALL support `iot_card`, `device`, `asset_allocation_record`, `exchange_order`, `shop` and `enterprise`; enterprise activity SHALL support only authorized `iot_card` and `device` resources. + +#### Scenario: Open an agent resource activity + +- **WHEN** an agent opens activity for a supported resource +- **THEN** the frontend MUST use ICCID for a card, VirtualNo for a device, or the documented stable business number for another supported resource +- **AND** it MUST NOT send or infer the agent identity or shop scope as query data + +#### Scenario: Open enterprise asset activity + +- **WHEN** an enterprise user opens an authorized card or device activity +- **THEN** the frontend MUST use ICCID or VirtualNo with the enterprise endpoint +- **AND** it MUST render only the subject-safe projection without platform actor, risk, internal reason or before/after fields + +#### Scenario: A stable subject identifier is missing + +- **WHEN** a business response does not contain the required ICCID, VirtualNo or stable business number +- **THEN** the frontend MUST hide the activity entry +- **AND** it MUST NOT substitute a display name or internal identifier intended for another subject + +### Requirement: Business Audit Entries + +The frontend SHALL add role-appropriate audit entries to card, device, shop, enterprise, order, refund and wallet list/detail contexts without changing existing business API URLs. Platform entries SHALL use backend internal IDs; agent and enterprise entries SHALL use their documented stable business identifiers. + +#### Scenario: Open platform asset or business history + +- **WHEN** a platform user opens audit history from a card, device, shop, enterprise, order or refund record +- **THEN** the frontend MUST use the corresponding `resource_type` and backend `response.data.id` with the resource timeline + +#### Scenario: Open wallet or transaction history + +- **WHEN** a user opens audit history for a wallet, order or refund with a stable ID +- **THEN** the frontend MUST offer the applicable finance timeline query +- **AND** the resource timeline MAY also be offered only when a stable resource reference is available + +#### Scenario: Preserve existing operation logs + +- **WHEN** new audit entries are introduced on asset pages +- **THEN** existing asset operation-log features MUST remain available +- **AND** the UI MUST distinguish the existing operation logs from the new cross-system audit investigation + +### Requirement: Controlled Notification Integration Target + +The frontend SHALL resolve a notification through `GET /api/admin/notifications/{id}/target` before opening an Integration detail. It SHALL open the detail only when the target is available, has `target_type=integration_log`, and contains a non-empty `target_key`. + +#### Scenario: Open an Integration notification + +- **WHEN** target resolution returns `available=true`, `target_type=integration_log`, and a non-empty `target_key` +- **THEN** the frontend MUST pass `target_key` unchanged as the Integration `integration_id` +- **AND** it MUST open the controlled internal Integration detail route + +#### Scenario: Notification target cannot be used + +- **WHEN** the target is unavailable, has another type, lacks `target_key`, or maps to no registered route +- **THEN** the frontend MUST not navigate or execute an arbitrary URL +- **AND** it MUST preserve the notification content and show a safe unavailable-target message + +### Requirement: Audit Authorization and Read-Only Boundary + +The frontend SHALL enforce the final platform, agent and enterprise route and action permissions, while preserving backend authentication, authorization and data-isolation enforcement. All capabilities in this change SHALL remain read-only. + +#### Scenario: A subject lacks audit permission + +- **WHEN** the current subject lacks the configured permission for an audit page or business entry +- **THEN** the frontend MUST hide or disable that page or entry and MUST NOT call the endpoint as a fallback + +#### Scenario: An audit request is rejected + +- **WHEN** an endpoint returns `400`, `401`, `403` or `500` +- **THEN** the frontend MUST use shared validation, authentication, authorization and server-error handling +- **AND** it MUST preserve the current investigation state when retrying would be unsafe or misleading + +#### Scenario: A user inspects an audit record + +- **WHEN** any audit, risk, finance, Integration or subject-activity view is displayed +- **THEN** the UI MUST NOT provide mutation, recovery, deletion, risk-disposition, blocking or export actions diff --git a/openspec/changes/add-audit-chain-frontend-integration/tasks.md b/openspec/changes/add-audit-chain-frontend-integration/tasks.md new file mode 100644 index 0000000..854480b --- /dev/null +++ b/openspec/changes/add-audit-chain-frontend-integration/tasks.md @@ -0,0 +1,44 @@ +## 1. Contract and Shared Infrastructure + +- [x] 1.1 根据 `默认模块.openapi.json` 建立审计事件、调查引用、资源、保留窗口、链路节点、资金节点、风险和 Integration 的 TypeScript 类型。 +- [x] 1.2 新增平台审计、主体资源活动、资金、风险、Integration 和链路时间线 API 方法,保持文档参数名、枚举和 nullable 语义。 +- [x] 1.3 建立共享的分页、RFC3339 时间范围、保留窗口、枚举名称和 API 错误展示能力。 +- [x] 1.4 建立只读事件详情、资源摘要、调查引用和时间线节点组件,不提供写操作或导出能力。 + +## 2. Platform Audit Center + +- [x] 2.1 实现审计事件列表及文档定义的时间、动作、类别、操作者、来源、结果、风险、范围、资源和链路筛选。 +- [x] 2.2 实现事件详情,展示事件、操作者、资源快照、结果、风险和元数据,并按非空 `investigation_refs` 提供跳转。 +- [x] 2.3 实现操作者行为时间线,使用 `actor_ref.kind/id` 和稳定 code 筛选。 +- [x] 2.4 实现资源精确搜索及资源时间线,原样使用选中项的 `resource_type/resource_id` 并提示历史快照命中。 +- [x] 2.5 实现请求和业务关联时间线,支持从返回引用进入,以及明确粘贴 Access Log `request_id` 的查询入口。 + +## 3. Specialized Investigations + +- [x] 3.1 实现资金调查时间线和全部文档筛选条件,以分为数据单位并展示金额权威来源。 +- [x] 3.2 实现风险总览和风险事件明细,按总览返回的稳定 code 回填筛选,并限制最长 31 天时间范围。 +- [x] 3.3 实现 Integration 总览、列表和详情,覆盖 provider、direction、result、result_category、趋势、尝试、内容摘要、保真度和关联字段。 +- [x] 3.4 实现各调查视角之间基于 `investigation_refs`、`linkage` 和稳定资源引用的受控跳转。 + +## 4. Subject Activity and Business Entries + +- [x] 4.1 实现代理资源活动页面,支持卡、设备、资产分配记录、换货单、店铺和企业的稳定业务标识。 +- [x] 4.2 实现企业资源活动页面,仅支持当前授权卡和设备并只渲染主体安全投影。 +- [x] 4.3 在卡、设备、店铺、企业、订单、退款和钱包列表/详情增加角色匹配的「审计记录」入口。 +- [x] 4.4 缺少内部 ID、ICCID、VirtualNo 或业务编号时隐藏入口,不使用页面文本或其他字段推断。 +- [x] 4.5 保留现有资产操作日志入口,并通过命名与说明区分旧操作日志和新审计调查能力。 + +## 5. Notification, Routes, and Permissions + +- [x] 5.1 增加审计中心、风险中心、Integration 和共享时间线的路由与菜单配置。 +- [x] 5.2 接入最终确认的平台、代理、企业菜单/按钮权限编码,隐藏越权页面和业务入口。 +- [x] 5.3 在通知目标导航中仅对可用 `integration_log` 目标开放 Integration 详情,并原样使用 `target_key`。 +- [x] 5.4 对未知、不可用或缺失目标保留安全提示,不执行任意 URL 或推测跳转。 + +## 6. Verification + +- [ ] 6.1 为 API 参数序列化、枚举、nullable 字段、分页和 RFC3339 时间范围增加单元测试。 +- [ ] 6.2 为稳定引用跳转、空引用隐藏、`fidelity=false`、通知 Integration 目标和禁止生成链路 ID 增加测试。 +- [ ] 6.3 验证平台、代理、企业角色可见性、服务端数据隔离、企业安全投影以及 `401/403/400/500` 处理。 +- [ ] 6.4 验证保留窗口、空数据、最长 31 天风险查询、资金金额单位和 Integration 五类趋势状态。 +- [ ] 6.5 完成 16 个接口的联调回归,并确认现有业务 URL、通知中心和旧资产操作日志未被破坏。 diff --git a/openspec/changes/add-bulk-purchase-upload-task/design.md b/openspec/changes/add-bulk-purchase-upload-task/design.md new file mode 100644 index 0000000..4a34fb7 --- /dev/null +++ b/openspec/changes/add-bulk-purchase-upload-task/design.md @@ -0,0 +1,65 @@ +## Context + +批量订购包含文件上传、异步任务处理、任务恢复和逐行失败查看四个阶段。任务创建需要绑定一个代理商和一种支付方式,线下支付还需要上传整批凭证;任务处理过程中可能出现部分成功和钱包余额不足,前端必须展示后端结果而不能把整批操作当成原子事务。 + +## Goals / Non-Goals + +- Goals: 提供批量订单 Excel 上传、支付方式选择、任务进度展示、任务恢复和逐行失败明细。 +- Goals: 使用 `request_id` 防止重复提交,并按任务 ID 查询服务端真实状态。 +- Goals: 复用公共异步任务的五态语义和终态规则。 +- Non-Goals: 不在前端解析 Excel 业务行、不执行订单创建、不计算订单金额、不回滚已成功订单。 +- Non-Goals: 不改造现有单笔订单创建和单笔支付凭证上传流程。 +- Non-Goals: 不新增后端模板下载接口。 + +## Decisions + +- Decision: 批量订购页面使用独立任务视图,订单列表只提供入口,不把逐行结果嵌入订单列表表格。 +- Rationale: 批量任务有独立生命周期和大量逐行结果,独立页面更适合恢复、轮询和分页查看。 + +- Decision: `payment_method` 在创建表单中为单选值,并在请求中对整批固定;线下支付时才允许提交 `voucher_file`。 +- Rationale: 产品明确一个批次不能混合支付方式,前端应避免生成含混请求。 + +- Decision: 使用前端静态 Excel 模板资源。 +- Rationale: 产品明确模板下载不依赖后端动态生成,减少接口依赖。 + +- Decision: 使用 `request_id` 作为客户端幂等键,并在创建前生成一次、提交重试时复用同一值。 +- Rationale: 防止网络重试或重复点击创建多个相同批次。 + +- Decision: 部分成功、钱包余额不足和逐行业务失败均由后端任务结果表达;前端只展示计数和失败明细,不自行推断或回滚。 +- Rationale: 钱包扣款和订单事务边界属于后端职责,前端不能可靠重建。 + +## Data Model + +- Create request: `shop_id`、`payment_method`、`file`、可选 `voucher_file`、`request_id`。 +- Task identity: `task_id`、`task_no`。 +- Task status: `1=待处理`、`2=处理中`、`3=已完成`、`4=已失败`、`5=已取消`;部分成功仍属于已完成终态。 +- Task summary: status, total count, success count, failed count, amount summary, timestamps and safe error summary when returned. +- Item result: row number, asset identifier, package code, row status and safe error reason. +- Item query: task ID, page, size and optional row status filter. + +## Risks / Trade-offs + +- Risk: 后端金额字段名称或单位未在产品文档中明确。 +- Mitigation: API 类型和页面以接口实际返回字段为准,统一标注金额单位;实施前补齐字段契约,前端不自行计算汇总。 + +- Risk: 文件或凭证上传成功但任务创建请求失败,可能留下孤立对象。 +- Mitigation: 创建失败时保留用户选择和错误提示;对象清理策略由后端存储生命周期或接口约定处理。 + +- Risk: 任务详情恢复时任务已过期、删除或用户失去权限。 +- Mitigation: 按页面权限和接口错误处理展示对应状态,不重复创建原任务。 + +## Migration Plan + +1. 确认批量订购任务摘要、金额和逐行结果字段契约。 +2. 新增 API、类型、权限和静态模板资源。 +3. 实现批量订购入口、文件上传、线下凭证上传和幂等提交。 +4. 实现任务详情、公共状态展示、轮询和 `task_id` 恢复。 +5. 实现逐行结果分页、状态筛选和失败明细展示。 +6. 验证钱包余额不足、部分成功、重复提交、刷新恢复和权限组合。 + +## Open Questions + +- 批量订购任务摘要中的金额字段名称、金额单位和金额分类需要以后端接口文档确认。 +- 失败明细中的资产字段是统一 `asset_identifier`,还是按资产类型返回 `iccid` / `virtual_no`,需要以后端确认。 +- `voucher_file` 是单文件、文件数组还是已上传对象存储 key,需要与 multipart 接口契约确认。 +- 批量订购入口的最终路由、菜单名称和权限编码需要产品/后端确认。 diff --git a/openspec/changes/add-bulk-purchase-upload-task/proposal.md b/openspec/changes/add-bulk-purchase-upload-task/proposal.md new file mode 100644 index 0000000..4461151 --- /dev/null +++ b/openspec/changes/add-bulk-purchase-upload-task/proposal.md @@ -0,0 +1,38 @@ +# Change: 新增批量订购任务与逐行结果 + +## Why + +运营需要为同一个代理商批量导入套餐订单,并统一指定整批支付方式。当前订单页面只支持单笔创建,无法上传批量订单文件、跟踪处理进度或定位逐行失败原因,批量操作也无法安全处理线下支付凭证和钱包余额不足场景。 + +## What Changes + +- 新增批量订购套餐入口,支持选择代理商、整批支付方式和上传 Excel 文件。 +- 支持线下支付时上传整批支付凭证;一个批次禁止混合支付方式。 +- 模板下载使用前端静态文件,不依赖后端模板接口。 +- 新增批量订购任务 API,提交 multipart 字段 `shop_id`、`payment_method`、`file`、`voucher_file` 和 `request_id`。 +- 创建成功后展示任务号、任务状态、总数、成功数、失败数和金额汇总。 +- 支持按 `task_id` 恢复任务详情,并轮询进行中的任务。 +- 新增逐行结果查询,支持分页和状态筛选。 +- 失败明细展示行号、资产、套餐编码和错误原因。 +- 部分成功作为任务终态;钱包余额不足只影响对应行或后续行,不回滚已经成功的订单。 +- 接入现有 RBAC 权限体系,批量订购创建、任务详情、逐行结果和模板下载权限独立控制。 + +## Impact + +- Affected specs: + - `bulk-purchase-task` + - `order-management` + - `async-task-interaction`(复用现有公共异步任务状态与恢复规则) +- Affected code: + - `src/api/modules/bulkPurchase.ts` + - `src/types/api/bulkPurchase.ts` + - `src/views/order-management/bulk-purchase/index.vue` + - `src/components/business/BulkPurchaseCreateDialog.vue`(如采用独立弹窗) + - `src/views/order-management/order-list/index.vue`(批量入口) + - 静态 Excel 模板资源、路由、菜单、权限和国际化配置 +- Dependencies: + - 后端提供三个批量订购接口及 multipart 鉴权契约。 + - 对象存储上传能力支持批量订单文件和线下支付凭证上传。 + - 需要确认批量订购任务的完整响应字段和并发轮询策略与公共异步任务规范一致。 +- Breaking changes: + - 无。新增 API、页面、权限和任务能力,不修改单笔订单创建接口。 diff --git a/openspec/changes/add-bulk-purchase-upload-task/specs/bulk-purchase-task/spec.md b/openspec/changes/add-bulk-purchase-upload-task/specs/bulk-purchase-task/spec.md new file mode 100644 index 0000000..64f2f23 --- /dev/null +++ b/openspec/changes/add-bulk-purchase-upload-task/specs/bulk-purchase-task/spec.md @@ -0,0 +1,133 @@ +## ADDED Requirements + +### Requirement: Bulk Purchase Creation Form + +The admin frontend SHALL provide a bulk purchase form that binds one uploaded batch to one shop and one payment method. + +#### Scenario: Select batch purchase inputs + +- **GIVEN** 用户打开批量订购入口 +- **WHEN** 用户填写批量订购表单 +- **THEN** 页面 MUST require a target shop, one payment method, and an Excel file +- **AND** 页面 MUST NOT provide a way to mix payment methods within the same batch + +#### Scenario: Show voucher upload for offline payment + +- **GIVEN** 用户选择线下支付 +- **WHEN** 页面渲染批量订购表单 +- **THEN** 页面 MUST show the batch voucher upload control +- **AND** 页面 MUST require a completed voucher upload before task creation + +#### Scenario: Hide voucher upload for wallet payment + +- **GIVEN** 用户选择代理钱包支付 +- **WHEN** 页面渲染或提交批量订购表单 +- **THEN** 页面 MUST NOT submit a voucher file +- **AND** 页面 MUST clear or ignore a voucher selected before switching to wallet payment + +### Requirement: Bulk Purchase File Upload And Creation + +The admin frontend SHALL create a bulk purchase task through `POST /api/admin/bulk-purchases` using multipart form data. + +#### Scenario: Create bulk purchase task + +- **GIVEN** 用户已选择代理商、支付方式并完成文件上传 +- **WHEN** 用户确认创建批量订购任务 +- **THEN** 系统 MUST submit multipart fields `shop_id`, `payment_method`, `file`, and `request_id` +- **AND** 系统 MUST submit `voucher_file` when the payment method is offline +- **AND** 系统 MUST save the returned `task_id` and `task_no` + +#### Scenario: Prevent creation during upload + +- **GIVEN** Excel 文件或线下支付凭证仍在上传 +- **WHEN** 用户点击创建任务 +- **THEN** 页面 MUST prevent task creation +- **AND** 页面 MUST show the upload-in-progress state until all required uploads finish + +#### Scenario: Download static template + +- **GIVEN** 用户打开批量订购表单 +- **WHEN** 用户点击模板下载 +- **THEN** 页面 MUST download the packaged frontend static Excel template +- **AND** 页面 MUST NOT require a template-generation API request + +### Requirement: Bulk Purchase Task Summary And Status + +The admin frontend SHALL display the server-provided bulk purchase task summary and use the shared five-state async task semantics. + +#### Scenario: Display task summary + +- **GIVEN** 批量订购任务创建成功或详情接口返回任务 +- **WHEN** 页面展示任务详情 +- **THEN** 页面 MUST display task ID or task number, status, total count, success count, failed count, and returned amount summaries +- **AND** 页面 MUST use server-provided values without recalculating amounts or counts from the uploaded file + +#### Scenario: Treat partial success as terminal completion + +- **GIVEN** 任务已处理完成且同时存在成功行和失败行 +- **WHEN** 页面展示任务状态 +- **THEN** 页面 MUST display the task as a completed terminal task +- **AND** 页面 MUST display success and failed counts separately +- **AND** 页面 MUST NOT introduce a separate partial-success status + +#### Scenario: Handle wallet insufficiency without rollback + +- **GIVEN** 代理钱包余额不足导致部分或后续行无法创建订单 +- **WHEN** 页面展示任务结果 +- **THEN** 页面 MUST show the affected rows as failed with their returned reasons +- **AND** 页面 MUST preserve and display rows that were already successful +- **AND** 页面 MUST NOT attempt a frontend rollback + +### Requirement: Bulk Purchase Task Recovery And Polling + +The admin frontend SHALL recover a bulk purchase task by `task_id` and poll its summary while it is active. + +#### Scenario: Query task summary + +- **GIVEN** 用户需要加载批量订购任务 +- **WHEN** 页面查询任务摘要 +- **THEN** 系统 MUST call `GET /api/admin/bulk-purchases/{task_id}` +- **AND** 页面 MUST update the task summary from the response + +#### Scenario: Recover task after refresh + +- **GIVEN** 页面存在已保存的 active `task_id` +- **WHEN** 用户刷新页面或重新进入批量订购任务页 +- **THEN** 页面 MUST query the existing task by `task_id` +- **AND** 页面 MUST NOT create another bulk purchase task + +#### Scenario: Stop polling at terminal state + +- **GIVEN** 批量订购任务状态为已完成、已失败或已取消 +- **WHEN** 页面收到任务摘要 +- **THEN** 页面 MUST stop automatic polling +- **AND** 页面 MUST retain the terminal summary and failure counts for viewing + +### Requirement: Bulk Purchase Item Results + +The admin frontend SHALL provide paginated and status-filterable row results for a bulk purchase task. + +#### Scenario: Query item results + +- **GIVEN** 用户打开批量订购任务结果 +- **WHEN** 页面加载逐行结果 +- **THEN** 系统 MUST call `GET /api/admin/bulk-purchases/{task_id}/items` +- **AND** 系统 MUST support `page`, `size`, and optional `status` query parameters + +#### Scenario: Display failed item details + +- **GIVEN** 逐行结果接口返回失败行 +- **WHEN** 页面渲染结果表格 +- **THEN** 页面 MUST display row number, asset identifier, package code, and safe error reason +- **AND** 页面 MUST NOT expose raw backend stack traces or technical error details + +### Requirement: Bulk Purchase Permissions + +The admin frontend SHALL gate bulk purchase creation, task viewing, item-result viewing, and template download with explicit permissions. + +#### Scenario: Hide unauthorized bulk purchase operations + +- **GIVEN** 当前用户缺少某项批量订购权限 +- **WHEN** 页面渲染对应入口或操作 +- **THEN** 页面 MUST NOT display or enable that operation +- **AND** direct API failure MUST NOT be treated as permission to continue diff --git a/openspec/changes/add-bulk-purchase-upload-task/tasks.md b/openspec/changes/add-bulk-purchase-upload-task/tasks.md new file mode 100644 index 0000000..fb0b46c --- /dev/null +++ b/openspec/changes/add-bulk-purchase-upload-task/tasks.md @@ -0,0 +1,46 @@ +## 1. Contract And Types + +- [ ] 1.1 确认 `POST /api/admin/bulk-purchases` 的 multipart 字段类型、文件字段格式和响应结构。 +- [ ] 1.2 确认 `GET /api/admin/bulk-purchases/{task_id}` 的任务摘要字段、状态值、金额字段和错误字段。 +- [ ] 1.3 确认 `GET /api/admin/bulk-purchases/{task_id}/items` 的逐行字段、分页结构和状态筛选参数。 +- [x] 1.4 新增批量订购 API service、请求类型、任务摘要类型和逐行结果类型。 +- [x] 1.5 明确 `request_id` 的生成、保存和重复提交响应处理规则。 + +## 2. Upload And Create + +- [x] 2.1 新增批量订购入口和表单,支持选择代理商、支付方式和 Excel 文件。 +- [x] 2.2 提供前端静态 Excel 模板下载,并限制上传文件类型和必要的文件状态。 +- [x] 2.3 线下支付时显示整批支付凭证上传,钱包支付时隐藏或清理凭证字段。 +- [x] 2.4 复用订单列表已有 `VoucherUpload` 组件展示凭证上传进度,上传未完成时禁止创建任务。 +- [x] 2.5 创建请求携带 `shop_id`、`payment_method`、`file`、必要的 `voucher_file` 和 `request_id`。 +- [x] 2.6 防止重复点击和重复提交,创建成功后保存 `task_id` 并进入任务详情。 + +## 3. Task Progress And Recovery + +- [x] 3.1 展示任务号、状态、总数、成功数、失败数、金额汇总和安全错误摘要。 +- [x] 3.2 按公共异步任务规则轮询待处理和处理中的任务,并在终态停止。 +- [x] 3.3 页面刷新或通过任务 ID 重新进入时恢复任务详情,不重复创建批次。 +- [x] 3.4 将部分成功展示为已完成终态,并同时展示成功数和失败数。 +- [x] 3.5 展示钱包余额不足导致的逐行或后续行失败,不回滚已成功订单。 + +## 4. Item Results + +- [x] 4.1 新增逐行结果表格,展示行号、资产、套餐编码、状态和错误原因。 +- [x] 4.2 支持按逐行状态筛选和分页查询。 +- [x] 4.3 失败原因优先展示后端安全错误文案,不直接展示底层技术错误。 +- [x] 4.4 任务详情和逐行结果保持当前任务 ID、筛选条件和分页状态。 + +## 5. Permissions And Verification + +- [x] 5.1 为批量订购创建、任务详情、逐行结果和模板下载配置独立权限。 +- [ ] 5.2 验证一个批次只能提交一种支付方式,线下支付缺少凭证时不能提交。 +- [ ] 5.3 验证重复提交复用 `request_id`,不会创建重复任务。 +- [ ] 5.4 验证空文件、上传失败、任务失败、部分成功和钱包余额不足场景。 +- [ ] 5.5 验证页面刷新恢复、任务终态停止轮询和逐行失败筛选。 +- [x] 5.6 运行 `openspec validate add-bulk-purchase-upload-task --strict`、类型检查、lint 和构建。 + +## Implementation Notes + +- 后端接口文档未随仓库提供,1.1、1.2、1.3 仍需联调确认精确金额字段、逐行字段和 multipart 文件格式;当前类型使用可选兼容字段。 +- 5.2 至 5.5 需要接入真实后端后进行手工回归,当前已完成前端校验、权限、幂等键、恢复、轮询和筛选逻辑。 +- 支付凭证复用 `src/components/business/VoucherUpload.vue`,提交 `voucher_file` 时使用该组件上传后返回的文件 key。 diff --git a/openspec/changes/add-business-list-approval-summaries/design.md b/openspec/changes/add-business-list-approval-summaries/design.md new file mode 100644 index 0000000..9a2b9a8 --- /dev/null +++ b/openspec/changes/add-business-list-approval-summaries/design.md @@ -0,0 +1,41 @@ +## Context + +三个后台业务列表都需要展示相同的审批摘要,但各自保留既有退款、充值或换货操作。审批来源和业务处理状态均由后端列表接口返回,前端不请求单条审批详情来填充表格。 + +## Goals / Non-Goals + +- Goals: + - 统一展示提交人、审批状态、当前审批人摘要和业务处理状态。 + - 正确区分无审批、历史本地审批和企微审批。 + - 在不增加逐行请求的前提下支持长摘要完整查看。 +- Non-Goals: + - 不创建、修改或撤回审批流程。 + - 不增加历史本地审批操作按钮。 + - 不以 `approval_status` 推断 `processing_status`,或反向推断。 + +## Decisions + +- Decision: 三个列表项模型复用相同名称和语义的审批摘要字段,字段由各自的列表接口直接返回。 +- Decision: `approval_source=none` 时审批状态与当前审批人摘要均显示 `-`;`legacy` 时审批状态固定显示“历史审批”,当前审批人摘要显示 `-`;`wecom` 时直接展示 `approval_status_name` 和 `current_approver_summary`。 +- Decision: 审批人摘要仅负责展示,使用表格溢出省略与 tooltip 呈现完整文本。 +- Decision: 业务处理状态单独读取 `processing_status_name`,不与审批状态混合或映射。 +- Decision: 列表分页和刷新仅调用现有列表 API,禁止为每条记录请求审批详情。 +- Alternatives considered: 从单条详情或企微审批 API 批量补齐摘要。未采用,因为会引入 N+1 请求并与列表响应已提供的摘要字段重复。 + +## Risks / Trade-offs + +- 后端遗漏摘要字段时信息不可用 -> 统一显示稳定占位,不影响原有列表和业务操作。 +- 审批状态名称可能为空 -> 企微来源显示稳定占位,不自行翻译状态码。 +- 审批人摘要长度不受控 -> 表格列使用溢出省略和 hover 完整文本。 + +## Migration Plan + +1. 扩展三个列表项类型以保留审批摘要字段。 +2. 在三个列表中添加四个展示列并遵循审批来源规则。 +3. 验证 `none`、`legacy`、`wecom` 和处理状态为空的响应。 +4. 验证刷新、分页未出现逐行审批详情请求。 +5. 如需回滚,移除列表列与附加类型字段;不涉及数据迁移。 + +## Open Questions + +- 无。 diff --git a/openspec/changes/add-business-list-approval-summaries/proposal.md b/openspec/changes/add-business-list-approval-summaries/proposal.md new file mode 100644 index 0000000..0da3b44 --- /dev/null +++ b/openspec/changes/add-business-list-approval-summaries/proposal.md @@ -0,0 +1,36 @@ +# Change: 新增业务列表提交人与审批摘要 + +## Why + +退款、代理充值和换货列表当前无法直接显示提交人、企微审批进度及业务处理进度。运营人员需要进入详情或依赖额外沟通才能判断记录由谁发起、审批进行到哪一步以及后续业务是否完成。 + +## What Changes + +- 在退款列表、代理充值列表和换货列表增加“提交人”“审批状态”“当前审批人摘要”“业务处理状态”四列。 +- 三个列表接口项统一支持 `submitter_name`、`approval_source`、`approval_status`、`approval_status_name`、`current_approver_summary`、`processing_status`、`processing_status_name`。 +- `approval_source=none` 时审批状态和当前审批人摘要显示 `-`。 +- `approval_source=legacy` 时审批状态显示“历史审批”,作为只读历史信息,不新增审批操作按钮。 +- `approval_source=wecom` 时显示后端返回的企微审批状态及当前审批人摘要;超长审批人摘要使用省略显示并在悬浮时展示完整文本。 +- 业务处理状态直接展示后端 `processing_status_name`,与审批状态分列展示。 +- 列表仅使用列表响应中的审批摘要字段,翻页和刷新时不得为每行额外请求审批详情。 + +## Impact + +- Affected specs: + - `order-management` + - `agent-recharge` + - `exchange-management` +- Affected code: + - `src/types/api/refund.ts` + - `src/types/api/agentRecharge.ts` + - `src/api/modules/exchange.ts` + - `src/views/finance/refund/index.vue` + - `src/views/finance/agent-recharge/index.vue` + - `src/views/asset-management/exchange-management/index.vue` +- API contracts: + - `GET /api/admin/refunds` + - `GET /api/admin/agent-recharges` + - `GET /api/admin/exchanges` +- Out of scope: + - 审批发起、撤回、审批操作或企微审批详情页。 + - 前端根据业务状态或审批步骤推导审批结果和业务处理状态。 diff --git a/openspec/changes/add-business-list-approval-summaries/specs/agent-recharge/spec.md b/openspec/changes/add-business-list-approval-summaries/specs/agent-recharge/spec.md new file mode 100644 index 0000000..8566089 --- /dev/null +++ b/openspec/changes/add-business-list-approval-summaries/specs/agent-recharge/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Agent Recharge List Approval Summary Contract + +The `GET /api/admin/agent-recharges` list-item contract SHALL support `submitter_name`, `approval_source`, `approval_status`, `approval_status_name`, `current_approver_summary`, `processing_status`, and `processing_status_name`. + +#### Scenario: Receive agent recharge approval summary fields + +- **GIVEN** 后台代理充值列表接口返回充值记录 +- **WHEN** 前端解析列表响应 +- **THEN** 代理充值列表项类型 MUST preserve all approval and processing summary fields +- **AND** 页面 MUST NOT request an individual approval-detail API to populate the row + +### Requirement: Agent Recharge List Approval And Processing Display + +The agent recharge list SHALL display `提交人`, `审批状态`, `当前审批人摘要`, and `业务处理状态` as distinct columns based on backend summary fields. + +#### Scenario: Display recharge with no approval source + +- **GIVEN** 代理充值记录的 `approval_source=none` +- **WHEN** 页面渲染代理充值列表行 +- **THEN** `审批状态` MUST display `-` +- **AND** `当前审批人摘要` MUST display `-` + +#### Scenario: Display legacy recharge approval read-only + +- **GIVEN** 代理充值记录的 `approval_source=legacy` +- **WHEN** 页面渲染代理充值列表行 +- **THEN** `审批状态` MUST display `历史审批` +- **AND** 页面 MUST NOT add an approval operation button for that historical approval + +#### Scenario: Display WeCom recharge approval summary + +- **GIVEN** 代理充值记录的 `approval_source=wecom` +- **WHEN** 页面渲染代理充值列表行 +- **THEN** `审批状态` MUST display backend `approval_status_name` +- **AND** `当前审批人摘要` MUST display backend `current_approver_summary` +- **AND** `业务处理状态` MUST independently display backend `processing_status_name` + +#### Scenario: View long recharge approver summary + +- **GIVEN** 代理充值记录的 `current_approver_summary` exceeds its table cell width +- **WHEN** 页面渲染当前审批人摘要列 +- **THEN** 摘要 MUST be visually truncated in the cell +- **AND** 用户 MUST be able to view the complete backend text on hover + +#### Scenario: Paginate recharges without per-row approval requests + +- **WHEN** 用户切换代理充值列表页码或刷新列表 +- **THEN** 页面 MUST use the agent recharge list API response for approval summaries +- **AND** 页面 MUST NOT issue approval-detail requests per recharge row diff --git a/openspec/changes/add-business-list-approval-summaries/specs/exchange-management/spec.md b/openspec/changes/add-business-list-approval-summaries/specs/exchange-management/spec.md new file mode 100644 index 0000000..718734c --- /dev/null +++ b/openspec/changes/add-business-list-approval-summaries/specs/exchange-management/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Exchange List Approval Summary Contract + +The `GET /api/admin/exchanges` list-item contract SHALL support `submitter_name`, `approval_source`, `approval_status`, `approval_status_name`, `current_approver_summary`, `processing_status`, and `processing_status_name`. + +#### Scenario: Receive exchange approval summary fields + +- **GIVEN** 后台换货列表接口返回换货记录 +- **WHEN** 前端解析列表响应 +- **THEN** 换货列表项类型 MUST preserve all approval and processing summary fields +- **AND** 页面 MUST NOT request an individual approval-detail API to populate the row + +### Requirement: Exchange List Approval And Processing Display + +The exchange management list SHALL display `提交人`, `审批状态`, `当前审批人摘要`, and `业务处理状态` as distinct columns based on backend summary fields. + +#### Scenario: Display exchange with no approval source + +- **GIVEN** 换货记录的 `approval_source=none` +- **WHEN** 页面渲染换货列表行 +- **THEN** `审批状态` MUST display `-` +- **AND** `当前审批人摘要` MUST display `-` + +#### Scenario: Display legacy exchange approval read-only + +- **GIVEN** 换货记录的 `approval_source=legacy` +- **WHEN** 页面渲染换货列表行 +- **THEN** `审批状态` MUST display `历史审批` +- **AND** 页面 MUST NOT add an approval operation button for that historical approval + +#### Scenario: Display WeCom exchange approval summary + +- **GIVEN** 换货记录的 `approval_source=wecom` +- **WHEN** 页面渲染换货列表行 +- **THEN** `审批状态` MUST display backend `approval_status_name` +- **AND** `当前审批人摘要` MUST display backend `current_approver_summary` +- **AND** `业务处理状态` MUST independently display backend `processing_status_name` + +#### Scenario: View long exchange approver summary + +- **GIVEN** 换货记录的 `current_approver_summary` exceeds its table cell width +- **WHEN** 页面渲染当前审批人摘要列 +- **THEN** 摘要 MUST be visually truncated in the cell +- **AND** 用户 MUST be able to view the complete backend text on hover + +#### Scenario: Paginate exchanges without per-row approval requests + +- **WHEN** 用户切换换货列表页码或刷新列表 +- **THEN** 页面 MUST use the exchange list API response for approval summaries +- **AND** 页面 MUST NOT issue approval-detail requests per exchange row diff --git a/openspec/changes/add-business-list-approval-summaries/specs/order-management/spec.md b/openspec/changes/add-business-list-approval-summaries/specs/order-management/spec.md new file mode 100644 index 0000000..8373924 --- /dev/null +++ b/openspec/changes/add-business-list-approval-summaries/specs/order-management/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Refund List Approval Summary Contract + +The `GET /api/admin/refunds` list-item contract SHALL support `submitter_name`, `approval_source`, `approval_status`, `approval_status_name`, `current_approver_summary`, `processing_status`, and `processing_status_name`. + +#### Scenario: Receive refund approval summary fields + +- **GIVEN** 后台退款列表接口返回退款记录 +- **WHEN** 前端解析列表响应 +- **THEN** 退款列表项类型 MUST preserve all approval and processing summary fields +- **AND** 页面 MUST NOT request an individual approval-detail API to populate the row + +### Requirement: Refund List Approval And Processing Display + +The refund management list SHALL display `提交人`, `审批状态`, `当前审批人摘要`, and `业务处理状态` as distinct columns based on backend summary fields. + +#### Scenario: Display refund with no approval source + +- **GIVEN** 退款记录的 `approval_source=none` +- **WHEN** 页面渲染退款列表行 +- **THEN** `审批状态` MUST display `-` +- **AND** `当前审批人摘要` MUST display `-` + +#### Scenario: Display legacy refund approval read-only + +- **GIVEN** 退款记录的 `approval_source=legacy` +- **WHEN** 页面渲染退款列表行 +- **THEN** `审批状态` MUST display `历史审批` +- **AND** 页面 MUST NOT add an approval operation button for that historical approval + +#### Scenario: Display WeCom refund approval summary + +- **GIVEN** 退款记录的 `approval_source=wecom` +- **WHEN** 页面渲染退款列表行 +- **THEN** `审批状态` MUST display backend `approval_status_name` +- **AND** `当前审批人摘要` MUST display backend `current_approver_summary` +- **AND** `业务处理状态` MUST independently display backend `processing_status_name` + +#### Scenario: View long refund approver summary + +- **GIVEN** 退款记录的 `current_approver_summary` exceeds its table cell width +- **WHEN** 页面渲染当前审批人摘要列 +- **THEN** 摘要 MUST be visually truncated in the cell +- **AND** 用户 MUST be able to view the complete backend text on hover + +#### Scenario: Paginate refunds without per-row approval requests + +- **WHEN** 用户切换退款列表页码或刷新列表 +- **THEN** 页面 MUST use the refund list API response for approval summaries +- **AND** 页面 MUST NOT issue approval-detail requests per refund row diff --git a/openspec/changes/add-business-list-approval-summaries/tasks.md b/openspec/changes/add-business-list-approval-summaries/tasks.md new file mode 100644 index 0000000..a81dfd8 --- /dev/null +++ b/openspec/changes/add-business-list-approval-summaries/tasks.md @@ -0,0 +1,33 @@ +## 1. List Contracts + +- [x] 1.1 扩展退款、代理充值和换货列表项类型,支持 `submitter_name`、`approval_source`、`approval_status`、`approval_status_name`、`current_approver_summary`、`processing_status`、`processing_status_name`。 +- [x] 1.2 确认三个列表 API 使用列表响应直接提供上述字段,不新增逐条审批详情查询。 + +## 2. Shared Summary Behavior + +- [x] 2.1 实现审批来源展示规则:`none` 显示 `-`,`legacy` 显示“历史审批”且只读,`wecom` 显示后端审批状态名称。 +- [x] 2.2 将当前审批人摘要限制为单行省略,并提供完整文本悬浮提示。 +- [x] 2.3 将业务处理状态独立显示为后端 `processing_status_name`,缺失时显示稳定占位内容。 + +## 3. Refund List + +- [x] 3.1 在退款列表增加提交人、审批状态、当前审批人摘要、业务处理状态列。 +- [x] 3.2 保留退款既有审批与业务操作,不为历史审批记录增加新操作按钮。 + +## 4. Agent Recharge List + +- [x] 4.1 在代理充值列表增加提交人、审批状态、当前审批人摘要、业务处理状态列。 +- [x] 4.2 保留代理充值既有确认支付、拒绝等操作,不为历史审批记录增加新操作按钮。 + +## 5. Exchange List + +- [x] 5.1 在换货列表增加提交人、审批状态、当前审批人摘要、业务处理状态列。 +- [x] 5.2 保留换货既有流程和资产筛选逻辑,不为历史审批记录增加新操作按钮。 + +## 6. Verification + +- [x] 6.1 验证三类列表在 `approval_source=none` 时审批状态和当前审批人摘要均显示 `-`。 +- [x] 6.2 验证 `approval_source=legacy` 时显示“历史审批”且没有新增审批操作,`wecom` 时显示后端审批状态和当前审批人摘要。 +- [x] 6.3 验证长当前审批人摘要会省略显示并能通过悬浮查看完整文本。 +- [x] 6.4 验证三个列表的业务处理状态独立显示,分页和刷新不触发逐行审批详情请求。 +- [x] 6.5 运行相关类型检查、lint 或构建验证。 diff --git a/openspec/changes/add-historical-approval-resend/design.md b/openspec/changes/add-historical-approval-resend/design.md new file mode 100644 index 0000000..cf539b1 --- /dev/null +++ b/openspec/changes/add-historical-approval-resend/design.md @@ -0,0 +1,50 @@ +## Context + +退款和代理充值已经接入企微审批,但部分历史记录在审批流程上线前创建,列表中的 `approval_status` 为空。这些记录需要由运营人员手动触发一次审批补发,才能进入企微审批链路。 + +## Goals / Non-Goals + +- Goals: 提供代理充值和退款的历史审批补发入口。 +- Goals: 通过路径参数指定目标记录,并复用后端返回的完整记录与审批状态。 +- Goals: 由明确的状态规则控制入口展示,后端做最终资格校验。 +- Non-Goals: 不在前端实现审批提交、审批回调或审批引擎。 +- Non-Goals: 不修改历史记录的业务字段、金额或支付/退款结果。 +- Non-Goals: 不提供批量自动补发。 + +## Decisions + +- Decision: 两个模块分别新增 `triggerApproval(id)` 服务方法,返回完整业务记录并保留审批字段。 +- Rationale: 两个接口契约一致,成功后页面需要立即展示最新审批状态,完整记录可直接用于刷新。 + +- Decision: 代理充值仅在 `approval_status` 为空且 `status` 不属于已完成、已驳回、已关闭时显示补发入口;退款仅在 `status=待审批` 且 `approval_status` 为空时显示补发入口。 +- Rationale: 与业务规则一致,避免对已进入审批或已终结的记录重复补发;后端仍做最终资格校验。 + +- Decision: 引入独立按钮权限 `agent_recharge:trigger_approval` 和 `refund:trigger_approval`。 +- Rationale: 补发审批是财务相关敏感操作,需与现有确认支付、拒绝、重新申请权限区分。 + +- Decision: 补发审批与现有“确认支付/拒绝”和“重新申请”操作共存。 +- Rationale: 它们承担不同职责;补发审批只是把历史记录推进企微审批,不改变后续人工确认或重新申请的流程。 + +## Risks / Trade-offs + +- Risk: 代理充值中 `status=已支付`、`已退款` 等非终态记录是否允许补发,需要后端最终确认。 +- Mitigation: 前端按约定的审批状态与状态排除规则展示入口,后端对不允许的记录返回错误并稳定展示。 + +- Risk: 接口可能对部分状态返回拒绝。 +- Mitigation: 前端处理后端错误信息,不将失败记录标记为已补发。 + +- Risk: 重复点击可能触发多次审批提交。 +- Mitigation: 提交期间锁定按钮并禁用重复触发;后端应保证幂等或返回明确的已存在审批提示。 + +## Migration Plan + +1. 确认两个 `trigger-approval` 接口的响应字段与现有 `AgentRecharge`、`Refund` 类型一致。 +2. 新增服务方法和按钮权限。 +3. 在列表接入补发审批入口及资格判断。 +4. 联调补发成功、接口拒绝、权限缺失、审批状态为空与状态不符合条件等场景。 +5. 验证与现有确认支付、拒绝、重新申请操作不冲突。 + +## Open Questions + +- 后端对可补发记录的最终状态校验以及幂等性需确认。 +- 两个接口是否都需要独立权限码,还是复用现有审批/财务权限,需与后端权限配置对齐。 diff --git a/openspec/changes/add-historical-approval-resend/proposal.md b/openspec/changes/add-historical-approval-resend/proposal.md new file mode 100644 index 0000000..624231f --- /dev/null +++ b/openspec/changes/add-historical-approval-resend/proposal.md @@ -0,0 +1,39 @@ +# Change: 补发历史线下代理充值审批与补发历史退款审批 + +## Why + +历史线下代理充值记录和历史退款申请在企微审批流程上线前创建,列表中这些记录的审批状态为空,运营人员无法为它们补发审批流程。需要为这两类业务提供“补发审批”入口,调用后端触发审批接口,将历史记录纳入企微审批。 + +## What Changes + +- 在 `AgentRechargeService` 新增 `triggerApproval(id)`,调用 `POST /api/admin/agent-recharges/{id}/trigger-approval`。 +- 在 `RefundService` 新增 `triggerApproval(id)`,调用 `POST /api/admin/refunds/{id}/trigger-approval`。 +- 在代理充值列表为 `approval_status` 为空且 `status` 不属于已完成、已驳回、已关闭的充值记录增加“补发审批”操作。 +- 在退款列表为 `status=待审批` 且 `approval_status` 为空的退款申请增加“补发审批”操作。 +- 引入权限 `agent_recharge:trigger_approval` 和 `refund:trigger_approval`,仅对有权限的平台账号展示操作。 +- 补发成功后刷新列表并展示返回的最新审批状态;失败时展示后端错误信息。 +- 不改变现有“确认支付”“拒绝”“重新申请”等操作的资格和职责。 + +## Impact + +- Affected specs: + - `agent-recharge` + - `refund-management` +- Affected code: + - `src/api/modules/agentRecharge.ts` + - `src/api/modules/refund.ts` + - `src/types/api/agentRecharge.ts` + - `src/types/api/refund.ts` + - `src/views/finance/agent-recharge/agentRechargeActions.ts` + - `src/views/finance/agent-recharge/index.vue` + - `src/views/finance/refund/index.vue` +- API contracts: + - `POST /api/admin/agent-recharges/{id}/trigger-approval` + - `POST /api/admin/refunds/{id}/trigger-approval` +- Dependencies: + - 后端按文档返回完整业务记录及当前审批状态。 + - 后端负责校验记录是否可补发审批,前端仅控制入口展示并处理后端拒绝。 +- Out of scope: + - 企微审批的发起、撤回、通过、驳回或删除动作本身。 + - 修改历史记录的业务字段、金额或支付/退款结果。 + - 自动判断并批量补发历史审批。 diff --git a/openspec/changes/add-historical-approval-resend/specs/agent-recharge/spec.md b/openspec/changes/add-historical-approval-resend/specs/agent-recharge/spec.md new file mode 100644 index 0000000..aa68abb --- /dev/null +++ b/openspec/changes/add-historical-approval-resend/specs/agent-recharge/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Agent Recharge Historical Approval Resend API + +The agent recharge service SHALL expose a historical approval resend operation through `POST /api/admin/agent-recharges/{id}/trigger-approval`. + +#### Scenario: Resend approval for a historical offline recharge + +- **GIVEN** 一个需要补发审批的历史线下代理充值记录 `id` +- **WHEN** 前端调用 `POST /api/admin/agent-recharges/{id}/trigger-approval` +- **THEN** 请求 MUST 在路径参数中携带该充值记录 `id` +- **AND** 成功响应 MUST 被解析为完整的 `AgentRecharge` 记录,并保留 `approval_provider`、`approval_instance_id`、`approval_status` 和 `approval_status_name` 字段 + +### Requirement: Agent Recharge Historical Approval Resend Entry + +The agent recharge list page SHALL provide a `补发审批` action only for recharge records whose `approval_status` is empty and whose business `status` is not completed, rejected, or closed, and SHALL gate it by permission. + +#### Scenario: Show resend action for an eligible recharge + +- **GIVEN** 平台账号拥有 `agent_recharge:trigger_approval` 权限 +- **AND** 充值记录的 `approval_status` 为空(`null` 或 `undefined`) +- **AND** 充值记录的 `status` 不属于已完成(3)、已驳回(6)、已关闭(4) +- **WHEN** 页面渲染代理充值列表 +- **THEN** 页面 MUST 为该记录显示“补发审批”操作 + +#### Scenario: Hide resend action when approval status is present + +- **GIVEN** 充值记录的 `approval_status` 不为空 +- **WHEN** 页面渲染该记录 +- **THEN** 页面 MUST NOT 显示“补发审批”操作 + +#### Scenario: Hide resend action for terminal recharge statuses + +- **GIVEN** 充值记录的 `status` 为已完成(3)、已驳回(6)或已关闭(4) +- **WHEN** 页面渲染该记录 +- **THEN** 页面 MUST NOT 显示“补发审批”操作 + +#### Scenario: Hide resend action without permission + +- **GIVEN** 当前账号不拥有 `agent_recharge:trigger_approval` 权限 +- **WHEN** 页面渲染代理充值记录 +- **THEN** 页面 MUST NOT 显示“补发审批”操作 + +#### Scenario: Resend approval succeeds + +- **GIVEN** 用户对符合条件的充值记录点击“补发审批” +- **WHEN** 接口返回 `code=0` +- **THEN** 页面 MUST 显示成功提示并刷新列表 +- **AND** 刷新后的记录 MUST 展示接口返回的最新审批状态 + +#### Scenario: Resend approval fails + +- **GIVEN** 接口返回非零 `code` 或请求失败 +- **WHEN** 用户触发“补发审批” +- **THEN** 页面 MUST 展示后端返回的错误信息 +- **AND** 页面 MUST NOT 将记录标记为已补发审批 diff --git a/openspec/changes/add-historical-approval-resend/specs/refund-management/spec.md b/openspec/changes/add-historical-approval-resend/specs/refund-management/spec.md new file mode 100644 index 0000000..7f95cd8 --- /dev/null +++ b/openspec/changes/add-historical-approval-resend/specs/refund-management/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Refund Historical Approval Resend API + +The refund service SHALL expose a historical approval resend operation through `POST /api/admin/refunds/{id}/trigger-approval`. + +#### Scenario: Resend approval for a historical refund + +- **GIVEN** 一个需要补发审批的历史退款申请 `id` +- **WHEN** 前端调用 `POST /api/admin/refunds/{id}/trigger-approval` +- **THEN** 请求 MUST 在路径参数中携带该退款申请 `id` +- **AND** 成功响应 MUST 被解析为完整的 `Refund` 记录,并保留 `approval_provider`、`approval_instance_id`、`approval_status` 和 `approval_status_name` 字段 + +### Requirement: Refund Historical Approval Resend Entry + +The refund list page SHALL provide a `补发审批` action only for refund records whose `status` is pending approval and whose `approval_status` is empty, and SHALL gate it by permission. + +#### Scenario: Show resend action for an eligible refund + +- **GIVEN** 平台账号拥有 `refund:trigger_approval` 权限 +- **AND** 退款申请的 `status` 为待审批(1) +- **AND** 退款申请的 `approval_status` 为空(`null` 或 `undefined`) +- **WHEN** 页面渲染退款列表 +- **THEN** 页面 MUST 为该记录显示“补发审批”操作 + +#### Scenario: Hide resend action when refund status is not pending + +- **GIVEN** 退款申请的 `status` 不为待审批(1) +- **WHEN** 页面渲染该记录 +- **THEN** 页面 MUST NOT 显示“补发审批”操作 + +#### Scenario: Hide resend action when approval status is present + +- **GIVEN** 退款申请的 `approval_status` 不为空 +- **WHEN** 页面渲染该记录 +- **THEN** 页面 MUST NOT 显示“补发审批”操作 + +#### Scenario: Hide resend action without permission + +- **GIVEN** 当前账号不拥有 `refund:trigger_approval` 权限 +- **WHEN** 页面渲染退款记录 +- **THEN** 页面 MUST NOT 显示“补发审批”操作 + +#### Scenario: Resend approval succeeds + +- **GIVEN** 用户对符合条件的退款申请点击“补发审批” +- **WHEN** 接口返回 `code=0` +- **THEN** 页面 MUST 显示成功提示并刷新列表 +- **AND** 刷新后的记录 MUST 展示接口返回的最新审批状态 + +#### Scenario: Resend approval fails + +- **GIVEN** 接口返回非零 `code` 或请求失败 +- **WHEN** 用户触发“补发审批” +- **THEN** 页面 MUST 展示后端返回的错误信息 +- **AND** 页面 MUST NOT 将记录标记为已补发审批 diff --git a/openspec/changes/add-historical-approval-resend/tasks.md b/openspec/changes/add-historical-approval-resend/tasks.md new file mode 100644 index 0000000..8f4f5a9 --- /dev/null +++ b/openspec/changes/add-historical-approval-resend/tasks.md @@ -0,0 +1,23 @@ +## 1. 类型与 API 契约 + +- [x] 1.1 在 `src/api/modules/agentRecharge.ts` 新增 `triggerApproval(id)` 方法 +- [x] 1.2 在 `src/api/modules/refund.ts` 新增 `triggerApproval(id)` 方法 +- [x] 1.3 确认 `AgentRecharge` 和 `Refund` 类型包含 `approval_provider`、`approval_source`、`approval_instance_id`、`approval_status`、`approval_status_name` + +## 2. 代理充值补发审批入口 + +- [x] 2.1 在 `agentRechargeActions.ts` 增加“补发审批”动作及资格判断 +- [x] 2.2 在代理充值列表接入权限 `agent_recharge:trigger_approval` 与成功/失败处理 +- [x] 2.3 补发成功后刷新列表并展示最新审批状态 + +## 3. 退款补发审批入口 + +- [x] 3.1 在退款列表 `getActions` 增加“补发审批”动作及资格判断 +- [x] 3.2 接入权限 `refund:trigger_approval` 与成功/失败处理 +- [x] 3.3 补发成功后刷新列表并展示最新审批状态 + +## 4. 校验与验证 + +- [x] 4.1 运行 `openspec validate add-historical-approval-resend --strict` +- [x] 4.2 运行 ESLint、类型检查并修复 +- [ ] 4.3 手工验证有/无审批实例、线上/线下充值、权限开关等场景 diff --git a/openspec/changes/add-notification-center/design.md b/openspec/changes/add-notification-center/design.md new file mode 100644 index 0000000..4c02f39 --- /dev/null +++ b/openspec/changes/add-notification-center/design.md @@ -0,0 +1,70 @@ +## Context + +系统已经存在 `ArtNotification` 顶部通知组件,但组件中的通知、消息和待办列表都是静态数据,顶部通知按钮处于注释状态。新的通知能力需要同时服务顶部快速查看和完整通知中心,并确保用户在任一入口执行已读操作后未读数与列表状态一致。 + +## Goals / Non-Goals + +- Goals: 提供余额预警、临期提醒、审批结果和系统告警统一的站内通知入口。 +- Goals: 在顶部快速查看最近 10 条通知,并提供进入通知中心的入口。 +- Goals: 支持通知分类、类型、严重级别和已读状态筛选。 +- Goals: 将通知点击导航限制在前端认可的业务目标内,禁止任意 URL 跳转。 +- Goals: 单条已读、全部已读、抽屉和通知中心共享同一份未读状态。 +- Non-Goals: 不实现 C 端通知页面。 +- Non-Goals: 不实现推送通道或服务端通知生成规则。 +- Non-Goals: 不让前端根据通知正文推断跳转地址。 + +## Decisions + +- Decision: 通知铃铛放在顶部全局导航的设置按钮和用户头像菜单附近。 +- Rationale: 该区域已承载全局设置和用户级入口,适合放置跨页面可访问的通知入口,也符合产品指定位置。 + +- Decision: 顶部抽屉只加载最近 10 条,完整通知中心使用独立路由 `/notifications`。 +- Rationale: 顶部入口保持轻量,筛选和完整历史查询放在独立页面,避免挤占全局导航空间。 + +- Decision: 未读数使用独立接口获取,单条通知和全部已读成功后立即更新本地共享状态,并以接口返回值为最终结果。 +- Rationale: 未读数是全局状态,不能依赖当前抽屉或列表的局部数量推导。 + +- Decision: 点击通知先调用单条已读接口,再调用目标接口,根据返回的受控 route name/route params 跳转。 +- Rationale: 已读状态必须在导航前落库,目标由后端业务引用解析,前端不执行通知携带的任意 URL。 + +- Decision: 未知 `ref_type` 或目标接口无可用目标时只展示通知正文和状态,不跳转。 +- Rationale: 保证安全,同时让无法关联页面的系统通知仍然可读。 + +- Decision: `unread-summary` 为顶部抽屉分类提供数据,`notifications` 为通知中心筛选列表提供数据。 +- Rationale: 顶部快速查看与完整列表的数据量和筛选职责不同,避免顶部加载完整历史数据。 + +## Data Contract + +- Notification item: id, title, content, category, type, severity, read status, created time and optional `ref_type`/reference ID. +- Unread count: non-negative integer; frontend formats it as `0`, `1`-`99` or `99+`. +- Unread summary: category counts and recent notification items, limited to the latest 10 items for the drawer. +- Notification list: paginated items plus total count, with category, type, severity and read-state filters. +- Target response: controlled internal route information, such as route name/path and route params; no arbitrary executable URL. + +## Risks / Trade-offs + +- Risk: 后端通知字段或枚举名称与文档不一致。 +- Mitigation: 在任务阶段先确认字段契约,类型层保留稳定的可选字段和未知值占位展示。 + +- Risk: 用户在多个标签页同时读通知,单页本地未读数短暂不一致。 +- Mitigation: 操作成功后刷新未读数和当前列表;跨标签实时同步不作为本期强制目标。 + +- Risk: 目标记录已删除或用户权限发生变化。 +- Mitigation: 目标接口返回不可跳转时只展示正文,并处理权限/不存在状态,不回退到任意 URL。 + +## Migration Plan + +1. 确认通知列表、摘要、未读数和目标接口字段及枚举。 +2. 新增通知 API service、类型和共享状态。 +3. 将顶部通知按钮放入设置/头像区域,接入未读数和最近通知抽屉。 +4. 新增 `/notifications` 路由及通知中心页面。 +5. 实现筛选、单条已读、全部已读和受控目标跳转。 +6. 删除或替换 `ArtNotification` 中的静态 mock 数据,并验证四类通知展示一致。 + +## Open Questions + +- 通知中心是否需要分页参数名称 `page/page_size`,以及默认每页数量需要后端确认。 +- `category`、`type`、`severity` 和 `read_status` 的枚举值及展示名称需要后端确认。 +- 目标接口返回 route name、path 还是 route key + params,需要与路由菜单契约确认。 +- 未读数是否需要页面进入时定时刷新或仅在打开抽屉/完成操作时刷新,本期默认按接口调用时机刷新。 +- 通知中心菜单权限和按钮权限编码需要后端权限表确认。 diff --git a/openspec/changes/add-notification-center/proposal.md b/openspec/changes/add-notification-center/proposal.md new file mode 100644 index 0000000..0d80157 --- /dev/null +++ b/openspec/changes/add-notification-center/proposal.md @@ -0,0 +1,46 @@ +# Change: 顶部通知铃铛与站内通知中心 + +## Why + +当前顶部通知组件仍使用硬编码 mock 数据,通知按钮也未启用,运营人员无法统一查看余额预警、临期提醒、审批结果和系统告警。需要建立真实的站内通知数据链路,并在全局导航和通知中心之间保持未读状态同步。 + +## What Changes + +- 在顶部全局导航的设置按钮与用户头像入口区域增加通知铃铛。 +- 铃铛展示未读数量,统一格式为 `0`、`1`~`99` 或 `99+`。 +- 点击铃铛展示最近 10 条通知抽屉,支持全部、审批、临期、同步/系统分类。 +- 抽屉提供进入 `/notifications` 通知中心的入口。 +- 新增通知中心页面,支持分类、通知类型、严重级别、已读状态筛选和全部已读。 +- 点击通知时先调用标记已读接口,再根据受控 `ref_type` 获取目标并跳转;未知目标只展示通知正文,不执行任意 URL 跳转。 +- 接入未读数、未读摘要、通知列表、单条已读和全部已读接口。 +- 替换现有 `ArtNotification` 硬编码 mock 数据实现,并复用现有顶部布局和设置/头像区域的视觉位置。 + +## Impact + +- Affected specs: + - `notification-center` +- Affected code: + - `src/components/core/layouts/art-header-bar/index.vue` + - `src/components/core/layouts/art-notification/index.vue` + - `src/components/core/layouts/art-notification/style.scss` + - `src/views/notifications/index.vue` + - `src/api/modules/notification.ts` + - `src/types/api/notification.ts` + - 路由、菜单和通知相关权限配置 +- API contracts: + - `GET /api/admin/notifications/unread-count` + - `GET /api/admin/notifications/unread-summary` + - `GET /api/admin/notifications` + - `PUT /api/admin/notifications/{id}/read` + - `PUT /api/admin/notifications/read-all` + - `GET /api/admin/notifications/{id}/target` +- Dependencies: + - 后端按当前用户返回通知数据、未读统计和受控跳转目标。 + - 后端明确通知分类、类型、严重级别、已读状态和 `ref_type` 枚举。 + - 目标接口不得返回可被前端直接执行的任意外部 URL。 +- Out of scope: + - C 端 `/api/c/v1/notifications` 接口和页面。 + - 前端创建、编辑或删除通知。 + - 浏览器推送、WebSocket 或轮询之外的实时推送机制。 +- Breaking changes: + - 现有 `ArtNotification` mock 通知数据和无效的空操作按钮将被真实通知数据及接口行为替换。 diff --git a/openspec/changes/add-notification-center/specs/notification-center/spec.md b/openspec/changes/add-notification-center/specs/notification-center/spec.md new file mode 100644 index 0000000..6cd8d5a --- /dev/null +++ b/openspec/changes/add-notification-center/specs/notification-center/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Global Notification Bell + +The admin frontend SHALL provide a global notification bell in the top navigation near the settings and user avatar entries. + +#### Scenario: Display unread count + +- **GIVEN** 用户已登录后台 +- **WHEN** 顶部导航加载未读通知数量 +- **THEN** 铃铛 MUST call `GET /api/admin/notifications/unread-count` +- **AND** 数量 MUST display as `0`, `1` through `99`, or `99+` + +#### Scenario: Open recent notification drawer + +- **WHEN** 用户点击顶部通知铃铛 +- **THEN** 页面 MUST display the latest 10 notifications from the unread summary contract +- **AND** 抽屉 MUST provide 全部、审批、临期、同步/系统分类 +- **AND** 抽屉 MUST provide an entry to `/notifications` + +### Requirement: Notification Center Filtering + +The admin frontend SHALL provide a `/notifications` notification center with server-backed filtering. + +#### Scenario: Filter notifications + +- **WHEN** 用户打开通知中心或调整筛选条件 +- **THEN** 页面 MUST support category, notification type, severity and read-state filters +- **AND** 页面 MUST load results from `GET /api/admin/notifications` +- **AND** 页面 MUST display notification title, content, category, severity, read state and created time + +#### Scenario: Mark all notifications read + +- **WHEN** 用户点击全部已读 +- **THEN** 页面 MUST call `PUT /api/admin/notifications/read-all` +- **AND** 页面 MUST refresh the unread count and visible notification read states after success + +### Requirement: Notification Read State Synchronization + +The notification drawer and notification center SHALL keep read state and unread count synchronized with the backend. + +#### Scenario: Mark a notification read + +- **WHEN** 用户点击一条未读通知 +- **THEN** 页面 MUST call `PUT /api/admin/notifications/{id}/read` before navigation +- **AND** 页面 MUST refresh or update the shared unread count after the read request succeeds + +#### Scenario: Preserve read state after refresh + +- **GIVEN** 用户已完成单条已读或全部已读操作 +- **WHEN** 用户关闭抽屉、切换页面或刷新通知中心 +- **THEN** 页面 MUST use the latest backend response rather than restoring stale local unread state + +### Requirement: Controlled Notification Navigation + +The admin frontend SHALL navigate from notifications only through controlled internal targets. + +#### Scenario: Navigate to a valid notification target + +- **GIVEN** 通知包含受支持的 `ref_type` 或业务引用 +- **WHEN** 单条已读成功后页面请求 `GET /api/admin/notifications/{id}/target` +- **THEN** 页面 MUST navigate only to a target recognized by the frontend route table +- **AND** 页面 MUST preserve the target route parameters returned by the backend + +#### Scenario: Handle an unknown target safely + +- **GIVEN** 通知的 `ref_type` 未知、目标已删除或目标无权限 +- **WHEN** 页面处理通知点击 +- **THEN** 页面 MUST not navigate to an arbitrary URL +- **AND** 页面 MUST still display the notification content and a stable unavailable-target state + +### Requirement: Consistent Notification Categories + +The notification drawer and notification center SHALL present balance warnings, expiry reminders, approval results and system alerts through the same notification data contract. + +#### Scenario: Display supported notification types + +- **GIVEN** 后端返回余额预警、临期提醒、审批结果或系统告警 +- **WHEN** 用户查看抽屉或通知中心 +- **THEN** 页面 MUST display each item using its backend category, type and severity +- **AND** 页面 MUST not replace the item with hardcoded mock notification content diff --git a/openspec/changes/add-notification-center/tasks.md b/openspec/changes/add-notification-center/tasks.md new file mode 100644 index 0000000..d49d4fe --- /dev/null +++ b/openspec/changes/add-notification-center/tasks.md @@ -0,0 +1,39 @@ +## 1. Contract And Types + +- [x] 1.1 按产品文档落地通知项、未读统计、未读摘要、分页列表和受控目标响应字段。 +- [x] 1.2 按产品文档落地分类、通知类型、严重级别、已读状态和 `ref_type` 兼容类型。 +- [x] 1.3 新增通知中心菜单权限 `notifications:view`、单条已读权限 `notifications:read`、全部已读权限 `notifications:read_all`。 +- [x] 1.4 新增 `notification.ts` API service,覆盖未读数、摘要、列表、单条已读、全部已读和目标接口。 +- [x] 1.5 新增通知实体、筛选参数、分页响应和目标响应类型。 + +## 2. Global Header And Drawer + +- [x] 2.1 在顶部设置按钮与用户头像入口区域增加通知铃铛。 +- [x] 2.2 实现未读数量格式化为 `0`、`1`~`99` 或 `99+`。 +- [x] 2.3 顶部抽屉加载最近 10 条通知,并按全部、审批、临期、同步/系统分类展示。 +- [x] 2.4 替换 `ArtNotification` 静态 mock 数据,接入真实摘要接口。 +- [x] 2.5 提供进入 `/notifications` 通知中心的入口,并保证桌面端和移动端布局可用。 + +## 3. Notification Center + +- [x] 3.1 新增 `/notifications` 路由和通知中心页面。 +- [x] 3.2 支持分类、通知类型、严重级别和已读状态筛选。 +- [x] 3.3 展示通知标题、正文、严重级别、分类、时间和已读状态。 +- [x] 3.4 实现全部已读,并在成功后同步未读数和列表状态。 +- [x] 3.5 处理空列表、加载失败、权限失败和未知枚举值的稳定展示。 + +## 4. Read And Controlled Navigation + +- [x] 4.1 点击通知时先调用 `PUT /api/admin/notifications/{id}/read`。 +- [x] 4.2 已读成功后调用 `GET /api/admin/notifications/{id}/target` 获取受控目标。 +- [x] 4.3 仅允许跳转到后端返回且前端路由表认可的内部目标。 +- [x] 4.4 未知 `ref_type`、目标不存在或无权限时只展示正文,不执行任意 URL 跳转。 +- [x] 4.5 抽屉和通知中心的已读操作后刷新共享未读数。 + +## 5. Verification + +- [x] 5.1 验证余额、临期、审批和系统通知在抽屉及通知中心展示一致。 +- [x] 5.2 验证未读数在单条已读、全部已读、抽屉关闭和页面刷新后与接口结果一致。 +- [x] 5.3 验证通知点击不会跳转到任意外部或未注册 URL。 +- [x] 5.4 验证未知目标通知仍可查看正文且不会阻塞其他通知操作。 +- [x] 5.5 运行类型检查、lint、相关测试和构建。 diff --git a/openspec/changes/add-shop-contact-phone-search/proposal.md b/openspec/changes/add-shop-contact-phone-search/proposal.md new file mode 100644 index 0000000..e9f9201 --- /dev/null +++ b/openspec/changes/add-shop-contact-phone-search/proposal.md @@ -0,0 +1,23 @@ +# Change: 新增店铺联系电话精确搜索 + +## Why + +运营无法通过店铺联系电话快速定位目标店铺。店铺列表已支持名称、编号和层级等条件筛选,但缺少联系电话的精确检索入口。 + +## What Changes + +- 在店铺列表筛选区增加“联系电话”输入框,限制输入为 11 位数字。 +- 将有效联系电话以 `contact_phone` 参数传递给现有 `GET /api/admin/shops` 查询,保持原店铺分页响应结构。 +- 空联系电话不传 `contact_phone` 参数;非法号码不发起查询并显示输入错误提示。 +- 使用搜索栏现有查询和清空能力:查询从第一页加载;清空联系电话后保留其他筛选条件并恢复不带联系电话条件的店铺列表。 +- 保持有效联系电话精确匹配;支持加载中和空结果状态。 + +## Impact + +- Affected specs: `shop-management` +- Affected code: + - `src/types/api/shop.ts` + - `src/api/modules/shop.ts` + - `src/views/shop-management/list/index.vue` +- API contract: + - `GET /api/admin/shops?contact_phone=13800138000` diff --git a/openspec/changes/add-shop-contact-phone-search/specs/shop-management/spec.md b/openspec/changes/add-shop-contact-phone-search/specs/shop-management/spec.md new file mode 100644 index 0000000..70f5e1d --- /dev/null +++ b/openspec/changes/add-shop-contact-phone-search/specs/shop-management/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: Shop Contact Phone Search Input + +The shop management list SHALL provide a `联系电话` search input in its filter area. The input MUST only accept an 11-digit numeric phone number for a contact phone search. + +#### Scenario: Enter a valid contact phone + +- **GIVEN** 用户正在查看店铺列表筛选区 +- **WHEN** 用户输入 11 位数字联系电话 +- **THEN** 页面 MUST retain the entered phone number as the `contact_phone` search value +- **AND** 用户 MUST be able to use the existing query action to search + +#### Scenario: Prevent invalid contact phone query + +- **GIVEN** 用户输入的联系电话不是 11 位数字 +- **WHEN** 用户发起查询 +- **THEN** 页面 MUST NOT send a shop list request +- **AND** 页面 MUST display an input validation error + +### Requirement: Shop Contact Phone Query Contract + +The shop management list SHALL use `contact_phone` as an optional exact-match query parameter of `GET /api/admin/shops` while preserving the existing shop pagination response structure. + +#### Scenario: Query shops by exact contact phone + +- **GIVEN** 用户输入有效的 11 位联系电话 +- **WHEN** 用户发起店铺列表查询 +- **THEN** 系统 MUST request `GET /api/admin/shops` with `contact_phone` equal to the entered value +- **AND** 系统 MUST reset the list to the first page +- **AND** 页面 MUST render the returned shop pagination result + +#### Scenario: Omit empty contact phone from query + +- **GIVEN** 联系电话筛选值为空 +- **WHEN** 系统加载店铺列表 +- **THEN** 请求 MUST NOT include `contact_phone` + +### Requirement: Shop Contact Phone Search Reset and Result States + +The shop management list SHALL clear the contact phone search through its existing reset or input clear interaction without affecting other selected filters. The page SHALL preserve existing loading and empty-result behavior. + +#### Scenario: Clear contact phone while preserving other filters + +- **GIVEN** 用户已按联系电话和其他店铺条件查询 +- **WHEN** 用户清空联系电话并重新查询 +- **THEN** 系统 MUST request the list without `contact_phone` +- **AND** 系统 MUST retain the other selected filters + +#### Scenario: Display no matching shop result + +- **GIVEN** 用户输入有效的 11 位联系电话 +- **WHEN** 店铺接口返回空分页结果 +- **THEN** 页面 MUST render the existing empty table result state + +#### Scenario: Display loading while querying contact phone + +- **WHEN** 系统正在按有效联系电话请求店铺列表 +- **THEN** 页面 MUST render the existing list loading state until the request completes diff --git a/openspec/changes/add-shop-contact-phone-search/tasks.md b/openspec/changes/add-shop-contact-phone-search/tasks.md new file mode 100644 index 0000000..e17a9b9 --- /dev/null +++ b/openspec/changes/add-shop-contact-phone-search/tasks.md @@ -0,0 +1,22 @@ +## 1. Query Contract + +- [x] 1.1 在店铺列表查询参数类型中增加可选 `contact_phone`。 +- [x] 1.2 保持调用 `GET /api/admin/shops` 和原店铺分页响应结构。 + +## 2. Shop List Search UI + +- [x] 2.1 在店铺列表筛选区新增“联系电话”输入框,限制为 11 位数字。 +- [x] 2.2 通过现有查询按钮发起联系电话筛选,并在查询时重置至第一页。 +- [x] 2.3 通过现有清空能力移除联系电话筛选,保留其他筛选条件并恢复列表。 + +## 3. Validation and Results + +- [x] 3.1 空联系电话不传 `contact_phone` 参数。 +- [x] 3.2 联系电话不是 11 位数字时不发起请求并提示错误。 +- [x] 3.3 有效联系电话按精确值传递 `contact_phone`,并支持加载中与空结果状态。 + +## 4. Verification + +- [ ] 4.1 验证有效 11 位联系电话精确命中店铺。 +- [ ] 4.2 验证空值、清空、非法号码和无匹配结果行为正确。 +- [x] 4.3 运行相关前端校验,并执行 `openspec validate add-shop-contact-phone-search --strict`。 diff --git a/openspec/changes/add-system-config-management/design.md b/openspec/changes/add-system-config-management/design.md new file mode 100644 index 0000000..724b32b --- /dev/null +++ b/openspec/changes/add-system-config-management/design.md @@ -0,0 +1,26 @@ +## Context + +系统配置由后端注册,前端不能假设固定字段。列表接口返回控件提示、值类型、枚举和范围,更新接口要求配置 Key 与路径一致且值字符串化。 + +## Goals / Non-Goals + +- Goals: + - 在设置管理提供统一的模块筛选、分页和配置编辑入口。 + - 动态遵循后端配置元数据进行展示和校验。 + - 防止误修改只读配置和敏感配置脱敏值。 +- Non-Goals: + - 不在前端维护配置 Key 白名单或复制后端注册规则。 + - 不对 JSON 配置做业务语义解析。 + - 不绕过后端对已注册配置、范围和枚举的最终校验。 + +## Decisions + +- 列表数据读取 `data.list`,分页使用 `page`、`page_size` 和 `total`。 +- 编辑控件优先根据 `control` 渲染,`value_type` 作为值转换和校验兜底。 +- `bool` 以开关展示并提交 `true`/`false` 字符串;`int` 以数字输入展示并提交十进制字符串;其他类型以输入框或文本域提交字符串。 +- `readonly=true` 的配置不显示可编辑操作;`sensitive=true` 的值仅显示接口返回值,只有用户主动输入新值时才提交更新。 + +## Risks / Trade-offs + +- 未知控件提示可能无法映射到专用控件,使用文本输入作为安全兜底,并仍由后端校验。 +- 脱敏敏感值无法判断是否为完整原值,因此禁止把未修改的脱敏值再次提交。 diff --git a/openspec/changes/add-system-config-management/proposal.md b/openspec/changes/add-system-config-management/proposal.md new file mode 100644 index 0000000..11086ae --- /dev/null +++ b/openspec/changes/add-system-config-management/proposal.md @@ -0,0 +1,18 @@ +# Change: 增加系统配置管理 + +## Why + +系统配置接口已经提供受控配置的注册信息、控件提示和校验范围,设置管理需要提供统一入口,避免为每个配置模块单独维护页面和字段。 + +## What Changes + +- 在设置管理下新增系统配置页面和路由。 +- 接入系统配置列表查询和按配置 Key 更新接口。 +- 按后端返回的 `control`、`value_type`、枚举值及范围动态渲染编辑控件。 +- 只读配置不可编辑,更新请求统一提交 `{ key, value }`,其中 `value` 为字符串。 +- 敏感配置按接口脱敏值展示,保存时不覆盖未重新输入的脱敏值。 + +## Impact + +- Affected specs: `system-config-management` +- Affected code: `src/api/modules/systemConfig.ts`, `src/types/api/systemConfig.ts`, `src/views/settings/system-configs/index.vue`, `src/router/routesAlias.ts`, `src/router/routes/asyncRoutes.ts` diff --git a/openspec/changes/add-system-config-management/specs/system-config-management/spec.md b/openspec/changes/add-system-config-management/specs/system-config-management/spec.md new file mode 100644 index 0000000..0787c4d --- /dev/null +++ b/openspec/changes/add-system-config-management/specs/system-config-management/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: 受控系统配置列表 + +系统 SHALL 在设置管理提供受控系统配置列表,支持按 `module` 筛选和分页,并展示后端返回的配置元数据及当前值。 + +#### Scenario: 查询系统配置 + +- **WHEN** 用户进入系统配置页面或执行刷新 +- **THEN** 前端调用 `GET /api/admin/system-configs` +- **AND** 使用响应中的 `data.list`、`data.page`、`data.page_size` 和 `data.total` 渲染列表 +- **AND** 展示配置 Key、模块、说明、值、控件提示、注册状态、只读状态、敏感状态和更新时间 + +#### Scenario: 按模块筛选 + +- **WHEN** 用户选择 `carrier_callback` 或 `c2b.payment` +- **THEN** 查询请求携带对应的 `module` 参数 +- **AND** 页面从第一页展示筛选后的配置 + +### Requirement: 动态配置编辑 + +系统 SHALL 根据配置返回的 `control`、`value_type`、`enum_values`、`min` 和 `max` 元数据渲染编辑控件并进行前端基础校验;更新请求 SHALL 统一提交字符串形式的 `value`。 + +#### Scenario: 更新可编辑配置 + +- **WHEN** 用户修改非只读配置并提交 +- **THEN** 前端调用 `PUT /api/admin/system-configs/{key}` +- **AND** 请求体为 `{ "key": "配置Key", "value": "字符串化配置值" }` +- **AND** 成功后使用接口返回的配置数据更新列表 + +#### Scenario: bool 和 int 配置 + +- **WHEN** 配置类型分别为 `bool` 或 `int` +- **THEN** 页面分别使用开关或数字输入控件 +- **AND** bool 提交 `true`/`false` 字符串,int 提交十进制数字字符串 +- **AND** int 值违反 `min` 或 `max` 时阻止提交 + +#### Scenario: 枚举配置 + +- **WHEN** 配置返回非空 `enum_values` +- **THEN** 页面使用枚举选择控件 +- **AND** 只能提交允许的枚举值 + +### Requirement: 只读和敏感配置保护 + +系统 SHALL 禁止前端编辑只读配置;敏感配置 SHALL 展示接口返回的脱敏值,用户未主动输入新值时不得将该脱敏值再次提交。 + +#### Scenario: 只读配置 + +- **WHEN** 配置的 `readonly` 为 `true` +- **THEN** 页面禁用编辑控件和保存操作 + +#### Scenario: 敏感配置未修改 + +- **WHEN** 敏感配置仅展示脱敏值且用户未输入新值 +- **THEN** 页面不提交该配置的更新请求 diff --git a/openspec/changes/add-system-config-management/tasks.md b/openspec/changes/add-system-config-management/tasks.md new file mode 100644 index 0000000..3c71154 --- /dev/null +++ b/openspec/changes/add-system-config-management/tasks.md @@ -0,0 +1,20 @@ +## 1. API And Types + +- [x] 1.1 新增系统配置字段、查询参数和分页响应类型。 +- [x] 1.2 新增 `GET /api/admin/system-configs` 服务方法,读取 `data.list`。 +- [x] 1.3 新增 `PUT /api/admin/system-configs/{key}` 服务方法,提交 `{ key, value }`。 + +## 2. Settings Management Page + +- [x] 2.1 在设置管理下新增系统配置路由和菜单入口。 +- [x] 2.2 实现模块筛选、分页、刷新和配置列表展示。 +- [x] 2.3 根据配置元数据动态渲染开关、数字、枚举、文本和 JSON 控件。 +- [x] 2.4 实现只读配置禁用编辑、敏感值脱敏保护和字符串化提交。 +- [x] 2.5 展示配置注册状态、更新时间、范围和校验提示。 + +## 3. Verification + +- [x] 3.1 验证两个模块筛选和分页参数正确传递。 +- [x] 3.2 验证 bool、int、enum、string 和 json 配置的展示及提交值。 +- [x] 3.3 验证只读配置不能提交,敏感配置未修改时不会回传脱敏值。 +- [x] 3.4 运行类型检查、lint 和构建。 diff --git a/openspec/changes/align-audit-cross-view-navigation/design.md b/openspec/changes/align-audit-cross-view-navigation/design.md new file mode 100644 index 0000000..a3e70d8 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/design.md @@ -0,0 +1,52 @@ +## Context + +审计调查横跨平台、代理和企业三种身份,以及事件、资源、请求、业务链路、资金和外部交互等多种事实视角。后端通过内部资源 ID、主体安全 identifier、`investigation_refs`、Integration `linkage` 和 `fidelity` 明确表达可导航关系;前端的职责是原样传递这些稳定引用,而不是补全关系。 + +现有实现已经形成 `AuditInvestigationDrawer`、`AuditResourceSearchDialog` 和全局控制器,但业务页面仍需逐一核对入口覆盖、角色映射和降级规则。本设计将导航判断集中到共享目标解析层,展示仍由只读组件负责。 + +## Goals / Non-Goals + +- Goals: 用一个类型安全的目标模型表达所有允许的审计调查入口。 +- Goals: 完成文档中资产、组织、账号、交易、资金和调查节点的导航矩阵。 +- Goals: 保证平台、代理和企业仅调用各自允许的接口,并隐藏缺少稳定参数的入口。 +- Goals: 使用弹窗/右侧抽屉承载续查,锁定底层滚动并支持分页与空状态。 +- Non-Goals: 不修改后端链路生成、数据库、留存或授权规则。 +- Non-Goals: 不提供恢复、重试、处置、封禁、修改、删除或导出。 +- Non-Goals: 不把旧 operation log 合并进新审计链路。 +- Non-Goals: 不从 Access Log、历史队列、自由文本或时间邻近关系自动发现链路。 + +## Decisions + +- Decision: 使用判别联合类型描述 `event/actor/resource/request/correlation/finance/integration/agent/enterprise` 调查目标;调用方只能提交该目标模型,不能提交任意 URL。 +- Decision: 使用全局调查宿主承载共享抽屉和精确资源弹窗。业务页面操作只负责构造目标,不各自维护弹窗、加载和滚动状态。 +- Decision: 独立的审计中心列表与详情可以保留路由;跨视角时间线和关联详情使用抽屉。移除旧时间线路由、别名和所有指向它的跳转。 +- Decision: 平台资源入口优先使用业务响应中的内部稳定 ID。只有调用上下文没有资源 ID、但明确持有 `iot_card/device/shop/order/refund` 的业务 Key 时,才进入精确资源搜索。 +- Decision: 精确资源搜索不展示 `resource_id` 或身份快照中的内部关联 ID。候选项只展示按资源类型白名单选择的业务字段;选中后在内部原样使用返回的 `resource_type/resource_id`。 +- Decision: 代理使用 ICCID、VirtualNo、分配单号、换货单号、店铺编号或企业编号;企业只使用当前授权卡的 ICCID 或设备 VirtualNo。主体身份和范围不作为前端参数。 +- Decision: `investigation_refs` 是来源详情续查的权威来源。字段缺失、目标与当前详情相同或 `fidelity=false` 时隐藏对应操作;共享调查抽屉内部保持只读,不重复嵌套一组调查按钮。 +- Decision: 请求和业务链路 ID 默认不展示原值;仅在明确的超级管理员开发调查上下文中允许复制真实 request ID,且不得由前端生成。 +- Decision: 资金目标只提交一个已知稳定条件,其他订单、支付、退款、充值和钱包关联由服务端解析;金额始终按分保存在应用数据中。 +- Decision: `401/403`、授权撤销或主体资源不存在时显示中性“活动不可用”,不回退平台接口、精确资源搜索或旧 operation log。 +- Decision: 所有抽屉使用 `append-to-body`、modal 和 scroll lock,关闭后销毁内容;分页切换保留目标和筛选,切换目标时清空旧数据。 + +## Risks / Trade-offs + +- 业务页面数量多,容易出现入口漏接或使用错误字段。通过集中目标解析函数和表驱动测试覆盖文档矩阵。 +- 全局抽屉减少重复代码,但必须防止上一目标的数据闪现。打开新目标前先清空状态,并以当前请求标识忽略过期响应。 +- 精确资源搜索结果来自当前表或历史快照;仅展示白名单字段会减少调试信息,但可以避免泄露内部 ID 和不稳定结构。 +- 本变更与 `add-audit-chain-frontend-integration` 修改同一组件目录。实施前必须以其最新状态为基线,避免覆盖已完成的 UI 调整。 + +## Migration Plan + +1. 固化共享调查目标类型、控制器和角色解析函数,并为现有入口提供兼容适配。 +2. 将仍指向旧时间线路由的入口切换为全局调查宿主,确认无引用后删除旧页面、路由和别名。 +3. 按文档矩阵依次接入资产组织页、账号交易页、资金页和调查节点续查。 +4. 收紧精确搜索触发条件、主体视角和空标识降级规则。 +5. 完成角色矩阵、参数原样传递、禁止推断、弹窗滚动和过期请求测试。 +6. 若出现回归,可按业务模块隐藏新入口;不恢复任意 URL 跳转或 ID 推断逻辑。 + +## Open Questions + +- 账号、代理充值、资产分配、换货、店铺资金概况等尚未接入页面的最终按钮权限是否沿用资源时间线/资金时间线权限,还是增加业务入口专用权限? +- 超级管理员是否需要一个显式的 Access Log request ID 粘贴入口;若不需要,前端应完全移除手工 request 查询。 +- `historical=true` 在产品要求隐藏“匹配来源”后,是否仍需用非字段式的警告图标提示资源可能已删除? diff --git a/openspec/changes/align-audit-cross-view-navigation/implementation-audit.md b/openspec/changes/align-audit-cross-view-navigation/implementation-audit.md new file mode 100644 index 0000000..c25d855 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/implementation-audit.md @@ -0,0 +1,41 @@ +## 实施差异核对 + +### 已接入矩阵 + +| 来源 | 平台视角 | 代理视角 | 企业视角 | 稳定字段 | +| --- | --- | --- | --- | --- | +| IoT 卡列表/统一资产详情 | 资源时间线 | 主体活动 | 主体活动 | `id` / `iccid` | +| 设备列表/统一资产详情 | 资源时间线 | 主体活动 | 主体活动 | `id` / `virtual_no` | +| 设备绑定卡 | 卡资源时间线、绑定资源时间线 | 卡主体活动 | 卡主体活动 | `card_id` / `iccid` | +| 资产分配列表/详情 | 分配记录、资产资源时间线 | 分配活动 | 不提供 | `id`、`asset_id` / `allocation_no` | +| 换货列表/详情 | 换货记录、旧/新资产时间线 | 换货活动 | 不提供 | `id`、资产 ID / `exchange_no` | +| 店铺列表 | 店铺资源、店铺资金链路 | 店铺活动 | 不提供 | `id` / `shop_no` | +| 企业列表 | 企业资源 | 企业活动 | 不提供 | `id` / `enterprise_no` | +| 账号列表 | 账号资源 | 不提供 | 不提供 | `id` | +| 订单列表/详情 | 订单资源、订单资金链路 | 不提供 | 不提供 | `id`、`order_id` | +| 退款列表/详情 | 退款资源、退款资金链路、审批资源 | 不提供 | 不提供 | `id`、`refund_id`、`approval_instance_id` | +| 代理充值列表/详情 | 充值资源、充值资金链路、审批资源 | 不提供 | 不提供 | `id`、`recharge_id`、`approval_instance_id` | +| 资产钱包 | 钱包资金链路、资产资源 | 不提供 | 不提供 | `wallet_id`、`asset_type + asset_id` | +| 店铺资金概况 | 店铺资金链路 | 不提供 | 不提供 | `shop_id` | + +所有入口先独立校验自身权限和完整参数;一个入口缺字段不会隐藏同一行的其他有效入口。平台资源只使用非零内部 ID,代理和企业活动只使用文档规定的业务标识。 + +### 共享调查与降级 + +- 事件、操作者、资源、请求、业务链路、资金、Integration、代理活动和企业活动均由全局调查宿主打开。 +- 精确注册资源只在调查引用缺少 `resource_id`、但包含受支持类型及业务 Key 时出现;业务列表已有内部 ID 时不再提供搜索按钮。 +- 精确搜索限制为 `iot_card/device/shop/order/refund`,候选项不显示 Registry ID 或快照内部关联 ID。 +- 目标切换会清空旧内容并忽略过期响应;关闭会销毁内容,抽屉和弹窗锁定底层滚动。 +- 缺字段、空白值、零值、不支持的主体资源类型和撤销授权均不推断或回退到其他视角。 + +### 受响应字段限制的入口 + +- 钱包流水响应只有 `asset_identifier`,没有完整的 `asset_type + asset_id`,因此不从流水行推断资产审计目标。 +- 当前路由中没有独立的账号详情和店铺详情页面,对应入口保留在列表。 +- 风险/链路/资金节点抽屉内部不增加继续调查按钮:产品要求移除弹窗内无意义按钮;事件详情仍使用权威 `investigation_refs` 提供跨视角入口。 + +### 路由核对 + +- 独立的审计事件和外部交互列表/详情路由保留。 +- 操作者、资源、请求、业务链路、资金及主体活动不再使用独立时间线路由或任意 URL 跳转。 +- 审计中心顶级菜单排列在财务管理之后。 diff --git a/openspec/changes/align-audit-cross-view-navigation/proposal.md b/openspec/changes/align-audit-cross-view-navigation/proposal.md new file mode 100644 index 0000000..d80646d --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/proposal.md @@ -0,0 +1,40 @@ +# Change: 对齐审计跨视角调查与前端导航契约 + +## Why + +现有审计中心已经接入事件、资源、链路、资金、风险和外部交互等只读查询,但业务页面入口、调查节点续查、平台/代理/企业视角以及缺失标识时的降级行为仍缺少统一约束。若各页面继续自行拼装目标参数,容易把内部 ID、业务 Key、主体安全标识或链路 ID 混用,并产生越权回退、错误串链和重复页面。 + +本变更以《跨视角调查与前端导航契约.md》为导航事实来源,在现有 `add-audit-chain-frontend-integration` 能力之上建立统一的跨视角导航规范和复用组件。 + +## What Changes + +- 建立统一的审计调查目标模型和导航控制器,覆盖事件详情、操作者行为、资源审计、请求链路、业务链路、资金链路、外部交互以及代理/企业资源活动。 +- 调查时间线、链路和外部交互续查统一使用可复用抽屉或弹窗,不再新增独立时间线跳转页面;同一目标不重复打开自身详情。 +- 按“内部稳定 ID → 主体安全业务标识 → 返回的调查引用 → 精确资源搜索 → 隐藏入口”的顺序解析入口,禁止从名称、摘要、时间、编号前缀或邻近记录推断关联。 +- 对齐资产、组织、账号、订单、退款、充值、钱包和资金页面的逐行入口矩阵,并按平台、代理、企业身份选择不同接口和参数。 +- 平台仅在缺少内部资源 ID、但持有允许搜索的 Registry Key 时调用精确资源搜索;零命中或多命中均保留用户选择,不自动猜测。 +- 所有弹窗和抽屉只显示业务可读字段,内部资源 ID、request/correlation 等技术标识仅作为查询参数使用,除明确的开发调查场景外不直接展示。 +- 统一处理空引用、`fidelity=false`、历史快照、授权撤销、归档窗口和接口拒绝,不回退旧日志、平台接口或其他主体接口。 +- 补充跨页面入口矩阵、参数映射、角色隔离和禁止推断的自动化测试。 + +## Impact + +- Affected specs: `audit-cross-view-navigation` +- Depends on: `add-audit-chain-frontend-integration` +- Affected code: + - `src/components/business/audit/` 调查抽屉、精确资源弹窗和全局导航控制器 + - `src/utils/business/auditNavigation.ts` 角色与目标解析 + - 审计事件、风险、外部交互页面的调查入口 + - 卡、设备、设备卡槽、统一资产、分配、换货、店铺、企业、账号、订单、退款、充值、钱包及资金列表/详情 + - 路由、权限和通知目标导航 +- API contracts: + - `GET /api/admin/audit/*` + - `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}` + - `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}` + - 现有业务列表与详情接口返回的稳定字段 +- Source of truth: + - `docs/产品迭代8月份/跨视角调查与前端导航契约.md` + - `docs/产品迭代8月份/默认模块.openapi.json` +- Breaking changes: + - 移除旧的独立审计时间线路由及其导航方式,统一改为抽屉/弹窗调查。 + - 入口缺少契约要求的稳定标识时将被隐藏,不再容忍前端推断或降级调用其他视角接口。 diff --git a/openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md b/openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md new file mode 100644 index 0000000..e219fe5 --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/specs/audit-cross-view-navigation/spec.md @@ -0,0 +1,130 @@ +## ADDED Requirements + +### Requirement: Shared Cross-View Investigation Surface + +The frontend SHALL open actor, resource, request, correlation, finance, Integration, agent-activity and enterprise-activity investigations through reusable modal or drawer components. It SHALL NOT require a standalone timeline route for these cross-view investigations. + +#### Scenario: Open an investigation from a business row + +- **WHEN** an authorized user selects an audit or activity action from a supported business row +- **THEN** the frontend MUST construct a typed investigation target from the row's documented stable fields +- **AND** it MUST open the shared investigation surface without navigating to a standalone timeline page + +#### Scenario: Replace an open investigation target + +- **WHEN** the user opens a second investigation after another target was loaded +- **THEN** the shared surface MUST clear stale content before loading the new target +- **AND** a late response from the previous target MUST NOT replace the current result + +### Requirement: Deterministic Investigation Target Resolution + +The frontend SHALL resolve investigation targets in this order: a platform internal stable ID for a platform resource timeline, a documented subject-safe business identifier for agent or enterprise activity, an explicit API investigation reference, an exact Registry resource-key search when no internal resource ID exists, or no entry. It SHALL NOT infer a target from display text or proximity. + +#### Scenario: A platform resource ID is available + +- **WHEN** a platform business response contains the documented non-zero internal resource ID +- **THEN** the frontend MUST call the resource timeline with the mapped resource type and that ID +- **AND** it MUST NOT search by the display name or business Key to replace the available ID + +#### Scenario: Only a supported Registry Key is available + +- **WHEN** a platform investigation reference contains a supported resource type and business Key but no resource ID +- **THEN** the frontend MUST use the exact resource search endpoint +- **AND** zero or multiple matches MUST remain unresolved until the user makes an explicit choice + +#### Scenario: No stable target exists + +- **WHEN** the required internal ID, subject identifier or explicit investigation reference is missing +- **THEN** the frontend MUST hide or disable the entry +- **AND** it MUST NOT infer a relationship from a name, summary, timestamp, identifier prefix, adjacent record or current-account profile + +### Requirement: Exact Registered Resource Selection + +The frontend SHALL support exact Registry lookup only for `iot_card`, `device`, `shop`, `order` and `refund`. It SHALL pass the selected candidate's returned `resource_type/resource_id` unchanged to the resource timeline while keeping internal identifiers out of the presentation. + +#### Scenario: Present an exact resource candidate + +- **WHEN** exact lookup returns a candidate with `identity_snapshot` +- **THEN** the frontend MUST display only a resource-type-specific whitelist of business-readable fields +- **AND** it MUST NOT display `resource_id` or snapshot fields that are internal IDs or internal foreign keys + +#### Scenario: Select a historical candidate + +- **WHEN** a user selects a candidate with `historical=true` and a stable returned resource ID +- **THEN** the frontend MUST use the returned type and ID without modification +- **AND** it MUST NOT claim that the resource still exists in the current business table + +### Requirement: Role-Isolated Resource Activity Navigation + +The frontend SHALL choose platform, agent or enterprise resource investigation APIs solely from the authenticated subject and the documented source-page fields. Agent and enterprise navigation SHALL NOT fall back to platform audit or Registry search. + +#### Scenario: Open agent activity + +- **WHEN** an agent opens a supported card, device, allocation, exchange, shop or enterprise activity +- **THEN** the frontend MUST use the documented ICCID, VirtualNo or business number with the agent activity endpoint +- **AND** it MUST NOT send agent identity or shop scope as proof of authorization + +#### Scenario: Open enterprise activity + +- **WHEN** an enterprise user opens activity for a currently authorized card or device +- **THEN** the frontend MUST use ICCID or VirtualNo with the enterprise activity endpoint +- **AND** it MUST NOT use the enterprise route parameter or a platform internal ID as authorization evidence + +#### Scenario: Subject activity becomes unavailable + +- **WHEN** authorization is revoked, the resource is out of scope, or the subject endpoint rejects access +- **THEN** the frontend MUST present an activity-unavailable state +- **AND** it MUST NOT retry through a platform endpoint, Registry search or legacy operation log + +### Requirement: Business and Finance Entry Matrix + +The frontend SHALL implement the documented row-level navigation matrix for asset, organization, account, order, refund, recharge, wallet and fund-summary contexts. Each action SHALL be independently visible only when its complete stable parameter set and required permission are available. + +#### Scenario: Open resource and finance investigations independently + +- **WHEN** a business record contains both a resource ID and a stable finance condition +- **THEN** the frontend MUST offer the applicable resource audit and finance investigation actions independently +- **AND** failure or absence of one parameter set MUST NOT suppress the other valid action + +#### Scenario: Let the server resolve finance relationships + +- **WHEN** the frontend opens finance history from an order, refund, recharge, wallet or shop +- **THEN** it MUST submit only the stable condition explicitly returned by the source business API +- **AND** it MUST NOT derive or supplement payment, refund, wallet, approval or third-party identifiers from text or number prefixes + +### Requirement: Investigation Reference Continuation + +The frontend SHALL treat `investigation_refs` and documented Integration references on supported source details as the authoritative sources for continuing an investigation. It SHALL expose only actions whose complete required reference is present and reliable. Shared investigation drawers SHALL remain read-only and SHALL NOT repeat nested investigation action groups inside their result nodes. + +#### Scenario: Continue from a complete reference + +- **WHEN** a supported source detail contains a complete actor, resource, request, correlation or Integration reference +- **THEN** the frontend MUST pass its type and identifier unchanged to the matching shared investigation surface + +#### Scenario: Display a result inside the shared drawer + +- **WHEN** a shared investigation drawer renders an event, resource, link, finance or Integration result +- **THEN** it MUST present that result as read-only evidence without nested investigation action buttons +- **AND** it MUST preserve documented source and reference-only boundaries in the displayed facts + +#### Scenario: Suppress unavailable or self-referential navigation + +- **WHEN** a reference is incomplete, its fidelity is false, or its event target is the detail already being displayed +- **THEN** the frontend MUST hide the corresponding action +- **AND** it MUST NOT create a substitute identifier or reopen the same detail + +### Requirement: Read-Only Presentation and Scroll Isolation + +All shared investigation surfaces SHALL remain read-only, hide non-business internal identifiers by default, and isolate modal scrolling from the underlying page. + +#### Scenario: Display an investigation surface + +- **WHEN** a modal or drawer is open +- **THEN** it MUST use a modal overlay, append to the document body, and lock underlying-page scrolling +- **AND** it MUST remain usable within its own scroll container at supported viewport sizes + +#### Scenario: Inspect a read-only result + +- **WHEN** audit, activity, finance, link or Integration data is displayed +- **THEN** the UI MUST NOT expose mutation, recovery, retry, deletion, disposition, blocking or export actions +- **AND** raw internal IDs MUST be retained only as navigation parameters unless an explicitly authorized developer-investigation workflow requires display diff --git a/openspec/changes/align-audit-cross-view-navigation/tasks.md b/openspec/changes/align-audit-cross-view-navigation/tasks.md new file mode 100644 index 0000000..b7e144d --- /dev/null +++ b/openspec/changes/align-audit-cross-view-navigation/tasks.md @@ -0,0 +1,44 @@ +## 1. Contract Audit and Shared Navigation + +- [x] 1.1 建立文档导航矩阵与当前页面入口的差异清单,覆盖资产组织、账号交易、资金和调查节点。 +- [x] 1.2 完善共享调查目标判别联合类型,覆盖事件、操作者、资源、请求、业务链路、资金、Integration、代理和企业活动。 +- [x] 1.3 将共享导航控制器改为唯一弹窗/抽屉打开入口,并处理目标切换、请求竞态、分页和关闭清理。 +- [x] 1.4 全局搜索并移除旧时间线路由、路由别名、页面和任意 URL 式审计跳转。 + +## 2. Stable Reference and Search Rules + +- [x] 2.1 实现平台内部 ID、主体业务 identifier、调查引用、资源 Key 搜索和隐藏入口的优先级解析。 +- [x] 2.2 收紧精确资源搜索,仅允许 `iot_card/device/shop/order/refund` 和文档规定的精确业务 Key。 +- [x] 2.3 精确资源结果只展示资源类型白名单业务字段,隐藏 `resource_id` 及快照内部关联 ID,并原样使用返回目标打开资源时间线。 +- [x] 2.4 对零命中、多命中、历史快照和缺少稳定资源 ID 分别提供不推断的降级状态。 +- [x] 2.5 对空引用、同目标引用和 `fidelity=false` 隐藏对应调查操作。 + +## 3. Asset and Organization Entry Matrix + +- [x] 3.1 对齐卡、设备、设备卡槽和统一资产详情的平台/代理/企业入口与稳定字段映射。 +- [x] 3.2 对齐资产分配、换货、店铺和企业列表/详情入口及角色范围。 +- [x] 3.3 对齐企业卡和企业设备列表入口,确保企业只调用主体活动接口且不使用路由企业 ID 证明授权。 +- [x] 3.4 缺少内部 ID、ICCID、VirtualNo 或业务编号时隐藏对应入口,不回退其他视角。 + +## 4. Account, Transaction, and Finance Entry Matrix + +- [x] 4.1 接入账号、店铺、企业、订单和退款的资源审计入口。 +- [x] 4.2 接入订单、退款、代理充值、资产钱包、店铺资金概况和钱包流水的资金链路入口。 +- [x] 4.3 对审批实例、钱包资源和流水资产引用实行独立字段完整性判断,缺少一组字段不影响其他有效入口。 +- [x] 4.4 资金查询仅传入当前业务返回的稳定条件,不由前端补齐 Payment、Refund、钱包或第三方流水关联。 + +## 5. Investigation Node Continuation + +- [x] 5.1 从支持的来源详情按 `investigation_refs` 打开共享调查抽屉,调查弹窗内部保持只读且不嵌套无意义按钮。 +- [x] 5.2 支持事件、操作者、资源、请求、业务链路和外部交互的非空引用,并抑制跳回当前详情的无意义入口。 +- [x] 5.3 请求/业务链路节点保留 `record_source` 和 `reference_only` 事实边界,不把投递成功展示为业务成功。 +- [x] 5.4 通知 Integration 目标继续使用受控 target 解析,不接受任意 URL 或数据库主键替代稳定 Integration ID。 + +## 6. Authorization, Presentation, and Verification + +- [x] 6.1 按平台、代理、企业及按钮权限验证所有入口可见性和接口选择。 +- [x] 6.2 验证抽屉 append-to-body、遮罩、底层滚动锁定、关闭销毁、分页和移动端布局。 +- [x] 6.3 为目标解析、参数原样传递、空值隐藏、同目标抑制、`fidelity=false` 和禁止推断增加单元测试。 +- [x] 6.4 为文档中资产组织和账号资金导航矩阵增加表驱动测试。 +- [ ] 6.5 验证 `400/401/403/404/500`、授权撤销、历史快照、归档窗口和过期异步响应处理。 +- [ ] 6.6 回归现有审计中心、风险中心、外部交互、通知导航和旧 operation log,确认只读边界未被破坏。 diff --git a/openspec/changes/enforce-audit-subject-api-boundaries/proposal.md b/openspec/changes/enforce-audit-subject-api-boundaries/proposal.md new file mode 100644 index 0000000..8395fbe --- /dev/null +++ b/openspec/changes/enforce-audit-subject-api-boundaries/proposal.md @@ -0,0 +1,18 @@ +# Change: 收紧审计接口的主体访问边界 + +## Why + +平台 `/api/admin/audit/*` 接口仅允许超级管理员和平台账号访问。当前部分业务入口未传递当前账号类型,可能让代理或企业用户构造平台资源时间线目标;平台审计页面和调查抽屉也缺少统一的前端请求拦截。 + +## What Changes + +- 平台审计中心、平台资源时间线、资金链路、请求链路、风险和 Integration 等 `/api/admin/audit/*` 调用只允许超级管理员或平台账号发起。 +- 代理账号仅通过代理主体活动接口查看其支持资源;企业账号仅通过企业主体活动接口查看卡和设备资源。 +- 业务入口基于当前登录账号类型选择对应权限与调查目标,账号、店铺等不受主体活动支持的资源不向代理或企业显示平台审计入口。 +- 在共享调查加载层增加防御性校验,越权目标不发起网络请求并提示不可用。 +- 保持审计能力严格只读:所有相关前端 API 保持 GET,不新增修改、删除、导出、恢复、重试、补偿或风险处置操作。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: `src/components/business/audit/`, `src/utils/business/auditNavigation.ts`, audit routes/views, and business pages with audit-entry buttons including account, shop, asset, order, refund and wallet views. diff --git a/openspec/changes/enforce-audit-subject-api-boundaries/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/enforce-audit-subject-api-boundaries/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..20e15ea --- /dev/null +++ b/openspec/changes/enforce-audit-subject-api-boundaries/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: 审计接口按当前主体隔离 + +前端 SHALL 仅允许超级管理员和平台账号调用 `/api/admin/audit/*` 平台审计接口。代理账号 MUST 仅调用其支持资源的代理主体活动接口;企业账号 MUST 仅调用卡和设备资源的企业主体活动接口。共享调查加载层 MUST 在请求前拒绝不匹配当前主体的调查目标。 + +#### Scenario: 代理或企业用户访问业务审计入口 + +- **WHEN** 代理或企业用户打开业务列表、详情或资源信息 +- **THEN** 前端仅显示当前主体支持的活动入口 +- **AND** 前端不请求任何 `/api/admin/audit/*` 接口 + +#### Scenario: 平台用户访问审计调查 + +- **WHEN** 超级管理员或平台账号打开平台审计、资源时间线、资金链路、请求链路、风险或 Integration 调查 +- **THEN** 前端使用对应的 `/api/admin/audit/*` GET 接口 + +### Requirement: 审计调查仅提供只读能力 + +审计调查前端 SHALL 仅使用 GET 请求读取平台审计或主体活动数据,且 MUST NOT 提供修改、删除、导出、恢复、重试、补偿或风险处置操作。 + +#### Scenario: 用户查看审计或主体活动记录 + +- **WHEN** 用户查询任一审计或主体活动视图 +- **THEN** 页面只展示只读数据和受控跳转 +- **AND** 页面不展示或调用任何写入、导出、恢复、重试、补偿或风险处置操作 diff --git a/openspec/changes/enforce-audit-subject-api-boundaries/tasks.md b/openspec/changes/enforce-audit-subject-api-boundaries/tasks.md new file mode 100644 index 0000000..7cdc1fc --- /dev/null +++ b/openspec/changes/enforce-audit-subject-api-boundaries/tasks.md @@ -0,0 +1,16 @@ +## 1. Shared Access Rules + +- [x] 1.1 Add shared current-user access checks for platform audit and subject activity APIs. +- [x] 1.2 Guard the shared investigation loader so it does not request a platform audit API for agent or enterprise users. + +## 2. Entry Points and Pages + +- [x] 2.1 Update business audit entry points to pass current user type and use only supported subject activity targets for agents and enterprises. +- [x] 2.2 Hide or block platform audit routes and actions for agent and enterprise users. +- [x] 2.3 Keep finance, risk, Integration and link-timeline entries platform-only. + +## 3. Read-only Verification + +- [x] 3.1 Verify all audit and subject-activity methods used by the UI are GET-only. +- [x] 3.2 Verify platform, agent and enterprise users cannot trigger the wrong API family. +- [x] 3.3 Run lint and type checks for changed files. diff --git a/openspec/changes/enrich-actor-timeline-detail/proposal.md b/openspec/changes/enrich-actor-timeline-detail/proposal.md new file mode 100644 index 0000000..f9846ae --- /dev/null +++ b/openspec/changes/enrich-actor-timeline-detail/proposal.md @@ -0,0 +1,16 @@ +# Change: 补全操作者行为时间线事件信息 + +## Why + +操作者行为时间线接口已返回完整审计事件字段,但当前调查抽屉仅展示动作、结果、摘要、操作者名称和来源,无法支持对操作者所属组织、业务范围、请求上下文、批次结果、失败原因和关联资源的有效追溯。 + +## What Changes + +- 在操作者行为时间线的每个事件节点展示接口返回的分类、风险、操作者身份与组织快照、业务范围、请求摘要、批次统计和错误摘要。 +- 展示关联资源的名称、类型、关系、业务角色和主体摘要;将资源快照与前后变更数据放入按需展开的只读区域。 +- 对缺失或空字段使用明确的空态,不从名称、时间或相邻事件推断数据;保留分页、排序与只读边界。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue` diff --git a/openspec/changes/enrich-actor-timeline-detail/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/enrich-actor-timeline-detail/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..948e0a8 --- /dev/null +++ b/openspec/changes/enrich-actor-timeline-detail/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: 操作者行为时间线完整事件展示 + +前端 SHALL 在操作者行为时间线中展示接口返回的事件分类、动作、结果、风险、摘要、操作者身份及所属组织快照、业务范围、批次统计、错误摘要和关联资源;分类、来源、操作者和业务范围 MUST 在同一行展示。请求摘要与扩展元数据 MUST 仅向超级管理员展示。资源身份快照、主体安全数据及变更前后数据 MUST 以按需展开的只读区域展示。 + +#### Scenario: 查看包含完整审计字段的操作者事件 + +- **WHEN** `/api/admin/audit/actors/{kind}/{id}/events` 返回包含操作者、范围、请求、批次、错误和资源字段的事件 +- **THEN** 前端在对应时间线节点中展示这些字段的业务可读值 +- **AND** 将资源快照与变更数据放入可折叠的只读区域 + +#### Scenario: 非超级管理员查看事件 + +- **WHEN** 非超级管理员打开操作者行为时间线 +- **THEN** 前端不展示请求上下文和扩展元数据 +- **AND** 其余允许展示的事件字段保持只读 + +#### Scenario: 字段缺失或资源为空 + +- **WHEN** 事件中可选字段为空或没有关联资源 +- **THEN** 前端显示明确空态或隐藏对应区域 +- **AND** 不从名称、时间或其他事件推断或补造数据 + +#### Scenario: 保持只读与分页语义 + +- **WHEN** 用户查看或翻页操作者行为时间线 +- **THEN** 前端继续使用文档规定的分页与排序结果 +- **AND** 不提供修改、删除、导出、恢复、重试、补偿或风险处置操作 diff --git a/openspec/changes/enrich-actor-timeline-detail/tasks.md b/openspec/changes/enrich-actor-timeline-detail/tasks.md new file mode 100644 index 0000000..ef5ffe0 --- /dev/null +++ b/openspec/changes/enrich-actor-timeline-detail/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation + +- [x] 1.1 为操作者行为时间线事件节点补充接口已返回的事件、操作者、范围、请求、批次和错误字段。 +- [x] 1.2 以可折叠只读区域展示关联资源与快照/变更数据,并处理空值和无资源场景。 +- [x] 1.3 保持资源、请求和关联标识只读展示,不新增跳转或写操作。 + +## 2. Verification + +- [x] 2.1 验证接口返回字段均按文档语义显示且不改变分页和排序。 +- [x] 2.2 运行格式化、类型检查和 ESLint。 diff --git a/openspec/changes/enrich-correlation-timeline-detail/proposal.md b/openspec/changes/enrich-correlation-timeline-detail/proposal.md new file mode 100644 index 0000000..e6917f1 --- /dev/null +++ b/openspec/changes/enrich-correlation-timeline-detail/proposal.md @@ -0,0 +1,17 @@ +# Change: 补全业务关联链路节点展示 + +## Why + +业务关联链路弹窗当前每个节点仅显示标题、结果、摘要和事实来源,无法清晰查看节点编码、关联范围和资源引用。操作者行为时间线已采用结构化、按需展开的只读展示,业务关联链路需要使用相同的视觉层级,但不能假设接口返回了操作者事件专有字段。 + +## What Changes + +- 基于 `AuditLinkTimelineNode` 的实际返回字段,使用与操作者行为时间线一致的卡片、标签和基础信息单行布局展示业务关联节点。 +- 仅展示节点实际返回的事实来源和稳定编码;关联链路、父审计事件、请求和资源内部 ID 不在界面显示,数据完整性信息仅超级管理员可见。 +- 当节点返回资源引用时,以折叠的只读区域展示资源名称、类型和业务 Key;不推断资源快照、操作者、风险或变更数据。 +- 保持业务关联链路全量查询、排序、只读和无额外跳转的现有语义。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue` diff --git a/openspec/changes/enrich-correlation-timeline-detail/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/enrich-correlation-timeline-detail/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..cba1380 --- /dev/null +++ b/openspec/changes/enrich-correlation-timeline-detail/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: 业务关联链路结构化节点展示 + +前端 SHALL 在业务关联链路中以与操作者行为时间线一致的层级展示每个 `AuditLinkTimelineNode`:节点标题、结果、事实来源和稳定编码 MUST 使用接口实际返回值;基础信息 MUST 在同一行展示。关联链路、父审计事件、请求和资源内部 ID MUST 不在界面显示;数据完整性信息 MUST 仅向超级管理员展示。资源引用 MUST 按需在折叠的只读区域展示。 + +#### Scenario: 查看包含资源引用的关联链路节点 + +- **WHEN** 业务关联链路返回含资源引用和关联标识的节点 +- **THEN** 前端展示节点基础信息并提供只读、可折叠的资源引用区域 +- **AND** 不推断操作者、分类、风险、资源快照或变更数据 + +#### Scenario: 非超级管理员查看关联链路 + +- **WHEN** 非超级管理员打开业务关联链路弹窗 +- **THEN** 前端不展示请求标识和数据完整性信息 +- **AND** 继续展示其余允许的节点字段 + +#### Scenario: 保持只读链路语义 + +- **WHEN** 用户查看业务关联链路 +- **THEN** 前端不提供修改、删除、导出、恢复、重试、补偿或风险处置操作 diff --git a/openspec/changes/enrich-correlation-timeline-detail/tasks.md b/openspec/changes/enrich-correlation-timeline-detail/tasks.md new file mode 100644 index 0000000..ff25ee3 --- /dev/null +++ b/openspec/changes/enrich-correlation-timeline-detail/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation + +- [x] 1.1 为业务关联链路节点增加与操作者时间线一致的基础信息单行布局。 +- [x] 1.2 隐藏关联链路中的所有 ID,并按超级管理员边界展示数据完整性信息。 +- [x] 1.3 对接口返回的资源引用增加折叠的只读展示,并处理空字段。 + +## 2. Verification + +- [x] 2.1 验证只使用 `AuditLinkTimelineNode` 已返回字段,不补造操作者事件字段。 +- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。 diff --git a/openspec/changes/enrich-finance-timeline-detail/proposal.md b/openspec/changes/enrich-finance-timeline-detail/proposal.md new file mode 100644 index 0000000..6554002 --- /dev/null +++ b/openspec/changes/enrich-finance-timeline-detail/proposal.md @@ -0,0 +1,17 @@ +# Change: 补全财务审计时间线节点展示 + +## Why + +资金调查时间线接口已返回金额、前后余额、金额权威来源、钱包引用和安全结构化事实,但当前弹窗只展示标题、结果、金额、编码和来源,无法支持资金事实核对。 + +## What Changes + +- 按“查询资金调查时间线”中的 `AuditFinanceTimelineNode` 响应结构,将财务审计节点改为与既有时间线一致的结构化卡片与折叠信息区。 +- 显示金额、变更前后余额、币种、事实来源、稳定编码和钱包类型;金额均按分转换为展示金额。 +- 金额权威来源与安全结构化事实使用按需展开的只读区域;不显示内部节点、店铺或钱包 ID,不新增跳转或写操作。 +- 保留现有稳定条件查询、分页、排序和权威金额字段语义。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue` diff --git a/openspec/changes/enrich-finance-timeline-detail/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/enrich-finance-timeline-detail/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..1147f92 --- /dev/null +++ b/openspec/changes/enrich-finance-timeline-detail/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: 财务审计时间线完整资金事实展示 + +前端 SHALL 在财务审计时间线中按 `AuditFinanceTimelineNode` 展示标题、结果、金额、变更前后余额、币种、事实来源、稳定编码和钱包类型。金额与余额 MUST 按分转换为展示金额。金额权威来源和安全结构化事实 MUST 使用按需展开的只读区域;内部节点、店铺和钱包 ID MUST 不在界面显示。 + +#### Scenario: 查看带金额权威信息的资金节点 + +- **WHEN** 资金节点返回金额、余额、钱包和 `amount_authority` +- **THEN** 前端展示格式化后的金额与余额,并在折叠区域展示权威来源说明 +- **AND** 不将非权威字段当作权威金额 + +#### Scenario: 查看安全结构化事实 + +- **WHEN** 资金节点返回非空 `facts` +- **THEN** 前端提供可折叠的只读 JSON 展示 +- **AND** 不显示内部 ID 或提供写操作 + +#### Scenario: 金额字段为空 + +- **WHEN** 资金节点不承载金额或余额 +- **THEN** 前端隐藏相应金额项或显示中性空态 +- **AND** 保持分页和排序结果不变 diff --git a/openspec/changes/enrich-finance-timeline-detail/tasks.md b/openspec/changes/enrich-finance-timeline-detail/tasks.md new file mode 100644 index 0000000..613f694 --- /dev/null +++ b/openspec/changes/enrich-finance-timeline-detail/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation + +- [x] 1.1 按 `AuditFinanceTimelineNode` 补充金额、余额、币种、来源、编码和钱包类型展示。 +- [x] 1.2 增加金额权威来源和安全结构化事实的折叠只读区域。 +- [x] 1.3 隐藏内部 ID,处理无金额、无钱包和空结构化字段场景。 + +## 2. Verification + +- [x] 2.1 验证金额单位按分转换,并保持 `amount_authority.authoritative` 语义。 +- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。 diff --git a/openspec/changes/enrich-request-timeline-detail/proposal.md b/openspec/changes/enrich-request-timeline-detail/proposal.md new file mode 100644 index 0000000..b2e8f9e --- /dev/null +++ b/openspec/changes/enrich-request-timeline-detail/proposal.md @@ -0,0 +1,17 @@ +# Change: 补全请求链路节点展示 + +## Why + +请求链路弹窗当前仅呈现标题、结果、摘要和事实来源,未充分利用接口返回的稳定编码、资源引用和关联完整性信息,且与已调整的操作者、业务关联链路节点样式不一致。 + +## What Changes + +- 基于 `查询请求关联时间线_简化版.md` 的 `AuditLinkTimelineNode`,将请求链路节点改为与既有时间线一致的结构化卡片与单行基础信息布局。 +- 节点仅展示标题、结果、事实来源和稳定编码;不显示请求、业务关联、父审计事件、节点或资源内部 ID。 +- 资源引用以折叠只读区域展示名称、类型和业务 Key;关联完整性信息仅超级管理员可见。 +- 保持接口全量排序和只读语义,不新增跳转或写操作。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue` diff --git a/openspec/changes/enrich-request-timeline-detail/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/enrich-request-timeline-detail/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..437ae48 --- /dev/null +++ b/openspec/changes/enrich-request-timeline-detail/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: 请求链路结构化节点展示 + +前端 SHALL 在请求链路中以与操作者和业务关联链路一致的层级展示每个 `AuditLinkTimelineNode`:节点标题、结果、事实来源和稳定编码 MUST 使用接口实际返回值,并在同一行展示基础信息。请求、业务关联、父审计事件、节点和资源内部 ID MUST 不在界面显示。资源引用 MUST 以按需展开的只读区域展示;数据完整性信息 MUST 仅向超级管理员展示。 + +#### Scenario: 查看含资源引用的请求链路节点 + +- **WHEN** 请求链路返回含资源引用和关联完整性信息的节点 +- **THEN** 前端展示节点基础信息和可折叠的资源引用区域 +- **AND** 不展示接口返回的任何 ID + +#### Scenario: 非超级管理员查看请求链路 + +- **WHEN** 非超级管理员打开请求链路弹窗 +- **THEN** 前端不展示数据完整性信息 +- **AND** 继续展示其余允许的节点字段 + +#### Scenario: 保持请求链路只读语义 + +- **WHEN** 用户查看请求链路 +- **THEN** 前端不提供修改、删除、导出、恢复、重试、补偿或风险处置操作 diff --git a/openspec/changes/enrich-request-timeline-detail/tasks.md b/openspec/changes/enrich-request-timeline-detail/tasks.md new file mode 100644 index 0000000..8cfcb0d --- /dev/null +++ b/openspec/changes/enrich-request-timeline-detail/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation + +- [x] 1.1 为请求链路节点增加与既有时间线一致的基础信息布局。 +- [x] 1.2 隐藏所有 ID,仅向超级管理员展示数据完整性信息。 +- [x] 1.3 对请求链路返回的资源引用增加折叠只读展示并处理空字段。 + +## 2. Verification + +- [x] 2.1 验证仅使用简化版文档已定义的 `AuditLinkTimelineNode` 字段。 +- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。 diff --git a/openspec/changes/enrich-resource-timeline-detail/proposal.md b/openspec/changes/enrich-resource-timeline-detail/proposal.md new file mode 100644 index 0000000..71e94a6 --- /dev/null +++ b/openspec/changes/enrich-resource-timeline-detail/proposal.md @@ -0,0 +1,17 @@ +# Change: 补全通用资源时间线事件展示 + +## Why + +通用资源时间线接口返回完整 `AuditEventPage` / `AuditEventView`,但当前弹窗仅显示动作、结果、摘要、操作者和来源,未使用接口返回的分类、风险、操作者组织、业务范围、批次、错误、资源快照与变更数据。 + +## What Changes + +- 依据“查询通用资源时间线”响应结构,在资源审计时间线中复用操作者时间线的完整事件节点布局。 +- 展示分类、来源、操作者、业务范围、风险、批次、错误及关联资源;基础信息同一行,资源 JSON 按需折叠。 +- 请求上下文与扩展元数据仅超级管理员可见;不新增请求筛选、跳转或任何写操作。 +- 保持接口现有分页、排序和“缺少 resource_id 隐藏入口”的调用边界。 + +## Impact + +- Affected specs: `audit-chain-frontend-integration` +- Affected code: `src/components/business/audit/AuditInvestigationDrawer.vue` diff --git a/openspec/changes/enrich-resource-timeline-detail/specs/audit-chain-frontend-integration/spec.md b/openspec/changes/enrich-resource-timeline-detail/specs/audit-chain-frontend-integration/spec.md new file mode 100644 index 0000000..36ffa8b --- /dev/null +++ b/openspec/changes/enrich-resource-timeline-detail/specs/audit-chain-frontend-integration/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: 通用资源时间线完整事件展示 + +前端 SHALL 按通用资源时间线返回的 `AuditEventPage` 展示完整 `AuditEventView` 节点:分类、来源、操作者、业务范围 MUST 在同一行展示;风险、批次统计、错误信息及关联资源 MUST 使用接口返回值。资源身份快照、主体安全数据及变更前后数据 MUST 以按需展开的只读区域展示。请求上下文与扩展元数据 MUST 仅向超级管理员展示。 + +#### Scenario: 查看包含资源快照的资源时间线事件 + +- **WHEN** 资源时间线返回包含批次、错误和关联资源快照的事件 +- **THEN** 前端在对应节点展示完整事件字段和折叠的资源数据 +- **AND** 不推断缺失字段或增加写操作 + +#### Scenario: 非超级管理员查看资源时间线 + +- **WHEN** 非超级管理员打开资源审计时间线 +- **THEN** 前端不展示请求上下文和扩展元数据 +- **AND** 继续展示其余允许的事件字段 + +#### Scenario: 保持资源时间线分页语义 + +- **WHEN** 用户翻页资源审计时间线 +- **THEN** 前端继续使用接口返回的页码、每页数量和稳定排序结果 diff --git a/openspec/changes/enrich-resource-timeline-detail/tasks.md b/openspec/changes/enrich-resource-timeline-detail/tasks.md new file mode 100644 index 0000000..35363e6 --- /dev/null +++ b/openspec/changes/enrich-resource-timeline-detail/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation + +- [x] 1.1 将资源时间线接入完整 `AuditEventView` 节点展示。 +- [x] 1.2 复用分类、来源、操作者、业务范围单行布局以及批次、错误和资源快照折叠区。 +- [x] 1.3 确保请求上下文和扩展元数据仅超级管理员可见。 + +## 2. Verification + +- [x] 2.1 验证接口分页、排序、空值和资源 ID 入口边界未改变。 +- [x] 2.2 运行格式化、ESLint、类型检查和 OpenSpec 严格校验。 diff --git a/openspec/changes/remove-legacy-asset-operation-logs/proposal.md b/openspec/changes/remove-legacy-asset-operation-logs/proposal.md new file mode 100644 index 0000000..1b07578 --- /dev/null +++ b/openspec/changes/remove-legacy-asset-operation-logs/proposal.md @@ -0,0 +1,18 @@ +# Change: 移除旧资产操作审计日志入口 + +## Why + +IoT 卡管理、设备管理和资产信息页仍展示旧的资产操作审计日志入口与内容,与当前审计调查能力重复,且不再需要向用户提供该旧日志交互。 + +## What Changes + +- 移除 IoT 卡管理列表的“操作审计日志”操作项及其抽屉弹窗。 +- 移除设备管理列表的“操作审计日志”操作项及其抽屉弹窗。 +- 移除资产信息页内嵌的操作审计日志卡片。 +- 清理上述页面对旧日志组件的引用;全局无其他前端调用时,删除旧日志组件、前端 API 方法及专用类型;仍有调用时保留共享实现。 +- 不改变后端历史日志数据或接口。 + +## Impact + +- Affected specs: `asset-audit-log-navigation` +- Affected code: `src/views/asset-management/iot-card-management/index.vue`, `src/views/asset-management/device-list/index.vue`, `src/views/asset-management/asset-information/index.vue`, and, only if unused, `src/components/business/OperationLogsDialog.vue`, `src/views/asset-management/asset-information/components/OperationLogsCard.vue`, `src/api/modules/asset.ts`, `src/types/api/asset.ts` diff --git a/openspec/changes/remove-legacy-asset-operation-logs/specs/asset-audit-log-navigation/spec.md b/openspec/changes/remove-legacy-asset-operation-logs/specs/asset-audit-log-navigation/spec.md new file mode 100644 index 0000000..88b24eb --- /dev/null +++ b/openspec/changes/remove-legacy-asset-operation-logs/specs/asset-audit-log-navigation/spec.md @@ -0,0 +1,11 @@ +## REMOVED Requirements + +### Requirement: 旧资产操作审计日志入口 + +**Reason**: 旧资产操作审计日志交互已由当前审计调查能力替代,页面不再提供重复入口。 **Migration**: 需要审计调查的用户通过当前审计中心和资源审计时间线访问记录。 + +#### Scenario: 查看 IoT 卡、设备或资产信息 + +- **WHEN** 用户打开 IoT 卡管理、设备管理或资产信息页面 +- **THEN** 页面不显示旧的“操作审计日志”入口、弹窗或内嵌日志卡片 +- **AND** 前端不因这些页面加载或操作而请求旧资产操作日志接口 diff --git a/openspec/changes/remove-legacy-asset-operation-logs/tasks.md b/openspec/changes/remove-legacy-asset-operation-logs/tasks.md new file mode 100644 index 0000000..9043076 --- /dev/null +++ b/openspec/changes/remove-legacy-asset-operation-logs/tasks.md @@ -0,0 +1,10 @@ +## 1. Remove User Interfaces + +- [x] 1.1 Remove the IoT card management operation-log action, state and dialog. +- [x] 1.2 Remove the device management operation-log action, state and dialog. +- [x] 1.3 Remove the embedded operation-log card from asset information. + +## 2. Cleanup and Verification + +- [x] 2.1 Search remaining consumers of the legacy log component, front-end API method and dedicated types; delete only implementations with no consumers. +- [x] 2.2 Run lint and type checks for changed files. diff --git a/openspec/changes/update-admin-notification-center-api/design.md b/openspec/changes/update-admin-notification-center-api/design.md new file mode 100644 index 0000000..3de5826 --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/design.md @@ -0,0 +1,36 @@ +## Context + +后端通知接口已经提供稳定的管理端 REST 合约。前端需要移除对旧字段和旧响应形态的依赖,统一使用真实接口返回的数据,并把通知目标解析限制在前端已注册的内部目标映射中。 + +## Goals / Non-Goals + +- Goals: 顶部铃铛、通知抽屉、通知中心和共享 Pinia 状态使用同一套真实 API 类型。 +- Goals: 保证未读数、分类汇总、列表已读状态在单条已读和全部已读后与后端同步。 +- Goals: 受控目标只能通过 `target_type`、`target_id`、`target_key` 白名单映射到内部页面。 +- Non-Goals: 不实现 C 端通知,不支持任意 URL 跳转,不改变通知产生规则。 + +## Decisions + +- Decision: 通知列表请求使用 `is_read?: boolean`,不再以 `read_status` 字符串代替后端参数。 +- Decision: 通知正文统一使用 `body`;必要时仅在迁移读取阶段兼容旧数据,不再把旧 `content` 作为 API 契约类型。 +- Decision: 顶部徽标优先使用 `unread-count.data.display_count`,数值统计使用 `data.count`;不在前端重新格式化徽标文本。 +- Decision: 未读汇总只保存四个固定分类计数和总数;抽屉最近10条通过 `GET /api/admin/notifications?page=1&page_size=10` 获取,避免把分类汇总误当通知列表。 +- Decision: 全部已读服务方法接收可选 `category`,请求体始终为对象;通知中心的“全部已读”传空对象,分类操作可传具体类别。 +- Decision: `target_type`、`target_key` 由前端白名单映射到已注册路由,`target_id` 仅作为参数;未知、无权限或已失效目标只展示正文,不跳转。 +- Alternatives considered: 继续保留旧字段的宽松兼容类型。Rejected because it会掩盖真实 API 字段错误,并导致筛选和已读状态请求不符合后端契约。 + +## Risks / Trade-offs + +- 风险:后端新增通知类型。Mitigation:类型字段保持 string,页面对未知类型采用原值展示。 +- 风险:受控目标类型新增。Mitigation:未知目标默认不可跳转,不执行后端返回的任意路径。 +- 风险:列表与汇总请求并行时数据短暂不一致。Mitigation:单条已读/全部已读成功后刷新未读数、汇总和当前列表。 + +## Migration Plan + +1. 先更新类型、API service 和通知 store 的真实响应结构。 +2. 再更新顶部抽屉和通知中心字段、筛选及已读交互。 +3. 最后替换目标跳转映射并验证未知目标安全降级。 + +## Open Questions + +- `target_type` 与 `target_key` 的完整白名单值需要以后端实际返回样例补充;未确认值统一按不可跳转处理。 diff --git a/openspec/changes/update-admin-notification-center-api/proposal.md b/openspec/changes/update-admin-notification-center-api/proposal.md new file mode 100644 index 0000000..e6e4fac --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/proposal.md @@ -0,0 +1,40 @@ +# Change: 按实际接口更新管理端通知中心 + +## Why + +通知中心已有前端链路,但当前类型和页面仍兼容旧字段,例如 `content`、`read_status`、旧的未读摘要结构和可路由目标结构。后端接口文档已更新为实际可用的管理端 API,需要按真实响应字段和受控目标协议修正通知铃铛、抽屉、通知中心及状态同步逻辑。 + +## What Changes + +- 按实际接口更新通知项字段:`body`、`is_read`、`read_at`、`ref_type`、`ref_id`、`ref_key`。 +- 按实际接口更新通知列表筛选参数:使用 `is_read` boolean,支持 `category`、`type`、`severity`、`page`、`page_size`。 +- 接入真实未读数响应 `{ count, display_count }`,顶部徽标直接展示后端 `display_count`。 +- 接入真实未读分类汇总 `{ approval, expiry, sync, system, total }`,抽屉最近通知通过通知列表接口获取,最多展示10条。 +- 全部已读调用 `PUT /api/admin/notifications/read-all`,支持可选 `category` 请求体。 +- 受控目标按 `target_type`、`target_id`、`target_key` 建立前端白名单映射,不接受或执行任意 URL/路由字段。 +- 通知中心展示正文 `body`、读取状态 `is_read`、首次已读时间 `read_at`,并支持余额、审批、临期、同步/系统分类。 +- 本变更只覆盖管理端,明确不接入 C 端 `/api/c/v1/notifications`。 + +## Impact + +- Affected specs: + - `notification-center` +- Affected code: + - `src/types/api/notification.ts` + - `src/api/modules/notification.ts` + - `src/store/modules/notification.ts` + - `src/components/core/layouts/art-header-bar/index.vue` + - `src/components/core/layouts/art-notification/index.vue` + - `src/views/notifications/index.vue` + - `src/utils/business/notificationNavigation.ts` +- API contracts: + - `GET /api/admin/notifications` + - `PUT /api/admin/notifications/{id}/read` + - `PUT /api/admin/notifications/read-all` + - `GET /api/admin/notifications/unread-count` + - `GET /api/admin/notifications/unread-summary` + - `GET /api/admin/notifications/{id}/target` +- Out of scope: + - C 端 `/api/c/v1/notifications` 接口和页面 + - 创建、编辑或删除通知 + - WebSocket、浏览器推送和新的实时推送机制 diff --git a/openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md b/openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md new file mode 100644 index 0000000..4c4a506 --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/specs/notification-center/spec.md @@ -0,0 +1,84 @@ +## ADDED Requirements + +### Requirement: Admin Notification Contract + +The admin notification frontend SHALL use the actual notification contract returned by `GET /api/admin/notifications`. Each item SHALL preserve `id`, `title`, `body`, `category`, `type`, `severity`, `is_read`, `created_at`, `read_at`, `ref_type`, `ref_id`, and `ref_key`. + +#### Scenario: Display actual notification fields + +- **WHEN** 管理端通知列表返回通知项 +- **THEN** 页面 MUST display `title` and `body` +- **AND** 页面 MUST use `is_read` for read state +- **AND** 页面 MUST preserve `read_at` and `ref_key` + +### Requirement: Admin Notification List Filters + +The notification center SHALL query `GET /api/admin/notifications` with `category`, `type`, `severity`, `is_read`, `page`, and `page_size`. The frontend SHALL not send `read_status` as a replacement for `is_read`. + +#### Scenario: Filter unread notifications + +- **WHEN** 用户选择未读筛选 +- **THEN** 前端 MUST send `is_read=false` +- **AND** 页面 MUST render the paginated `data.items` response + +#### Scenario: Filter read notifications + +- **WHEN** 用户选择已读筛选 +- **THEN** 前端 MUST send `is_read=true` + +### Requirement: Unread Count And Summary + +The admin header and notification drawer SHALL use `GET /api/admin/notifications/unread-count` and `GET /api/admin/notifications/unread-summary`. The unread count response SHALL preserve `count` and `display_count`; the summary response SHALL preserve `approval`, `expiry`, `sync`, `system`, and `total`. + +#### Scenario: Display backend unread badge + +- **WHEN** 未读数接口返回 `count` and `display_count` +- **THEN** 顶部铃铛 MUST display the backend `display_count` +- **AND** shared notification state MUST preserve the numeric `count` + +#### Scenario: Load drawer notifications + +- **WHEN** 用户打开顶部通知抽屉 +- **THEN** 前端 MUST use the summary endpoint for category counts +- **AND** 前端 MUST query the notification list endpoint with `page=1` and `page_size=10` for recent notifications + +### Requirement: Read State Operations + +The admin frontend SHALL mark notifications read through `PUT /api/admin/notifications/{id}/read` and mark notifications read in bulk through `PUT /api/admin/notifications/read-all`. The bulk request SHALL support an optional JSON `category` field. + +#### Scenario: Mark one notification read + +- **WHEN** 用户点击通知 +- **THEN** 前端 MUST call the single-read PUT endpoint before target resolution +- **AND** after success MUST refresh the shared unread count and relevant notification state + +#### Scenario: Mark all notifications read + +- **WHEN** 用户点击全部已读 +- **THEN** 前端 MUST call `PUT /api/admin/notifications/read-all` with an object body +- **AND** after success MUST refresh the list, summary and unread count + +### Requirement: Controlled Notification Target + +The admin frontend SHALL resolve notification navigation through `GET /api/admin/notifications/{id}/target`. It SHALL map only recognized `target_type` or `target_key` values to registered internal routes and SHALL use `target_id` as a route parameter when required. + +#### Scenario: Navigate to recognized target + +- **WHEN** 目标接口返回可用的受支持 `target_type`、`target_key` 和 `target_id` +- **THEN** 前端 MUST navigate to the corresponding registered internal route +- **AND** 前端 MUST not execute an arbitrary URL from the response + +#### Scenario: Handle unknown target safely + +- **WHEN** 目标类型未知、目标不可用或当前路由未注册 +- **THEN** 前端 MUST not navigate +- **AND** 页面 MUST display the notification body or an unavailable-target message + +### Requirement: Admin-Only Notification Scope + +This notification capability SHALL apply only to the admin APIs under `/api/admin/notifications` and the admin `/notifications` interface. It SHALL not add or modify C-end `/api/c/v1/notifications` APIs or pages. + +#### Scenario: Exclude C-end notifications + +- **WHEN** this change is implemented +- **THEN** C-end notification APIs and pages MUST remain outside the change scope diff --git a/openspec/changes/update-admin-notification-center-api/tasks.md b/openspec/changes/update-admin-notification-center-api/tasks.md new file mode 100644 index 0000000..a47fb2d --- /dev/null +++ b/openspec/changes/update-admin-notification-center-api/tasks.md @@ -0,0 +1,34 @@ +## 1. Contract And Types + +- [x] 1.1 按实际接口更新通知项、列表响应、未读数、未读汇总和受控目标类型。 +- [x] 1.2 将通知列表筛选参数从 `read_status` 改为 `is_read?: boolean`。 +- [x] 1.3 更新通知 service:未读数、未读汇总、列表、单条已读、全部已读和目标接口的请求/响应类型。 + +## 2. Shared Notification State + +- [x] 2.1 使用 `data.count` 和 `data.display_count` 同步未读数量及顶部徽标。 +- [x] 2.2 使用 `{ approval, expiry, sync, system, total }` 保存分类汇总,并通过通知列表接口加载抽屉最近10条。 +- [x] 2.3 单条已读和全部已读成功后刷新真实未读数、汇总和相关通知状态,避免仅修改本地计数。 + +## 3. Header And Notification Center + +- [x] 3.1 顶部铃铛展示后端 `display_count`,支持0、普通数量和 `99+`。 +- [x] 3.2 抽屉展示最近10条真实通知,支持全部、审批、临期、同步/系统分类和进入通知中心。 +- [x] 3.3 通知中心使用 `body`、`is_read`、`read_at` 展示正文、状态和已读时间。 +- [x] 3.4 通知中心筛选使用 `category`、`type`、`severity`、`is_read`、`page`、`page_size`。 +- [x] 3.5 严重级别支持 `info`、`warning`、`error`、`critical`,未知值稳定展示。 +- [x] 3.6 全部已读和单条已读严格按实际 PUT 接口调用。 + +## 4. Controlled Navigation And Scope + +- [x] 4.1 根据 `target_type`、`target_key` 和 `target_id`实现前端内部目标白名单映射。 +- [x] 4.2 未知、无权限或不可用目标只展示通知正文,不执行任意 URL 或未注册路由跳转。 +- [x] 4.3 保留管理端通知中心路由和顶部入口,不新增 C 端通知接口、页面或逻辑。 + +## 5. Verification + +- [x] 5.1 验证真实通知字段 `body`、`is_read`、`read_at`、`ref_key` 正确展示和保存。 +- [x] 5.2 验证未读徽标、分类汇总、抽屉列表和通知中心状态一致。 +- [x] 5.3 验证 `is_read=true/false` 筛选及全部已读 `category` 请求体。 +- [x] 5.4 验证受控目标白名单和未知目标安全降级。 +- [x] 5.5 运行类型检查、lint、相关测试、构建和 `openspec validate update-admin-notification-center-api --strict`。 diff --git a/openspec/changes/update-agent-fund-low-balance-warning/proposal.md b/openspec/changes/update-agent-fund-low-balance-warning/proposal.md new file mode 100644 index 0000000..a5575c3 --- /dev/null +++ b/openspec/changes/update-agent-fund-low-balance-warning/proposal.md @@ -0,0 +1,22 @@ +# Change: 代理现金余额不足100元展示 + +## Why + +当前代理资金概况缺少对现金可用余额不足的明确提示,运营人员无法快速识别需要关注的店铺资金状态。业务要求以固定阈值 100 元展示预警,不开放阈值配置,也不把信用额度计入预警文案。 + +## What Changes + +- 在代理资金概况中展示现金余额、冻结金额和基于后端 `low_balance_warning` 的“现金余额不足100元”红色状态。 +- 复用 `GET /api/admin/shops/fund-summary`,读取 `balance`、`frozen_balance`、`cash_available`、`low_balance_warning` 字段,金额统一按分转元展示。 +- 明确前端不得自行计算 100 元阈值,也不得展示阈值输入框。 +- 不包含任何公共站内通知相关功能的实现或改造。 + +## Impact + +- Affected specs: `commission-management` +- Affected code: + - `src/types/api/commission.ts` + - `src/views/commission-management/agent-fund-overview/index.vue` +- Out of scope: + - 顶部通知抽屉、站内通知中心及其他公共站内通知功能的开发或改造 + - 任意余额预警阈值配置能力 diff --git a/openspec/changes/update-agent-fund-low-balance-warning/specs/commission-management/spec.md b/openspec/changes/update-agent-fund-low-balance-warning/specs/commission-management/spec.md new file mode 100644 index 0000000..10b0473 --- /dev/null +++ b/openspec/changes/update-agent-fund-low-balance-warning/specs/commission-management/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Agent Cash Available Low Balance Warning + +The agent fund overview SHALL display cash balance data and a fixed-threshold low balance warning using the backend-provided `low_balance_warning` flag from `GET /api/admin/shops/fund-summary`. The frontend MUST NOT calculate the threshold itself and MUST NOT expose any threshold configuration input. + +#### Scenario: Display normal balance state above threshold + +- **GIVEN** 用户进入代理资金概况页面 +- **WHEN** `GET /api/admin/shops/fund-summary` 返回 `balance`、`frozen_balance`、`cash_available` +- **AND** `low_balance_warning=false` +- **THEN** 页面 MUST 展示现金余额和冻结金额 +- **AND** 所有金额 MUST 按分转元展示 +- **AND** 页面 MUST NOT 展示“现金余额不足100元”红色状态 + +#### Scenario: Display warning state at or below threshold + +- **GIVEN** 用户进入代理资金概况页面 +- **WHEN** `GET /api/admin/shops/fund-summary` 返回 `low_balance_warning=true` +- **THEN** 页面 MUST 展示“现金余额不足100元”红色状态 +- **AND** 该提示 MUST NOT 将信用额度计入文案 + +#### Scenario: Frontend does not expose threshold configuration + +- **WHEN** 用户查看代理资金概况页面 +- **THEN** 页面 MUST NOT 展示余额预警阈值输入框或配置控件 +- **AND** 前端 MUST NOT 依据 `cash_available` 自行计算是否低于 `100` 元 + +### Requirement: Agent Fund Summary Low Balance Response Fields + +The frontend SHALL read the low-balance display state exclusively from the `GET /api/admin/shops/fund-summary` response fields `balance:int64`, `frozen_balance:int64`, `cash_available:int64`, and `low_balance_warning:bool`. + +#### Scenario: Read low balance response contract + +- **WHEN** 前端请求 `GET /api/admin/shops/fund-summary` +- **THEN** 前端 MUST 读取 `balance`、`frozen_balance`、`cash_available`、`low_balance_warning` +- **AND** `low_balance_warning` MUST be the only source of truth for whether the warning is shown + +### Requirement: Public Notification Features Are Out Of Scope + +This change SHALL NOT add or modify top notification drawer, notification center, or any other public in-app notification feature. + +#### Scenario: Deliver low balance overview without notification changes + +- **WHEN** 本次变更落地 +- **THEN** 代理资金概况的低余额展示 MUST be available +- **AND** 顶部通知抽屉、站内通知中心及其他公共站内通知功能 MUST NOT be changed by this proposal diff --git a/openspec/changes/update-agent-fund-low-balance-warning/tasks.md b/openspec/changes/update-agent-fund-low-balance-warning/tasks.md new file mode 100644 index 0000000..4f8cb65 --- /dev/null +++ b/openspec/changes/update-agent-fund-low-balance-warning/tasks.md @@ -0,0 +1,15 @@ +## 1. API Contract + +- [x] 1.1 更新代理资金概况相关数据模型,补充 `balance`、`frozen_balance`、`cash_available`、`low_balance_warning` 字段读取。 + +## 2. Agent Fund Overview UI + +- [x] 2.1 在代理资金概况中展示现金余额、冻结金额,并统一按分转元格式化。 +- [x] 2.2 当 `low_balance_warning=true` 时展示“现金余额不足100元”红色状态。 +- [x] 2.3 不展示阈值输入框,不把信用额度计入预警文案,也不由前端自行计算余额阈值。 + +## 3. Verification + +- [ ] 3.1 验证现金可用余额高于、等于、低于100元三种情况下的状态展示正确,且仅由 `low_balance_warning` 控制。 +- [x] 3.2 验证本次改动未引入公共站内通知相关功能。 +- [x] 3.3 运行相关前端校验,并执行 `openspec validate update-agent-fund-low-balance-warning --strict`。 diff --git a/openspec/changes/update-asset-exchange-generation-links/proposal.md b/openspec/changes/update-asset-exchange-generation-links/proposal.md new file mode 100644 index 0000000..251c43c --- /dev/null +++ b/openspec/changes/update-asset-exchange-generation-links/proposal.md @@ -0,0 +1,28 @@ +# Change: 资产详情前代后代换货标识 + +## Why + +资产详情虽然可展示业务状态和世代编号,但运营无法判断当前资产在换货链中的位置,也无法在具备权限时快速前往关联资产。需要基于后端返回的换货链路数据明确展示前代和后代资产关系。 + +## What Changes + +- 扩展资产解析响应,读取 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`。 +- 前代资产存在时,在当前资产详情展示“换货新资产”标签与前代资产信息。 +- 后代资产存在时,在当前资产详情展示“已换出旧资产”标签与后代资产信息。 +- 中间资产同时展示前代和后代换货标识。 +- 当关联项 `can_view=true` 时允许跳转资产详情;为 `false` 时仅展示标识文本,不提供跳转操作。 +- 不由前端根据换货状态、世代编号或其他字段自行推导换货链;关联项为空时不展示对应区域。 + +## Impact + +- Affected specs: `asset-information` +- Related active changes: `add-asset-status-generation-display`(共享资产世代和详情展示区域) +- Affected code: + - `src/types/api/asset.ts` + - `src/views/asset-management/asset-information/types.ts` + - `src/views/asset-management/asset-information/composables/useAssetInfo.ts` + - `src/views/asset-management/asset-information/components/BasicInfoCard.vue` +- API contract: + - `GET /api/admin/assets/resolve/{identifier}` + - Response field: `exchange_trace.previous_asset` and `exchange_trace.next_asset` + - Related asset item fields: `asset_type:string`, `asset_id:int64|null`, `identifier:string`, `exchange_no:string`, `can_view:bool` diff --git a/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md b/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md new file mode 100644 index 0000000..368369b --- /dev/null +++ b/openspec/changes/update-asset-exchange-generation-links/specs/asset-information/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: Asset Exchange Generation Trace Display + +The asset information page SHALL display exchange generation relationships exclusively from `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. The frontend MUST NOT infer the exchange chain from asset status, generation, exchange order status, or any field outside `exchange_trace`. + +#### Scenario: Display previous asset for a replacement asset + +- **GIVEN** 用户正在查看资产信息页 +- **WHEN** 解析响应包含非空的 `exchange_trace.previous_asset` +- **THEN** 页面 MUST 显示“换货新资产”标签 +- **AND** 页面 MUST 展示前代资产的标识符和换货单号 +- **AND** 页面 MUST use `previous_asset.identifier` and `previous_asset.exchange_no` as the displayed source values + +#### Scenario: Display next asset for a replaced asset + +- **GIVEN** 用户正在查看资产信息页 +- **WHEN** 解析响应包含非空的 `exchange_trace.next_asset` +- **THEN** 页面 MUST 显示“已换出旧资产”标签 +- **AND** 页面 MUST 展示后代资产的标识符和换货单号 +- **AND** 页面 MUST use `next_asset.identifier` and `next_asset.exchange_no` as the displayed source values + +#### Scenario: Display both relationships for an intermediate asset + +- **GIVEN** 用户正在查看位于换货链中间的资产 +- **WHEN** 解析响应同时包含非空的 `previous_asset` 和 `next_asset` +- **THEN** 页面 MUST 同时展示“换货新资产”和“已换出旧资产”两个关联区域 +- **AND** 两个区域 MUST use their corresponding relationship item values independently + +#### Scenario: Hide absent exchange relationship + +- **GIVEN** 用户正在查看资产信息页 +- **WHEN** `previous_asset` 或 `next_asset` 为 `null`、空值或未返回 +- **THEN** 页面 MUST NOT 展示对应的换货关联区域 +- **AND** 前端 MUST NOT 根据资产状态、世代编号、换货单状态或其他字段推导该区域 + +#### Scenario: Display no exchange relationship for unrelated asset + +- **GIVEN** 用户正在查看无前代和后代换货关系的资产 +- **WHEN** 解析响应未返回 `exchange_trace` 或 `exchange_trace` 内两个关联项均为空 +- **THEN** 页面 MUST NOT 展示“换货新资产”标签 +- **AND** 页面 MUST NOT 展示“已换出旧资产”标签 +- **AND** 页面 MUST NOT render an empty exchange relationship placeholder + +### Requirement: Exchange Related Asset Navigation Authorization + +The asset information page SHALL allow navigation to a related exchange asset only when that relationship item has `can_view=true`. When `can_view=false`, the relationship item SHALL remain visible as text only and MUST NOT provide any asset-detail navigation affordance. + +#### Scenario: Navigate to authorized related asset + +- **GIVEN** 换货关联资产项的 `can_view=true` +- **WHEN** 用户点击关联资产标识符 +- **THEN** 系统 MUST 跳转到该关联资产的资产详情 +- **AND** 跳转 MUST 使用关联项返回的 `identifier` +- **AND** 页面 MUST NOT reconstruct the target identifier from the current asset or exchange order + +#### Scenario: Render unauthorized related asset as text + +- **GIVEN** 换货关联资产项的 `can_view=false` +- **WHEN** 页面渲染换货关联区域 +- **THEN** 页面 MUST 展示关联资产标识符和换货单号 +- **AND** 页面 MUST NOT 将关联资产标识符渲染为可点击跳转操作 + +#### Scenario: Render nullable related asset ID without navigation + +- **GIVEN** 换货关联资产项的 `asset_id=null` +- **AND** 该关联资产项的 `can_view=false` +- **WHEN** 页面渲染换货关联区域 +- **THEN** 页面 MUST 展示关联资产标识符和换货单号 +- **AND** 页面 MUST NOT depend on `asset_id` to render the relationship text +- **AND** 页面 MUST NOT provide navigation to the related asset + +### Requirement: Exchange Trace Response Contract + +The frontend asset resolve contract SHALL support optional `exchange_trace.previous_asset` and `exchange_trace.next_asset` returned by `GET /api/admin/assets/resolve/{identifier}`. Each non-empty relationship item SHALL preserve `asset_type:string`, `asset_id:int64|null`, `identifier:string`, `exchange_no:string`, and `can_view:bool`. + +#### Scenario: Read exchange trace response fields + +- **WHEN** 前端解析 `GET /api/admin/assets/resolve/{identifier}` 响应 +- **THEN** 前端类型 MUST 支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset` +- **AND** 每个非空关联项 MUST 支持读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view` +- **AND** `asset_id` MUST allow `null` for no-permission relationship items +- **AND** `previous_asset` and `next_asset` MUST each allow `null`, empty, or omitted values + +#### Scenario: Preserve asset type in exchange trace item + +- **WHEN** 解析响应返回 `exchange_trace.previous_asset` 或 `exchange_trace.next_asset` +- **THEN** 前端类型 MUST preserve `asset_type` as a string value such as `device` or `card` +- **AND** 页面 logic MUST NOT assume all related exchange assets are the same type as the current asset diff --git a/openspec/changes/update-asset-exchange-generation-links/tasks.md b/openspec/changes/update-asset-exchange-generation-links/tasks.md new file mode 100644 index 0000000..4517e08 --- /dev/null +++ b/openspec/changes/update-asset-exchange-generation-links/tasks.md @@ -0,0 +1,22 @@ +## 1. API Contract + +- [x] 1.1 更新资产解析响应类型,支持 `exchange_trace.previous_asset` 和 `exchange_trace.next_asset`。 +- [x] 1.2 定义换货关联资产类型,读取 `asset_type`、`asset_id`、`identifier`、`exchange_no`、`can_view`。 + +## 2. Asset Detail Exchange Trace UI + +- [x] 2.1 `previous_asset` 存在时展示“换货新资产”标签和前代资产信息。 +- [x] 2.2 `next_asset` 存在时展示“已换出旧资产”标签和后代资产信息。 +- [x] 2.3 当前资产同时存在前代和后代时,同时展示两个关联区域。 +- [x] 2.4 关联资产为空时隐藏对应区域,不通过换货状态或世代编号推导关联链路。 + +## 3. Exchange Trace Navigation + +- [x] 3.1 `can_view=true` 时允许点击关联资产标识跳转资产详情。 +- [x] 3.2 `can_view=false` 时仅展示关联资产标识文本,不渲染跳转操作。 + +## 4. Verification + +- [ ] 4.1 验证旧资产、新资产、中间资产和无关联资产的展示正确。 +- [ ] 4.2 验证有权限和无权限关联资产的跳转行为正确。 +- [x] 4.3 运行相关前端校验,并执行 `openspec validate update-asset-exchange-generation-links --strict`。 diff --git a/openspec/changes/update-asset-start-realname-validation-feedback/proposal.md b/openspec/changes/update-asset-start-realname-validation-feedback/proposal.md new file mode 100644 index 0000000..570091e --- /dev/null +++ b/openspec/changes/update-asset-start-realname-validation-feedback/proposal.md @@ -0,0 +1,25 @@ +# Change: 适配复机实名校验结果提示 + +## Why + +复机是否允许取决于运营商的实名能力和后端实名规则,不能以行业卡类别作为前端放行依据。当前复机失败不会向运营清晰呈现后端返回的中文业务原因,无法区分同为行业卡但实名能力不同的放行或拦截结果。 + +## What Changes + +- 复用资产信息页现有复机入口和确认框,继续调用 `POST /api/admin/assets/{identifier}/start`。 +- 补齐复机成功响应类型,读取最新的 `status`、`status_name`、`real_name_status`、`real_name_status_name`。 +- 复机提交期间禁用入口,避免重复提交。 +- 复机被后端实名规则或其他业务规则拦截时,在当前复机操作上下文直接展示后端返回的中文业务原因,不以通用前端文案替代,也不只记录到控制台。 +- 复机成功后重新拉取资产详情,以最新资产数据更新页面。 +- 前端不得读取或根据 `card_category` 判断是否需要实名、是否允许复机,所有放行或拦截均以后端结果为准。 + +## Impact + +- Affected specs: `asset-information` +- Affected code: + - `src/types/api/asset.ts` + - `src/api/modules/asset.ts` + - `src/views/asset-management/asset-information/composables/useAssetOperations.ts` + - `src/views/asset-management/asset-information/components/BasicInfoCard.vue` +- API contract: + - `POST /api/admin/assets/{identifier}/start` diff --git a/openspec/changes/update-asset-start-realname-validation-feedback/specs/asset-information/spec.md b/openspec/changes/update-asset-start-realname-validation-feedback/specs/asset-information/spec.md new file mode 100644 index 0000000..eac9586 --- /dev/null +++ b/openspec/changes/update-asset-start-realname-validation-feedback/specs/asset-information/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Asset Start Response Contract + +The asset start API contract SHALL use `POST /api/admin/assets/{identifier}/start` and support a successful response containing the latest `status`, `status_name`, `real_name_status`, and `real_name_status_name` values. + +#### Scenario: Read successful asset start result + +- **WHEN** 用户对资产提交复机且后端成功处理请求 +- **THEN** 前端类型 MUST support `status`、`status_name`、`real_name_status` 和 `real_name_status_name` +- **AND** 前端 MUST use the existing asset start endpoint + +### Requirement: Asset Start Submission and Refresh + +The asset information page SHALL retain the existing asset start entry and confirmation dialog. It MUST prevent duplicate submissions while the asset start request is pending and refresh the asset detail after a successful start. + +#### Scenario: Keep existing asset start confirmation + +- **GIVEN** 用户正在查看处于停机状态的资产信息页 +- **WHEN** 用户发起复机操作 +- **THEN** 页面 MUST display the existing start confirmation dialog +- **AND** 页面 MUST NOT add a separate asset start entry + +#### Scenario: Prevent duplicate asset start submissions + +- **GIVEN** 用户已确认复机操作 +- **WHEN** 复机请求仍在提交中 +- **THEN** 复机按钮 MUST be disabled +- **AND** 系统 MUST NOT send another asset start request for that interaction + +#### Scenario: Refresh asset detail after a successful start + +- **WHEN** 复机接口成功返回 +- **THEN** 页面 MUST reload the current asset detail +- **AND** 页面 MUST render the refreshed asset status and real-name status data + +### Requirement: Backend-Controlled Asset Start Realname Validation + +The asset information page SHALL rely exclusively on the backend asset start result to decide whether an asset is allowed to start. The frontend MUST NOT read or use `card_category` to infer real-name requirements or to permit an asset start. + +#### Scenario: Allow start based on backend result + +- **GIVEN** 用户对行业卡资产发起复机 +- **WHEN** 后端允许复机 +- **THEN** 页面 MUST treat the operation as successful regardless of the asset `card_category` +- **AND** 页面 MUST reload the asset detail + +#### Scenario: Block start based on backend realname validation + +- **GIVEN** 用户对行业卡资产发起复机 +- **WHEN** 后端因实名规则拒绝复机并返回中文业务原因 +- **THEN** 页面 MUST keep the asset start operation unsuccessful +- **AND** 页面 MUST NOT use `card_category` to override or bypass the backend decision + +### Requirement: Asset Start Business Failure Feedback + +When the asset start API rejects an operation for real-name validation or another business rule, the asset information page SHALL show the backend-provided Chinese business reason in the current asset start operation context. + +#### Scenario: Show backend realname rejection reason + +- **GIVEN** 用户已在资产信息页确认复机 +- **WHEN** 后端返回实名校验失败及中文业务原因 +- **THEN** 页面 MUST display that backend Chinese business reason directly in the asset start operation context +- **AND** 页面 MUST NOT replace it with a generic frontend failure message + +#### Scenario: Show other backend business rejection reason + +- **GIVEN** 用户已在资产信息页确认复机 +- **WHEN** 后端返回非实名相关的业务拦截及中文业务原因 +- **THEN** 页面 MUST display that backend Chinese business reason directly in the asset start operation context +- **AND** 页面 MUST NOT only log the failure to the browser console diff --git a/openspec/changes/update-asset-start-realname-validation-feedback/tasks.md b/openspec/changes/update-asset-start-realname-validation-feedback/tasks.md new file mode 100644 index 0000000..e012d9d --- /dev/null +++ b/openspec/changes/update-asset-start-realname-validation-feedback/tasks.md @@ -0,0 +1,24 @@ +## 1. API Contract + +- [x] 1.1 定义复机成功响应类型,支持 `status`、`status_name`、`real_name_status`、`real_name_status_name`。 +- [x] 1.2 更新复机接口返回类型,继续使用 `POST /api/admin/assets/{identifier}/start`。 + +## 2. Asset Start Interaction + +- [x] 2.1 保持资产信息页现有复机入口和确认框。 +- [x] 2.2 复机请求提交期间禁用复机按钮,防止重复提交。 +- [x] 2.3 复机成功后重新拉取资产详情。 + +## 3. Backend Realname Validation Feedback + +- [x] 3.1 后端返回实名或其他业务拦截结果时,在复机操作上下文显示后端中文业务原因。 +- [x] 3.2 不以通用前端错误文案覆盖后端中文业务原因,也不只将错误写入控制台。 +- [x] 3.3 不读取或根据 `card_category` 推断实名要求或复机放行结果。 + +## 4. Verification + +- [ ] 4.1 验证复机成功后页面重新加载并展示最新资产详情。 +- [ ] 4.2 验证后端实名校验拦截时显示其中文业务原因。 +- [ ] 4.3 验证同为行业卡但运营商实名能力不同的资产,页面按后端结果分别放行或拦截。 +- [ ] 4.4 验证提交期间无法重复发起复机请求。 +- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-asset-start-realname-validation-feedback --strict`。 diff --git a/openspec/changes/update-asset-sync-status-trail/proposal.md b/openspec/changes/update-asset-sync-status-trail/proposal.md new file mode 100644 index 0000000..5f912b1 --- /dev/null +++ b/openspec/changes/update-asset-sync-status-trail/proposal.md @@ -0,0 +1,30 @@ +# Change: 调整资产同步状态与同步轨迹入口 + +## Why + +运营需要通过统一审计查看资产业务事件、运营商回调和周期轮询的同步轨迹。新需求明确资产信息页只保留已有手动刷新和轮询展示,不新增用于推断轮询计划的状态字段。 + +## What Changes + +- 保留卡和设备资产信息页现有的手动刷新按钮、接口契约和轮询状态展示,不新增第二个同步或刷新按钮。 +- 不展示活跃级别、最后活跃场景或下次轮询时间,也不根据事件轨迹推算新的周期轮询时间。 +- 增加“查看同步轨迹”入口,跳转全局审计外部集成页并带入当前资产的 `resource_type` 和 `resource_key` 筛选参数。 +- 约定审计查询支持 `resource_type`、`resource_key`、`correlation_id`,以供目标页连续展示立即、3分钟、5分钟的同步结果,以及运营商回调和周期轮询的 Integration Log。 +- 对 `rate_limited` 手动刷新结果仅展示本次失败,不展示自动退避倒计时。 +- 全局审计外部集成页面自身暂不在本提案中开发或改造。 + +## Impact + +- Affected specs: `asset-information` +- Related active changes: `update-asset-realtime-sync-timestamps-display`(共享资产同步信息展示) +- Affected code: + - `src/views/asset-management/asset-information/components/BasicInfoCard.vue` + - `src/views/asset-management/asset-information/index.vue` + - 资产信息页到全局审计外部集成页的路由跳转参数 +- API contracts: + - `POST /api/admin/assets/{identifier}/refresh`(保持既有契约) + - `GET /api/admin/audit/integrations`(复用 `resource_type`、`resource_key`、`correlation_id` 查询参数) +- Out of scope: + - 全局审计外部集成页面自身的开发或改造 + - 审计轨迹结果页的加载、空记录和失败状态界面实现 + - 新增轮询活跃级别、最后活跃场景、下次轮询时间或自动退避倒计时展示 diff --git a/openspec/changes/update-asset-sync-status-trail/specs/asset-information/spec.md b/openspec/changes/update-asset-sync-status-trail/specs/asset-information/spec.md new file mode 100644 index 0000000..79d1865 --- /dev/null +++ b/openspec/changes/update-asset-sync-status-trail/specs/asset-information/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Existing Asset Sync Controls and Polling Display + +The asset information page SHALL preserve the existing manual refresh control and existing polling status display for both card and device assets. The page MUST NOT add another synchronization or refresh control. + +#### Scenario: Retain existing controls for a card asset + +- **GIVEN** 用户正在查看卡资产信息页 +- **THEN** 页面 MUST 保留现有手动刷新按钮和现有轮询状态展示 +- **AND** 页面 MUST NOT 增加第二个同步或刷新按钮 + +#### Scenario: Retain existing controls for a device asset + +- **GIVEN** 用户正在查看设备资产信息页 +- **THEN** 页面 MUST 保留现有手动刷新按钮和现有轮询状态展示 +- **AND** 页面 MUST NOT 增加第二个同步或刷新按钮 + +#### Scenario: Do not display new polling activity fields + +- **GIVEN** 用户正在查看卡或设备资产信息页 +- **WHEN** 资产解析响应包含 `activity_level`、`last_activity_scene` 或 `next_poll_at` +- **THEN** 页面 MUST NOT 展示活跃级别、最后活跃场景或下次轮询时间字段 + +### Requirement: Asset Manual Refresh Remains the Only Refresh Entry + +The asset information page SHALL preserve the existing manual refresh button and interface contract as the only asset refresh entry. The frontend MUST NOT infer or display a future periodic polling time from refresh results or event traces. + +#### Scenario: Preserve existing manual refresh behavior + +- **WHEN** 用户在资产信息页发起手动刷新 +- **THEN** 系统 MUST 调用现有手动刷新接口 +- **AND** 页面 MUST NOT 新增第二个同步或刷新按钮 + +#### Scenario: Show rate limited result without countdown + +- **WHEN** 手动刷新请求返回 `rate_limited` 或等价限流失败结果 +- **THEN** 系统 MUST 仅展示本次请求失败信息 +- **AND** 系统 MUST NOT 展示自动退避倒计时 + +#### Scenario: Do not infer a future polling schedule + +- **WHEN** 用户查看刷新结果或同步事件轨迹 +- **THEN** 前端 MUST NOT 根据结果推算或展示新的周期轮询时间 + +### Requirement: Asset Sync Trail Navigation + +The asset information page SHALL provide a `查看同步轨迹` entry that navigates to the global audit integration page with filters for the current asset. The target audit query contract SHALL support `resource_type`, `resource_key`, and `correlation_id` filters. + +#### Scenario: Navigate to filtered sync trail from a card asset + +- **GIVEN** 用户正在查看卡资产信息页 +- **WHEN** 用户点击“查看同步轨迹” +- **THEN** 系统 MUST 跳转到全局审计外部集成页 +- **AND** 跳转参数 MUST 带入当前卡资产对应的 `resource_type` 和 `resource_key` 筛选信息 + +#### Scenario: Navigate to filtered sync trail from a device asset + +- **GIVEN** 用户正在查看设备资产信息页 +- **WHEN** 用户点击“查看同步轨迹” +- **THEN** 系统 MUST 跳转到全局审计外部集成页 +- **AND** 跳转参数 MUST 带入当前设备资产对应的 `resource_type` 和 `resource_key` 筛选信息 + +#### Scenario: Audit query filters a correlation group + +- **WHEN** 全局审计外部集成页按一次业务事件的 `correlation_id` 查询同步轨迹 +- **THEN** 审计查询契约 MUST 支持该筛选参数 + +#### Scenario: Audit target page is outside this change + +- **WHEN** 本次变更落地 +- **THEN** 资产信息页的同步轨迹跳转入口 MUST 可用 +- **AND** 全局审计外部集成页面的加载、空记录和失败状态界面 MUST NOT 被要求在本提案中开发或改造 diff --git a/openspec/changes/update-asset-sync-status-trail/tasks.md b/openspec/changes/update-asset-sync-status-trail/tasks.md new file mode 100644 index 0000000..670978f --- /dev/null +++ b/openspec/changes/update-asset-sync-status-trail/tasks.md @@ -0,0 +1,24 @@ +## 1. Asset Sync Controls + +- [x] 1.1 保留卡和设备资产信息页现有手动刷新按钮及其接口调用方式,不新增同步或刷新按钮。 +- [x] 1.2 保留现有轮询状态展示,移除活跃级别、最后活跃场景和下次轮询时间展示。 +- [x] 1.3 `rate_limited` 时仅展示本次刷新失败,不展示自动退避倒计时或新的轮询时间。 + +## 2. Sync Trail Navigation + +- [x] 2.1 在卡和设备资产信息页增加“查看同步轨迹”入口,跳转全局审计外部集成页。 +- [x] 2.2 跳转时带入当前资产的 `resource_type` 和 `resource_key` 筛选参数。 +- [x] 2.3 前端不根据事件轨迹推算或展示新的周期轮询时间。 + +## 3. Audit Integration Contract + +- [ ] 3.1 与审计接口确认并联调 `resource_type`、`resource_key`、`correlation_id` 查询参数。 +- [ ] 3.2 确认目标查询可覆盖立即、3分钟、5分钟的连续同步结果,以及运营商回调和周期轮询的 Integration Log。 +- [x] 3.3 不开发或改造全局审计外部集成页面的加载、空记录和失败状态界面。 + +## 4. Verification + +- [ ] 4.1 验证卡和设备均保留既有手动刷新和轮询状态展示,且不显示新增轮询活跃字段。 +- [ ] 4.2 验证“查看同步轨迹”跳转携带当前资产筛选。 +- [ ] 4.3 待全局审计外部集成页就绪后,验证过滤后的轨迹加载、空记录、失败状态及连续同步结果完整。 +- [x] 4.4 运行相关前端校验,并执行 `openspec validate update-asset-sync-status-trail --strict`。 diff --git a/openspec/changes/update-exchange-inherited-shop-hint/proposal.md b/openspec/changes/update-exchange-inherited-shop-hint/proposal.md new file mode 100644 index 0000000..31c8b18 --- /dev/null +++ b/openspec/changes/update-exchange-inherited-shop-hint/proposal.md @@ -0,0 +1,23 @@ +# Change: 换货新资产归属继承提示 + +## Why + +当前换货流程在选择新资产后,页面没有明确告知换货完成后的最终归属店铺,运营人员容易误以为可以手工选择目标店铺,进而对换货结果产生误判。 + +## What Changes + +- 在换货创建入口、发货确认入口、换货详情页补充旧资产所属店铺和新资产继承归属店铺的展示。 +- 直接换货创建入口查询新资产时,按已选旧资产的 `shop_id` 过滤新资产候选,确保新旧资产限定在同一店铺范围内选择。 +- 保持现有换货创建、发货、确认完成接口和入参语义不变,不新增目标店铺选择控件或目标店铺提交字段。 +- 扩展换货响应数据,返回 `inherited_shop_id` 和 `inherited_shop_name` 供前端统一展示最终归属店铺。 +- 在创建、发货、确认完成流程中补充提交中禁用、防重复提交、后端归属校验失败提示,以及成功后的详情刷新;创建换货单失败提示复用统一 HTTP 错误处理。 + +## Impact + +- Affected specs: `exchange-management` +- Related active changes: `update-exchange-flow-direct-type`(本提案建立在现有换货流程类型改造之上,需合并考虑) +- Affected code: + - `src/api/modules/exchange.ts` + - `src/views/asset-management/exchange-management/index.vue` + - `src/views/asset-management/exchange-management/detail.vue` + - 相关旧资产/新资产搜索结果类型或映射逻辑(如需补充店铺名称展示数据) diff --git a/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md b/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md new file mode 100644 index 0000000..9e6eaf2 --- /dev/null +++ b/openspec/changes/update-exchange-inherited-shop-hint/specs/exchange-management/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: 换货归属继承上下文展示 + +系统 SHALL 在换货创建和发货确认流程中展示旧资产当前所属店铺,并在用户选定新资产后以只读方式提示换货完成后的继承归属店铺,避免用户误认为可以手工指定目标店铺。 + +#### Scenario: 创建入口展示旧资产所属店铺 + +- **GIVEN** 用户进入现有换货创建入口 +- **WHEN** 用户选择旧资产 +- **THEN** 系统 MUST 显示该旧资产当前所属店铺名称 +- **AND** 该所属店铺信息 MUST be read-only + +#### Scenario: 选择新资产后展示继承归属提示 + +- **GIVEN** 用户正在换货创建或发货确认流程中操作 +- **WHEN** 用户选择新资产 +- **THEN** 系统 MUST 显示只读提示 `换货完成后将归属:{店铺名称}` +- **AND** `{店铺名称}` MUST 表示该新资产换货完成后的最终归属店铺 + +#### Scenario: 页面不提供目标店铺选择控件 + +- **WHEN** 用户查看换货创建或发货确认入口 +- **THEN** 系统 MUST NOT 渲染目标店铺选择控件 +- **AND** 用户 MUST NOT 能够手工覆盖继承归属店铺 + +#### Scenario: 直接换货新资产候选按旧资产店铺过滤 + +- **GIVEN** 用户正在创建直接换货单 +- **AND** 用户已选择旧资产 +- **WHEN** 前端加载或搜索新资产标识符候选项 +- **THEN** 新 IoT 卡候选查询 MUST include the selected old asset `shop_id` +- **AND** 新设备候选查询 MUST include the selected old asset `shop_id` +- **AND** 查询 MUST continue to include `status=1` + +#### Scenario: 未选择旧资产时不加载直接换货新资产候选 + +- **GIVEN** 用户正在创建直接换货单 +- **AND** 用户尚未选择旧资产 +- **WHEN** 新资产标识符候选项准备加载 +- **THEN** 前端 MUST NOT request new asset candidates without the old asset `shop_id` +- **AND** 新资产候选列表 MUST remain empty until an old asset is selected + +### Requirement: 换货归属继承接口契约 + +系统 SHALL 复用现有换货创建、发货和确认完成接口,并在换货数据模型中返回继承后的店铺标识和店铺名称,供创建结果、发货结果、完成结果和详情页统一展示。 + +#### Scenario: 创建请求保持既有字段语义 + +- **WHEN** 前端调用 `POST /api/admin/exchanges` +- **THEN** 请求体 MUST 继续沿用旧资产、新资产、换货类型和资料迁移字段 +- **AND** 请求体 MUST NOT 新增目标店铺字段 + +#### Scenario: 发货和完成请求不新增目标店铺字段 + +- **WHEN** 前端调用 `POST /api/admin/exchanges/{id}/ship` 或 `POST /api/admin/exchanges/{id}/complete` +- **THEN** 请求体 MUST NOT 新增目标店铺字段 +- **AND** 后端 MUST 继续按现有换货流程处理归属继承 + +#### Scenario: 换货响应返回继承店铺字段 + +- **WHEN** 前端收到换货创建、发货、确认完成结果或读取换货详情 +- **THEN** 返回的 `data` MUST 包含 `inherited_shop_id:int64` 和 `inherited_shop_name:string` +- **AND** `inherited_shop_name` MUST 表示换货完成后新资产最终归属的店铺名称 + +### Requirement: 换货详情继承归属展示 + +系统 SHALL 在换货详情页展示继承后的店铺名称,并在关键操作成功后刷新详情数据,保证页面展示的归属信息与后端一致。 + +#### Scenario: 详情页展示继承后的店铺名称 + +- **GIVEN** 用户进入换货详情页 +- **WHEN** 详情数据加载成功 +- **THEN** 页面 MUST 展示继承后的店铺名称 +- **AND** 页面 MUST NOT 隐藏该归属字段 + +#### Scenario: 发货或确认完成成功后刷新详情 + +- **GIVEN** 用户完成换货发货或确认完成操作 +- **WHEN** 接口返回成功 +- **THEN** 系统 MUST 重新拉取该换货单详情 +- **AND** 刷新后的详情 MUST 展示最新的继承归属店铺信息 + +### Requirement: 换货归属继承错误与提交状态处理 + +系统 SHALL 在新资产归属校验失败时展示后端错误,并在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交,同时保证失败后可以重试。创建换货单接口失败 MUST use the unified HTTP error handling and MUST NOT render a duplicate local error alert inside the create dialog. + +#### Scenario: 新资产属于其他店铺时展示后端错误 + +- **GIVEN** 用户选择了不允许用于当前换货流程的新资产 +- **WHEN** 后端返回“新资产属于其他店铺”或等价归属校验错误 +- **THEN** 系统 MUST 通过统一 HTTP 错误处理展示后端错误信息 +- **AND** 系统 MUST NOT 提供手工修改目标店铺的替代入口 +- **AND** 创建换货单弹窗内 MUST NOT 额外展示重复错误 Alert + +#### Scenario: 提交期间禁用按钮防止重复提交 + +- **WHEN** 用户提交创建、发货或确认完成操作 +- **THEN** 当前操作按钮 MUST 进入禁用或 loading 状态直到请求结束 +- **AND** 在请求完成前的重复点击 MUST NOT 触发第二次提交 + +#### Scenario: 请求失败后恢复可重试状态 + +- **WHEN** 创建、发货或确认完成请求失败 +- **THEN** 系统 MUST 恢复当前操作按钮可用状态 +- **AND** 页面 MUST 保留当前已展示的资产与归属提示,便于用户修正后重试 diff --git a/openspec/changes/update-exchange-inherited-shop-hint/tasks.md b/openspec/changes/update-exchange-inherited-shop-hint/tasks.md new file mode 100644 index 0000000..8633b83 --- /dev/null +++ b/openspec/changes/update-exchange-inherited-shop-hint/tasks.md @@ -0,0 +1,23 @@ +## 1. API Contract + +- [x] 1.1 更新换货数据模型,补充 `inherited_shop_id` 和 `inherited_shop_name` 响应字段,并覆盖创建、发货、确认完成及详情读取场景。 +- [x] 1.2 保持 `POST /api/admin/exchanges`、`POST /api/admin/exchanges/{id}/ship`、`POST /api/admin/exchanges/{id}/complete` 的既有入参语义,不新增目标店铺字段。 + +## 2. Exchange Create And Ship UI + +- [x] 2.1 在换货创建入口中,选择旧资产后展示旧资产所属店铺。 +- [x] 2.2 在换货创建入口和发货确认入口中,选择新资产后展示只读提示 `换货完成后将归属:{店铺名称}`。 +- [x] 2.3 确保上述页面不新增目标店铺选择控件,也不允许手工覆盖继承归属。 +- [x] 2.4 直接换货创建入口的新资产默认列表和搜索请求按已选旧资产 `shop_id` 过滤,并继续固定 `status=1`。 + +## 3. Exchange Detail And Submission States + +- [x] 3.1 在换货详情页展示继承后的店铺名称,并在发货或确认完成成功后重新拉取详情。 +- [x] 3.2 在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交。 +- [x] 3.3 在新资产归属校验失败时通过统一 HTTP 错误处理展示后端错误,并在失败后恢复可重试状态。 + +## 4. Verification + +- [x] 4.1 验证创建、发货确认、详情三个入口均能正确展示旧资产所属店铺和继承归属店铺。 +- [x] 4.2 验证加载中、失败提示、重复提交防护和成功后详情刷新的交互状态。 +- [x] 4.3 运行相关前端校验,并执行 `openspec validate update-exchange-inherited-shop-hint --strict`。 diff --git a/openspec/changes/update-exchange-refund-interception-feedback/proposal.md b/openspec/changes/update-exchange-refund-interception-feedback/proposal.md new file mode 100644 index 0000000..c7d464c --- /dev/null +++ b/openspec/changes/update-exchange-refund-interception-feedback/proposal.md @@ -0,0 +1,26 @@ +# Change: 展示换货退款拦截错误 + +## Why + +创建换货单时,后端会拦截存在活跃退款申请的资产,但当前创建页仅使用通用提示或控制台日志,运营无法直接知道资产因退款申请不能换货。 + +## What Changes + +- 复用 `POST /api/admin/exchanges` 的现有失败 `code`、`msg` 契约,不改变成功响应结构。 +- 创建换货单被退款拦截或其他后端业务规则拒绝时,通过统一 HTTP 错误处理展示后端业务原因,例如“该资产存在退款申请”,不在创建弹窗内额外渲染本地错误区域。 +- 创建失败后保留当前填写的表单、已选资产与流程类型,恢复提交按钮使用户可更换资产后重新提交。 +- 前端不新增退款状态查询、预检查区域或退款申请入口。 +- 前端不读取或推断退款审批、撤销、拒绝或处理完成状态;是否允许换货完全依据创建接口结果。 + +## Impact + +- Affected specs: `exchange-management` +- Affected code: + - `src/api/modules/exchange.ts` + - `src/views/asset-management/exchange-management/index.vue` +- API contract: + - `POST /api/admin/exchanges` +- Out of scope: + - 退款状态查询和预检查 + - 退款审批、撤销、拒绝或处理功能 + - 换货创建成功响应结构调整 diff --git a/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md b/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md new file mode 100644 index 0000000..b3e63c2 --- /dev/null +++ b/openspec/changes/update-exchange-refund-interception-feedback/specs/exchange-management/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Exchange Creation Refund Interception Feedback + +The exchange creation page SHALL display the backend business reason through the unified HTTP error handling when `POST /api/admin/exchanges` rejects the selected old asset because of an active refund application. The page MUST use the response `code` and `msg` contract without changing the successful exchange response structure, and MUST NOT render an additional local error area inside the create dialog for the same failure. + +#### Scenario: Display active refund interception reason + +- **GIVEN** 用户已填写创建换货单表单并选择存在活跃退款申请的旧资产 +- **WHEN** `POST /api/admin/exchanges` 返回失败 `code` 和 `msg` +- **THEN** 页面 MUST display the backend business reason through the unified HTTP error handling +- **AND** 当后端 `msg` 为“该资产存在退款申请”时,页面 MUST display that message directly +- **AND** 页面 MUST NOT display a duplicate error alert inside the create exchange dialog +- **AND** 系统 MUST NOT create a new exchange order + +#### Scenario: Display other exchange creation business reason + +- **GIVEN** 用户已填写创建换货单表单 +- **WHEN** 创建接口返回非退款相关的业务失败 `code` 和 `msg` +- **THEN** 页面 MUST display the backend `msg` through the unified HTTP error handling +- **AND** 页面 MUST NOT replace the backend business reason with a generic frontend failure message +- **AND** 页面 MUST NOT display a duplicate error alert inside the create exchange dialog + +### Requirement: Exchange Creation Retry After Interception + +The exchange creation page SHALL preserve the current form values after a backend rejection and allow the user to change assets and submit again after the request completes. + +#### Scenario: Preserve form after refund interception + +- **GIVEN** 创建换货单请求被后端退款规则拒绝 +- **WHEN** 页面展示后端业务原因 +- **THEN** 页面 MUST retain the selected old asset, new asset when applicable, flow type, reason, and other form values +- **AND** 提交按钮 MUST leave its loading state after the request completes + +#### Scenario: Retry with another asset + +- **GIVEN** 用户已收到退款拦截错误 +- **WHEN** 用户更换旧资产并再次提交创建换货单 +- **THEN** 系统 MUST send a new `POST /api/admin/exchanges` request using the updated form values + +### Requirement: Backend-Controlled Exchange Refund Eligibility + +The exchange creation page MUST rely exclusively on the exchange creation response to determine refund-related eligibility. The frontend MUST NOT add a refund status lookup, a refund pre-check area, or infer eligibility from refund approval, rejection, withdrawal, or completion states. + +#### Scenario: Do not pre-check refund eligibility + +- **WHEN** 用户打开或填写创建换货单表单 +- **THEN** 页面 MUST NOT issue a refund status query solely to determine exchange eligibility +- **AND** 页面 MUST NOT render a refund status pre-check area + +#### Scenario: Permit assets according to backend decision + +- **GIVEN** 用户选择曾有退款申请但后端允许换货的资产 +- **WHEN** 创建接口成功返回 +- **THEN** 页面 MUST treat the exchange creation as successful +- **AND** 页面 MUST NOT block the operation based on a frontend interpretation of refund history diff --git a/openspec/changes/update-exchange-refund-interception-feedback/tasks.md b/openspec/changes/update-exchange-refund-interception-feedback/tasks.md new file mode 100644 index 0000000..de521b4 --- /dev/null +++ b/openspec/changes/update-exchange-refund-interception-feedback/tasks.md @@ -0,0 +1,22 @@ +## 1. Exchange Creation Failure Contract + +- [x] 1.1 保持 `POST /api/admin/exchanges` 及其成功响应结构不变。 +- [x] 1.2 读取创建失败响应的统一 `code` 和 `msg`,不将后端业务原因替换为通用前端文案。 + +## 2. Refund Interception Feedback + +- [x] 2.1 通过统一 HTTP 错误处理展示后端返回的退款拦截业务原因,不在创建弹窗内额外渲染本地错误区域。 +- [x] 2.2 退款拦截失败后保留表单输入、已选资产和流程类型。 +- [x] 2.3 请求结束后恢复提交按钮,使用户能够更换资产并再次提交。 + +## 3. Backend-Controlled Eligibility + +- [x] 3.1 不增加退款状态查询、预检查区域或退款申请入口。 +- [x] 3.2 不根据退款审批、撤销、拒绝或处理状态自行判断资产能否换货。 + +## 4. Verification + +- [ ] 4.1 验证活跃退款资产创建换货时显示“该资产存在退款申请”或后端等价业务原因,且不创建换货单。 +- [ ] 4.2 验证已拒绝、撤销或处理完成退款的资产按后端结果可继续创建换货。 +- [x] 4.3 验证失败后保留表单并可更换资产重新提交。 +- [x] 4.4 运行相关前端校验,并执行 `openspec validate update-exchange-refund-interception-feedback --strict`。 diff --git a/openspec/changes/update-exchange-separate-asset-search-display/proposal.md b/openspec/changes/update-exchange-separate-asset-search-display/proposal.md new file mode 100644 index 0000000..1a452dd --- /dev/null +++ b/openspec/changes/update-exchange-separate-asset-search-display/proposal.md @@ -0,0 +1,29 @@ +# Change: 更新换货列表的新旧资产展示与独立搜索 + +## Why + +换货管理列表当前使用一个资产标识符条件同时匹配旧资产和新资产,且表格未完整区分两侧资产类型与标识。运营人员无法准确按换出资产或换入资产检索、识别换货单。 + +## What Changes + +- 将换货列表筛选区的单一“资产标识符”替换为“旧资产”和“新资产”两个独立输入框。 +- 列表查询使用 `old_asset_keyword` 和 `new_asset_keyword`;两个条件可独立或组合传递,空条件不传。 +- 停止在换货列表查询中使用旧的通用 `identifier` 参数。 +- 扩展换货列表项契约,支持旧资产和新资产各自的 `type`、`id`、`identifier`,以及 `status`、`status_name`。 +- 换货列表分别展示旧资产类型、旧资产标识、新资产类型、新资产标识四列,不能混列。 +- 物联网卡使用后端返回的 ICCID,设备使用后端返回的设备号;前端不转换接入号、虚拟号或其他输入标识。 + +## Impact + +- Affected specs: + - `exchange-management` +- Related active changes: + - `update-exchange-flow-direct-type`,本提案替换其换货列表通用资产标识符筛选口径,不改变流程类型筛选与换货操作。 + - `update-asset-exchange-generation-links`,仅共享换货领域概念,不修改资产详情换货链路展示。 +- Affected code: + - `src/api/modules/exchange.ts` + - `src/views/asset-management/exchange-management/index.vue` +- API contract: + - `GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=` +- Dependencies: + - 后端列表接口支持分别按旧资产和新资产关键词查询,并返回新旧资产字段。 diff --git a/openspec/changes/update-exchange-separate-asset-search-display/specs/exchange-management/spec.md b/openspec/changes/update-exchange-separate-asset-search-display/specs/exchange-management/spec.md new file mode 100644 index 0000000..96b58a5 --- /dev/null +++ b/openspec/changes/update-exchange-separate-asset-search-display/specs/exchange-management/spec.md @@ -0,0 +1,96 @@ +## ADDED Requirements + +### Requirement: Exchange List Separate Asset Query Contract + +The exchange management list integration SHALL query `GET /api/admin/exchanges` with optional `old_asset_keyword` and `new_asset_keyword` parameters instead of the deprecated generic `identifier` parameter. + +#### Scenario: Query by old asset only + +- **GIVEN** 用户在换货管理列表的“旧资产”输入框中填写关键词 +- **AND** “新资产”输入框为空 +- **WHEN** 用户执行搜索 +- **THEN** 请求 MUST include `old_asset_keyword` with the entered value +- **AND** 请求 MUST NOT include `new_asset_keyword` +- **AND** 请求 MUST NOT include `identifier` + +#### Scenario: Query by new asset only + +- **GIVEN** 用户在换货管理列表的“新资产”输入框中填写关键词 +- **AND** “旧资产”输入框为空 +- **WHEN** 用户执行搜索 +- **THEN** 请求 MUST include `new_asset_keyword` with the entered value +- **AND** 请求 MUST NOT include `old_asset_keyword` +- **AND** 请求 MUST NOT include `identifier` + +#### Scenario: Combine old and new asset keywords + +- **GIVEN** 用户在“旧资产”和“新资产”输入框中均填写关键词 +- **WHEN** 用户执行搜索 +- **THEN** 请求 MUST include both `old_asset_keyword` and `new_asset_keyword` +- **AND** 后端返回同时匹配两项条件的换货单 + +#### Scenario: Omit empty asset keywords + +- **GIVEN** “旧资产”和“新资产”输入框均为空 +- **WHEN** 用户查询、刷新、切换分页或重置搜索条件 +- **THEN** 请求 MUST NOT include `old_asset_keyword` +- **AND** 请求 MUST NOT include `new_asset_keyword` +- **AND** 请求 MUST NOT include `identifier` + +#### Scenario: Preserve raw asset keyword input + +- **GIVEN** 用户输入 ICCID、接入号、虚拟号、IMEI 或 SN 作为旧资产或新资产关键词 +- **WHEN** 页面发送列表查询请求 +- **THEN** 页面 MUST pass the entered text unchanged in its corresponding keyword parameter +- **AND** 页面 MUST NOT convert an access number, virtual number, or other identifier into another asset identifier + +### Requirement: Exchange List Separate Asset Display Contract + +The exchange list-item contract SHALL preserve `old_asset_type`, `old_asset_id`, `old_asset_identifier`, `new_asset_type`, `new_asset_id`, `new_asset_identifier`, `status`, and `status_name` from each `GET /api/admin/exchanges` item. + +#### Scenario: Parse old and new asset fields + +- **GIVEN** 换货列表接口返回一条换货单 +- **WHEN** 前端解析该列表项 +- **THEN** 前端类型 MUST support the old asset type, ID, and identifier fields independently from the new asset fields +- **AND** 前端类型 MUST support `status` and `status_name` + +### Requirement: Exchange List Separate Asset Columns + +The exchange management list SHALL display distinct columns for `旧资产类型`, `旧资产标识`, `新资产类型`, and `新资产标识`. + +#### Scenario: Display separate old and new IoT card assets + +- **GIVEN** 换货列表项的 `old_asset_type` 和 `new_asset_type` 均为 `iot_card` +- **WHEN** 页面渲染换货列表行 +- **THEN** 旧资产类型和新资产类型 MUST each display `物联网卡` +- **AND** 旧资产标识 MUST display `old_asset_identifier` as the backend-returned ICCID +- **AND** 新资产标识 MUST display `new_asset_identifier` as the backend-returned ICCID + +#### Scenario: Display separate old and new device assets + +- **GIVEN** 换货列表项的旧资产或新资产类型为 `device` +- **WHEN** 页面渲染换货列表行 +- **THEN** 对应资产类型 MUST display `设备` +- **AND** 对应资产标识 MUST display its backend-returned device number + +#### Scenario: Keep old and new asset values separated + +- **GIVEN** 换货列表接口返回新旧资产字段 +- **WHEN** 页面渲染换货列表行 +- **THEN** 旧资产列 MUST only read `old_asset_type` and `old_asset_identifier` +- **AND** 新资产列 MUST only read `new_asset_type` and `new_asset_identifier` +- **AND** 页面 MUST NOT substitute a missing new asset value with an old asset value + +#### Scenario: Display unavailable new asset placeholder + +- **GIVEN** 换货列表项尚未关联新资产,或新资产类型或标识为空 +- **WHEN** 页面渲染新资产列 +- **THEN** 页面 MUST display a stable placeholder for each unavailable value + +#### Scenario: Display backend exchange status name + +- **GIVEN** 换货列表项返回 `status_name` +- **WHEN** 页面渲染状态列 +- **THEN** 页面 MUST display `status_name` +- **AND** 当 `status_name` 缺失时页面 MUST display a stable placeholder diff --git a/openspec/changes/update-exchange-separate-asset-search-display/tasks.md b/openspec/changes/update-exchange-separate-asset-search-display/tasks.md new file mode 100644 index 0000000..026874d --- /dev/null +++ b/openspec/changes/update-exchange-separate-asset-search-display/tasks.md @@ -0,0 +1,26 @@ +## 1. API Contract And Types + +- [x] 1.1 将换货列表查询类型中的通用 `identifier` 替换为可选的 `old_asset_keyword` 与 `new_asset_keyword`。 +- [x] 1.2 扩展换货列表项类型,支持 `old_asset_type`、`old_asset_id`、`old_asset_identifier`、`new_asset_type`、`new_asset_id`、`new_asset_identifier`、`status` 和 `status_name`。 + +## 2. Exchange List Search + +- [x] 2.1 将换货管理列表筛选区的“资产标识符”替换为“旧资产”和“新资产”输入框。 +- [x] 2.2 查询、刷新和分页时保留并传递非空的 `old_asset_keyword`、`new_asset_keyword`,不再发送 `identifier`。 +- [x] 2.3 重置搜索条件时清空新旧资产关键词,后续请求不发送这两个参数。 +- [x] 2.4 保留用户输入的 ICCID、接入号、虚拟号、IMEI 或 SN 原始值,不在前端转换后再传递给后端。 + +## 3. Exchange List Display + +- [x] 3.1 增加旧资产类型、旧资产标识、新资产类型、新资产标识四个独立列表列。 +- [x] 3.2 根据后端资产类型显示“物联网卡”或“设备”,并原样显示对应后端资产标识。 +- [x] 3.3 状态列优先展示后端 `status_name`,缺失时显示稳定占位内容。 +- [x] 3.4 新资产字段在未分配时显示稳定占位内容,不与旧资产字段混用。 + +## 4. Verification + +- [ ] 4.1 验证仅输入旧资产、仅输入新资产、同时输入新旧资产时,请求分别传递正确的关键词参数。 +- [ ] 4.2 验证两个资产关键词均为空时不传筛选参数,重置后保持相同行为。 +- [ ] 4.3 验证旧资产和新资产类型、标识符分别显示,IoT 卡显示 ICCID、设备显示设备号。 +- [ ] 4.4 验证卡的 ICCID、接入号或虚拟号关键词均由后端按输入所在的旧/新资产条件命中,前端不转换输入值。 +- [x] 4.5 运行相关类型检查、lint 或构建验证。 diff --git a/openspec/changes/update-export-task-scene-pages/design.md b/openspec/changes/update-export-task-scene-pages/design.md index be66852..c1ea89e 100644 --- a/openspec/changes/update-export-task-scene-pages/design.md +++ b/openspec/changes/update-export-task-scene-pages/design.md @@ -32,11 +32,11 @@ - Decision: 业务列表创建导出任务按钮继续使用来源业务权限,默认建议如下。 - Rationale: 创建导出任务属于来源业务列表能力,不应因为拥有某个导出任务管理页面权限就获得业务列表导出入口。 -| 入口 | 创建导出任务权限 | scene | -| --- | --- | --- | -| 设备管理 | `devices:export` | `device` | +| 入口 | 创建导出任务权限 | scene | +| ---------- | ----------------- | ---------- | +| 设备管理 | `devices:export` | `device` | | IOT 卡管理 | `iot_card:export` | `iot_card` | -| 订单列表 | `orders:export` | `order` | +| 订单列表 | `orders:export` | `order` | - Decision: 详情页可继续复用同一组件,但必须根据任务场景或来源页面使用对应场景的详情、下载、取消权限。 - Rationale: 详情页面本身也包含下载和取消按钮,若继续使用通用权限会绕过三个列表页面的按钮隔离要求。 diff --git a/openspec/changes/update-july-iteration-frontend-integration/design.md b/openspec/changes/update-july-iteration-frontend-integration/design.md new file mode 100644 index 0000000..9ba8a59 --- /dev/null +++ b/openspec/changes/update-july-iteration-frontend-integration/design.md @@ -0,0 +1,51 @@ +## Context + +七月迭代后端实现已形成一份统一的前端联调说明,但需求横跨多个后台业务域,并同时涉及 H5/C 端接口。若不先固定边界,前端容易继续依赖旧字段、重复计算服务端事实,或误把 C 端工作纳入后台改造。 + +## Goals / Non-Goals + +- Goals: 固定后台页面的调用顺序、字段来源、权限边界、金额单位、异步任务和错误处理规则。 +- Goals: 明确 H5/C 端依赖并标注为外部范围,不在本提案任务中实施。 +- Non-Goals: 不新增后端接口,不修改数据库,不实现企微/Gateway/Redis/Asynq/对象存储服务端逻辑。 +- Non-Goals: 不修改 H5 或 C 端页面、组件和调用代码。 + +## Decisions + +- Decision: 后端返回值是业务事实来源。前端不得自行推导实名状态、支付方式、预计到期、可用金额、欠款、审批状态或异步任务状态。 +- Decision: 金额统一以分传输和计算,展示层才转换为元;信用和资金更新必须携带服务端返回的 `version`。 +- Decision: H5/C 端接口在规格中单独列为外部依赖,后台只负责在需要时保持接口契约一致。 +- Decision: 企微审批继续由原退款/线下充值业务入口触发,后台只读展示 `approval_provider`、`approval_status` 和 `approval_status_name`,不恢复旧人工审批按钮。 +- Decision: 设备批量分配复用设备导入任务页面和任务外壳,通过 `operation_type` 区分分配代理与设置套餐系列。 +- Decision: 系列套餐授权使用现有 `packages[]` 数组完成多选,授权详情中的 `package_id` 用于已授权标记,套餐列表中的公司成本价与授权成本价分开使用。 +- Decision: `docs/所需接口文档` 是本变更的接口契约索引;页面 API 类型、请求参数、枚举和响应字段必须以对应模块文档为准,不从七月说明中自行扩展路径。 +- Decision: 文件类业务统一采用 `POST /api/admin/storage/upload-url` 获取预签名 URL,直传成功后仅把 `file_key` 传给批量订购或设备分配接口。 +- Decision: 批量订购、设备分配和导出均保存服务端返回的任务 ID;页面恢复时查询原任务,终态后停止轮询,不能因刷新或超时重复创建。 +- Decision: 代理钱包在线充值和平台线下代充共用 `POST /api/admin/agent-recharges`,但使用两套互斥的表单模型。在线表单只允许 `amount`、`payment_method` 和 `request_id`;线下表单才允许 `shop_id`、`payment_voucher_key` 和 `remark`,不能把线下表单对象整体复用到在线请求中。 +- Decision: 在线充值弹窗打开时先请求 `/api/admin/agent-recharges/payment-methods`,支付方式和金额上下限完全由响应驱动。金额在界面显示为元,提交前转换为整数分;支付方式为空时禁止提交并显示无可用支付方式提示。 +- Decision: 在线创建成功后只使用返回的 `recharge_id` 查询 `/api/admin/agent-recharges/{id}/payment-status`,把原始 `qr_content` 交给二维码组件。二维码弹窗可见且订单非终态时每 3 秒轮询;弹窗隐藏、页面进入后台或状态进入 3/4/5/6 后停止轮询。 +- Decision: `request_id` 是一次主动在线充值的幂等键。网络超时或断开只用同一个 ID 重试一次并复用后端返回的原订单/二维码;用户关闭弹窗后重新发起充值或改变金额/支付方式时生成新的 ID,冲突响应不得覆盖原表单或自动创建新订单。 +- Decision: 充值列表和详情均以 `recharge_source` 做业务分支,并优先展示 `recharge_source_name`。在线单展示支付方式、第三方流水和支付/完成时间;线下单展示凭证、备注、提交人和审批状态。存在企微审批来源或实例时只读展示审批进度,不显示确认/驳回操作。 +- Decision: 充值相关导出权限按场景拆分:业务列表入口使用 `agent_recharge:export`,导出任务详情使用 `export_task:agent_recharge_detail`,下载文件使用 `export_task:agent_recharge_download`。权限不足时隐藏入口或操作,不通过前端绕过权限。 + +## Risks / Trade-offs + +- 接口 DTO 若未按说明返回,页面无法可靠展示审批人、历史资产标识或新旧字段;通过联调清单阻断实现前确认。 +- 支付配置为空或二维码内容为空时,在线充值无法继续;通过动态支付方式加载、空状态和联调环境中微信/支付宝二维码内容非空校验阻断上线。 +- 在线创建请求若错误生成新的 `request_id`,可能产生重复充值单;通过提交态保存 ID、幂等重试和冲突保护降低重复支付风险。 +- `status=2` 只代表第三方已支付,不代表钱包已经入账;通过以 `status=3` 作为余额刷新条件避免提前展示到账。 +- 设备批量分配接口文档已明确任务列表和详情仅平台用户可操作,前端不得向代理账号展示任务入口或通过前端绕过权限。 +- H5/C 端不在本次实现范围,后台只能依赖后端提供的最终字段,跨端验收需要单独安排。 +- 部分接口文档只给出字段模型,缺少业务错误码、权限编码和分页默认值;这些列入待确认清单。 + +## Migration Plan + +1. 先确认接口待补充项和后台权限编码。 +2. 按任务清单分模块适配后台页面和 API 类型。 +3. 完成后台接口 Mock/联调、权限组合和错误场景验收。 +4. H5/C 端另行创建或关联变更,不在本变更中合并实现。 + +## Open Questions + +- 退款和代理充值详情是否需要直接返回企微审批节点人员列表? +- 代理在线充值创建和查询的最终权限编码是否与现有 `agent_recharge:*` 权限保持一致;导出权限编码已由接口文档确定。 +- 批量任务、导出任务、企微审批和 Gateway 超时的稳定错误码及重试语义是什么? diff --git a/openspec/changes/update-july-iteration-frontend-integration/proposal.md b/openspec/changes/update-july-iteration-frontend-integration/proposal.md new file mode 100644 index 0000000..d753c38 --- /dev/null +++ b/openspec/changes/update-july-iteration-frontend-integration/proposal.md @@ -0,0 +1,27 @@ +# Change: 对接七月迭代后台实现与接口契约 + +## Why + +最新的七月迭代实现说明明确了后端已完成的接口、字段语义、前端调用顺序和联调边界。当前后台页面仍需要按这些最新契约完成筛选、展示、权限、审批、批量任务、导出和代理资金能力的统一对接,避免继续使用旧字段或前端自行推导业务规则。 + +## What Changes + +- 按 `00-七月迭代实现与接口对接说明.md` 更新后台管理端的接口调用和页面展示契约。 +- 按 `产品迭代7月份/000-代理钱包扫码充值接口对接说明.md` 和 `所需接口文档/000-代理钱包扫码充值接口对接.md` 补齐代理钱包在线扫码充值、线下代充和充值导出权限的前端契约。 +- 以 `docs/所需接口文档/01-店铺.md` 至 `21-设备批量分配.md` 为接口参数、响应字段、枚举、权限和错误语义的具体来源。 +- 对资产、店铺、订单、换货、套餐、资金、系列授权、审批、通知、批量任务、导出和 Gateway 相关页面补齐前端适配要求。 +- 统一使用后端返回的实名策略、支付方式、预计到期、审批状态、金额计算结果和任务状态,不在前端重复推导。 +- 代理在线充值只提交金额、动态可用支付方式和幂等 `request_id`,用后端返回的原始 `qr_content` 渲染二维码,并按充值状态轮询到帐结果。 +- 按 `recharge_source` 区分代理在线自充与平台线下代充;线下单展示凭证和审批状态,在线单隐藏线下字段和人工审批操作。 +- 补齐代理充值列表导出入口、导出任务详情和下载的权限编码。 +- 补齐 CSV 上传模板、异步任务恢复、权限按钮、错误处理和并发冲突处理要求。 +- 明确预签名上传先取 `file_key`、再调用批量业务接口的两阶段流程,并固定批量订购、设备分配和导出任务的终态处理规则。 +- **不实施 H5/C 端页面改动**,但在规格和联调清单中标注其依赖的接口及外部工作项。 +- **不实施后端接口、数据库、Worker、企微回调、Gateway 和对象存储改造**;本变更只描述后台前端联调所需的适配边界。 + +## Impact + +- Affected specs: `july-iteration-frontend-integration` +- Affected code: 后台资产、店铺、订单、换货、套餐、代理充值与资金、系列授权、审批配置、通知、批量任务和导出页面;重点涉及 `src/views/finance/agent-recharge`、`src/api/modules/agentRecharge.ts` 和 `src/types/api/agentRecharge.ts` +- External follow-up: H5/C 端认证限制、实名流程、支付按钮、C 端订单续费、C 端通知和 C 端资产展示 +- Approval gate: 接口待确认项完成前不进入大范围实现 diff --git a/openspec/changes/update-july-iteration-frontend-integration/specs/july-iteration-frontend-integration/spec.md b/openspec/changes/update-july-iteration-frontend-integration/specs/july-iteration-frontend-integration/spec.md new file mode 100644 index 0000000..929d601 --- /dev/null +++ b/openspec/changes/update-july-iteration-frontend-integration/specs/july-iteration-frontend-integration/spec.md @@ -0,0 +1,319 @@ +## ADDED Requirements + +### Requirement: Backend-first field semantics + +The admin frontend SHALL use backend-provided business facts for real-name status, payment methods, estimated expiry, approval status, task status, balances, available amounts, debt amounts, and asset identifiers. The frontend MUST NOT infer or recalculate these values from partial data. + +#### Scenario: Payment methods are displayed from the API + +- **WHEN** the asset or recharge API returns `allowed_payment_methods` +- **THEN** the admin or related integration UI uses that list instead of hard-coded payment rules + +#### Scenario: Financial amounts are displayed from the API + +- **WHEN** a fund or credit response returns calculated balances +- **THEN** the frontend displays the returned values in fen-based data and only converts them for presentation + +### Requirement: Admin asset and shop integration + +The admin frontend SHALL support the confirmed July filters and fields for shops, IoT cards, devices, orders, exchanges, and assets, including contact-phone search, salesperson data, client login restriction, real-name filters, separate old/new exchange searches, asset identifiers, and estimated final expiry. + +#### Scenario: Exchange assets are searched independently + +- **WHEN** a user enters old and new asset search terms +- **THEN** the frontend sends `old_asset_keyword` and `new_asset_keyword` independently and preserves AND filtering semantics + +#### Scenario: A shop login restriction is edited + +- **WHEN** an authorized user updates a shop +- **THEN** the request includes the optional `client_login_disabled` value and the list/detail views display the returned value + +### Requirement: Package allocation and series authorization + +The admin frontend SHALL use the existing package and series authorization APIs for default expiry settings, override expiry settings, package multi-selection, authorization detail lookup, and package add/update/remove operations. + +#### Scenario: A series authorization is edited + +- **WHEN** a user opens an existing authorization +- **THEN** the frontend queries by authorization record ID, marks authorized package IDs, and keeps company cost price separate from authorization cost price + +#### Scenario: Authorized packages are updated + +- **WHEN** a user submits package changes +- **THEN** the frontend sends a 1–100 item `packages[]` array, uses `remove=true` for removals, and loads additional package pages when the series contains more than 100 packages + +### Requirement: Credit and system configuration integration + +The admin frontend SHALL integrate role default credit, shop actual credit, fund summary, and controlled system configuration APIs using fen-based amounts, server-calculated values, optimistic-lock versions, and registered configuration validation metadata. + +#### Scenario: A shop credit limit update conflicts + +- **WHEN** the credit update is rejected because the wallet `version` is stale +- **THEN** the frontend refreshes the latest fund summary and asks the user to retry with the latest version + +#### Scenario: A configuration is read-only + +- **WHEN** a configuration item is returned with `readonly=true` +- **THEN** the frontend disables its editing control and does not submit an update for that item + +### Requirement: WeCom approval status integration + +The admin frontend SHALL provide the confirmed WeCom configuration workflow and SHALL display business approval status as read-only for refund and offline recharge records when the approval provider is WeCom or an approval instance exists. + +#### Scenario: A WeCom business record is awaiting approval + +- **WHEN** a refund or offline recharge record has `approval_provider=wecom` or an `approval_instance_id` +- **THEN** the frontend displays the backend approval status and hides legacy manual approval actions + +#### Scenario: A template mapping is rejected + +- **WHEN** the backend rejects a scene template mapping +- **THEN** the frontend preserves the submitted form values and displays the backend validation message + +### Requirement: Notifications, asynchronous tasks, exports, and Gateway controls + +The admin frontend SHALL integrate low-balance notifications, expiring asset summaries, batch purchase tasks, export tasks, card speed tiers, and device allocation tasks according to the confirmed task, CSV, datasource, and timeout semantics. + +#### Scenario: A batch task is restored + +- **WHEN** a user reloads or re-enters a batch purchase or device allocation page +- **THEN** the frontend queries the existing task detail using the saved task identifier and does not create a duplicate task + +#### Scenario: A speed tier is selected + +- **WHEN** a user operates on an IoT card +- **THEN** the frontend submits a fixed speed-tier code from `-1` through `8`; a device page does not expose a speed-tier control + +### Requirement: H5 and C-end work is explicitly external + +The July integration specification SHALL identify H5/C-end changes as external follow-up work. This change MUST NOT modify H5/C-end pages or their API call implementations. + +#### Scenario: A requirement depends on C-end behavior + +- **WHEN** a requirement involves C-end login restriction, real-name flow, payment methods, asset display, order renewal, or C-end notifications +- **THEN** the item is marked as an external dependency and excluded from this change's implementation tasks + +### Requirement: Permission and error boundaries + +The admin frontend SHALL apply confirmed permission codes to new pages, buttons, and row actions, enforce data isolation in the UI, and present stable user-facing errors for authentication, authorization, validation, timeout, conflict, and unknown-result cases. + +#### Scenario: A user lacks an action permission + +- **WHEN** the current account does not have the configured permission code +- **THEN** the corresponding page action is hidden or disabled and the frontend does not invoke the API + +#### Scenario: An asynchronous operation has an unknown result + +- **WHEN** a task or Gateway operation times out with an unknown result +- **THEN** the frontend preserves the current data, shows a safe status message, and provides a refresh or manual verification path instead of blindly retrying + +### Requirement: Documented API contract alignment + +The admin frontend SHALL implement the July integration against the documented API paths and request/response contracts. The documented interface files under `docs/所需接口文档/` SHALL be treated as the source of truth for parameter names, enum values, nullable fields, pagination, permissions, and HTTP error semantics. + +#### Scenario: A page loads a documented resource + +- **WHEN** a July page queries or mutates a resource +- **THEN** it uses the corresponding documented `/api/admin` endpoint and sends the documented parameter names without translating them to legacy frontend names + +#### Scenario: A response contains a nullable or enumerated field + +- **WHEN** a documented response returns a nullable field, numeric status, string enum, or `*_name` display field +- **THEN** the frontend preserves the nullable state, uses the documented enum values, and prefers the backend display name instead of inventing a local label + +#### Scenario: An API rejects a request + +- **WHEN** a documented endpoint returns `400`, `401`, `403`, or `500` +- **THEN** the frontend applies the shared authentication, authorization, validation, server-error, and retry-safe handling without exposing raw transport details as the user-facing message + +### Requirement: File upload and batch task contract + +The admin frontend SHALL use `POST /api/admin/storage/upload-url` for file-backed July operations, upload to the returned `upload_url` with the requested content type, and pass only the returned `file_key` to the business endpoint. Batch purchase and device allocation CSVs SHALL be UTF-8 single-column files, accept an optional UTF-8 BOM, stay within 10 MB and 1000 data rows, and preserve identifiers as text. + +#### Scenario: A batch purchase is submitted + +- **WHEN** the user selects one package and one payment method and uploads a valid CSV +- **THEN** the frontend calls `POST /api/admin/asset-package-batch-orders` with `file_key`, `package_id`, `payment_method`, and, for offline payment, 1–5 `voucher_keys` + +#### Scenario: A device allocation is submitted + +- **WHEN** a platform user selects an allocation target and uploads a valid device CSV +- **THEN** the frontend calls `POST /api/admin/devices/import/allocations` with `file_key`, `operation_type` set to `assign_shop` or `assign_series`, and the selected `target_id` + +#### Scenario: A batch task is resumed + +- **WHEN** the page is reloaded while a task is non-terminal +- **THEN** the frontend queries the saved task ID, displays the backend `status_name` and progress/result counts, stops polling at the documented terminal state, and never creates a second task + +### Requirement: WeCom configuration API workflow + +The admin frontend SHALL implement the documented WeCom configuration sequence: list or save applications through `/api/admin/wecom/applications`, test the application, synchronize and query visible members, set the default creator, bind system accounts through `/api/admin/accounts/{account_id}/wecom-binding`, and save or query the two supported scene mappings. + +#### Scenario: A WeCom application is configured + +- **WHEN** a super administrator saves valid application credentials +- **THEN** the frontend refreshes the application, allows connection testing, and clearly distinguishes `data.success=true` for access-token acquisition from completed member, template, and callback configuration + +#### Scenario: A default creator is selected + +- **WHEN** the user sets a default creator +- **THEN** the frontend only submits a `userid` selected from the latest synchronized visible-member list and preserves the real local submitter in business records + +#### Scenario: A scene mapping is validated + +- **WHEN** the user saves `refund_approval` or `offline_recharge_approval` +- **THEN** the frontend submits `application_id`, `template_id`, `control_mapping`, and `status`, preserves input on validation failure, and displays the returned verification/status fields after success + +### Requirement: Approval and financial business API contract + +The admin frontend SHALL use the documented refund and agent-recharge APIs for business creation and read-only approval tracking. Amounts and voucher keys SHALL follow the documented units and cardinality, and a record with `approval_provider=wecom` or `approval_instance_id` SHALL not expose legacy manual approval actions. + +#### Scenario: An offline agent recharge is created + +- **WHEN** a platform user submits an offline recharge +- **THEN** the frontend calls `POST /api/admin/agent-recharges` with the target `shop_id`, amount in fen, `payment_method=offline`, and 1–5 voucher keys + +#### Scenario: A refund is awaiting WeCom approval + +- **WHEN** `GET /api/admin/refunds` or `GET /api/admin/refunds/{id}` returns a WeCom approval instance +- **THEN** the frontend displays `approval_status_name`, `submitter_name`, and documented amount fields as read-only and does not show approve/reject actions + +#### Scenario: Approval submission has an unknown result + +- **WHEN** `approval_status=8` or the documented status name indicates an unknown submission result +- **THEN** the frontend does not recreate the business record and offers refresh or manual verification instead + +### Requirement: Documented device allocation permissions + +The admin frontend SHALL enforce the documented permission boundary for device allocation task list and detail APIs. Because those APIs are documented as platform-only, agent and enterprise accounts SHALL not see the allocation task entry or be allowed to invoke the task list/detail endpoints from the UI. + +#### Scenario: A non-platform user opens device allocation + +- **WHEN** an agent or enterprise account loads the device allocation area +- **THEN** the frontend hides the entry and does not call `/api/admin/devices/import/tasks` or `/api/admin/devices/import/tasks/{id}` + +### Requirement: Agent Wallet Online Recharge Contract + +The admin frontend SHALL support the documented agent wallet online QR recharge flow through `/api/admin/agent-recharges/payment-methods` and `/api/admin/agent-recharges`. The available payment methods and amount limits SHALL come from the payment-methods response; the online create request SHALL contain only `amount` in integer fen, `payment_method` from the returned methods, and a required `request_id`. Online agent recharge requests MUST NOT send `shop_id`, `payment_voucher_key`, `remark`, merchant credentials, or payment configuration identifiers. + +#### Scenario: Load available online payment methods + +- **WHEN** an agent opens the online recharge dialog +- **THEN** the frontend calls `GET /api/admin/agent-recharges/payment-methods` +- **AND** it displays only the returned `methods` values (`wechat` and/or `alipay`) +- **AND** it validates the integer-fen amount against the returned `min_amount` and `max_amount` + +#### Scenario: No online payment method is available + +- **WHEN** the payment-methods response returns an empty `methods` array +- **THEN** the frontend hides or disables the submit action +- **AND** it displays that no online payment method is currently available +- **AND** it does not call the online create endpoint + +#### Scenario: Create an online QR recharge + +- **WHEN** an authorized agent submits a valid online recharge +- **THEN** the frontend calls `POST /api/admin/agent-recharges` with `amount`, `payment_method`, and `request_id` +- **AND** it converts the displayed yuan amount to an integer amount in fen before submission +- **AND** it uses the returned `recharge_id` and does not derive the target shop or wallet from user-editable form fields + +#### Scenario: Render the returned QR content + +- **WHEN** online recharge creation succeeds with a non-empty `qr_content` +- **THEN** the frontend passes `qr_content` unchanged to the QR-code component +- **AND** it does not parse, concatenate, replace, upload, persist, or log the QR content + +### Requirement: Idempotent Online Recharge Submission + +The frontend SHALL preserve one `request_id` for the lifetime of one active online recharge submission. A timeout or network interruption MAY retry the same submission once with the same `request_id`, but a user-initiated new recharge MUST generate a new request ID. A conflict caused by reusing the same request ID with a different amount or payment method SHALL preserve the existing form state and require an explicit new recharge attempt. + +#### Scenario: Retry after an uncertain create result + +- **WHEN** the online create request times out or the network disconnects +- **THEN** the frontend retries at most once with the original `request_id` +- **AND** it does not create a new `request_id` or blindly open a second recharge dialog + +#### Scenario: Start a new recharge after closing the dialog + +- **WHEN** the user closes the QR dialog and clicks online recharge again +- **THEN** the frontend creates a new `request_id` +- **AND** the new amount and payment method are treated as a new active submission + +#### Scenario: Reused request ID conflicts + +- **WHEN** the backend rejects a request because the same `request_id` is paired with different recharge parameters +- **THEN** the frontend displays the backend business message +- **AND** it does not overwrite the original request or automatically submit another amount + +### Requirement: Online Payment And Wallet Arrival Polling + +The online QR recharge dialog SHALL poll only the documented local payment-status endpoint `GET /api/admin/agent-recharges/{id}/payment-status`. While the QR dialog is visible and the recharge is non-terminal, the frontend SHALL poll at approximately three-second intervals. The frontend SHALL stop polling when the dialog is hidden, the page is not visible, or the recharge status is `3` (completed), `4` (closed), `5` (refunded), or `6` (rejected). It MUST treat `status=2` as third-party payment completed but wallet arrival still processing; only `status=3` permits refreshing the wallet balance and recharge list. + +#### Scenario: Payment is waiting for the user + +- **WHEN** payment-status returns `status=1` and `payment_status=0` +- **THEN** the dialog displays that the user is waiting to scan and pay +- **AND** polling continues while the dialog is visible + +#### Scenario: Payment is received but wallet arrival is processing + +- **WHEN** payment-status returns `status=2` and `payment_status=1` +- **THEN** the dialog displays payment success and wallet arrival processing +- **AND** the frontend does not increase or refresh the wallet balance as completed +- **AND** polling continues + +#### Scenario: Wallet arrival completes + +- **WHEN** payment-status returns `status=3` +- **THEN** the frontend displays that the recharge has arrived +- **AND** it stops polling +- **AND** it refreshes the current wallet balance and recharge list + +#### Scenario: Online recharge reaches a terminal failure state + +- **WHEN** payment-status returns `status=4`, `status=5`, or `status=6` +- **THEN** the frontend displays the backend status name or an appropriate failure message +- **AND** it stops polling without automatically creating a replacement recharge + +### Requirement: Recharge Source And Operation Boundaries + +The recharge list and detail pages SHALL use `recharge_source` as the only business branch: `agent_online` for agent online self-recharge and `platform_offline` for platform offline代充. They SHALL prefer `recharge_source_name` for display and use the documented list filters `shop_id`, `status`, `recharge_source`, `start_date`, and `end_date`. Online records SHALL show payment information and payment/completion timestamps while hiding offline vouchers and manual approval operations. Offline records SHALL show vouchers, remarks, submitter, and approval status; they SHALL not show QR content or invoke the online payment-status endpoint. + +#### Scenario: Filter online recharge records + +- **WHEN** a user selects online recharge source or status filters +- **THEN** the frontend requests `GET /api/admin/agent-recharges` with the documented filter names and values +- **AND** it displays the backend recharge source name, amount, payment method, status, submitter, and timestamps + +#### Scenario: Display a platform offline recharge + +- **WHEN** a platform user opens a record with `recharge_source=platform_offline` +- **THEN** the detail view displays the payment voucher, remark, submitter, and approval status +- **AND** it does not render a QR code or start payment-status polling + +#### Scenario: Confirm or reject an offline recharge + +- **WHEN** a pending offline recharge has no WeCom approval provider or approval instance +- **THEN** an authorized platform user MAY confirm it through `POST /api/admin/agent-recharges/{id}/offline-pay` with `operation_password` +- **AND** an authorized platform user MAY reject it through `POST /api/admin/agent-recharges/{id}/reject` with a required `rejection_reason` of at most 500 characters + +#### Scenario: WeCom approval controls the offline record + +- **WHEN** an offline recharge has `approval_provider=wecom` or an `approval_instance_id` +- **THEN** the frontend displays approval progress as read-only +- **AND** it hides confirm and reject operations + +### Requirement: Agent Recharge Export Permissions + +The agent recharge list SHALL show its export entry only with `agent_recharge:export`. The export-task page SHALL protect the `agent_recharge` detail and download operations with `export_task:agent_recharge_detail` and `export_task:agent_recharge_download`, respectively. The frontend SHALL hide unauthorized controls and SHALL NOT invoke the corresponding API as a fallback. + +#### Scenario: An authorized user exports agent recharges + +- **WHEN** the current account has `agent_recharge:export` +- **THEN** the agent recharge page displays the export entry and opens the `agent_recharge` export flow + +#### Scenario: An unauthorized user accesses export actions + +- **WHEN** the current account lacks an export permission +- **THEN** the related entry, detail action, or download action is hidden +- **AND** the frontend does not call the export-task detail or download endpoint diff --git a/openspec/changes/update-july-iteration-frontend-integration/tasks.md b/openspec/changes/update-july-iteration-frontend-integration/tasks.md new file mode 100644 index 0000000..f224fc7 --- /dev/null +++ b/openspec/changes/update-july-iteration-frontend-integration/tasks.md @@ -0,0 +1,71 @@ +## 1. Contract and API Types + +- [x] 1.1 根据 `docs/所需接口文档/01-店铺.md` 至 `21-设备批量分配.md` 以及最新说明核对所有纳入本期范围的 API 类型。 +- [x] 1.2 补齐 `purchase_role`、`asset_identifier`、`effective_realname_policy`、`allowed_payment_methods`、预计到期和换货链路字段。 +- [x] 1.3 补齐退款、代理充值和换货的提交人及审批状态字段。 +- [x] 1.4 补齐系统配置、批量任务、导出任务、Gateway 限速和设备分配任务字段。 +- [x] 1.5 为金额、版本号、枚举和 nullable 字段增加明确的前端类型约束。 + +## 2. Asset, Shop, Order, and Exchange Pages + +- [x] 2.1 店铺列表接入联系电话精确查询、业务员筛选和 `client_login_disabled` 展示/编辑。 +- [x] 2.2 资产卡和设备列表接入 `real_name_status` 筛选及状态展示。 +- [x] 2.3 实名策略页面接入单条和最多 500 条批量修改,并处理全成全败结果。 +- [x] 2.4 换货列表拆分旧资产和新资产搜索参数,并展示新旧资产信息。 +- [x] 2.5 资产、订单和 C 端相关后台页面展示正确的订单渠道及资产标识。 +- [x] 2.6 展示预计最终到期时间并按后端临期字段高亮。 +- [x] 2.7 退款中资产创建换货时展示后端业务错误,不调用企微实时查询。 +- [x] 2.8 历史订单或当前资产续费继续调用现有订单创建接口,不新增续费接口。 + +## 3. Package, Fund, and Authorization Pages + +- [x] 3.1 套餐页面传递 `expiry_base` 并展示默认生效条件。 +- [x] 3.2 套餐分配页面传递 `expiry_base_override` 并展示最终生效条件。 +- [x] 3.3 系列授权页面支持 1~100 个套餐多选,创建使用 `packages[]`。 +- [x] 3.4 系列授权编辑使用授权记录 ID 查询详情,区分公司成本价和授权成本价。 +- [x] 3.5 系列授权套餐管理使用 `remove=true` 删除项,并继续分页加载超过 100 个套餐的系列。 +- [x] 3.6 角色默认信用、店铺实际信用和资金概况统一使用分单位字段。 +- [x] 3.7 店铺调额携带资金 `version`,并处理并发冲突刷新和重试。 +- [x] 3.8 系统配置页面接入卡/设备支付方式配置,提交 JSON 数组字符串并保留至少一种支付方式。 + +## 4. WeCom Approval and Business Status + +- [x] 4.1 实现企微应用配置页的应用列表、保存、测试连接和状态展示。 +- [x] 4.2 实现成员同步、成员查询、默认发起人设置和账号绑定入口。 +- [x] 4.3 实现退款和线下代充值场景模板映射配置及后端校验错误保留输入。 +- [x] 4.4 退款、代理充值列表/详情只读展示审批状态,隐藏企微业务的旧人工审批按钮。 +- [x] 4.5 对提交中、审批中、已通过、已拒绝、已撤销、通过后撤销、已删除、提交失败和提交结果未知状态使用后端名称。 + +## 5. Notifications, Async Tasks, Exports, and Gateway + +- [x] 5.1 接入后台低余额通知列表、未读数和已读操作。 +- [x] 5.2 接入临期资产列表、统计摘要、优先级和高亮规则。 +- [x] 5.3 批量订购使用 UTF-8 单列 CSV、上传用途、套餐和支付方式独立选择。 +- [x] 5.4 批量订购任务展示逐行成功/失败结果并支持任务详情恢复。 +- [x] 5.5 导出任务支持本期六类 datasource、筛选条件 `query`、统一任务状态、进度和完成后 24 小时下载链接;保留接口文档中已有的设备/订单场景兼容性。 +- [x] 5.6 卡页面接入 ICCID 固定限速档位 `-1~8`,设备页面不展示限速入口。 +- [x] 5.7 设备批量分配复用导入任务页面,支持 `assign_shop` 和 `assign_series` 两种 operation_type。 +- [x] 5.8 提供两份静态 UTF-8 CSV 模板,校验单列、10MB 和最多 1000 行限制。 + +## 6. Permissions, Errors, and Verification + +- [x] 6.1 为新增页面、按钮和行操作接入最终确认的权限编码。 +- [x] 6.2 统一处理 400、401、403、业务错误、超时和结果未知,不使用底层错误直接作为用户文案。 +- [x] 6.3 验证代理数据隔离、平台权限、敏感字段脱敏和财务操作审计入口。 +- [ ] 6.4 完成后台页面的接口联调、权限组合、空数据、错误、并发冲突和任务恢复验收。 + +## 7. Agent Wallet QR Recharge + +- [ ] 7.1 Extend `AgentRecharge` types and `AgentRechargeService` for `alipay`, dynamic payment methods, amount bounds, `request_id`, `qr_content`, `payment_status`, and the documented payment-status endpoint while preserving nullable fields. +- [ ] 7.2 Add the agent online recharge dialog flow that loads `/api/admin/agent-recharges/payment-methods`, renders only returned payment methods, validates the returned fen bounds, and disables submission when no method is available. +- [ ] 7.3 Split online and offline create payloads; submit online recharge with only `amount`, `payment_method`, and a generated `request_id`, render the returned raw `qr_content`, and prevent leakage of shop, voucher, remark, or payment configuration fields. +- [ ] 7.4 Implement idempotent online-create retry and conflict handling: reuse the original `request_id` for one uncertain retry, generate a new ID for a new user action, and preserve the original form on request conflicts. +- [ ] 7.5 Implement visible-dialog payment polling with a three-second interval, stop on hidden/background or terminal status, distinguish `status=2` payment success from `status=3` wallet arrival, and refresh the wallet balance/list only after completion. +- [ ] 7.6 Update recharge list/detail branches to use `recharge_source`, document the online/offline field visibility rules, keep offline confirmation/rejection conditional on the absence of WeCom approval, and prevent online records from invoking offline operations. +- [ ] 7.7 Apply agent recharge export permissions: `agent_recharge:export`, `export_task:agent_recharge_detail`, and `export_task:agent_recharge_download`; add focused tests for role visibility, payload exclusion, idempotent retry, polling termination, and status transitions. + +## 8. Explicitly Out Of Scope + +- H5/C 端登录限制、实名流程、支付按钮、资产详情、订单续费和 C 端通知不在本次实现任务内。 +- H5 首页隐藏设备下 ICCID 仍按最新说明标记为不做。 +- H5/C 端相关接口仅作为后台联调依赖记录,不在本变更中修改页面或调用代码。 diff --git a/openspec/changes/update-notification-drawer-pagination/design.md b/openspec/changes/update-notification-drawer-pagination/design.md new file mode 100644 index 0000000..98601bf --- /dev/null +++ b/openspec/changes/update-notification-drawer-pagination/design.md @@ -0,0 +1,14 @@ +## Context + +顶部通知抽屉已经通过通知列表接口加载最近 10 条,但当前共享状态没有保存列表分页信息。分页应复用现有通知列表接口,避免新增接口或改变通知数据契约。 + +## Decisions + +- Decision: 分页状态由通知 store 保存,包括当前页、每页数量和总条数;列表请求返回后整体替换 `recentNotifications`。 +- Decision: 页码切换使用 `GET /api/admin/notifications?page=&page_size=10`,加载期间沿用现有 loading 状态。 +- Decision: 分类切换将页码重置为 1,并重新加载列表;不使用滚动事件触发请求。 +- Decision: 分页控件放在通知列表底部,列表区域继续保持固定高度,避免抽屉整体布局跳动。 + +## Risks / Trade-offs + +- 分类当前由抽屉对已加载页面进行过滤,分类页的总数仍由通知列表接口返回的总体 `total` 表示;本次不扩展后端分类分页契约。 diff --git a/openspec/changes/update-notification-drawer-pagination/proposal.md b/openspec/changes/update-notification-drawer-pagination/proposal.md new file mode 100644 index 0000000..c761f4f --- /dev/null +++ b/openspec/changes/update-notification-drawer-pagination/proposal.md @@ -0,0 +1,24 @@ +# Change: 通知抽屉改为分页浏览 + +## Why + +顶部通知抽屉目前只展示最近 10 条通知,用户无法通过页码查看更早的通知。通知列表应使用明确的分页操作,避免依赖下滑加载更多的交互。 + +## What Changes + +- 顶部通知抽屉增加页码分页控件,默认每页展示 10 条。 +- 切换页码时调用通知列表接口并替换当前列表,不追加滚动加载。 +- 切换通知分类时重置到第 1 页,并保持现有分类统计、已读和跳转行为。 +- 保留 `/api/admin/notifications` 的 `page`、`page_size` 分页参数和后端返回的 `total`。 + +## Impact + +- Affected specs: + - `notification-center` +- Affected code: + - `src/components/core/layouts/art-notification/index.vue` + - `src/components/core/layouts/art-notification/style.scss` + - `src/store/modules/notification.ts` +- Out of scope: + - 不改变通知接口、通知分类统计和已读接口。 + - 不接入下滑加载、无限滚动或新的实时推送机制。 diff --git a/openspec/changes/update-notification-drawer-pagination/specs/notification-center/spec.md b/openspec/changes/update-notification-drawer-pagination/specs/notification-center/spec.md new file mode 100644 index 0000000..abf9a8f --- /dev/null +++ b/openspec/changes/update-notification-drawer-pagination/specs/notification-center/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: Global Notification Bell + +The admin frontend SHALL provide a global notification bell in the top navigation near the settings and user avatar entries. + +#### Scenario: Display unread count + +- **GIVEN** 用户已登录后台 +- **WHEN** 顶部导航加载未读通知数量 +- **THEN** 铃铛 MUST call `GET /api/admin/notifications/unread-count` +- **AND** 数量 MUST display as `0`, `1` through `99`, or `99+` + +#### Scenario: Open paginated notification drawer + +- **WHEN** 用户点击顶部通知铃铛 +- **THEN** 页面 MUST display notification items from `GET /api/admin/notifications` using `page=1` and `page_size=10` +- **AND** 抽屉 MUST provide 全部、审批、临期、同步/系统分类 +- **AND** 抽屉 MUST provide page controls based on the response `total` +- **AND** changing page MUST replace the visible items instead of appending items from a scroll event +- **AND** changing category MUST reset the page to 1 +- **AND** 抽屉 MUST provide an entry to `/notifications` diff --git a/openspec/changes/update-notification-drawer-pagination/tasks.md b/openspec/changes/update-notification-drawer-pagination/tasks.md new file mode 100644 index 0000000..b9278b1 --- /dev/null +++ b/openspec/changes/update-notification-drawer-pagination/tasks.md @@ -0,0 +1,15 @@ +## 1. Notification State + +- [x] 1.1 在通知 store 中保存当前页、每页数量和列表总数。 +- [x] 1.2 支持按指定页请求通知,并用新结果替换当前列表。 + +## 2. Notification Drawer + +- [x] 2.1 在通知列表底部增加页码分页控件,默认每页 10 条。 +- [x] 2.2 切换页码时重新请求列表并回到列表顶部。 +- [x] 2.3 切换分类时重置页码并保持现有分类、已读和跳转行为。 + +## 3. Verification + +- [x] 3.1 验证分页请求携带正确的 `page` 和 `page_size`,且列表不会累加旧页数据。 +- [x] 3.2 运行通知相关 lint、类型、格式、样式和编码检查;当前仓库暂无通知专项测试。 diff --git a/openspec/changes/update-package-allocation-expiry-base-override/proposal.md b/openspec/changes/update-package-allocation-expiry-base-override/proposal.md new file mode 100644 index 0000000..4b2053f --- /dev/null +++ b/openspec/changes/update-package-allocation-expiry-base-override/proposal.md @@ -0,0 +1,36 @@ +# Change: 支持套餐分配生效条件覆盖 + +## Why + +代理套餐分配目前只能使用套餐本身的默认生效条件,运营无法针对某个店铺套餐授权选择购买即生效或实名即生效。分配页也无法区分套餐默认值、分配覆盖值和最终实际生效值,容易误解后续订单的生效规则。 + +## What Changes + +- 在套餐授权/分配和已分配套餐编辑入口增加“生效条件”单选:跟随套餐默认、购买即生效、实名激活时生效。 +- 新建分配时调用 `POST /api/admin/shop-package-allocations`,明确传递 `expiry_base_override`;选择“跟随套餐默认”时 MUST 显式传递 `null`,不得省略字段。 +- 系列授权创建、系列授权新增套餐和批量套餐分配时,均 MUST 显式传递 `expiry_base_override`;该配置只作用于本次新建分配。 +- 已分配套餐修改生效条件时调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,请求体为 `expiry_base_override:null|from_purchase|from_activation`。 +- 在分配详情、编辑弹框和代理系列授权套餐列表展示后端返回的默认生效条件、覆盖生效条件和最终生效条件的中文名称。 +- 修改弹框提示“仅影响后续新订单,不影响已购买套餐”。 +- 前端不根据默认值和覆盖值自行计算最终生效条件,直接使用后端返回的 `effective_expiry_base` 及其名称字段。 + +## Impact + +- Affected specs: + - `shop-package-allocation` + - `shop-series-grant-packages` +- Affected code: + - `src/types/api/packageManagement.ts` + - 新增或扩展 `src/api/modules` 中的店铺套餐分配服务 + - `src/views/package-management/series-grants/index.vue` + - `src/views/package-management/series-grants/packages.vue` + - `src/views/package-management/series-grants/detail.vue` +- API contracts: + - `POST /api/admin/shop-package-allocations` + - `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` + - `POST /api/admin/shop-series-grants` + - `PUT /api/admin/shop-series-grants/{id}/packages` + - `POST /api/admin/shop-package-batch-allocations` +- Out of scope: + - 已购买套餐或历史订单的生效条件迁移 + - 前端根据分配字段推导或覆盖实际生效条件 diff --git a/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md new file mode 100644 index 0000000..999365a --- /dev/null +++ b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-package-allocation/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: Package Allocation Expiry Base Override Contract + +The shop package allocation contract SHALL support an explicit `expiry_base_override` when creating or updating an allocation. The allowed request values MUST be `null`, `from_purchase`, and `from_activation`. The frontend MUST NOT send `from_realname`. + +#### Scenario: Create allocation following the package default + +- **GIVEN** 用户在套餐授权/分配弹框选择“跟随套餐默认” +- **WHEN** 用户创建店铺套餐分配 +- **THEN** 系统 MUST call `POST /api/admin/shop-package-allocations` +- **AND** 请求 MUST explicitly contain `expiry_base_override: null` +- **AND** 系统 MUST NOT omit `expiry_base_override` to represent following the default + +#### Scenario: Create allocation with purchase activation override + +- **GIVEN** 用户在套餐授权/分配弹框选择“购买即生效” +- **WHEN** 用户创建店铺套餐分配 +- **THEN** 请求 MUST contain `expiry_base_override: "from_purchase"` + +#### Scenario: Create allocation with activation override + +- **GIVEN** 用户在套餐授权/分配弹框选择“实名激活时生效” +- **WHEN** 用户创建店铺套餐分配 +- **THEN** 请求 MUST contain `expiry_base_override: "from_activation"` + +### Requirement: Package Allocation Expiry Base Update + +The system SHALL update an existing package allocation's expiry-base override through `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` with a body containing `expiry_base_override`. + +#### Scenario: Update allocation expiry base override + +- **GIVEN** 用户正在编辑已分配套餐的生效条件 +- **WHEN** 用户选择“购买即生效”或“实名激活时生效”并保存 +- **THEN** 系统 MUST call `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` +- **AND** 请求体 MUST contain the selected `expiry_base_override` + +#### Scenario: Restore allocation to package default + +- **GIVEN** 用户正在编辑已分配套餐的生效条件 +- **WHEN** 用户选择“跟随套餐默认”并保存 +- **THEN** 请求体 MUST explicitly contain `expiry_base_override: null` + +#### Scenario: Use activation override enum + +- **GIVEN** 用户在已分配套餐编辑弹框选择“实名激活时生效” +- **WHEN** 用户保存生效条件 +- **THEN** 请求体 MUST contain `expiry_base_override: "from_activation"` +- **AND** 请求体 MUST NOT contain `expiry_base_override: "from_realname"` + +### Requirement: Package Allocation Expiry Base Resolution Response + +The package allocation response SHALL provide `default_expiry_base`, `expiry_base_override`, and `effective_expiry_base`, together with their corresponding Chinese name fields. The frontend MUST render the returned effective value and name without calculating it from other fields. + +#### Scenario: Display distinct expiry base values + +- **WHEN** 前端加载或更新店铺套餐分配记录 +- **THEN** 前端类型 MUST support `default_expiry_base`、`expiry_base_override`、`effective_expiry_base` +- **AND** 前端类型 MUST support the corresponding Chinese name fields +- **AND** 页面 MUST distinguish the default value, override value, and effective value + +#### Scenario: Use backend effective expiry base + +- **GIVEN** 分配响应同时返回默认值和覆盖值 +- **WHEN** 页面展示最终生效条件 +- **THEN** 页面 MUST display the backend returned `effective_expiry_base_name` +- **AND** 前端 MUST NOT calculate or replace the effective value from the default value and override value + +#### Scenario: Display null override as following package default + +- **GIVEN** 分配响应的 `expiry_base_override` 为 `null` +- **WHEN** 页面展示覆盖生效条件 +- **THEN** 页面 MUST use `expiry_base_override_name` when it is returned +- **AND** 页面 MUST display “跟随套餐默认” when the returned override name is empty or null + +### Requirement: Package Allocation Expiry Base Applies Prospectively + +The package allocation expiry-base override SHALL apply only to future new orders. The allocation creation and editing UI MUST inform the user that purchased packages are unaffected. + +#### Scenario: Inform user before changing allocation expiry base + +- **WHEN** 用户在已分配套餐编辑弹框查看或修改生效条件 +- **THEN** 页面 MUST display `仅影响后续新订单,不影响已购买套餐` + +#### Scenario: Preserve purchased package behavior + +- **WHEN** 用户创建、更新或恢复套餐分配的生效条件 +- **THEN** 前端 MUST NOT attempt to update existing purchased packages or historical orders + +### Requirement: Package Allocation Response Field Contract + +All package allocation and package response objects involved in allocation management SHALL preserve the six expiry-base fields returned by the backend: `default_expiry_base`, `default_expiry_base_name`, `expiry_base_override`, `expiry_base_override_name`, `effective_expiry_base`, and `effective_expiry_base_name`. + +#### Scenario: Preserve six expiry-base fields in allocation responses + +- **WHEN** 前端接收套餐分配创建、更新、详情或列表响应 +- **THEN** 前端类型 MUST support all six expiry-base fields +- **AND** 页面 MUST use backend-provided `*_name` fields for display + +#### Scenario: Preserve six expiry-base fields in package responses + +- **WHEN** 前端接收套餐列表、套餐详情、套餐创建或套餐更新响应 +- **THEN** 前端类型 MUST support all six expiry-base fields when returned by the API +- **AND** 前端 MUST NOT infer allocation effective expiry behavior from package default fields diff --git a/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md new file mode 100644 index 0000000..397b3ca --- /dev/null +++ b/openspec/changes/update-package-allocation-expiry-base-override/specs/shop-series-grant-packages/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Series Grant Package Expiry Base Visibility + +The series grant package list and package edit context SHALL display the package allocation's default expiry base, override expiry base, and effective expiry base using the Chinese name fields returned by the backend. + +#### Scenario: Display expiry bases in series grant package list + +- **GIVEN** 用户进入代理系列授权套餐列表 +- **WHEN** 已授权套餐记录返回默认、覆盖和最终生效条件字段 +- **THEN** 页面 MUST display the default expiry base name, override expiry base name, and effective expiry base name for the package allocation + +#### Scenario: Display expiry bases in package edit context + +- **GIVEN** 用户打开代理系列授权中已授权套餐的编辑弹框 +- **WHEN** 套餐分配数据加载完成 +- **THEN** 页面 MUST display the default expiry base name, override expiry base name, and effective expiry base name +- **AND** 页面 MUST display `仅影响后续新订单,不影响已购买套餐` + +### Requirement: Series Grant Package Expiry Base Editing + +The series grant package edit context SHALL provide `跟随套餐默认`, `购买即生效`, and `实名激活时生效` options, then update the corresponding package allocation record through the package allocation expiry-base API. + +#### Scenario: Edit override from series grant package list + +- **GIVEN** 用户正在代理系列授权套餐列表编辑已授权套餐 +- **WHEN** 用户选择生效条件并保存 +- **THEN** 系统 MUST use the corresponding package allocation record ID to call `PATCH /api/admin/shop-package-allocations/{id}/expiry-base` +- **AND** 选择“跟随套餐默认”时请求 MUST explicitly contain `expiry_base_override: null` + +### Requirement: Series Grant Creation Expiry Base Contract + +The series grant creation and package addition flows SHALL explicitly send one shared `expiry_base_override` for all newly created package allocations. The allowed values MUST be `null`, `from_purchase`, and `from_activation`. + +#### Scenario: Create series grant with shared expiry base override + +- **WHEN** 前端调用 `POST /api/admin/shop-series-grants` +- **THEN** 请求 MUST explicitly contain `expiry_base_override` +- **AND** the selected value MUST apply to all package allocations created by the initial authorization + +#### Scenario: Add packages to existing series grant with shared expiry base override + +- **WHEN** 前端调用 `PUT /api/admin/shop-series-grants/{id}/packages` to add packages +- **THEN** 请求 MUST explicitly contain `expiry_base_override` +- **AND** the selected value MUST apply only to the newly added package allocations +- **AND** modifying or deleting existing packages MUST NOT change their historical override values + +#### Scenario: Restore default during series grant creation or package addition + +- **WHEN** 用户选择“跟随套餐默认” +- **THEN** 请求 MUST explicitly contain `expiry_base_override: null` +- **AND** 请求 MUST NOT omit the field to represent the default + +### Requirement: Batch Package Allocation Expiry Base Contract + +The batch package allocation flow SHALL apply one explicitly selected `expiry_base_override` to all allocations created by the batch request. + +#### Scenario: Create batch allocations with shared expiry base + +- **WHEN** 前端调用 `POST /api/admin/shop-package-batch-allocations` +- **THEN** 请求 MUST explicitly contain `expiry_base_override` +- **AND** all returned `data.allocations[]` items MUST preserve the allocation ID and six expiry-base fields + +#### Scenario: Batch allocation follows package default + +- **WHEN** 用户在批量分配中选择“跟随套餐默认” +- **THEN** 请求 MUST explicitly contain `expiry_base_override: null` + +#### Scenario: Refresh effective value after editing + +- **WHEN** 代理系列授权套餐的生效条件更新成功 +- **THEN** 页面 MUST refresh the relevant package allocation data +- **AND** 页面 MUST display the latest backend returned effective expiry base name diff --git a/openspec/changes/update-package-allocation-expiry-base-override/tasks.md b/openspec/changes/update-package-allocation-expiry-base-override/tasks.md new file mode 100644 index 0000000..c22694d --- /dev/null +++ b/openspec/changes/update-package-allocation-expiry-base-override/tasks.md @@ -0,0 +1,26 @@ +## 1. Allocation Contract + +- [x] 1.1 扩展店铺套餐分配请求与响应类型,支持 `expiry_base_override`、`default_expiry_base`、`effective_expiry_base` 及对应中文名称字段。 +- [x] 1.2 接入创建分配接口 `POST /api/admin/shop-package-allocations` 的 `expiry_base_override` 参数。 +- [x] 1.3 接入修改生效条件接口 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。 + +## 2. Allocation Expiry Condition UI + +- [x] 2.1 在套餐授权/分配弹框提供“跟随套餐默认”“购买即生效”“实名激活时生效”三选一。 +- [x] 2.2 选择“跟随套餐默认”时显式提交 `expiry_base_override: null`。 +- [x] 2.3 在已分配套餐编辑弹框显示“仅影响后续新订单,不影响已购买套餐”提示。 +- [x] 2.4 在已分配套餐详情或编辑弹框显示默认值、覆盖值和最终值的后端中文名称。 + +## 3. Series Grant Package Visibility + +- [x] 3.1 在代理系列授权套餐列表显示默认生效条件、覆盖生效条件和最终生效条件。 +- [x] 3.2 从代理系列授权套餐列表编辑生效条件时,使用对应套餐分配记录 ID 调用生效条件更新接口。 +- [x] 3.3 保存成功后刷新授权套餐列表或详情,展示后端返回的最新最终生效条件。 + +## 4. Policy Integrity and Verification + +- [x] 4.1 前端不从默认值和覆盖值计算或修改 `effective_expiry_base`。 +- [x] 4.2 验证新建分配时三种选项分别提交 `null`、`from_purchase`、`from_activation`,且不提交 `from_realname`。 +- [x] 4.3 验证编辑覆盖值和恢复默认均只影响后续新订单,不影响已购买套餐。 +- [x] 4.4 验证页面可区分默认值、覆盖值和最终生效条件,并正确显示后端中文名称。 +- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-package-allocation-expiry-base-override --strict`。 diff --git a/openspec/changes/update-refund-wecom-approval-processing-status/design.md b/openspec/changes/update-refund-wecom-approval-processing-status/design.md new file mode 100644 index 0000000..c06109d --- /dev/null +++ b/openspec/changes/update-refund-wecom-approval-processing-status/design.md @@ -0,0 +1,64 @@ +## Context + +退款申请由后台提交业务资料,审批过程在企微完成,后台只负责展示企微审批信息以及订单退款的业务处理结果。当前页面仍按本地审批模型展示通过、拒绝和退回操作,需要调整为只读状态展示并保留受限的重新申请能力。 + +## Goals / Non-Goals + +- Goals: 让退款创建、列表和详情与企微审批及业务处理状态保持一致。 +- Goals: 展示可追溯的审批单号、参与人、意见、附件和时间线。 +- Goals: 明确区分审批状态与业务处理状态,并覆盖处理失败和自动重试提示。 +- Goals: 允许符合条件的退款重新申请,同时锁定订单、资产和提交人快照。 +- Non-Goals: 不在前端实现企微审批动作、审批回调或审批引擎。 +- Non-Goals: 不在后台执行非代理钱包的人工退款。 +- Non-Goals: 不根据本地旧状态推断企微审批状态或处理状态。 + +## Decisions + +- Decision: 详情接口返回的 `approval` 对象作为企微审批信息的唯一数据源。 +- Rationale: 审批动作在企微完成,前端不应通过本地状态或额外请求重建审批过程。 + +- Decision: `processing_status` 与审批状态独立展示,并使用后端返回的状态名称、失败摘要和业务处理结果。 +- Rationale: “审批通过”不等于退款处理完成,两个状态必须避免混用。 + +- Decision: 创建和重新申请的附件统一使用 `{ file_key, file_name, file_size }[]`。 +- Rationale: 页面既要提交对象存储文件标识,也要在审批详情中保留可读的文件元数据。 + +- Decision: 重新申请资格由后端最终校验,前端按已驳回、已撤销或已删除状态控制入口并处理接口拒绝。 +- Rationale: 状态可能被并发更新,前端展示控制不能替代服务端业务校验。 + +- Decision: 移除本地通过、驳回、退回和人工退款确认入口及调用。 +- Rationale: 这些操作与企微审批职责冲突,继续保留会造成重复审批或错误退款。 + +## Data Contract + +- Create request: refund amount, reason, remark and `attachments`. +- Detail response: refund business data, `approval` object and `processing_status`. +- Approval object: `source`, `sp_no`, `status`, `status_name`, `template_version`, `applicant`, `approvers`, `comments`, `attachments`, `timeline`, `business_process_result`. +- Resubmit request: editable refund amount, attachments and reason; immutable order, asset and submitter snapshots. +- Resubmit eligible statuses: rejected, revoked or deleted, using the backend status contract. + +## Risks / Trade-offs + +- Risk: 企微审批状态名称或时间线字段可能为空或增加新状态. +- Mitigation: 保留后端原始字段,缺失时显示稳定占位,不在前端硬编码未知状态映射。 + +- Risk: 审批通过后业务处理可能失败或进入重试. +- Mitigation: 独立展示处理状态、失败摘要,并按后端结果显示系统重试或联系管理员提示。 + +- Risk: 后端仍允许旧本地审批接口调用. +- Mitigation: 前端移除入口和调用,同时由后端按新审批模式拒绝不适用的本地操作。 + +## Migration Plan + +1. 确认退款创建、详情和重新申请接口的字段名称及状态枚举。 +2. 扩展退款类型和对象存储附件元数据类型。 +3. 更新创建退款表单和附件提交结构。 +4. 重构退款详情为业务信息、企微审批信息和业务处理结果三个区域。 +5. 移除本地审批及人工退款操作,保留并限制重新申请入口。 +6. 验证审批中、通过处理中、处理成功、驳回、撤销和通过后撤销异常状态。 + +## Open Questions + +- `processing_status` 的完整枚举、展示名称和“系统重试中/联系管理员”的触发字段需要后端确认。 +- “已删除”是退款记录状态、审批状态还是业务处理状态,需要接口契约明确。 +- `approval.attachments` 是否与退款创建附件共用文件对象结构,需要联调确认。 diff --git a/openspec/changes/update-refund-wecom-approval-processing-status/proposal.md b/openspec/changes/update-refund-wecom-approval-processing-status/proposal.md new file mode 100644 index 0000000..4c60164 --- /dev/null +++ b/openspec/changes/update-refund-wecom-approval-processing-status/proposal.md @@ -0,0 +1,41 @@ +# Change: 退款企微审批详情与业务处理状态 + +## Why + +退款申请创建时需要一次性提交完整业务资料,后续审批由企微完成,后台系统不再承担本地通过、驳回、退回或人工退款确认。当前退款页面仍展示本地审批操作,且详情无法完整呈现企微审批过程和退款业务处理结果,容易造成审批状态与实际处理状态混淆。 + +## What Changes + +- 退款创建表单支持退款金额、退款原因、备注和附件,并以对象存储附件对象数组提交。 +- 创建后申请退款金额不可在本地修改,审批金额以企微审批结果为准。 +- 退款详情拆分为退款业务信息、企微审批信息和业务处理结果三个区域。 +- 企微审批区域展示 `sp_no`、审批状态、申请人、审批人、意见、附件和审批时间线。 +- 业务处理区域展示 `processing_status`、处理失败摘要和“系统重试中/联系管理员”提示。 +- 退款列表和详情仅展示审批及处理状态,不提供本地通过、驳回、退回或人工退款确认按钮。 +- 重新申请仅允许已驳回、已撤销或已删除的退款记录,并允许修改金额、凭证和原因;订单、资产和提交人快照不可修改。 +- 继续使用 `POST /api/admin/refunds`、`GET /api/admin/refunds/{id}` 和 `POST /api/admin/refunds/{id}/resubmit`,同步扩展请求和响应契约。 + +## Impact + +- Affected specs: + - `refund-management` +- Affected code: + - `src/types/api/refund.ts` + - `src/api/modules/refund.ts` + - `src/views/finance/refund/index.vue` + - `src/views/finance/refund/detail.vue` + - 退款创建与附件展示组件 +- API contracts: + - `POST /api/admin/refunds` + - `GET /api/admin/refunds/{id}` + - `POST /api/admin/refunds/{id}/resubmit` +- Dependencies: + - 后端返回完整 `approval` 对象和 `processing_status`。 + - 对象存储上传接口可返回 `file_key`,前端维护文件名和文件大小。 + - 企微审批系统负责审批动作和审批结果同步。 +- Out of scope: + - 前端发起、撤回或执行企微审批动作。 + - 前端实现财务线下退款。 + - 前端根据状态码自行推导审批结果或业务处理结果。 +- Breaking changes: + - 退款审批相关本地操作按钮和对应前端调用不再适用于企微审批退款流程。 diff --git a/openspec/changes/update-refund-wecom-approval-processing-status/specs/refund-management/spec.md b/openspec/changes/update-refund-wecom-approval-processing-status/specs/refund-management/spec.md new file mode 100644 index 0000000..19dbe43 --- /dev/null +++ b/openspec/changes/update-refund-wecom-approval-processing-status/specs/refund-management/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: Refund Creation Submits WeCom Approval Materials + +The refund creation flow SHALL submit the refund business materials required for an external WeCom approval. + +#### Scenario: Create refund with business materials + +- **GIVEN** 用户打开退款创建表单 +- **WHEN** 用户填写退款金额、退款原因、备注并完成附件上传 +- **THEN** 系统 MUST submit the refund amount, reason, remark and attachment metadata to `POST /api/admin/refunds` +- **AND** 每个附件 MUST use the structure `{ file_key, file_name, file_size }` + +#### Scenario: Submitted amount is immutable locally + +- **GIVEN** 退款申请已创建并进入企微审批 +- **WHEN** 用户查看退款记录 +- **THEN** 页面 MUST NOT provide a local control to modify the submitted refund amount +- **AND** any changed amount MUST be submitted through the defined resubmission flow only + +### Requirement: Refund Detail Displays WeCom Approval + +The refund detail page SHALL display the backend-provided WeCom approval object as a read-only approval record. + +#### Scenario: Display approval details + +- **GIVEN** `GET /api/admin/refunds/{id}` returns an `approval` object +- **WHEN** 用户打开退款详情 +- **THEN** 页面 MUST display the approval source, `sp_no`, status, status name, applicant, approvers, comments, attachments and timeline +- **AND** 页面 MUST preserve the backend approval values without locally changing or deriving them + +#### Scenario: No local approval controls + +- **GIVEN** 用户查看退款列表或退款详情 +- **WHEN** 页面展示企微审批信息 +- **THEN** 页面 MUST NOT display local approve, reject, return or manual refund confirmation buttons +- **AND** 页面 MUST NOT call local approval action endpoints for this flow + +### Requirement: Refund Processing Result Is Independent From Approval + +The refund detail page SHALL display business processing status separately from WeCom approval status. + +#### Scenario: Display processing result + +- **GIVEN** 退款详情返回 `processing_status` and its result fields +- **WHEN** 用户查看退款业务处理区域 +- **THEN** 页面 MUST display the processing status, failure summary and business process result +- **AND** 页面 MUST display a system-retrying or contact-administrator message when indicated by the backend + +#### Scenario: Display required lifecycle states + +- **GIVEN** 后端返回审批中、通过处理中、处理成功、驳回、撤销或通过后撤销异常状态 +- **WHEN** 页面渲染退款列表或详情 +- **THEN** 页面 MUST show an explicit status for the returned state +- **AND** 页面 MUST NOT infer processing completion from approval status alone + +### Requirement: Refund Resubmission Has Restricted Editable Fields + +The refund resubmission flow SHALL allow resubmission only for rejected, revoked or deleted records and SHALL preserve immutable business snapshots. + +#### Scenario: Resubmit an eligible refund + +- **GIVEN** 退款状态为已驳回、已撤销或已删除 +- **WHEN** 用户发起重新申请 +- **THEN** 页面 MUST allow editing the refund amount, attachments and refund reason +- **AND** 系统 MUST submit the request to `POST /api/admin/refunds/{id}/resubmit` + +#### Scenario: Reject resubmission for an ineligible refund + +- **GIVEN** 退款记录不属于已驳回、已撤销或已删除状态 +- **WHEN** 用户查看退款详情 +- **THEN** 页面 MUST NOT enable the resubmission action +- **AND** 页面 MUST handle a backend rejection without creating a new approval request + +#### Scenario: Preserve refund snapshots on resubmission + +- **GIVEN** 用户填写重新申请表单 +- **WHEN** 用户修改可编辑字段 +- **THEN** 页面 MUST NOT provide controls to change the order snapshot, asset snapshot or submitter snapshot + +### Requirement: Refund Pages Use Backend Approval And Processing Data + +The refund list and detail pages SHALL use backend response data as the source of truth for approval and processing display. + +#### Scenario: Refresh refund status + +- **WHEN** 用户刷新退款列表或重新加载退款详情 +- **THEN** 页面 MUST use the latest refund, approval and processing data returned by the backend +- **AND** 页面 MUST NOT reconstruct a status from stale local form data or legacy status mappings diff --git a/openspec/changes/update-refund-wecom-approval-processing-status/tasks.md b/openspec/changes/update-refund-wecom-approval-processing-status/tasks.md new file mode 100644 index 0000000..8d023cc --- /dev/null +++ b/openspec/changes/update-refund-wecom-approval-processing-status/tasks.md @@ -0,0 +1,38 @@ +## 1. Contract And Types + +- [x] 1.1 按产品文档落地退款创建接口的金额、原因、备注和附件字段名称。 +- [x] 1.2 按产品文档扩展退款详情响应中的 `approval`、`processing_status`、失败摘要和业务处理结果字段。 +- [x] 1.3 按产品文档限制重新申请的可编辑字段、允许状态和不可变快照字段。 +- [x] 1.4 扩展退款请求、响应和审批对象 TypeScript 类型,支持附件对象数组。 +- [x] 1.5 更新退款 API service 类型及注释,移除不再适用的本地审批调用契约。 + +## 2. Refund Creation + +- [x] 2.1 更新退款创建表单,提交退款金额、原因、备注和附件。 +- [x] 2.2 复用现有对象存储上传流程,并将上传结果规范化为 `{ file_key, file_name, file_size }[]`。 +- [x] 2.3 创建成功后明确提示已提交企微审批,创建后的申请金额不可在本地修改。 +- [x] 2.4 验证附件上传中、上传失败、空附件和接口失败时不能提交错误数据。 + +## 3. Refund List And Detail + +- [x] 3.1 退款详情拆分为退款业务信息、企微审批信息和业务处理结果三个区域。 +- [x] 3.2 展示 `sp_no`、审批状态、申请人、审批人、意见、附件和审批时间线。 +- [x] 3.3 展示 `processing_status`、失败摘要,以及系统重试中/联系管理员提示。 +- [x] 3.4 退款列表和详情不显示本地通过、驳回、退回或人工退款确认按钮。 +- [x] 3.5 保留既有退款业务信息和附件查看能力,不根据前端状态推导审批或处理结果。 + +## 4. Resubmission + +- [x] 4.1 仅在已驳回、已撤销或已删除状态显示重新申请入口。 +- [x] 4.2 重新申请允许修改退款金额、凭证和退款原因。 +- [x] 4.3 重新申请禁止修改订单、资产和提交人快照。 +- [x] 4.4 提交 `/api/admin/refunds/{id}/resubmit` 后刷新详情并展示新的企微审批状态。 +- [x] 4.5 处理后端状态变化或无权限时的重新申请失败提示。 + +## 5. Verification + +- [x] 5.1 验证审批中、通过处理中、处理成功、驳回、撤销和通过后撤销异常展示明确。 +- [x] 5.2 验证审批信息与业务处理状态分区展示且互不推导。 +- [x] 5.3 验证本地审批及人工退款操作入口和 API 调用均已移除。 +- [x] 5.4 验证刷新、列表进入详情和重新申请后的数据来自后端最新响应。 +- [x] 5.5 运行类型检查、lint、相关测试和构建。 diff --git a/openspec/changes/update-role-default-credit-and-shop-credit-limit/design.md b/openspec/changes/update-role-default-credit-and-shop-credit-limit/design.md new file mode 100644 index 0000000..3eb5986 --- /dev/null +++ b/openspec/changes/update-role-default-credit-and-shop-credit-limit/design.md @@ -0,0 +1,33 @@ +## Context + +信用额度分为两个层级:客户角色上的默认信用只决定未来新建代理店铺的初始值,店铺上的实际信用额度才参与资金页展示和后续额度调整。两者必须解耦,避免角色配置变更影响已有店铺。 + +## Goals / Non-Goals + +Goals: + +- 明确角色默认信用和店铺实际信用额度的职责边界。 +- 明确资金概况页读取后端返回的金额和版本作为展示、调额和并发控制来源。 +- 明确关闭信用时额度输入归零。 + +Non-Goals: + +- 不在前端重新计算可用金额、欠款金额或是否欠款。 +- 不为平台员工角色提供信用配置。 +- 不批量同步历史店铺额度。 + +## Decisions + +- 角色默认信用保存到 `PUT /api/admin/roles/{id}/default-credit`,请求体为 `credit_enabled` 和 `credit_limit`。 +- 店铺实际信用额度保存到 `PUT /api/admin/shops/{id}/credit-limit`,请求体为 `credit_enabled`、`credit_limit` 和 `version`。 +- 资金概况继续通过 `GET /api/admin/shops/fund-summary` 获取,前端展示 `main_balance`、`main_frozen_balance`、`cash_available_balance`、`credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` 和 `version`。 +- 调整弹框展示修改前后金额预览,但最终可用金额、欠款金额和欠款状态仍以后端刷新后的资金概况为准。 + +## Risks / Trade-offs + +- 并发更新可能覆盖他人修改。通过提交 `version` 并在冲突时刷新资金概况降低风险。 +- 用户可能误以为角色默认信用会修改已有店铺。通过固定提示“修改后不会影响已有店铺”降低误解。 + +## Open Questions + +- 并发冲突的后端错误码或 HTTP 状态码是否固定为某个值,需要实现时与接口文档对齐。 diff --git a/openspec/changes/update-role-default-credit-and-shop-credit-limit/proposal.md b/openspec/changes/update-role-default-credit-and-shop-credit-limit/proposal.md new file mode 100644 index 0000000..51d75cb --- /dev/null +++ b/openspec/changes/update-role-default-credit-and-shop-credit-limit/proposal.md @@ -0,0 +1,30 @@ +# Change: 角色默认信用与店铺实际额度管理 + +## Why + +当前角色信用配置容易被理解为会直接影响已有店铺,且代理商资金页缺少单独维护店铺实际信用额度的入口。需要将“未来新建店铺的默认信用”和“已有店铺的实际信用额度”拆分管理,避免修改角色配置误伤存量店铺。 + +## What Changes + +- 客户角色配置页新增“新建代理默认信用”开关和额度输入,并提示“修改后不会影响已有店铺”。 +- 平台员工角色不展示默认信用配置。 +- 店铺创建时使用所选客户角色的默认信用初始化新店铺实际信用额度。 +- 代理商资金概况页展示现金余额、冻结金额、实际信用额度、可用金额、欠款金额和版本。 +- 代理商资金概况页提供独立的店铺实际额度调整弹框,展示修改前后金额预览。 +- 调整店铺实际额度时携带资金概况版本,遇到并发冲突后提示用户并刷新最新资金概况。 +- 前端不重新计算可用金额、欠款金额或资金状态,金额展示以接口返回为准。 + +## Impact + +- Affected specs: `role-management`, `shop-management`, `commission-management` +- Affected code: + - `src/api/modules/role.ts` + - `src/api/modules/shop.ts` + - `src/api/modules/commission.ts` + - `src/types/api/role.ts` + - `src/types/api/shop.ts` + - `src/types/api/commission.ts` + - `src/views/system/role/index.vue` + - `src/views/shop-management/list/index.vue` + - `src/views/commission-management/agent-fund-overview/index.vue` +- Source product note: `docs/产品迭代7月份/15- 角色默认信用与店铺实际额度管理.md` diff --git a/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/commission-management/spec.md b/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/commission-management/spec.md new file mode 100644 index 0000000..60b0d27 --- /dev/null +++ b/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/commission-management/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Agent Fund Summary Actual Credit Display + +The agent fund overview page SHALL display shop actual credit and fund summary fields from `GET /api/admin/shops/fund-summary` without recalculating monetary values on the frontend. + +#### Scenario: Display fund summary credit fields + +- **GIVEN** 用户进入代理商资金概况页 +- **WHEN** `GET /api/admin/shops/fund-summary` 返回资金概况记录 +- **THEN** 页面 MUST 展示账面余额 `main_balance` +- **AND** 页面 MUST 展示冻结金额 `main_frozen_balance` +- **AND** 页面 MUST 展示实际信用额度 `credit_limit` +- **AND** 页面 MUST 展示可用金额 `available_balance` +- **AND** 页面 MUST 展示欠款金额 `debt_amount` +- **AND** 页面 MUST 展示版本 `version` + +#### Scenario: Display credit disabled state + +- **GIVEN** 资金概况记录返回 `credit_enabled=false` +- **WHEN** 页面渲染该记录 +- **THEN** 页面 MUST 将实际信用显示为关闭状态 +- **AND** 页面 MUST display credit limit as `0` or backend-provided formatted value + +#### Scenario: Do not recalculate fund amounts on frontend + +- **GIVEN** 资金概况接口返回 `available_balance`、`is_in_debt` 和 `debt_amount` +- **WHEN** 页面渲染资金概况 +- **THEN** 前端 MUST 使用接口返回的 `available_balance` +- **AND** 前端 MUST 使用接口返回的 `is_in_debt` +- **AND** 前端 MUST 使用接口返回的 `debt_amount` +- **AND** 前端 MUST NOT 根据现金余额、冻结金额或信用额度重新计算这些字段 + +### Requirement: Shop Actual Credit Adjustment Dialog + +The agent fund overview page SHALL provide an independent dialog for adjusting a shop's actual credit limit and previewing the before/after amount. + +#### Scenario: Open actual credit adjustment dialog + +- **GIVEN** 用户正在代理商资金概况页查看店铺资金记录 +- **WHEN** 用户点击调整实际信用额度入口 +- **THEN** 页面 MUST 打开独立调整弹框 +- **AND** 弹框 MUST 展示修改前信用启用状态和信用额度 +- **AND** 弹框 MUST 展示修改后信用启用状态和信用额度预览 + +#### Scenario: Submit actual credit adjustment + +- **GIVEN** 用户已经在调整弹框内修改实际信用配置 +- **WHEN** 用户确认提交 +- **THEN** 前端 MUST submit `credit_enabled`、`credit_limit` and `version` to `PUT /api/admin/shops/{id}/credit-limit` +- **AND** 保存成功后页面 MUST 关闭弹框 +- **AND** 页面 MUST 刷新代理商资金概况列表 + +#### Scenario: Disable credit in adjustment dialog + +- **GIVEN** 用户正在调整弹框内修改实际信用配置 +- **WHEN** 用户关闭信用开关 +- **THEN** 弹框 MUST 将额度输入归零 +- **AND** 修改后金额预览 MUST reflect disabled credit with zero credit limit + +#### Scenario: Handle concurrent credit update conflict + +- **GIVEN** 用户打开调整弹框时记录了资金概况 `version` +- **AND** 该店铺资金概况已被其他操作更新 +- **WHEN** 用户提交旧版本的实际信用调整 +- **THEN** 页面 MUST 展示并发冲突提示 +- **AND** 页面 MUST 刷新最新代理商资金概况 +- **AND** 页面 MUST NOT keep showing stale version as current data + +### Requirement: Agent Fund Summary Credit Response Contract + +The frontend SHALL read actual credit fields from `GET /api/admin/shops/fund-summary` response records. + +#### Scenario: Read actual credit response fields + +- **WHEN** 前端请求 `GET /api/admin/shops/fund-summary` +- **THEN** 前端 MUST read `main_balance` +- **AND** 前端 MUST read `main_frozen_balance` +- **AND** 前端 MUST read `cash_available_balance` +- **AND** 前端 MUST read `credit_enabled` +- **AND** 前端 MUST read `credit_limit` +- **AND** 前端 MUST read `available_balance` +- **AND** 前端 MUST read `is_in_debt` +- **AND** 前端 MUST read `debt_amount` +- **AND** 前端 MUST read `version` diff --git a/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/role-management/spec.md b/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/role-management/spec.md new file mode 100644 index 0000000..274bc35 --- /dev/null +++ b/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/role-management/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Customer Role Default Credit Configuration + +The role management page SHALL allow customer roles to configure default credit only for future newly created agent shops. + +#### Scenario: Display default credit controls for customer roles + +- **GIVEN** 用户正在客户角色配置页编辑客户角色 +- **WHEN** 页面渲染角色表单或配置区域 +- **THEN** 页面 MUST 展示“新建代理默认信用”开关 +- **AND** 页面 MUST 展示信用额度输入框 +- **AND** 页面 MUST 展示提示文案“修改后不会影响已有店铺” + +#### Scenario: Hide default credit controls for platform employee roles + +- **GIVEN** 用户正在编辑平台员工角色 +- **WHEN** 页面渲染角色表单或配置区域 +- **THEN** 页面 MUST NOT 展示“新建代理默认信用”开关 +- **AND** 页面 MUST NOT 展示信用额度输入框 + +#### Scenario: Save customer role default credit + +- **GIVEN** 用户正在编辑客户角色默认信用 +- **WHEN** 用户提交默认信用配置 +- **THEN** 前端 MUST call `PUT /api/admin/roles/{id}/default-credit` +- **AND** 请求体 MUST include `credit_enabled: bool` and `credit_limit: int64` +- **AND** 保存成功后页面 MUST 提示保存成功 + +#### Scenario: Disable customer role default credit + +- **GIVEN** 用户正在编辑客户角色默认信用 +- **WHEN** 用户关闭“新建代理默认信用”开关 +- **THEN** 页面 MUST 将额度输入归零 +- **AND** 提交时请求体 MUST include `credit_enabled=false` +- **AND** 提交时请求体 MUST include `credit_limit=0` + +#### Scenario: Role default credit does not mutate existing shops + +- **GIVEN** 已有店铺已经创建并拥有实际信用额度 +- **WHEN** 用户修改该店铺所属客户角色的默认信用配置 +- **THEN** 系统 MUST NOT 修改已有店铺的实际信用额度 +- **AND** 已有店铺的实际额度 MUST 只能通过店铺实际额度调整入口修改 diff --git a/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/shop-management/spec.md b/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/shop-management/spec.md new file mode 100644 index 0000000..158ea0f --- /dev/null +++ b/openspec/changes/update-role-default-credit-and-shop-credit-limit/specs/shop-management/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: New Shop Credit Initialization From Customer Role + +The shop creation flow SHALL initialize a newly created agent shop's actual credit from the selected customer role's default credit configuration. + +#### Scenario: Create agent shop with enabled role default credit + +- **GIVEN** 选中的客户角色已启用默认信用 +- **AND** 该客户角色配置了默认信用额度 +- **WHEN** 用户创建新的代理店铺 +- **THEN** 新店铺的实际信用 MUST 初始化为启用状态 +- **AND** 新店铺的实际信用额度 MUST 使用该客户角色的默认信用额度 + +#### Scenario: Create agent shop with disabled role default credit + +- **GIVEN** 选中的客户角色未启用默认信用 +- **WHEN** 用户创建新的代理店铺 +- **THEN** 新店铺的实际信用 MUST 初始化为关闭状态 +- **AND** 新店铺的实际信用额度 MUST be `0` + +#### Scenario: Existing shop keeps actual credit after role default change + +- **GIVEN** 店铺已经完成创建 +- **WHEN** 用户修改该店铺所属客户角色的默认信用配置 +- **THEN** 该店铺的实际信用启用状态 MUST NOT change +- **AND** 该店铺的实际信用额度 MUST NOT change + +### Requirement: Shop Actual Credit Limit Update API + +The frontend SHALL update an existing shop's actual credit limit through `PUT /api/admin/shops/{id}/credit-limit` with optimistic concurrency versioning. + +#### Scenario: Submit shop actual credit update + +- **GIVEN** 用户正在调整已有店铺实际信用额度 +- **WHEN** 用户提交调整 +- **THEN** 前端 MUST call `PUT /api/admin/shops/{id}/credit-limit` +- **AND** 请求体 MUST include `credit_enabled: bool` +- **AND** 请求体 MUST include `credit_limit: int64` +- **AND** 请求体 MUST include `version: int64` + +#### Scenario: Disable shop actual credit + +- **GIVEN** 用户正在调整已有店铺实际信用额度 +- **WHEN** 用户关闭信用开关 +- **THEN** 页面 MUST 将额度输入归零 +- **AND** 提交时请求体 MUST include `credit_enabled=false` +- **AND** 提交时请求体 MUST include `credit_limit=0` diff --git a/openspec/changes/update-role-default-credit-and-shop-credit-limit/tasks.md b/openspec/changes/update-role-default-credit-and-shop-credit-limit/tasks.md new file mode 100644 index 0000000..f346d3a --- /dev/null +++ b/openspec/changes/update-role-default-credit-and-shop-credit-limit/tasks.md @@ -0,0 +1,42 @@ +## 1. API And Types + +- [x] 1.1 在角色 API 和类型中新增 `PUT /api/admin/roles/{id}/default-credit` 请求类型与服务方法。 +- [x] 1.2 在店铺 API 和类型中新增 `PUT /api/admin/shops/{id}/credit-limit` 请求类型与服务方法。 +- [x] 1.3 扩展代理商资金概况类型,包含 `credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` 和 `version`。 + +## 2. Role Default Credit + +- [x] 2.1 在客户角色配置页展示“新建代理默认信用”开关、额度输入和“修改后不会影响已有店铺”提示。 +- [x] 2.2 平台员工角色不展示信用配置。 +- [x] 2.3 关闭默认信用时将额度输入归零并提交 `credit_limit=0`。 +- [x] 2.4 保存默认信用配置时调用 `PUT /api/admin/roles/{id}/default-credit`。 + +## 3. Shop Credit Initialization + +- [x] 3.1 确认店铺创建页不把角色默认信用表现为会影响已有店铺的配置。 +- [x] 3.2 确认新建店铺实际信用额度由后端按所选客户角色默认信用初始化。 + +## 4. Agent Fund Overview + +- [x] 4.1 在代理商资金概况页展示现金余额、冻结金额、实际信用额度、可用金额、欠款金额和版本。 +- [x] 4.2 金额字段按接口返回值展示,不在前端重新计算可用金额或欠款金额。 +- [x] 4.3 新增店铺实际额度调整弹框,展示当前值、修改后值和关闭信用归零行为。 +- [x] 4.4 提交调额时携带当前 `version` 调用 `PUT /api/admin/shops/{id}/credit-limit`。 +- [x] 4.5 并发冲突时展示提示并刷新最新资金概况。 + +## 5. Verification + +- [x] 5.1 验证客户角色默认信用保存后不会修改已有店铺展示的实际信用额度。 +- [x] 5.2 验证新建店铺使用所选客户角色默认信用初始化实际额度。 +- [x] 5.3 验证资金概况页展示字段与接口返回一致。 +- [x] 5.4 验证关闭信用时额度输入归零。 +- [x] 5.5 验证并发冲突后页面提示并刷新最新资金概况。 +- [x] 5.6 运行相关 lint、类型检查或构建命令。 + +## Verification Notes + +- 角色默认信用保存只调用角色默认信用接口,店铺资金概况只读取后端返回的实际额度,未发现前端同步历史店铺额度的逻辑。 +- 店铺创建参数未包含信用额度字段,实际额度初始化由后端按所选客户角色处理。 +- 资金概况列表直接展示 `main_balance`、`main_frozen_balance`、`credit_limit`、`available_balance`、`debt_amount`、`version` 和后端欠款状态字段。 +- 关闭信用时角色配置和店铺调额表单均将额度归零,并以 `credit_limit=0` 提交。 +- 调额响应或异常命中版本冲突时,页面提示并重新加载资金概况。 diff --git a/openspec/changes/update-series-grant-package-contract/design.md b/openspec/changes/update-series-grant-package-contract/design.md new file mode 100644 index 0000000..663d1bc --- /dev/null +++ b/openspec/changes/update-series-grant-package-contract/design.md @@ -0,0 +1,36 @@ +## Context + +代理系列授权详情返回完整的 `packages[]`,套餐列表接口返回可选套餐候选。前端创建授权时需要从候选套餐中选择初始套餐,详情和套餐管理页面则需要同时展示候选套餐信息与已授权状态。 + +## Goals / Non-Goals + +- Goals: + - 以 `package_id` 为唯一键合并套餐候选和授权详情。 + - 在创建前校验初始套餐数量为 1~100 且不重复。 + - 使用 `remove=true` 表达后续删除,保持接口响应结构不变。 +- Non-Goals: + - 不修改后端接口路径或响应字段结构。 + - 不在前端复制后端的成本价、生效条件或套餐状态计算规则。 + - 不新增独立的套餐删除 API 调用。 + +## Decisions + +- 套餐候选状态以 `package_id` 合并:详情返回的套餐标记为已授权,候选列表中不存在于详情的套餐标记为未授权。 +- 创建请求始终携带 `packages`,数量范围为 1~100;每个 `package_id` 只能出现一次。 +- 后续新增或调价沿用 `PUT /api/admin/shop-series-grants/{id}/packages`,删除项只提交 `{ package_id, remove: true }`。 +- 成功响应直接按后端返回的完整授权详情刷新页面,不根据请求内容自行拼装最终响应。 + +## Risks / Trade-offs + +- 套餐候选接口分页或搜索结果可能不包含已授权套餐,合并时必须保留详情中的已授权项,避免误显示为未授权。 +- 用户一次选择超过 100 个套餐时前端应提前阻止提交,同时仍依赖后端校验作为最终约束。 + +## Migration Plan + +1. 更新类型和 API 请求约束。 +2. 更新创建页、授权详情页和套餐列表页的候选合并及套餐操作逻辑。 +3. 使用接口响应重新加载授权详情,验证创建、调价、删除和空候选场景。 + +## Open Questions + +- 套餐候选接口的分页大小是否允许一次返回全部候选;若不允许,页面需要继续使用远程搜索加载候选。 diff --git a/openspec/changes/update-series-grant-package-contract/proposal.md b/openspec/changes/update-series-grant-package-contract/proposal.md new file mode 100644 index 0000000..8335dcd --- /dev/null +++ b/openspec/changes/update-series-grant-package-contract/proposal.md @@ -0,0 +1,18 @@ +# Change: 更新代理系列授权套餐管理契约 + +## Why + +代理系列授权接口已更新为创建时必须一次提交初始套餐,后续通过套餐批量管理接口维护套餐。前端需要将套餐列表作为候选集合、授权详情作为已授权状态来源进行合并,避免新增、编辑和删除时状态不一致。 + +## What Changes + +- 套餐列表页和授权详情页统一按 `package_id` 合并套餐候选数据与已授权数据。 +- 创建代理系列授权时强制提交 1~100 个不重复套餐。 +- 后续套餐管理继续通过 `packages[]` 提交新增、调价和 `remove=true` 删除操作。 +- 保持创建、详情、更新和套餐管理接口的完整授权详情响应结构不变。 +- 更新 API 类型、表单校验和页面状态管理,禁止通过空套餐列表创建授权或使用独立删除接口删除授权套餐。 + +## Impact + +- Affected specs: `package-series-allocation` +- Affected code: `src/api/modules/shopSeriesGrant.ts`, `src/types/api/packageManagement.ts`, `src/views/package-management/series-grants/index.vue`, `src/views/package-management/series-grants/detail.vue`, `src/views/package-management/series-grants/packages.vue` diff --git a/openspec/changes/update-series-grant-package-contract/specs/package-series-allocation/spec.md b/openspec/changes/update-series-grant-package-contract/specs/package-series-allocation/spec.md new file mode 100644 index 0000000..b375763 --- /dev/null +++ b/openspec/changes/update-series-grant-package-contract/specs/package-series-allocation/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: 创建代理系列授权时提交初始套餐 + +系统 SHALL 在创建代理系列授权时要求提交 `packages`,且 `packages` 必须包含 1~100 个套餐项;套餐 ID 不得重复。创建成功响应 SHALL 保持完整授权详情结构不变。 + +#### Scenario: 创建授权并提交有效初始套餐 + +- **WHEN** 用户提交 `shop_id`、`series_id` 和包含 1~100 个不重复套餐项的 `packages` +- **THEN** 前端提交 `POST /api/admin/shop-series-grants` +- **AND** 每个套餐项携带必要的 `package_id`,新增或调价时携带 `cost_price` +- **AND** 页面按接口返回的完整授权详情刷新授权信息 + +#### Scenario: 创建授权未选择套餐 + +- **WHEN** 用户未选择任何初始套餐 +- **THEN** 前端阻止提交并提示至少选择 1 个套餐 + +#### Scenario: 创建授权超过套餐数量上限或包含重复套餐 + +- **WHEN** 用户选择超过 100 个套餐或同一 `package_id` 重复出现 +- **THEN** 前端阻止提交并提示套餐数量或重复项错误 + +### Requirement: 合并套餐候选与授权状态 + +系统 SHALL 在套餐列表和授权详情相关页面以 `package_id` 为唯一键合并套餐候选数据与授权详情中的 `packages[]`。已授权套餐即使不在当前候选搜索结果中,也 SHALL 保留并标记为已授权。 + +#### Scenario: 候选套餐与已授权套餐合并 + +- **WHEN** 套餐候选列表和授权详情均加载成功 +- **THEN** 页面按 `package_id` 合并两者 +- **AND** 详情中的套餐显示为已授权并保留其成本价、生效条件和状态字段 +- **AND** 仅出现在候选列表中的套餐显示为未授权候选 + +#### Scenario: 候选搜索结果不包含已授权套餐 + +- **WHEN** 当前候选搜索结果缺少某个已授权套餐 +- **THEN** 页面仍从授权详情保留该套餐 +- **AND** 不将其误判为未授权或从当前编辑状态中删除 + +### Requirement: 后续套餐管理使用批量操作删除 + +系统 SHALL 使用 `PUT /api/admin/shop-series-grants/{id}/packages` 管理既有授权套餐。新增和调价提交套餐项,删除套餐授权 SHALL 提交对应套餐项的 `remove: true`;操作成功后 SHALL 按接口返回的完整授权详情刷新页面。 + +#### Scenario: 新增或调整授权套餐 + +- **WHEN** 用户在已有授权中新增套餐或调整成本价 +- **THEN** 前端提交 `packages[]` 中对应的套餐项及成本价 +- **AND** 不调用独立删除接口 + +#### Scenario: 删除授权套餐 + +- **WHEN** 用户删除已有授权套餐 +- **THEN** 前端提交 `{ package_id, remove: true }` +- **AND** 页面使用接口返回的完整授权详情刷新套餐列表 + +#### Scenario: 套餐管理响应结构保持不变 + +- **WHEN** 套餐批量管理接口成功 +- **THEN** 前端按详情响应读取授权基本信息、佣金配置、强充配置、`packages[]`、状态和时间字段 +- **AND** 不要求后端返回新的响应包装结构 diff --git a/openspec/changes/update-series-grant-package-contract/tasks.md b/openspec/changes/update-series-grant-package-contract/tasks.md new file mode 100644 index 0000000..9f32ee3 --- /dev/null +++ b/openspec/changes/update-series-grant-package-contract/tasks.md @@ -0,0 +1,26 @@ +## 1. API And Types + +- [x] 1.1 按 21 号接口文档核对代理系列授权列表、创建、详情、更新和套餐管理的请求响应类型。 +- [x] 1.2 将创建请求的 `packages` 定义为必填,并补充套餐 1~100 项及套餐 ID 不重复的约束说明。 +- [x] 1.3 明确 `GrantPackageItem.remove` 仅用于后续套餐删除,保留完整授权详情响应类型。 + +## 2. Candidate State Merge + +- [x] 2.1 在创建授权页面按 `package_id` 合并套餐候选列表和当前选择状态。 +- [x] 2.2 在授权详情页面按 `package_id` 合并套餐列表候选和详情中的已授权套餐,已授权套餐必须保留在合并结果中。 +- [x] 2.3 在独立授权套餐列表页面复用同一状态规则,避免候选套餐和已授权套餐重复或丢失。 + +## 3. Create And Manage Packages + +- [x] 3.1 创建授权提交前校验套餐数量为 1~100,且套餐 ID 不重复。 +- [x] 3.2 创建授权始终提交 `packages[]`,禁止空数组或缺失套餐列表提交。 +- [x] 3.3 新增或调价继续提交套餐项和成本价;删除只提交 `remove: true`,不调用独立删除接口。 +- [x] 3.4 套餐管理成功后使用后端返回的完整授权详情刷新页面,不前端重算响应结构。 + +## 4. Verification + +- [x] 4.1 验证创建 1 个套餐成功,创建 0 个、101 个和重复套餐被阻止。 +- [x] 4.2 验证套餐候选与授权详情合并后,已授权状态、成本价和生效条件展示正确。 +- [x] 4.3 验证新增、调价和 `remove=true` 删除均使用套餐管理接口。 +- [x] 4.4 验证接口成功响应仍按完整授权详情结构处理。 +- [x] 4.5 运行类型检查、lint 和构建。 diff --git a/openspec/changes/update-series-grant-package-options-api/proposal.md b/openspec/changes/update-series-grant-package-options-api/proposal.md new file mode 100644 index 0000000..de26014 --- /dev/null +++ b/openspec/changes/update-series-grant-package-options-api/proposal.md @@ -0,0 +1,17 @@ +# Change: 使用授权套餐候选项接口创建代理系列授权 + +## Why + +新增代理系列授权时,通用套餐分页接口不能按当前操作者、目标店铺和套餐系列返回真正可授权的套餐,也不能提供目标店铺已授权状态。 + +## What Changes + +- 新增代理系列授权弹窗的套餐选择改用 `GET /api/admin/shop-series-grants/package-options`。 +- 仅在已选择目标店铺和套餐系列后请求候选项接口,并原样传递 `shop_id` 与 `series_id`。 +- 以接口候选项作为可选集合,保留候选项的授权状态,且不再由前端额外过滤赠送套餐。 +- 为候选项接口增加前端 API 方法与类型定义,并用其价格字段初始化套餐成本价和建议零售价校验数据。 + +## Impact + +- Affected specs: `package-series-allocation` +- Affected code: `src/api/modules/shopSeriesGrant.ts`, `src/types/api/packageManagement.ts`, `src/views/package-management/series-grants/index.vue` diff --git a/openspec/changes/update-series-grant-package-options-api/specs/package-series-allocation/spec.md b/openspec/changes/update-series-grant-package-options-api/specs/package-series-allocation/spec.md new file mode 100644 index 0000000..cbd246b --- /dev/null +++ b/openspec/changes/update-series-grant-package-options-api/specs/package-series-allocation/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: 授权创建使用店铺系列套餐候选项 + +系统 SHALL 在新增代理系列授权时,使用 `GET /api/admin/shop-series-grants/package-options` 获取套餐候选项。前端 MUST 在目标店铺和套餐系列均已选择后,原样传递非空的 `shop_id` 与 `series_id`;接口返回的候选项 SHALL 作为可选套餐及其价格、授权状态的权威来源。 + +#### Scenario: 选择店铺和套餐系列后加载候选项 + +- **WHEN** 用户在新增代理系列授权弹窗中已选择目标店铺和套餐系列 +- **THEN** 前端请求 `/api/admin/shop-series-grants/package-options` 并携带所选 `shop_id` 与 `series_id` +- **AND** 前端展示返回的候选套餐而不再调用通用套餐分页接口作为该弹窗的候选来源 + +#### Scenario: 更换店铺或套餐系列 + +- **WHEN** 用户更换目标店铺或套餐系列 +- **THEN** 前端清空此前的套餐候选项和已选择套餐 +- **AND** 仅使用新组合返回的候选项进行后续选择 diff --git a/openspec/changes/update-series-grant-package-options-api/tasks.md b/openspec/changes/update-series-grant-package-options-api/tasks.md new file mode 100644 index 0000000..d494ff8 --- /dev/null +++ b/openspec/changes/update-series-grant-package-options-api/tasks.md @@ -0,0 +1,14 @@ +## 1. API and Types + +- [x] 1.1 Add the package-options API method and response types defined by the August interface document. + +## 2. Create Grant Dialog + +- [x] 2.1 Load package candidates only after both shop and series are selected, passing their IDs unchanged. +- [x] 2.2 Use returned candidates for selection, package names, cost prices and price validation. +- [x] 2.3 Reset candidate and selected-package state when the shop or series changes. + +## 3. Verification + +- [x] 3.1 Verify request parameters, candidate rendering and selected-package submission. +- [x] 3.2 Run lint and type checks for changed files. diff --git a/openspec/changes/update-shop-salesperson-selection/proposal.md b/openspec/changes/update-shop-salesperson-selection/proposal.md new file mode 100644 index 0000000..acee413 --- /dev/null +++ b/openspec/changes/update-shop-salesperson-selection/proposal.md @@ -0,0 +1,30 @@ +# Change: 店铺平台业务员选择、展示与筛选 + +## Why + +当前店铺没有记录负责的平台业务员,运营无法在新建、编辑、列表和详情中维护或查看业务归属。需要为店铺绑定可选的平台业务员,该绑定仅用于业务归属和通知关系,不承载分销、佣金或发展层级语义。 + +## What Changes + +- 在店铺新建和编辑表单新增可清空、可搜索的“平台业务员”选择器。 +- 候选业务员使用店铺业务员候选接口,展示账号名和手机号摘要。 +- 扩展店铺创建、更新、列表查询、列表返回和详情返回的数据契约,支持 `business_owner_account_id` 及业务员展示字段。 +- 在店铺列表新增业务员列和按业务员筛选项,在店铺详情展示业务员名称和手机号摘要。 +- 已停用的已绑定业务员仅用于历史列表和详情展示,不能作为新建或编辑表单的候选项。 +- 不新增分销、佣金、发展层级或其他业务结算行为。 + +## Impact + +- Affected specs: `shop-management` +- Affected code: + - `src/api/modules/shop.ts` + - `src/types/api/shop.ts` + - 平台账号查询 API 类型或服务模块 + - `src/views/shop-management/list/index.vue` + - 店铺详情页或详情抽屉的展示配置 +- API contracts: + - `POST /api/admin/shops` + - `PUT /api/admin/shops/{id}` + - `GET /api/admin/shops` +- `GET /api/admin/shops/{id}` +- `GET /api/admin/shops/business-owner-candidates` diff --git a/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md b/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md new file mode 100644 index 0000000..4d780f4 --- /dev/null +++ b/openspec/changes/update-shop-salesperson-selection/specs/shop-management/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Shop Platform Salesperson Assignment + +The system SHALL allow users to assign or clear an optional platform salesperson for a shop during shop creation and editing. The assignment SHALL represent only business ownership and notification relationship and MUST NOT create distribution, commission, or hierarchy behavior. + +#### Scenario: Assign an enabled platform account during shop creation + +- **GIVEN** 用户进入店铺新建表单 +- **WHEN** 用户在“平台业务员”可搜索下拉中选择一个启用的平台账号 +- **THEN** 系统 MUST 提交该账号的 `business_owner_account_id` +- **AND** 候选项 MUST 展示账号名和手机号摘要 + +#### Scenario: Edit a shop salesperson assignment + +- **GIVEN** 用户进入已存在店铺的编辑表单 +- **WHEN** 用户选择不同的启用平台账号 +- **THEN** 系统 MUST 在 `PUT /api/admin/shops/{id}` 请求中提交新的 `business_owner_account_id` + +#### Scenario: Clear a shop salesperson assignment + +- **GIVEN** 店铺已绑定平台业务员 +- **WHEN** 用户清空“平台业务员”选择器并提交编辑表单 +- **THEN** 系统 MUST 提交 `business_owner_account_id: null` +- **AND** 店铺不再关联该业务员 + +#### Scenario: Do not expose disabled accounts as editable candidates + +- **WHEN** 用户打开或搜索“平台业务员”选择器 +- **THEN** 系统 MUST query `GET /api/admin/shops/business-owner-candidates` +- **AND** 请求 MUST support `keyword`、`page` 和 `page_size` +- **AND** 候选项 MUST use `id`、`username` and `phone_summary` +- **AND** 已停用平台账号 MUST NOT 作为新建或编辑候选项显示 + +#### Scenario: No eligible salesperson exists + +- **GIVEN** 不存在启用的平台账号 +- **WHEN** 用户打开“平台业务员”选择器 +- **THEN** 系统 MUST 显示无可选账号状态 +- **AND** 用户仍 MUST 能够不选择业务员完成表单提交 + +### Requirement: Shop Salesperson List Filtering And Display + +The shop list SHALL display the assigned salesperson and support filtering by `business_owner_account_id`. + +#### Scenario: Display salesperson in shop list + +- **WHEN** 用户查看店铺列表 +- **THEN** 列表 MUST 显示业务员列 +- **AND** 已绑定业务员的行 MUST 展示 `business_owner_username` 和 `business_owner_phone_summary` + +#### Scenario: Filter shops by salesperson + +- **WHEN** 用户在店铺列表选择一个平台业务员作为筛选条件 +- **THEN** 前端 MUST 在 `GET /api/admin/shops` 请求中提交该账号的 `business_owner_account_id` +- **AND** 列表 MUST 仅展示后端返回的匹配店铺 + +#### Scenario: Display historical disabled salesperson in list + +- **GIVEN** 店铺历史上已绑定的平台业务员后来被停用 +- **WHEN** 用户查看店铺列表 +- **THEN** 列表 MUST 继续展示该店铺返回的 `business_owner_username` 和 `business_owner_phone_summary` + +### Requirement: Shop Detail Salesperson Display + +The shop detail SHALL display the assigned salesperson name and masked phone from the shop detail response, including historical assignments to disabled accounts. + +#### Scenario: Display salesperson in shop detail + +- **WHEN** 用户查看已绑定平台业务员的店铺详情 +- **THEN** 页面 MUST 展示业务员名称和手机号摘要 + +#### Scenario: Display historical disabled salesperson in detail + +- **GIVEN** 店铺绑定的业务员已停用 +- **WHEN** 用户查看该店铺详情 +- **THEN** 页面 MUST request `GET /api/admin/shops/{id}` +- **AND** 页面 MUST 继续使用详情响应中的 `business_owner_username` 和 `business_owner_phone_summary` 展示历史业务员信息 + +#### Scenario: Do not add distribution or commission semantics + +- **WHEN** 用户查看店铺新建、编辑、列表或详情页面 +- **THEN** 页面 MUST NOT 将平台业务员描述为分销关系、佣金关系或发展层级 diff --git a/openspec/changes/update-shop-salesperson-selection/tasks.md b/openspec/changes/update-shop-salesperson-selection/tasks.md new file mode 100644 index 0000000..f5ffd80 --- /dev/null +++ b/openspec/changes/update-shop-salesperson-selection/tasks.md @@ -0,0 +1,27 @@ +## 1. API Contract + +- [x] 1.1 更新店铺创建和编辑请求类型,支持可空的 `business_owner_account_id`。 +- [x] 1.2 更新店铺列表查询类型,支持 `business_owner_account_id` 筛选参数。 +- [x] 1.3 更新店铺列表和详情响应类型,读取业务员 ID、名称和脱敏手机号字段。 +- [x] 1.4 接入店铺业务员候选接口,读取 `id`、`username`、`phone_summary` 作为候选项。 + +## 2. Shop Form UI + +- [x] 2.1 在店铺新建和编辑表单增加可搜索、可清空的“平台业务员”下拉选择。 +- [x] 2.2 候选项展示账号名和手机号摘要,仅查询并展示启用的平台账号。 +- [x] 2.3 支持清空已选择业务员,并按 `business_owner_account_id: null` 提交。 +- [x] 2.4 已停用业务员不出现在编辑候选项中。 + +## 3. Shop List And Detail UI + +- [x] 3.1 在店铺列表增加业务员列,展示业务员名称和手机号摘要。 +- [x] 3.2 在店铺列表搜索区增加按业务员筛选项,并提交选中的 `business_owner_account_id`。 +- [x] 3.3 在店铺详情展示业务员名称和手机号摘要;已停用但历史绑定的业务员仍正常显示。 + +## 4. Verification + +- [x] 4.1 验证创建、编辑、清空业务员和无可选账号状态。 +- [x] 4.2 验证列表业务员展示和按业务员筛选正确。 +- [x] 4.3 验证详情对历史停用业务员仍能展示名称和手机号摘要。 +- [x] 4.4 确认页面不引入分销、佣金或发展层级文案。 +- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-shop-salesperson-selection --strict`。 diff --git a/openspec/changes/update-unified-async-task-interaction/design.md b/openspec/changes/update-unified-async-task-interaction/design.md new file mode 100644 index 0000000..8977c9a --- /dev/null +++ b/openspec/changes/update-unified-async-task-interaction/design.md @@ -0,0 +1,63 @@ +## Context + +设备批量分配、批量订购和导出都属于可能耗时的异步操作,但现有页面和任务模型不统一。产品要求以相同的状态语义、进度结构、轮询节奏和恢复行为覆盖这些入口,同时不改变已有业务接口中未涉及的字段。 + +## Goals / Non-Goals + +- Goals: 统一三类页面的公共状态展示、任务进度展示、任务恢复、轮询和错误重试行为。 +- Goals: 为导出任务补齐稳定的任务标识、计数、错误摘要和更新时间字段。 +- Non-Goals: 不在前端实现任务调度、任务执行、失败明细生成或后端状态流转。 +- Non-Goals: 不新增“部分成功”状态,不替换既有业务接口路径,不删除导出接口原字段。 + +## Decisions + +- Decision: 使用固定五态映射:`1=待处理`、`2=处理中`、`3=已完成`、`4=已失败`、`5=已取消`。 +- Rationale: 三类任务使用同一状态语义;部分成功由计数表达,避免增加状态分支。 + +- Decision: 将任务轮询实现为可复用的 composable 或等价公共模块,由页面提供任务详情查询函数和终态判断函数。 +- Rationale: 轮询节奏、页面可见性处理和清理逻辑必须只维护一份,避免各页面出现漂移。 + +- Decision: 任务恢复使用持久化的业务入口标识与 `task_id`,恢复时优先查询任务详情;任务进入终态后清理当前入口的活动任务引用。 +- Rationale: 页面刷新后可以继续展示原任务,同时终态任务不会阻止用户创建新的任务。 + +- Decision: 轮询采用单次调度而非固定高频定时器,延迟序列为 2 秒、3 秒、5 秒,随后保持 10 秒;页面隐藏时取消待执行调度,重新可见时立即查询一次。 +- Rationale: 降低无效请求,并满足页面恢复后的即时反馈要求。 + +- Decision: 任务失败优先使用后端安全字段 `error_summary` 作为用户文案;`error_code` 只用于前端分类和日志,不直接展示底层技术错误。 +- Rationale: 保证用户可理解,同时避免泄露内部错误信息。 + +## Data Model + +- `task_id`: 稳定任务标识,用于持久化和恢复查询。 +- `status`: 五态任务状态。 +- `total_count`: 任务总数。 +- `success_count`: 成功数。 +- `failed_count`: 失败数。 +- `failure_details`: 失败明细;具体字段由业务任务详情接口定义。 +- `error_code`: 稳定错误分类码。 +- `error_summary`: 可安全展示的中文错误摘要。 +- `updated_at`: 任务最近更新时间。 + +## Risks / Trade-offs + +- Risk: 不同业务详情接口的失败明细字段不完全一致。 +- Mitigation: 公共交互层只约束失败明细可展示,业务页面负责将各自接口字段映射为统一展示模型。 + +- Risk: 页面刷新时本地保存的任务已被删除或无权限访问。 +- Mitigation: 清理失效任务引用,并按 403 或普通接口失败规则展示对应状态,不重新创建任务。 + +- Risk: 页面不可见期间任务已进入终态。 +- Mitigation: 页面恢复可见后立即执行一次详情查询,并根据返回状态停止轮询。 + +## Migration Plan + +1. 明确三类任务接口的 `task_id` 创建响应和详情查询契约。 +2. 实现公共任务状态、进度模型和轮询/恢复逻辑。 +3. 先接入设备批量分配、批量订购和导出页面,保留各业务原有提交参数。 +4. 更新导出任务列表和详情类型,兼容旧字段并优先使用新增统一字段。 +5. 验证状态、重试、权限、页面刷新、页面可见性和部分成功场景。 + +## Open Questions + +- 批量分配和批量订购的任务详情接口路径及失败明细字段,需要以后端实际契约为准补入实施任务。 +- `task_id` 的存储范围需要按业务入口区分,还是由统一任务中心集中管理,需要实施前确认。 diff --git a/openspec/changes/update-unified-async-task-interaction/proposal.md b/openspec/changes/update-unified-async-task-interaction/proposal.md new file mode 100644 index 0000000..21b6f03 --- /dev/null +++ b/openspec/changes/update-unified-async-task-interaction/proposal.md @@ -0,0 +1,34 @@ +# Change: 统一公共状态与异步任务交互 + +## Why + +设备批量分配、批量订购和导出页面目前可能分别处理加载、失败、进度和任务恢复,容易产生不同的状态语义和重试行为。需要建立一套可复用的公共交互规则,让用户在不同批量操作页面获得一致的反馈,并避免页面刷新或切换后重复创建任务。 + +## What Changes + +- 为设备批量分配、批量订购和导出页面统一加载中、空数据、无权限、接口失败和重试状态。 +- 统一异步任务的五态语义:待处理、处理中、已完成、已失败、已取消。 +- 统一任务进度数据展示:任务状态、总数、成功数、失败数和失败明细。 +- 明确部分成功的表达方式:任务进入已完成状态,通过成功数和失败数表达部分成功,不新增部分成功状态。 +- 创建任务成功后保存 `task_id`,页面刷新或重新进入时恢复原任务详情,不重复创建任务。 +- 对待处理和处理中任务按 2 秒、3 秒、5 秒递增轮询,之后最大间隔 10 秒;进入终态后停止轮询。 +- 页面不可见时暂停轮询,恢复可见后立即刷新一次。 +- 统一 403、瞬时接口失败和任务失败的用户反馈及重试规则。 +- 为 `GET /api/admin/export-tasks` 和 `GET /api/admin/export-tasks/{id}` 增加兼容性字段契约:`task_id`、`total_count`、`success_count`、`failed_count`、`error_code`、`error_summary`、`updated_at`;原字段不删除、不改名。 + +## Impact + +- Affected specs: + - `async-task-interaction` + - `export-task-management` +- Affected code: + - 批量分配页面及其设备、IOT 卡任务提交逻辑 + - 批量订购页面及其任务提交逻辑 + - 导出任务 API 类型和导出任务列表/详情页面 + - 可复用异步任务状态、轮询和任务恢复逻辑 + - 统一错误、空数据、无权限和重试状态组件或页面状态配置 +- Dependencies: + - 后端批量分配、批量订购和导出接口必须返回可关联的 `task_id`,并提供按 `task_id` 查询任务详情的能力。 + - 后端导出接口按本提案新增兼容字段,且继续保留现有字段。 +- Breaking changes: + - 无。导出接口仅新增兼容字段;前端统一交互规则不改变既有任务创建接口的必填参数。 diff --git a/openspec/changes/update-unified-async-task-interaction/specs/async-task-interaction/spec.md b/openspec/changes/update-unified-async-task-interaction/specs/async-task-interaction/spec.md new file mode 100644 index 0000000..f261790 --- /dev/null +++ b/openspec/changes/update-unified-async-task-interaction/specs/async-task-interaction/spec.md @@ -0,0 +1,147 @@ +## ADDED Requirements + +### Requirement: Unified Async Task States + +The admin frontend SHALL use one state vocabulary for device batch allocation, batch ordering, and export tasks: `1=待处理`, `2=处理中`, `3=已完成`, `4=已失败`, and `5=已取消`. + +#### Scenario: Render pending and processing states + +- **GIVEN** 异步任务状态分别为待处理或处理中 +- **WHEN** 页面展示任务 +- **THEN** 页面 MUST 使用对应的统一状态文案和视觉状态 +- **AND** 页面 MUST treat both states as active states that can be polled + +#### Scenario: Render terminal states + +- **GIVEN** 异步任务状态为已完成、已失败或已取消 +- **WHEN** 页面展示任务 +- **THEN** 页面 MUST 使用对应的统一终态文案和视觉状态 +- **AND** 页面 MUST stop automatic polling for that task + +#### Scenario: Represent partial success + +- **GIVEN** 任务执行结束且成功数大于零、失败数大于零 +- **WHEN** 页面展示任务结果 +- **THEN** 页面 MUST show status as 已完成 +- **AND** 页面 MUST show total count, success count, failed count, and available failure details +- **AND** 页面 MUST NOT introduce or display 部分成功 as a separate task status + +### Requirement: Unified Common Page States + +The admin frontend SHALL provide consistent loading, empty, forbidden, request failure, and retry interactions on the three supported async-task pages. + +#### Scenario: Show loading state + +- **GIVEN** 页面正在请求任务列表或任务详情 +- **WHEN** 请求尚未完成 +- **THEN** 页面 MUST show a loading state +- **AND** 页面 MUST prevent duplicate requests caused by repeated automatic triggers + +#### Scenario: Show empty state + +- **GIVEN** 任务查询成功但没有任务数据 +- **WHEN** 页面完成渲染 +- **THEN** 页面 MUST show the unified empty state +- **AND** 页面 MUST keep the page search or task creation entry available when the user has permission + +#### Scenario: Handle forbidden response + +- **GIVEN** 任务列表或详情接口 responds with HTTP 403 +- **WHEN** 页面 handles the response +- **THEN** 页面 MUST show an explicit no-permission state +- **AND** 页面 MUST NOT automatically retry the request + +#### Scenario: Retry transient request failure + +- **GIVEN** a task list or detail request fails for a transient reason other than 403 +- **WHEN** 页面 handles the response +- **THEN** 页面 MUST preserve already loaded task data and user input +- **AND** 页面 MUST provide an explicit retry action +- **AND** automatic polling MUST continue only after a successful retry or a subsequent successful refresh + +### Requirement: Unified Task Progress Model + +The admin frontend SHALL normalize task progress into task status, total count, success count, failed count, and failure details for device batch allocation, batch ordering, and export tasks. + +#### Scenario: Display active task progress + +- **GIVEN** a task is pending or processing +- **WHEN** task details are available +- **THEN** 页面 MUST display the current status and total count +- **AND** 页面 MUST display success count and failed count when returned +- **AND** 页面 MUST update the displayed values after each successful refresh + +#### Scenario: Display failed task details + +- **GIVEN** a task is failed or has failed items +- **WHEN** 页面 renders the task result +- **THEN** 页面 MUST display a safe failure summary when available +- **AND** 页面 MUST display failure details when the business detail API provides them + +### Requirement: Task Creation And Recovery + +The admin frontend SHALL persist the `task_id` returned by an async task creation request and use it to recover the task instead of creating a duplicate task after refresh or re-entry. + +#### Scenario: Save task after creation + +- **GIVEN** 用户确认设备批量分配、批量订购或导出操作 +- **WHEN** task creation succeeds and returns `task_id` +- **THEN** 系统 MUST persist the task identifier for the corresponding business entry +- **AND** 系统 MUST start the unified task detail interaction for that task + +#### Scenario: Recover task after page refresh + +- **GIVEN** 当前业务入口存在已保存的 active `task_id` +- **WHEN** 用户刷新页面或重新进入该入口 +- **THEN** 系统 MUST query the existing task detail using that `task_id` +- **AND** 系统 MUST NOT create another task automatically + +#### Scenario: Clear task reference after terminal state + +- **GIVEN** a recovered or newly created task reaches a terminal state +- **WHEN** 页面 handles the terminal task response +- **THEN** 系统 MUST stop polling +- **AND** 系统 MUST retain the result for display while allowing a later operation to create a new task + +### Requirement: Visibility-Aware Incremental Polling + +The admin frontend SHALL poll active async tasks at 2 seconds, 3 seconds, and 5 seconds after creation or refresh, then use an interval no longer than 10 seconds until the task reaches a terminal state. + +#### Scenario: Poll active task with increasing delay + +- **GIVEN** a task is in 待处理 or 处理中 +- **WHEN** the previous task detail request succeeds and the task remains active +- **THEN** the next refresh MUST be scheduled after 2 seconds, then 3 seconds, then 5 seconds +- **AND** subsequent refreshes MUST NOT be more than 10 seconds apart + +#### Scenario: Pause polling while page is hidden + +- **GIVEN** an active task is being polled +- **WHEN** the browser page becomes not visible +- **THEN** the frontend MUST pause or cancel the pending poll +- **AND** the frontend MUST NOT issue automatic polling requests while the page remains hidden + +#### Scenario: Refresh immediately when page becomes visible + +- **GIVEN** an active task was paused because the page was hidden +- **WHEN** the page becomes visible again +- **THEN** the frontend MUST request the task detail immediately +- **AND** the frontend MUST resume the incremental polling schedule only if the task remains active + +### Requirement: Safe Async Task Error Messaging + +The admin frontend SHALL use a safe user-facing error summary for task failures and SHALL NOT expose a raw error code or low-level technical error as the primary user message. + +#### Scenario: Show task failure summary + +- **GIVEN** a failed task response contains `error_summary` +- **WHEN** 页面展示失败原因 +- **THEN** 页面 MUST show `error_summary` as the primary failure message +- **AND** 页面 MUST NOT use `error_code` as the user-facing text + +#### Scenario: Fallback when safe summary is absent + +- **GIVEN** a failed task response does not contain a safe error summary +- **WHEN** 页面展示失败原因 +- **THEN** 页面 MUST show a generic actionable failure message +- **AND** 页面 MUST keep the raw error code and technical error out of the primary user-facing message diff --git a/openspec/changes/update-unified-async-task-interaction/specs/export-task-management/spec.md b/openspec/changes/update-unified-async-task-interaction/specs/export-task-management/spec.md new file mode 100644 index 0000000..6a9c4b4 --- /dev/null +++ b/openspec/changes/update-unified-async-task-interaction/specs/export-task-management/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Export Task API Integration + +The admin frontend SHALL provide a typed API integration for unified export tasks using the existing REST contract and the following compatibility additions. Existing response fields MUST remain available and MUST NOT be renamed or removed. + +#### Scenario: Query export tasks with unified fields + +- **GIVEN** 用户打开任一导出任务页面 +- **WHEN** 用户按页面固定场景、状态、分页或创建时间范围查询导出任务 +- **THEN** 系统 MUST call `GET /api/admin/export-tasks` +- **AND** 每条任务 MUST support `task_id`, `total_count`, `success_count`, `failed_count`, `error_code`, `error_summary`, and `updated_at` +- **AND** 原有任务字段 MUST remain parseable by the frontend + +#### Scenario: Fetch export task detail with unified fields + +- **GIVEN** 用户查看或恢复一个导出任务 +- **WHEN** 系统按 `task_id` 查询任务详情 +- **THEN** 系统 MUST call `GET /api/admin/export-tasks/{id}` +- **AND** 详情 MUST support `task_id`, `total_count`, `success_count`, `failed_count`, `error_code`, `error_summary`, and `updated_at` +- **AND** 详情 MUST continue to support existing download and timestamp fields when returned + +#### Scenario: Preserve compatibility with missing optional additions + +- **GIVEN** 后端暂未返回某个新增兼容字段 +- **WHEN** 前端解析导出任务列表或详情 +- **THEN** 系统 MUST keep the response usable with stable empty or zero placeholders +- **AND** 系统 MUST NOT fail solely because an optional unified field is absent diff --git a/openspec/changes/update-unified-async-task-interaction/tasks.md b/openspec/changes/update-unified-async-task-interaction/tasks.md new file mode 100644 index 0000000..33422da --- /dev/null +++ b/openspec/changes/update-unified-async-task-interaction/tasks.md @@ -0,0 +1,44 @@ +## 1. Contract And Model + +- [x] 1.1 确认设备批量分配、批量订购和导出任务创建响应均返回稳定的 `task_id`。 +- [x] 1.2 确认三类任务按 `task_id` 查询详情的接口路径、状态字段和失败明细字段。 +- [x] 1.3 新增统一任务状态、进度、错误摘要和任务恢复类型,兼容各业务字段别名。 +- [x] 1.4 更新导出任务列表和详情类型,补充 `task_id`、`total_count`、`success_count`、`failed_count`、`error_code`、`error_summary`、`updated_at`。 + +## 2. Shared Interaction + +- [x] 2.1 实现统一加载中、空数据、无权限、接口失败和重试状态的展示规则。 +- [x] 2.2 实现五态任务状态映射,并明确终态为已完成、已失败或已取消。 +- [x] 2.3 实现统一任务进度展示,支持总数、成功数、失败数和失败明细。 +- [x] 2.4 实现任务创建后的 `task_id` 持久化、刷新恢复和失效任务清理。 +- [x] 2.5 实现 2 秒、3 秒、5 秒递增且最大 10 秒的轮询调度。 +- [x] 2.6 实现页面不可见暂停轮询、恢复可见立即刷新和组件卸载清理。 + +## 3. Business Pages + +- [x] 3.1 接入设备批量分配页面,创建成功后进入统一任务交互流程。 +- [x] 3.2 接入批量订购页面,创建成功后进入统一任务交互流程。 +- [x] 3.3 接入导出任务列表和详情页面,统一展示任务进度、失败摘要和更新时间。 +- [x] 3.4 保证部分成功任务仍展示为已完成,并显示成功数与失败数,不引入新状态。 +- [x] 3.5 保证页面刷新或重新进入时恢复任务,不重复提交创建请求。 + +## 4. Error And Permission Handling + +- [x] 4.1 403 响应展示无权限状态并停止自动重试。 +- [x] 4.2 瞬时接口失败保留已有任务数据和用户输入,并提供明确的手动重试入口。 +- [x] 4.3 任务失败优先展示 `error_summary`,不直接展示 `error_code` 或底层技术错误。 +- [x] 4.4 验证无权限用户无法看到或触发对应批量操作、任务详情和重试操作。 + +## 5. Verification + +- [ ] 5.1 覆盖五态及部分成功的单元测试。 +- [ ] 5.2 覆盖轮询延迟、终态停止、页面隐藏暂停和恢复立即刷新测试。 +- [ ] 5.3 覆盖页面刷新恢复任务及避免重复创建测试。 +- [ ] 5.4 覆盖 403、瞬时失败、任务失败和安全错误文案测试。 +- [x] 5.5 运行类型检查、lint,并对已接入的导出任务页面进行回归验证。 + +## Implementation Notes + +- 设备批量分配使用 `/api/admin/devices/import/allocations` 创建任务,并复用设备任务详情接口及统一轮询恢复逻辑。 +- 批量订购使用文件 key 创建任务,创建成功后通过 `task_id` 路由参数和持久化状态恢复任务详情。 +- 真实后端环境下的五态、权限和并发回归测试仍需在联调环境执行。 diff --git a/public/templates/bulk-purchase-template.csv b/public/templates/bulk-purchase-template.csv new file mode 100644 index 0000000..49279b8 --- /dev/null +++ b/public/templates/bulk-purchase-template.csv @@ -0,0 +1 @@ +资产标识 diff --git a/public/templates/bulk-purchase-template.xlsx b/public/templates/bulk-purchase-template.xlsx new file mode 100644 index 0000000..53faa54 Binary files /dev/null and b/public/templates/bulk-purchase-template.xlsx differ diff --git a/public/templates/device-batch-allocation-template.csv b/public/templates/device-batch-allocation-template.csv new file mode 100644 index 0000000..26a6c5d --- /dev/null +++ b/public/templates/device-batch-allocation-template.csv @@ -0,0 +1 @@ +设备标识 diff --git a/scripts/tests/agentRechargeActions.test.mjs b/scripts/tests/agentRechargeActions.test.mjs index d910d68..ea63813 100644 --- a/scripts/tests/agentRechargeActions.test.mjs +++ b/scripts/tests/agentRechargeActions.test.mjs @@ -50,13 +50,13 @@ async function importBundledActions() { } } -test('pending agent recharge exposes reject action for admins with rejection permission', async () => { +test('pending offline recharge exposes reject action for admins with rejection permission', async () => { const { module, cleanup } = await importBundledActions() const { buildAgentRechargeActions } = module const row = { id: 42, status: 1, - payment_method: 'wechat', + payment_method: 'offline', payment_voucher_key: [] } const handledRows = [] @@ -108,3 +108,27 @@ test('non-pending agent recharge does not expose reject action', async () => { await cleanup() } }) + +test('pending online recharge does not expose offline reject action', async () => { + const { module, cleanup } = await importBundledActions() + const { buildAgentRechargeActions } = module + const row = { + id: 42, + status: 1, + payment_method: 'wechat', + payment_voucher_key: [] + } + + try { + const actions = buildAgentRechargeActions(row, { + hasAuth: (permission) => permission === 'agent_recharge:reject', + onViewPaymentVoucher: () => {}, + onConfirmPayment: () => {}, + onReject: () => {} + }) + + assert.deepEqual(actions, []) + } finally { + await cleanup() + } +}) diff --git a/scripts/tests/agentRechargeOnline.test.mjs b/scripts/tests/agentRechargeOnline.test.mjs new file mode 100644 index 0000000..bee89b5 --- /dev/null +++ b/scripts/tests/agentRechargeOnline.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import * as esbuild from 'esbuild' + +async function importBundledOnlineHelpers() { + const tempDir = await mkdtemp(join(tmpdir(), 'agent-recharge-online-')) + const bundlePath = join(tempDir, 'agentRechargeOnline.mjs') + + await esbuild.build({ + entryPoints: ['src/views/finance/agent-recharge/agentRechargeOnline.ts'], + bundle: true, + outfile: bundlePath, + format: 'esm', + platform: 'node', + absWorkingDir: process.cwd(), + plugins: [ + { + name: 'alias-src', + setup(build) { + build.onResolve({ filter: /^@\// }, async (args) => { + const resolvedPath = join(process.cwd(), 'src', args.path.slice(2)) + try { + if ((await stat(resolvedPath)).isDirectory()) + return { path: join(resolvedPath, 'index.ts') } + } catch { + // Let esbuild report the original resolution failure. + } + return { path: resolvedPath } + }) + } + } + ] + }) + + const module = await import(pathToFileURL(bundlePath).href) + return { + module, + cleanup: () => rm(tempDir, { recursive: true, force: true }) + } +} + +test('online recharge helpers convert yuan to integer fen and identify terminal states', async () => { + const { module, cleanup } = await importBundledOnlineHelpers() + + try { + assert.equal(module.amountYuanToFen(100.01), 10001) + assert.equal(module.isOnlineRechargeTerminal(1), false) + assert.equal(module.isOnlineRechargeTerminal(2), false) + assert.equal(module.isOnlineRechargeTerminal(3), true) + assert.equal(module.isOnlineRechargeTerminal(6), true) + } finally { + await cleanup() + } +}) + +test('online recharge request IDs are unique and namespaced', async () => { + const { module, cleanup } = await importBundledOnlineHelpers() + + try { + const first = module.createOnlineRechargeRequestId() + const second = module.createOnlineRechargeRequestId() + assert.match(first, /^recharge-/) + assert.match(second, /^recharge-/) + assert.notEqual(first, second) + } finally { + await cleanup() + } +}) diff --git a/scripts/tests/agentRechargeService.test.mjs b/scripts/tests/agentRechargeService.test.mjs index 0179c7f..6010096 100644 --- a/scripts/tests/agentRechargeService.test.mjs +++ b/scripts/tests/agentRechargeService.test.mjs @@ -20,6 +20,17 @@ test('agent recharge rejection posts the rejection reason to the order rejection post(options) { calls.push(options) return Promise.resolve({ code: 0, data: undefined }) + }, + get(options) { + calls.push(options) + return Promise.resolve({ + code: 0, + data: { + methods: ['wechat', 'alipay'], + min_amount: 10000, + max_amount: 100000000 + } + }) } } `, @@ -53,6 +64,8 @@ test('agent recharge rejection posts the rejection reason to the order rejection await AgentRechargeService.rejectAgentRecharge(42, { rejection_reason: '支付凭证不符合要求' }) + await AgentRechargeService.getPaymentMethods() + await AgentRechargeService.getPaymentStatus(42) assert.deepEqual(calls, [ { @@ -60,6 +73,14 @@ test('agent recharge rejection posts the rejection reason to the order rejection data: { rejection_reason: '支付凭证不符合要求' } + }, + { + url: '/api/admin/agent-recharges/payment-methods', + params: undefined + }, + { + url: '/api/admin/agent-recharges/42/payment-status', + params: undefined } ]) } finally { diff --git a/scripts/tests/auditNavigation.test.mjs b/scripts/tests/auditNavigation.test.mjs new file mode 100644 index 0000000..40d1aa0 --- /dev/null +++ b/scripts/tests/auditNavigation.test.mjs @@ -0,0 +1,136 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import ts from 'typescript' + +async function importAuditNavigation() { + const tempDir = await mkdtemp(join(tmpdir(), 'audit-navigation-')) + const bundlePath = join(tempDir, 'auditNavigation.mjs') + + const source = await readFile('src/utils/business/auditNavigation.ts', 'utf8') + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022 + } + }) + await writeFile(bundlePath, transpiled.outputText, 'utf8') + + return { + module: await import(pathToFileURL(bundlePath).href), + cleanup: () => rm(tempDir, { recursive: true, force: true }) + } +} + +test('resource targets follow the platform, agent and enterprise stable-reference matrix', async () => { + const { module, cleanup } = await importAuditNavigation() + const { resolveAuditResourceTarget } = module + const cases = [ + { + name: 'platform uses the internal resource id', + input: { + userType: 2, + resourceType: 'iot_card', + internalId: 8020, + businessIdentifier: 'iccid' + }, + expected: { mode: 'resource', resourceType: 'iot_card', id: '8020' } + }, + { + name: 'agent uses the trimmed subject identifier', + input: { + userType: 3, + resourceType: 'iot_card', + internalId: 8020, + businessIdentifier: ' 8986112520903885262 ' + }, + expected: { mode: 'agent', resourceType: 'iot_card', id: '8986112520903885262' } + }, + { + name: 'enterprise uses a device identifier', + input: { + userType: 4, + resourceType: 'device', + internalId: 9, + businessIdentifier: ' SN-001 ' + }, + expected: { mode: 'enterprise', resourceType: 'device', id: 'SN-001' } + }, + { + name: 'enterprise cannot use organization resources', + input: { userType: 4, resourceType: 'shop', businessIdentifier: 'SHOP-1' }, + expected: null + }, + { + name: 'agent cannot infer an unsupported business resource', + input: { userType: 3, resourceType: 'refund', businessIdentifier: 'RF-1' }, + expected: null + }, + { + name: 'platform hides a missing internal id', + input: { userType: 2, resourceType: 'order', businessIdentifier: 'ORD-1' }, + expected: null + }, + { + name: 'platform hides zero ids', + input: { userType: 1, resourceType: 'device', internalId: 0 }, + expected: null + }, + { + name: 'platform hides whitespace ids', + input: { userType: 1, resourceType: 'device', internalId: ' ' }, + expected: null + } + ] + + try { + for (const scenario of cases) { + assert.deepEqual(resolveAuditResourceTarget(scenario.input), scenario.expected, scenario.name) + } + } finally { + await cleanup() + } +}) + +test('finance targets preserve only complete backend-provided conditions', async () => { + const { module, cleanup } = await importAuditNavigation() + const { resolveFinanceAuditTarget } = module + const cases = [ + ['numeric business id', 'order_id', 72, { mode: 'finance', field: 'order_id', value: 72 }], + [ + 'trimmed business number', + 'refund_no', + ' RF20260807171319801844 ', + { mode: 'finance', field: 'refund_no', value: 'RF20260807171319801844' } + ], + ['missing value', 'wallet_id', undefined, null], + ['zero value', 'payment_id', 0, null], + ['whitespace value', 'correlation_id', ' ', null] + ] + + try { + for (const [name, field, value, expected] of cases) { + assert.deepEqual(resolveFinanceAuditTarget(field, value), expected, name) + } + } finally { + await cleanup() + } +}) + +test('explicit investigation references suppress empty, unreliable and self targets', async () => { + const { module, cleanup } = await importAuditNavigation() + const { resolveInvestigationReference } = module + + try { + assert.equal(resolveInvestigationReference(' request-1 '), 'request-1') + assert.equal(resolveInvestigationReference(' '), null) + assert.equal(resolveInvestigationReference('event-1', { currentId: 'event-1' }), null) + assert.equal(resolveInvestigationReference('event-2', { currentId: 'event-1' }), 'event-2') + assert.equal(resolveInvestigationReference('correlation-1', { fidelity: false }), null) + } finally { + await cleanup() + } +}) diff --git a/src/App.vue b/src/App.vue index 313a2ea..d9a3bc4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ + @@ -17,6 +18,7 @@ import { checkStorageCompatibility } from '@/utils' import ChunkErrorBoundary from '@/components/core/others/ChunkErrorBoundary.vue' import { ElMessageBox } from 'element-plus' + import AuditInvestigationHost from '@/components/business/audit/AuditInvestigationHost.vue' const userStore = useUserStore() const { language } = storeToRefs(userStore) @@ -74,7 +76,7 @@ } const getEntrySignature = (html: string) => { - const matches = html.match(/(?:src|href)="[^"]*\/assets\/[^""]+\.(?:js|css)"/g) + const matches = html.match(/(?:src|href)="[^"]*\/assets\/[^"]+\.(?:js|css)"/g) return matches?.sort().join('|') || '' } @@ -104,7 +106,6 @@ [ navigator.userAgent, navigator.language, - navigator.platform, Intl.DateTimeFormat().resolvedOptions().timeZone, `${screen.width}x${screen.height}x${screen.colorDepth}` ].join('|') diff --git a/src/api/modules/account.ts b/src/api/modules/account.ts index 8b8a39f..454e013 100644 --- a/src/api/modules/account.ts +++ b/src/api/modules/account.ts @@ -8,10 +8,14 @@ import type { AccountQueryParams, CreatePlatformAccountParams, BaseResponse, - PaginationResponse + PaginationResponse, + WecomAccountBindingRequest } from '@/types/api' export class AccountService extends BaseService { + static bindWecom(id: number, data: WecomAccountBindingRequest): Promise { + return this.put(`/api/admin/accounts/${id}/wecom-binding`, data) + } // ========== 账号管理 (Account Management) ========== /** @@ -30,7 +34,7 @@ export class AccountService extends BaseService { */ static createAccount( data: CreatePlatformAccountParams - ): Promise> { + ): Promise> { return this.create('/api/admin/accounts', data) } diff --git a/src/api/modules/agentRecharge.ts b/src/api/modules/agentRecharge.ts index 84413ca..6b07651 100644 --- a/src/api/modules/agentRecharge.ts +++ b/src/api/modules/agentRecharge.ts @@ -8,12 +8,23 @@ import type { AgentRechargeQueryParams, AgentRechargeListResponse, CreateAgentRechargeRequest, + AgentRechargePaymentMethods, + AgentRechargePaymentStatusResponse, ConfirmOfflinePaymentRequest, RejectAgentRechargeRequest, BaseResponse } from '@/types/api' export class AgentRechargeService extends BaseService { + /** + * 获取代理在线充值可用支付方式和金额限制 + */ + static getPaymentMethods(): Promise> { + return this.get>( + '/api/admin/agent-recharges/payment-methods' + ) + } + /** * 获取代理充值订单列表 * @param params 查询参数 @@ -32,6 +43,15 @@ export class AgentRechargeService extends BaseService { return this.getOne(`/api/admin/agent-recharges/${id}`) } + /** + * 查询在线充值支付及钱包到账状态 + */ + static getPaymentStatus(id: number): Promise> { + return this.getOne( + `/api/admin/agent-recharges/${id}/payment-status` + ) + } + /** * 创建代理充值订单 * @param data 创建充值订单请求参数 @@ -68,4 +88,14 @@ export class AgentRechargeService extends BaseService { ): Promise> { return this.post>(`/api/admin/agent-recharges/${id}/reject`, data) } + + /** + * 补发历史线下代理充值审批 + * @param id 充值记录ID + */ + static triggerApproval(id: number): Promise> { + return this.post>( + `/api/admin/agent-recharges/${id}/trigger-approval` + ) + } } diff --git a/src/api/modules/asset.ts b/src/api/modules/asset.ts index 5e83a46..1ca0810 100644 --- a/src/api/modules/asset.ts +++ b/src/api/modules/asset.ts @@ -19,6 +19,7 @@ import type { AssetPackageParams, AssetCurrentPackageResponse, DeviceStopResponse, + AssetStartResponse, AssetWalletTransactionListResponse, AssetWalletTransactionParams, AssetWalletResponse, @@ -26,25 +27,33 @@ import type { AssetOrdersResponse, UpdateAssetRealnameStatusRequest, DtoUpdateAssetRealnameStatusResponse, - AssetOperationLogsResponse, - AssetOperationLogsParams, AssetPackageUsageRecord, UpdateAssetPackageUsedDataRequest, - UpdateAssetPackageExpiresAtRequest + UpdateAssetPackageExpiresAtRequest, + ExpiringAssetListResponse, + ExpiringAssetQueryParams } from '@/types/api' const runRateLimitedAssetAction = async ( - action: AssetRateLimitedAction, + action: Exclude, identifier: string, request: () => Promise ): Promise => { assertAssetActionAllowed(action, identifier) markAssetActionCalled(action, identifier) - const response = await request() - return response + return request() } export class AssetService extends BaseService { + /** + * 获取管理端临期资产列表 + * GET /api/admin/expiring-assets + */ + static getExpiringAssets( + params?: ExpiringAssetQueryParams + ): Promise> { + return this.get>('/api/admin/expiring-assets', params) + } /** * 通过任意标识符查询设备或卡的完整详情 * 支持虚拟号、ICCID、IMEI、SN、MSISDN @@ -54,9 +63,14 @@ export class AssetService extends BaseService { */ static resolveAsset( identifier: string, - params?: AssetResolveParams + params?: AssetResolveParams, + config?: Record ): Promise> { - return this.getOne(`/api/admin/assets/resolve/${identifier}`, params) + return this.getOne( + `/api/admin/assets/resolve/${identifier}`, + params, + config + ) } /** @@ -79,16 +93,13 @@ export class AssetService extends BaseService { /** * 主动调网关拉取最新数据后返回 * POST /api/admin/assets/:identifier/refresh - * 前端按资产标识限制 5 分钟内只能调用一次 * @param identifier 资产标识符(ICCID 或 VirtualNo) */ static refreshAsset(identifier: string): Promise> { - return runRateLimitedAssetAction('refresh', identifier, () => - this.post>( - `/api/admin/assets/${identifier}/refresh`, - {}, - { timeout: 60000 } - ) + return this.post>( + `/api/admin/assets/${identifier}/refresh`, + {}, + { timeout: 60000 } ) } @@ -181,9 +192,16 @@ export class AssetService extends BaseService { * 前端按资产标识限制 5 分钟内只能调用一次 * @param identifier 资产标识符(ICCID 或 VirtualNo) */ - static startAsset(identifier: string): Promise> { + static startAsset( + identifier: string, + config?: Record + ): Promise> { return runRateLimitedAssetAction('start', identifier, () => - this.post>(`/api/admin/assets/${identifier}/start`, {}) + this.post>( + `/api/admin/assets/${identifier}/start`, + {}, + config + ) ) } @@ -291,22 +309,4 @@ export class AssetService extends BaseService { data ) } - - // ========== 资产操作审计日志 ========== - - /** - * 查询资产操作审计日志 - * GET /api/admin/assets/:identifier/operation-logs - * @param identifier 资产标识符 - * @param params 查询参数 - */ - static getOperationLogs( - identifier: string, - params?: AssetOperationLogsParams - ): Promise> { - return this.get>( - `/api/admin/assets/${identifier}/operation-logs`, - params - ) - } } diff --git a/src/api/modules/audit.ts b/src/api/modules/audit.ts new file mode 100644 index 0000000..353bead --- /dev/null +++ b/src/api/modules/audit.ts @@ -0,0 +1,155 @@ +import { BaseService } from '../BaseService' +import { useUserStore } from '@/store/modules/user' +import { isPlatformAuditAccount } from '@/utils/business/auditAccess' +import type { + AuditActorEventQuery, + AuditActorKind, + AuditEventDetail, + AuditEventPage, + AuditEventQuery, + AuditFinanceQuery, + AuditFinanceTimelinePage, + AuditLinkTimeline, + AuditResourceSearchPage, + AuditResourceSearchQuery, + AuditResourceTimelineQuery, + AuditRiskEventPage, + AuditRiskEventQuery, + AuditRiskOverview, + AuditRiskQuery, + AuditSubjectActivityPage, + AuditSubjectActivityQuery, + AuditSubjectResourceType, + BaseResponse, + IntegrationDetailResponse, + IntegrationListPage, + IntegrationOverview, + IntegrationQuery +} from '@/types/api' + +export class AuditService extends BaseService { + private static ensurePlatformAuditAccess() { + const userStore = useUserStore() + if (!isPlatformAuditAccount(userStore.info.user_type, userStore.isSuperAdmin)) { + throw new Error('当前账号无权访问平台审计接口') + } + } + + private static ensureSubjectActivityAccess(subject: 'agent' | 'enterprise') { + const userType = Number(useUserStore().info.user_type) + if ((subject === 'agent' && userType !== 3) || (subject === 'enterprise' && userType !== 4)) { + throw new Error('当前账号无权访问主体活动接口') + } + } + + static getEvents(params?: AuditEventQuery): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/events', params) + } + + static getEventDetail(eventId: string): Promise> { + this.ensurePlatformAuditAccess() + return this.get(`/api/admin/audit/events/${eventId}`) + } + + static getActorEvents( + kind: AuditActorKind, + id: string, + params?: AuditActorEventQuery + ): Promise> { + this.ensurePlatformAuditAccess() + return this.get( + `/api/admin/audit/actors/${encodeURIComponent(kind)}/${encodeURIComponent(id)}/events`, + params + ) + } + + static searchResources( + params: AuditResourceSearchQuery + ): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/resources/search', params) + } + + static getResourceTimeline( + resourceType: string, + resourceId: string, + params?: AuditResourceTimelineQuery + ): Promise> { + this.ensurePlatformAuditAccess() + return this.get( + `/api/admin/audit/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/timeline`, + params + ) + } + + static getRequestTimeline(requestId: string): Promise> { + this.ensurePlatformAuditAccess() + return this.get(`/api/admin/audit/requests/${encodeURIComponent(requestId)}/timeline`) + } + + static getCorrelationTimeline(correlationId: string): Promise> { + this.ensurePlatformAuditAccess() + return this.get(`/api/admin/audit/correlations/${encodeURIComponent(correlationId)}/timeline`) + } + + static getFinanceTimeline( + params: AuditFinanceQuery + ): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/finance/timeline', params) + } + + static getRiskOverview(params?: AuditRiskQuery): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/risks/overview', params) + } + + static getRiskEvents(params?: AuditRiskEventQuery): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/risks/events', params) + } + + static getIntegrationOverview( + params?: IntegrationQuery & { bucket?: 'hour' | 'day' } + ): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/integrations/overview', params) + } + + static getIntegrations(params?: IntegrationQuery): Promise> { + this.ensurePlatformAuditAccess() + return this.get('/api/admin/audit/integrations', params) + } + + static getIntegrationDetail( + integrationId: string + ): Promise> { + this.ensurePlatformAuditAccess() + return this.get(`/api/admin/audit/integrations/${encodeURIComponent(integrationId)}`) + } + + static getAgentResourceActivities( + resourceType: AuditSubjectResourceType, + identifier: string, + params?: AuditSubjectActivityQuery + ): Promise> { + this.ensureSubjectActivityAccess('agent') + return this.get( + `/api/admin/agent/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`, + params + ) + } + + static getEnterpriseResourceActivities( + resourceType: Extract, + identifier: string, + params?: AuditSubjectActivityQuery + ): Promise> { + this.ensureSubjectActivityAccess('enterprise') + return this.get( + `/api/admin/enterprise/resource-activities/${encodeURIComponent(resourceType)}/${encodeURIComponent(identifier)}`, + params + ) + } +} diff --git a/src/api/modules/bulkPurchase.ts b/src/api/modules/bulkPurchase.ts new file mode 100644 index 0000000..4c7622c --- /dev/null +++ b/src/api/modules/bulkPurchase.ts @@ -0,0 +1,33 @@ +import request from '@/utils/http' +import type { + BulkPurchaseCreateRequest, + BulkPurchaseCreateApiResponse, + BulkPurchaseTaskListApiResponse, + BulkPurchaseTaskApiResponse +} from '@/types/api' + +export class BulkPurchaseService { + static createTask(data: BulkPurchaseCreateRequest): Promise { + return request.post({ + url: '/api/admin/asset-package-batch-orders', + data + }) + } + + static getTasks(params?: { + page?: number + page_size?: number + status?: number + }): Promise { + return request.get({ + url: '/api/admin/asset-package-batch-orders', + params + }) + } + + static getTask(taskId: number): Promise { + return request.get({ + url: `/api/admin/asset-package-batch-orders/${taskId}` + }) + } +} diff --git a/src/api/modules/card.ts b/src/api/modules/card.ts index 7cb6ea8..dc90861 100644 --- a/src/api/modules/card.ts +++ b/src/api/modules/card.ts @@ -35,7 +35,11 @@ import type { AssetAllocationRecord, AssetAllocationRecordDetail, BatchSetCardSeriesBindingRequest, - BatchSetCardSeriesBindingResponse + BatchSetCardSeriesBindingResponse, + BatchUpdateAssetRealnamePolicyRequest, + BatchUpdateAssetRealnamePolicyResponse, + SpeedTierCode, + SetSpeedTierResponse } from '@/types/api' type ApiQueryParams = PaginationParams & Record @@ -56,6 +60,16 @@ interface CardChangeNotice { } export class CardService extends BaseService { + static setSpeedTier( + iccid: string, + code: SpeedTierCode + ): Promise> { + return this.put>( + `/api/admin/iot-cards/${encodeURIComponent(iccid)}/speed-tier`, + { code }, + { requestOptions: { errorMessageMode: 'none' } } + ) + } // ========== 号卡商品管理 ========== /** @@ -411,6 +425,20 @@ export class CardService extends BaseService { ) } + /** + * 批量更新卡实名认证策略 + */ + static batchUpdateRealnamePolicy( + data: BatchUpdateAssetRealnamePolicyRequest, + config?: Record + ): Promise> { + return this.post>( + '/api/admin/iot-cards/batch-update-realname-policy', + data, + config + ) + } + // ========== IoT卡网关操作相关 ========== /** diff --git a/src/api/modules/device.ts b/src/api/modules/device.ts index f7be7ac..e014b2b 100644 --- a/src/api/modules/device.ts +++ b/src/api/modules/device.ts @@ -17,13 +17,16 @@ import type { RecallDevicesResponse, BatchSetDeviceSeriesBindingRequest, BatchSetDeviceSeriesBindingResponse, + BatchUpdateAssetRealnamePolicyRequest, + BatchUpdateAssetRealnamePolicyResponse, ImportDeviceRequest, ImportDeviceResponse, DeviceImportTaskQueryParams, DeviceImportTaskListResponse, DeviceImportTaskDetail, + DeviceBatchAllocationRequest, + DeviceBatchAllocationResponse, BaseResponse, - SetSpeedLimitRequest, SwitchCardRequest, SetWiFiRequest, DeviceOperationResponse @@ -146,6 +149,15 @@ export class DeviceService extends BaseService { ) } + static createAllocationTask( + data: DeviceBatchAllocationRequest + ): Promise> { + return this.post>( + '/api/admin/devices/import/allocations', + data + ) + } + /** * 获取导入任务详情 * @param id 任务ID @@ -169,6 +181,20 @@ export class DeviceService extends BaseService { ) } + /** + * 批量更新设备实名认证策略 + */ + static batchUpdateRealnamePolicy( + data: BatchUpdateAssetRealnamePolicyRequest, + config?: Record + ): Promise> { + return this.post>( + '/api/admin/devices/batch-update-realname-policy', + data, + config + ) + } + // ========== 设备操作相关 ========== /** @@ -193,21 +219,6 @@ export class DeviceService extends BaseService { ) } - /** - * 设置限速 - * @param imei 设备号(IMEI) - * @param data 限速参数 - */ - static setSpeedLimit( - imei: string, - data: SetSpeedLimitRequest - ): Promise> { - return this.put>( - `/api/admin/devices/by-identifier/${imei}/speed-limit`, - data - ) - } - /** * 切换SIM卡 * @param imei 设备号(IMEI) diff --git a/src/api/modules/exchange.ts b/src/api/modules/exchange.ts index d6ae11f..f2e5b00 100644 --- a/src/api/modules/exchange.ts +++ b/src/api/modules/exchange.ts @@ -5,13 +5,18 @@ import { BaseService } from '../BaseService' import type { BaseResponse } from '@/types/api' +export type ExchangeAssetType = 'iot_card' | 'device' +export type ExchangeFlowType = 'shipping' | 'direct' +export type ExchangeStatus = 1 | 2 | 3 | 4 | 5 + // 换货单查询参数 export interface ExchangeQueryParams { page?: number page_size?: number - status?: number // 换货状态 - flow_type?: string // 流程类型(shipping/direct) - identifier?: string // 资产标识符(模糊匹配,同时匹配旧资产、新资产) + status?: ExchangeStatus // 换货状态 + flow_type?: ExchangeFlowType // 流程类型(shipping/direct) + old_asset_keyword?: string // 旧资产关键词(ICCID、接入号、虚拟号、IMEI、SN) + new_asset_keyword?: string // 新资产关键词(ICCID、接入号、虚拟号、IMEI、SN) created_at_start?: string // 创建时间起始 created_at_end?: string // 创建时间结束 } @@ -19,9 +24,9 @@ export interface ExchangeQueryParams { // 创建换货单请求 export interface CreateExchangeRequest { exchange_reason: string // 换货原因 - old_asset_type: string // 旧资产类型 (iot_card 或 device) + old_asset_type: ExchangeAssetType // 旧资产类型 (iot_card 或 device) old_identifier: string // 旧资产标识符(ICCID/虚拟号/IMEI/SN) - flow_type?: string // 流程类型(shipping/direct),默认 shipping + flow_type?: ExchangeFlowType // 流程类型(shipping/direct),默认 shipping new_identifier?: string // 新资产标识符(direct 时必填) migrate_data?: boolean // 是否迁移数据(默认 false) remark?: string // 备注(可选) @@ -32,13 +37,16 @@ export interface ExchangeResponse { id: number exchange_no: string exchange_reason: string - old_asset_type: string + old_asset_type: ExchangeAssetType + old_asset_id: number old_asset_identifier: string - new_asset_type: string - new_asset_identifier: string - status: number // 换货状态(1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) + new_asset_type?: ExchangeAssetType | null + new_asset_id?: number | null + new_asset_identifier?: string | null + status: ExchangeStatus // 换货状态(1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) + status_name?: string status_text: string - flow_type: string // 流程类型(shipping/direct) + flow_type: ExchangeFlowType // 流程类型(shipping/direct) flow_type_name: string // 流程类型名称 shipped_at?: string | null // 发货时间(仅 shipping 发货后有值) completed_at?: string | null // 换货完成时间 @@ -47,6 +55,15 @@ export interface ExchangeResponse { recipient_address?: string express_company?: string express_no?: string + inherited_shop_id?: number | null + inherited_shop_name?: string | null + submitter_name?: string | null // 提交人名称 + approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源 + approval_status?: string | null // 审批状态 + approval_status_name?: string | null // 审批状态名称 + current_approver_summary?: string | null // 当前审批人摘要 + processing_status?: string | null // 业务处理状态 + processing_status_name?: string | null // 业务处理状态名称 remark?: string created_at: string updated_at: string @@ -86,8 +103,11 @@ export class ExchangeService extends BaseService { * POST /api/admin/exchanges * @param data 创建参数 */ - static createExchange(data: CreateExchangeRequest): Promise> { - return this.create('/api/admin/exchanges', data) + static createExchange( + data: CreateExchangeRequest, + config?: Record + ): Promise> { + return this.post>('/api/admin/exchanges', data, config) } /** @@ -114,8 +134,8 @@ 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): Promise> { + return this.post>(`/api/admin/exchanges/${id}/complete`, {}) } /** diff --git a/src/api/modules/exportTask.ts b/src/api/modules/exportTask.ts index e6633a8..fbd6477 100644 --- a/src/api/modules/exportTask.ts +++ b/src/api/modules/exportTask.ts @@ -1,9 +1,7 @@ import { BaseService } from '../BaseService' import type { - CancelExportTaskApiResponse, CreateExportTaskApiResponse, CreateExportTaskRequest, - ExportTaskDetail, ExportTaskDetailApiResponse, ExportTaskListApiResponse, ExportTaskQueryParams @@ -19,10 +17,6 @@ export class ExportTaskService extends BaseService { } static getExportTaskDetail(id: number): Promise { - return this.getOne(`/api/admin/export-tasks/${id}`) - } - - static cancelExportTask(id: number): Promise { - return this.post(`/api/admin/export-tasks/${id}/cancel`, {}) + return this.get(`/api/admin/export-tasks/${id}`) } } diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index 0f1f1b7..90b4cb8 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -18,10 +18,12 @@ export { CarrierService } from './carrier' export { PackageSeriesService } from './packageSeries' export { PackageManageService } from './packageManage' export { ShopSeriesGrantService } from './shopSeriesGrant' +export { ShopPackageAllocationService } from './shopPackageAllocation' export { OrderService } from './order' export { AssetService } from './asset' export { AgentRechargeService } from './agentRecharge' export { PaymentSettingsService } from './paymentSettings' +export { SystemConfigService } from './systemConfig' export { ExchangeService } from './exchange' export { RefundService } from './refund' export { DataCleanupService } from './dataCleanup' @@ -33,6 +35,10 @@ export { PollingMonitorService } from './pollingMonitor' export { SuperAdminService } from './superAdmin' export { ExportTaskService } from './exportTask' export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask' +export { BulkPurchaseService } from './bulkPurchase' +export { NotificationService } from './notification' +export { AuditService } from './audit' +export { WecomService } from './wecom' // TODO: 按需添加其他业务模块 // export { SettingService } from './setting' diff --git a/src/api/modules/notification.ts b/src/api/modules/notification.ts new file mode 100644 index 0000000..9e94a7a --- /dev/null +++ b/src/api/modules/notification.ts @@ -0,0 +1,50 @@ +import { BaseService } from '../BaseService' +import type { + BaseResponse, + NotificationListResponse, + NotificationQueryParams, + NotificationReadRequest, + NotificationReadAllRequest, + NotificationReadAllResponse, + NotificationReadResponse, + NotificationUnreadCount, + NotificationUnreadSummary, + NotificationTarget +} from '@/types/api' + +export class NotificationService extends BaseService { + static getUnreadCount(): Promise> { + return this.get>('/api/admin/notifications/unread-count') + } + + static getUnreadSummary(): Promise> { + return this.get>( + '/api/admin/notifications/unread-summary' + ) + } + + static getNotifications( + params?: NotificationQueryParams + ): Promise> { + return this.get>('/api/admin/notifications', params) + } + + static markRead(id: number): Promise> { + return this.put>(`/api/admin/notifications/${id}/read`, { + id + } satisfies NotificationReadRequest) + } + + static markAllRead( + data: NotificationReadAllRequest = {} + ): Promise> { + return this.put>( + '/api/admin/notifications/read-all', + data + ) + } + + static getTarget(id: number): Promise> { + return this.get>(`/api/admin/notifications/${id}/target`) + } +} diff --git a/src/api/modules/packageManage.ts b/src/api/modules/packageManage.ts index 77004f9..9d5f629 100644 --- a/src/api/modules/packageManage.ts +++ b/src/api/modules/packageManage.ts @@ -11,7 +11,7 @@ import type { UpdatePackageStatusRequest, UpdatePackageShelfStatusRequest, BaseResponse, - PaginationResponse, + PaginationResponse } from '@/types/api' export class PackageManageService extends BaseService { @@ -68,7 +68,7 @@ export class PackageManageService extends BaseService { * 更新套餐状态 * PUT /api/admin/packages/{id}/status * @param id 套餐ID - * @param status 状态 (1:启用, 2:禁用) + * @param status 状态 (0:禁用, 1:启用) */ static updatePackageStatus(id: number, status: number): Promise { const data: UpdatePackageStatusRequest = { status } diff --git a/src/api/modules/refund.ts b/src/api/modules/refund.ts index d8271a9..4b8f0de 100644 --- a/src/api/modules/refund.ts +++ b/src/api/modules/refund.ts @@ -8,10 +8,7 @@ import type { RefundQueryParams, RefundListResponse, CreateRefundRequest, - ApproveRefundRequest, - RejectRefundRequest, ResubmitRefundRequest, - ReturnRefundRequest, BaseResponse } from '@/types/api' @@ -40,24 +37,6 @@ export class RefundService extends BaseService { return this.post>('/api/admin/refunds', data) } - /** - * 审批通过退款申请 - * @param id 退款申请ID - * @param data 审批通过请求参数 - */ - static approveRefund(id: number, data: ApproveRefundRequest): Promise> { - return this.post>(`/api/admin/refunds/${id}/approve`, data) - } - - /** - * 审批拒绝退款申请 - * @param id 退款申请ID - * @param data 审批拒绝请求参数 - */ - static rejectRefund(id: number, data: RejectRefundRequest): Promise> { - return this.post>(`/api/admin/refunds/${id}/reject`, data) - } - /** * 重新提交退款申请 * @param id 退款申请ID @@ -68,11 +47,10 @@ export class RefundService extends BaseService { } /** - * 驳回申请 + * 补发历史退款审批 * @param id 退款申请ID - * @param data 退回请求参数 */ - static returnRefund(id: number, data: ReturnRefundRequest): Promise> { - return this.post>(`/api/admin/refunds/${id}/return`, data) + static triggerApproval(id: number): Promise> { + return this.post>(`/api/admin/refunds/${id}/trigger-approval`) } } diff --git a/src/api/modules/role.ts b/src/api/modules/role.ts index 209e95e..c827ff7 100644 --- a/src/api/modules/role.ts +++ b/src/api/modules/role.ts @@ -8,7 +8,8 @@ import type { PlatformRole, RoleQueryParams, PlatformRoleFormData, - PermissionTreeNode, + UpdateRoleDefaultCreditRequest, + UpdateRoleDefaultCreditResponse, BaseResponse, PaginationResponse } from '@/types/api' @@ -47,10 +48,26 @@ export class RoleService extends BaseService { * @param id 角色ID * @param data 角色数据 */ - static updateRole(id: number, data: PlatformRoleFormData): Promise { + static updateRole(id: number, data: Partial): Promise { return this.update(`/api/admin/roles/${id}`, data) } + /** + * 更新客户角色默认信用 + * PUT /api/admin/roles/{id}/default-credit + * @param id 角色ID + * @param data 默认信用配置 + */ + static updateRoleDefaultCredit( + id: number, + data: UpdateRoleDefaultCreditRequest + ): Promise> { + return this.put>( + `/api/admin/roles/${id}/default-credit`, + data + ) + } + /** * 删除角色 * DELETE /api/admin/roles/{id} diff --git a/src/api/modules/shop.ts b/src/api/modules/shop.ts index 29d62cc..656d586 100644 --- a/src/api/modules/shop.ts +++ b/src/api/modules/shop.ts @@ -10,8 +10,12 @@ import type { UpdateShopParams, ShopRolesResponse, AssignShopRolesRequest, + UpdateShopCreditLimitRequest, + UpdateShopCreditLimitResponse, BaseResponse, - PaginationResponse + PaginationResponse, + ShopBusinessOwnerCandidate, + ShopBusinessOwnerCandidateQueryParams } from '@/types/api' export class ShopService extends BaseService { @@ -24,6 +28,27 @@ export class ShopService extends BaseService { return this.getPage('/api/admin/shops', params) } + /** + * 获取店铺业务员候选 + * GET /api/admin/shops/business-owner-candidates + */ + static getBusinessOwnerCandidates( + params?: ShopBusinessOwnerCandidateQueryParams + ): Promise> { + return this.getPage( + '/api/admin/shops/business-owner-candidates', + params + ) + } + + /** + * 获取店铺详情 + * GET /api/admin/shops/{id} + */ + static getShopDetail(id: number): Promise> { + return this.getOne(`/api/admin/shops/${id}`) + } + /** * 店铺级联查询(树结构) * GET /api/admin/shops/cascade @@ -114,4 +139,20 @@ export class ShopService extends BaseService { static deleteShopRole(shopId: number, roleId: number): Promise { return this.delete(`/api/admin/shops/${shopId}/roles/${roleId}`) } + + /** + * 更新店铺实际信用额度 + * PUT /api/admin/shops/{id}/credit-limit + * @param shopId 店铺ID + * @param data 实际信用额度配置 + */ + static updateShopCreditLimit( + shopId: number, + data: UpdateShopCreditLimitRequest + ): Promise> { + return this.put>( + `/api/admin/shops/${shopId}/credit-limit`, + data + ) + } } diff --git a/src/api/modules/shopPackageAllocation.ts b/src/api/modules/shopPackageAllocation.ts new file mode 100644 index 0000000..ebbaae1 --- /dev/null +++ b/src/api/modules/shopPackageAllocation.ts @@ -0,0 +1,55 @@ +/** + * 店铺套餐分配 API 服务 + */ + +import { BaseService } from '../BaseService' +import type { + BaseResponse, + CreateShopPackageBatchAllocationsRequest, + CreateShopPackageAllocationRequest, + ShopPackageAllocationResponse, + ShopPackageBatchAllocationsResponse, + UpdateShopPackageAllocationExpiryBaseRequest +} from '@/types/api' + +export class ShopPackageAllocationService extends BaseService { + /** + * 创建店铺套餐分配 + * POST /api/admin/shop-package-allocations + */ + static createShopPackageAllocation( + data: CreateShopPackageAllocationRequest + ): Promise> { + return this.post>( + '/api/admin/shop-package-allocations', + data + ) + } + + /** + * 批量创建店铺套餐分配 + * POST /api/admin/shop-package-batch-allocations + */ + static createShopPackageBatchAllocations( + data: CreateShopPackageBatchAllocationsRequest + ): Promise> { + return this.post>( + '/api/admin/shop-package-batch-allocations', + data + ) + } + + /** + * 更新店铺套餐分配生效条件 + * PATCH /api/admin/shop-package-allocations/{id}/expiry-base + */ + static updateShopPackageAllocationExpiryBase( + id: number, + data: UpdateShopPackageAllocationExpiryBaseRequest + ): Promise> { + return this.patch>( + `/api/admin/shop-package-allocations/${id}/expiry-base`, + data + ) + } +} diff --git a/src/api/modules/shopSeriesGrant.ts b/src/api/modules/shopSeriesGrant.ts index 3f1d1f5..09e7134 100644 --- a/src/api/modules/shopSeriesGrant.ts +++ b/src/api/modules/shopSeriesGrant.ts @@ -9,6 +9,7 @@ import type { CreateShopSeriesGrantRequest, UpdateShopSeriesGrantRequest, ManageGrantPackagesRequest, + ShopSeriesGrantPackageOptionsResponse, BaseResponse, PaginationResponse } from '@/types/api' @@ -45,6 +46,20 @@ export class ShopSeriesGrantService extends BaseService { return this.getOne(`/api/admin/shop-series-grants/${id}`) } + /** + * 获取代理系列授权可选套餐 + * GET /api/admin/shop-series-grants/package-options + */ + static getPackageOptions( + shopId: number, + seriesId: number + ): Promise> { + return this.getOne( + '/api/admin/shop-series-grants/package-options', + { shop_id: shopId, series_id: seriesId } + ) + } + /** * 更新代理系列授权 * PUT /api/admin/shop-series-grants/{id} diff --git a/src/api/modules/storage.ts b/src/api/modules/storage.ts index 077ee8a..456481d 100644 --- a/src/api/modules/storage.ts +++ b/src/api/modules/storage.ts @@ -23,7 +23,12 @@ const triggerBrowserDownload = (downloadUrl: string, fileName: string) => { /** * 文件用途枚举 */ -export type FilePurpose = 'iot_import' | 'device_import' | 'export' | 'attachment' +export type FilePurpose = + | 'iot_import' + | 'batch_purchase' + | 'device_batch_allocation' + | 'export' + | 'attachment' /** * 获取上传 URL 请求参数 diff --git a/src/api/modules/systemConfig.ts b/src/api/modules/systemConfig.ts new file mode 100644 index 0000000..c22f357 --- /dev/null +++ b/src/api/modules/systemConfig.ts @@ -0,0 +1,28 @@ +import { BaseService } from '../BaseService' +import type { BaseResponse } from '@/types/api' +import type { + SystemConfigItem, + SystemConfigPageResult, + SystemConfigQueryParams, + UpdateSystemConfigRequest +} from '@/types/api/systemConfig' + +const SYSTEM_CONFIG_BASE_URL = '/api/admin/system-configs' + +export class SystemConfigService extends BaseService { + static getSystemConfigs( + params?: SystemConfigQueryParams + ): Promise> { + return this.get>(SYSTEM_CONFIG_BASE_URL, params) + } + + static updateSystemConfig( + key: string, + data: UpdateSystemConfigRequest + ): Promise> { + return this.put>( + `${SYSTEM_CONFIG_BASE_URL}/${encodeURIComponent(key)}`, + data + ) + } +} diff --git a/src/api/modules/wecom.ts b/src/api/modules/wecom.ts new file mode 100644 index 0000000..bbb2c4f --- /dev/null +++ b/src/api/modules/wecom.ts @@ -0,0 +1,93 @@ +import { BaseService } from '../BaseService' +import type { + BaseResponse, + WecomAccountBindingRequest, + WecomApplication, + WecomApplicationListResponse, + WecomApplicationQueryParams, + WecomApplicationRequest, + WecomApplicationResponse, + WecomBusinessFieldListResponse, + WecomBusinessType, + WecomMemberListResponse, + WecomMemberQueryParams, + WecomSceneListResponse, + WecomSceneQueryParams, + WecomSceneRequest, + WecomSceneResponse, + WecomSyncMembersApiResponse, + WecomTemplateDetailResponse, + WecomTemplateInspectRequest +} from '@/types/api' + +export class WecomService extends BaseService { + static getApplications( + params?: WecomApplicationQueryParams + ): Promise { + return this.get('/api/admin/wecom/applications', params) + } + + static saveApplication(data: WecomApplicationRequest): Promise { + return this.post('/api/admin/wecom/applications', data) + } + + static testApplication(id: number): Promise> { + return this.post>(`/api/admin/wecom/applications/${id}/test`) + } + + static syncMembers(id: number): Promise { + return this.post( + `/api/admin/wecom/applications/${id}/members/sync` + ) + } + + static getMembers(id: number, params?: WecomMemberQueryParams): Promise { + return this.get(`/api/admin/wecom/applications/${id}/members`, params) + } + + static setDefaultCreator(id: number, userid: string): Promise { + return this.put( + `/api/admin/wecom/applications/${id}/default-creator`, + { userid } + ) + } + + static getScenes(params?: WecomSceneQueryParams): Promise { + return this.get('/api/admin/wecom/scenes', params) + } + + static saveScene( + businessType: WecomBusinessType, + data: WecomSceneRequest + ): Promise { + return this.put(`/api/admin/wecom/scenes/${businessType}`, data) + } + + static inspectTemplate( + applicationId: number, + data: WecomTemplateInspectRequest + ): Promise { + return this.post( + `/api/admin/wecom/applications/${applicationId}/templates/inspect`, + data + ) + } + + static getBusinessFields( + businessType: WecomBusinessType + ): Promise { + return this.get( + `/api/admin/wecom/scenes/${businessType}/fields` + ) + } + + static bindAccount( + accountId: number, + data: WecomAccountBindingRequest + ): Promise> { + return this.put>( + `/api/admin/accounts/${accountId}/wecom-binding`, + data + ) + } +} diff --git a/src/assets/styles/el-ui.scss b/src/assets/styles/el-ui.scss index aec4ef9..41b3bdc 100644 --- a/src/assets/styles/el-ui.scss +++ b/src/assets/styles/el-ui.scss @@ -72,19 +72,24 @@ } .el-dialog { - border-radius: 100px !important; - border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important; + border-radius: 12px !important; overflow: hidden; } .el-dialog__header { + padding: 20px 24px 16px !important; + margin-right: 0 !important; + border-bottom: 1px solid var(--el-border-color-lighter); + .el-dialog__title { font-size: 16px; + font-weight: 600; + color: var(--el-text-color-primary); } } .el-dialog__body { - padding: 25px 0 !important; + padding: 20px 24px !important; position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275; } diff --git a/src/components/business/BatchRealnamePolicyDialog.vue b/src/components/business/BatchRealnamePolicyDialog.vue new file mode 100644 index 0000000..ad35982 --- /dev/null +++ b/src/components/business/BatchRealnamePolicyDialog.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/components/business/CreateRefundDialog.vue b/src/components/business/CreateRefundDialog.vue index 3cca01b..f8e093d 100644 --- a/src/components/business/CreateRefundDialog.vue +++ b/src/components/business/CreateRefundDialog.vue @@ -1,6 +1,6 @@ -
+
@@ -288,8 +367,7 @@ } } - .failure-section, - .skipped-section { + .result-section { margin-top: 20px; .section-title { diff --git a/src/views/audit/events/detail.vue b/src/views/audit/events/detail.vue new file mode 100644 index 0000000..f1ea788 --- /dev/null +++ b/src/views/audit/events/detail.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/src/views/audit/events/index.vue b/src/views/audit/events/index.vue new file mode 100644 index 0000000..1c4d40c --- /dev/null +++ b/src/views/audit/events/index.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/src/views/audit/integrations/detail.vue b/src/views/audit/integrations/detail.vue new file mode 100644 index 0000000..11c9d77 --- /dev/null +++ b/src/views/audit/integrations/detail.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/src/views/audit/integrations/index.vue b/src/views/audit/integrations/index.vue new file mode 100644 index 0000000..dd47c87 --- /dev/null +++ b/src/views/audit/integrations/index.vue @@ -0,0 +1,498 @@ + + + + + diff --git a/src/views/audit/risks/index.vue b/src/views/audit/risks/index.vue new file mode 100644 index 0000000..90380f6 --- /dev/null +++ b/src/views/audit/risks/index.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/src/views/commission-management/agent-fund-overview/index.vue b/src/views/commission-management/agent-fund-overview/index.vue index ed187ca..3458f1a 100644 --- a/src/views/commission-management/agent-fund-overview/index.vue +++ b/src/views/commission-management/agent-fund-overview/index.vue @@ -30,9 +30,9 @@ :currentPage="pagination.page" :pageSize="pagination.pageSize" :total="pagination.total" - :marginTop="10" - :actions="getActions" + :actions="getAuditActions" :actionsWidth="120" + :marginTop="10" @size-change="handleSizeChange" @current-change="handleCurrentChange" > @@ -48,7 +48,7 @@ @@ -249,11 +249,11 @@ @@ -269,9 +269,22 @@ 搜索 重置 + + 导出 +
+ ([]) + const getAuditActions = (row: ShopFundSummaryItem) => { + if (!hasAuth(AUDIT_PERMISSIONS.financeTimeline)) return [] + const target = resolveFinanceAuditTarget('shop_id', row.shop_id) + return target + ? [ + { + label: '资金链路', + handler: () => openAuditInvestigation(target), + type: 'primary' as const + } + ] + : [] + } + // 搜索表单 const searchForm = reactive({ shop_name: '', @@ -543,6 +574,17 @@ date_range: [], asset_identifier: '' }) + const mainWalletExportDialogVisible = ref(false) + const mainWalletExportQuery = computed(() => { + const [startDate, endDate] = mainWalletSearchForm.date_range || [] + return { + shop_id: currentShop.value?.shop_id, + transaction_type: mainWalletSearchForm.transaction_type, + start_date: startDate || undefined, + end_date: endDate || undefined, + asset_identifier: mainWalletSearchForm.asset_identifier.trim() || undefined + } + }) // 列配置 const columnOptions = [ @@ -550,7 +592,14 @@ { label: '店铺名称', prop: 'shop_name' }, { label: '用户名', prop: 'username' }, { label: '手机号', prop: 'phone' }, - { label: '预充值余额', prop: 'main_balance' }, + { label: '账面余额', prop: 'main_balance' }, + { label: '冻结金额', prop: 'main_frozen_balance' }, + { label: '现金可用', prop: 'cash_available_balance' }, + { label: '实际信用额度', prop: 'credit_limit' }, + { label: '总可用金额', prop: 'available_balance' }, + { label: '欠款金额', prop: 'debt_amount' }, + { label: '钱包版本', prop: 'version' }, + { label: '余额预警', prop: 'low_balance_warning' }, { label: '总佣金', prop: 'total_commission' }, { label: '可提现', prop: 'available_commission' }, { label: '冻结中', prop: 'frozen_commission' }, @@ -611,6 +660,7 @@ prop: 'shop_name', label: '店铺名称', minWidth: 160, + fixed: 'left', showOverflowTooltip: true, formatter: (row: ShopFundSummaryItem) => { return h( @@ -638,7 +688,7 @@ }, { prop: 'main_balance', - label: '预充值余额', + label: '账面余额', minWidth: 130, formatter: (row: ShopFundSummaryItem) => { return h( @@ -648,6 +698,73 @@ ) } }, + { + prop: 'main_frozen_balance', + label: '冻结金额', + minWidth: 120, + formatter: (row: ShopFundSummaryItem) => formatMoney(row.main_frozen_balance) + }, + { + prop: 'cash_available_balance', + label: '现金可用', + minWidth: 120, + formatter: (row: ShopFundSummaryItem) => formatMoney(row.cash_available_balance) + }, + { + prop: 'credit_limit', + label: '实际信用额度', + minWidth: 150, + formatter: (row: ShopFundSummaryItem) => { + if (!row.credit_enabled) { + return h('span', { style: 'color: var(--el-text-color-secondary)' }, '未启用 / ¥0.00') + } + + return h( + 'span', + { style: 'color: var(--el-color-warning); font-weight: 500' }, + formatMoney(row.credit_limit) + ) + } + }, + { + prop: 'available_balance', + label: '总可用金额', + minWidth: 130, + formatter: (row: ShopFundSummaryItem) => { + return h( + 'span', + { style: 'color: var(--el-color-success); font-weight: 500' }, + formatMoney(row.available_balance) + ) + } + }, + { + prop: 'debt_amount', + label: '欠款金额', + minWidth: 130, + formatter: (row: ShopFundSummaryItem) => { + const amountText = formatMoney(row.debt_amount) + if (!row.is_in_debt) return amountText + + return h('span', { style: 'color: var(--el-color-danger); font-weight: 500' }, amountText) + } + }, + { + prop: 'version', + label: '钱包版本', + minWidth: 90, + formatter: (row: ShopFundSummaryItem) => row.version ?? '-' + }, + { + prop: 'low_balance_warning', + label: '余额预警', + minWidth: 170, + formatter: (row: ShopFundSummaryItem) => { + if (!row.low_balance_warning) return '-' + + return h(ElTag, { type: 'danger' }, () => '现金余额不足100元') + } + }, { prop: 'total_commission', label: '总佣金', @@ -698,10 +815,6 @@ } ]) - onMounted(() => { - getTableData() - }) - // 获取代理商资金汇总列表 const getTableData = async () => { loading.value = true @@ -725,6 +838,14 @@ } } + const loadInitialData = async () => { + await getTableData() + const shopId = Number(route.query.shop_id) + if (!shopId) return + const targetShop = summaryList.value.find((item) => item.shop_id === shopId) + if (targetShop) showDetail(targetShop) + } + // 搜索 const handleSearch = () => { pagination.page = 1 @@ -773,21 +894,11 @@ loadCommissionRecords() } + onMounted(() => { + void loadInitialData() + }) + // 获取操作按钮 - const getActions = (row: ShopFundSummaryItem) => { - const actions: any[] = [] - - if (hasAuth('agent_commission:detail')) { - actions.push({ - label: '查看详情', - handler: () => showDetail(row), - type: 'primary' - }) - } - - return actions - } - // 监听tab切换 watch(activeTab, (newTab) => { if (newTab === 'commission') { diff --git a/src/views/commission-management/my-commission/index.vue b/src/views/commission-management/my-commission/index.vue index 9b9d0a8..b02caa7 100644 --- a/src/views/commission-management/my-commission/index.vue +++ b/src/views/commission-management/my-commission/index.vue @@ -28,6 +28,45 @@ v-permission="'my_commission:add'" >发起提现 + +
+
+ 总佣金 + {{ + formatMoney(summary.total_commission, false) + }} +
+
+ 可提现 + {{ + formatMoney(summary.available_commission, false) + }} +
+
+ 冻结中 + {{ + formatMoney(summary.frozen_commission, false) + }} +
+
+ 提现中 + {{ + formatMoney(summary.withdrawing_commission, false) + }} +
+
+ 已提现 + {{ + formatMoney(summary.withdrawn_commission, false) + }} +
+
+ 未提现 + {{ + formatMoney(summary.unwithdraw_commission, false) + }} +
+
@@ -121,15 +160,16 @@
- 金额单位为分,如1元=100分 + 金额单位为元
@@ -183,7 +223,12 @@ diff --git a/src/views/finance/agent-recharge/agentRechargeActions.ts b/src/views/finance/agent-recharge/agentRechargeActions.ts index 42c581f..1b0c8e9 100644 --- a/src/views/finance/agent-recharge/agentRechargeActions.ts +++ b/src/views/finance/agent-recharge/agentRechargeActions.ts @@ -12,6 +12,7 @@ interface BuildAgentRechargeActionsOptions { onViewPaymentVoucher: (row: AgentRecharge) => void onConfirmPayment: (row: AgentRecharge) => void onReject: (row: AgentRecharge) => void + onTriggerApproval: (row: AgentRecharge) => void } export const buildAgentRechargeActions = ( @@ -19,6 +20,19 @@ export const buildAgentRechargeActions = ( options: BuildAgentRechargeActionsOptions ): AgentRechargeAction[] => { const actions: AgentRechargeAction[] = [] + const hasApprovalRecord = + row.approval_provider === 'wecom' || + row.approval_source === 'wecom' || + row.approval_source === 'legacy' || + (row.approval_instance_id !== undefined && row.approval_instance_id !== null) + const approvalStatusEmpty = row.approval_status === undefined || row.approval_status === null + const canTriggerApproval = + approvalStatusEmpty && + ![ + AgentRechargeStatus.COMPLETED, + AgentRechargeStatus.REJECTED, + AgentRechargeStatus.CLOSED + ].includes(row.status) if ( row.payment_method === 'offline' && @@ -26,13 +40,14 @@ export const buildAgentRechargeActions = ( options.hasAuth('agent_recharge:view_payment_voucher') ) { actions.push({ - label: '查看支付凭证', + label: '支付凭证', handler: () => options.onViewPaymentVoucher(row), type: 'primary' }) } if ( + !hasApprovalRecord && row.status === AgentRechargeStatus.PENDING && row.payment_method === 'offline' && options.hasAuth('agent_recharge:confirm_payment') @@ -44,7 +59,20 @@ export const buildAgentRechargeActions = ( }) } - if (row.status === AgentRechargeStatus.PENDING && options.hasAuth('agent_recharge:reject')) { + if (canTriggerApproval && options.hasAuth('agent_recharge:trigger_approval')) { + actions.push({ + label: '补发审批', + handler: () => options.onTriggerApproval(row), + type: 'primary' + }) + } + + if ( + !hasApprovalRecord && + row.status === AgentRechargeStatus.PENDING && + row.payment_method === 'offline' && + options.hasAuth('agent_recharge:reject') + ) { actions.push({ label: '拒绝', handler: () => options.onReject(row), diff --git a/src/views/finance/agent-recharge/agentRechargeOnline.ts b/src/views/finance/agent-recharge/agentRechargeOnline.ts new file mode 100644 index 0000000..1811309 --- /dev/null +++ b/src/views/finance/agent-recharge/agentRechargeOnline.ts @@ -0,0 +1,17 @@ +import type { AgentRechargeStatus } from '@/types/api' + +export const ONLINE_RECHARGE_TERMINAL_STATUSES: AgentRechargeStatus[] = [3, 4, 5, 6] + +export const isOnlineRechargeTerminal = (status: AgentRechargeStatus): boolean => { + return ONLINE_RECHARGE_TERMINAL_STATUSES.includes(status) +} + +export const createOnlineRechargeRequestId = (): string => { + const randomId = globalThis.crypto?.randomUUID?.() + const suffix = randomId || Math.random().toString(36).slice(2, 12) + return `recharge-${Date.now()}-${suffix}` +} + +export const amountYuanToFen = (amount: number): number => { + return Math.round(amount * 100) +} diff --git a/src/views/finance/agent-recharge/detail.vue b/src/views/finance/agent-recharge/detail.vue index f79b494..3830fcb 100644 --- a/src/views/finance/agent-recharge/detail.vue +++ b/src/views/finance/agent-recharge/detail.vue @@ -41,12 +41,15 @@ import { formatDateTime } from '@/utils/business/format' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' + import { useUserStore } from '@/store/modules/user' import { formatRejectionReason } from './agentRechargeDisplay' defineOptions({ name: 'AgentRechargeDetail' }) const route = useRoute() const router = useRouter() + const userStore = useUserStore() + const isRestrictedCustomerRole = computed(() => [3, 4].includes(Number(userStore.info.user_type))) const loading = ref(false) const detailData = ref(null) @@ -68,129 +71,205 @@ 2: 'success', // 已支付 3: 'success', // 已完成 4: 'info', // 已关闭 - 5: 'danger' // 已退款 + 5: 'danger', // 已退款 + 6: 'danger' // 已驳回 } return statusMap[status] || 'info' } - // 获取状态文本(优先使用 status_name,否则使用本地映射) - const getStatusText = (status: AgentRechargeStatus, statusName?: string): string => { - if (statusName) return statusName - const statusMap: Record = { - 1: '待支付', - 2: '已支付', - 3: '已完成', - 4: '已关闭', - 5: '已退款' - } - return statusMap[status] || '-' - } - // 获取支付方式文本 const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => { const methodMap: Record = { wechat: '微信在线支付', + alipay: '支付宝在线支付', offline: '线下转账' } return methodMap[method] || method } + const getRechargeSourceText = (data: AgentRecharge): string => { + if (data.recharge_source_name) return data.recharge_source_name + if (data.recharge_source === 'agent_online') return '代理在线自充' + if (data.recharge_source === 'platform_offline') return '平台线下代充' + return '-' + } + + const getApprovalProviderText = (data: AgentRecharge) => { + if (data.approval_provider === 'wecom' || data.approval_source === 'wecom') return '企微' + if (data.approval_source === 'legacy') return '历史审批' + return data.approval_provider || data.approval_source || '-' + } + // 详情配置 - const detailSections = computed((): DetailSection[] => [ - { - title: '订单信息', - fields: [ - { label: '充值单号', prop: 'recharge_no' }, - { label: '店铺名称', prop: 'shop_name' }, - { - label: '充值金额', - formatter: (_, data) => formatCurrency(data.amount) - }, - { - label: '状态', - render: (data) => - h(ElTag, { type: getStatusType(data.status) }, () => - getStatusText(data.status, data.status_name) - ) - }, - { - label: '驳回原因', - prop: 'rejection_reason', - formatter: (value) => formatRejectionReason(value), - fullWidth: true - } - ] - }, - { - title: '支付信息', - fields: [ - { - label: '支付方式', - formatter: (_, data) => getPaymentMethodText(data.payment_method) - }, - { - label: '支付通道', - prop: 'payment_channel', - formatter: (value) => value || '-' - }, - { - label: '第三方支付流水号', - prop: 'payment_transaction_id', - formatter: (value) => value || '-', - fullWidth: true - }, - { - label: '支付凭证', - fullWidth: true, - render: (data) => - hasVoucherKeys(data.payment_voucher_key) - ? h( - ElButton, - { - type: 'primary', - link: true, - onClick: () => { - paymentVoucherFileKeys.value = toVoucherKeyList(data.payment_voucher_key) - } - }, - () => '查看支付凭证' - ) - : h('span', '-') - }, - { - label: '运营备注', - prop: 'remark', - formatter: (value) => value || '-', - fullWidth: true - } - ] - }, - { - title: '时间信息', - fields: [ - { - label: '创建时间', - prop: 'created_at', - formatter: (value) => formatDateTime(value) - }, - { - label: '支付时间', - prop: 'paid_at', - formatter: (value) => (value ? formatDateTime(value) : '-') - }, - { - label: '完成时间', - prop: 'completed_at', - formatter: (value) => (value ? formatDateTime(value) : '-') - }, - { - label: '更新时间', - prop: 'updated_at', - formatter: (value) => formatDateTime(value) - } - ] - } - ]) + const detailSections = computed((): DetailSection[] => { + if (!detailData.value) return [] + + const isOfflineRecharge = + detailData.value.recharge_source === 'platform_offline' || + detailData.value.payment_method === 'offline' + + const sections: DetailSection[] = [ + { + title: '订单信息', + fields: [ + { label: '充值单号', prop: 'recharge_no' }, + { label: '店铺名称', prop: 'shop_name' }, + { label: '充值来源', formatter: (_, data) => getRechargeSourceText(data) }, + ...(!isRestrictedCustomerRole.value + ? [ + { + label: '提交人', + prop: 'submitter_name', + formatter: (value: string | null | undefined) => value || '-' + } + ] + : []), + { + label: '充值金额', + formatter: (_, data) => formatCurrency(data.amount) + }, + { + label: '状态', + render: (data) => + h(ElTag, { type: getStatusType(data.status) }, () => data.status_name || '-') + }, + ...(!isRestrictedCustomerRole.value + ? [ + { + label: '驳回原因', + prop: 'rejection_reason', + formatter: (value: string | null | undefined) => formatRejectionReason(value), + fullWidth: true + } + ] + : []) + ] + }, + ...(isOfflineRecharge && !isRestrictedCustomerRole.value + ? [ + { + title: '企微审批信息', + fields: [ + { + label: '审批渠道', + formatter: (_: unknown, data: AgentRecharge) => getApprovalProviderText(data) + }, + { + label: '审批状态', + formatter: (_: unknown, data: AgentRecharge) => data.approval_status_name || '-' + }, + { + label: '当前审批人摘要', + formatter: (_: unknown, data: AgentRecharge) => + data.current_approver_summary || '-', + fullWidth: true + } + ] + } + ] + : []), + ...(!isRestrictedCustomerRole.value + ? [ + { + title: '业务处理结果', + fields: [ + { + label: '处理状态', + formatter: (_: unknown, data: AgentRecharge) => data.processing_status_name || '-' + } + ] + } + ] + : []), + { + title: '支付信息', + fields: [ + { + label: '支付方式', + formatter: (_, data) => getPaymentMethodText(data.payment_method) + }, + { + label: '支付通道', + prop: 'payment_channel', + formatter: (value) => value || '-' + }, + { + label: '第三方支付流水号', + prop: 'payment_transaction_id', + formatter: (value) => value || '-', + fullWidth: true + }, + { + label: '支付单号', + prop: 'payment_no', + formatter: (value) => value || '-', + fullWidth: true + }, + ...(isOfflineRecharge + ? [ + { + label: '支付凭证', + fullWidth: true, + render: (data: AgentRecharge) => + hasVoucherKeys(data.payment_voucher_key) + ? h( + ElButton, + { + type: 'primary', + link: true, + onClick: () => { + paymentVoucherFileKeys.value = toVoucherKeyList( + data.payment_voucher_key + ) + } + }, + () => '查看支付凭证' + ) + : h('span', '-') + } + ] + : []), + ...(!isRestrictedCustomerRole.value + ? [ + { + label: '运营备注', + prop: 'remark', + formatter: (value: string | null | undefined) => value || '-', + fullWidth: true + } + ] + : []) + ] + }, + { + title: '时间信息', + fields: [ + { + label: '创建时间', + prop: 'created_at', + formatter: (value) => formatDateTime(value) + }, + { + label: '支付时间', + prop: 'paid_at', + formatter: (value) => (value ? formatDateTime(value) : '-') + }, + { + label: '完成时间', + prop: 'completed_at', + formatter: (value) => (value ? formatDateTime(value) : '-') + }, + { + label: '更新时间', + prop: 'updated_at', + formatter: (value) => formatDateTime(value) + } + ] + } + ] + + return sections + }) // 加载详情数据 const loadDetailData = async () => { diff --git a/src/views/finance/agent-recharge/index.vue b/src/views/finance/agent-recharge/index.vue index dd3393d..3d91918 100644 --- a/src/views/finance/agent-recharge/index.vue +++ b/src/views/finance/agent-recharge/index.vue @@ -5,7 +5,7 @@ @@ -21,9 +21,12 @@ 创建充值订单{{ createButtonLabel }} + + 导出 + @@ -38,8 +41,8 @@ :total="pagination.total" :marginTop="10" :actions="getActions" - :inlineActionsCount="2" - :actionsWidth="200" + :inlineActionsCount="1" + :actionsWidth="160" @size-change="handleSizeChange" @current-change="handleCurrentChange" > @@ -51,22 +54,25 @@
- 最小: ¥0.01,最大: 不限制 + 金额范围:¥{{ minimumAmountYuan.toFixed(2) }} - ¥{{ + maximumAmountYuan.toLocaleString('zh-CN', { minimumFractionDigits: 2 }) + }}
@@ -74,11 +80,27 @@ v-model="createForm.payment_method" placeholder="请选择支付方式" style="width: 100%" + :loading="paymentMethodsLoading" > - + +
+ 当前暂无可用在线支付方式,暂时无法提交充值。 +
- + - + - {{ voucherUploading ? '凭证上传中...' : '确认创建' }} + {{ voucherUploading ? '凭证上传中...' : createButtonLabel }}
+ + +
+ + +
+
充值单号:{{ onlineQrRecharge.recharge_no || '-' }}
+
充值金额:{{ formatCurrency(onlineQrRecharge.amount) }}
+
+ 当前主钱包余额:{{ formatCurrency(onlineWalletBalance) }} +
+ + {{ onlinePaymentStatus?.status_name || onlineQrRecharge.status_name || '-' }} + +
+ {{ onlinePaymentStatusMessage }} +
+
+ 正在查询支付状态... +
+
+
+ +
+ + + + import { h } from 'vue' import { useRouter } from 'vue-router' - import { AgentRechargeService, ShopService } from '@/api/modules' + import QrcodeVue from 'qrcode.vue' + import { AgentRechargeService, CommissionService, ShopService } from '@/api/modules' import { ElMessage, + ElMessageBox, ElTag, ElButton, ElCascader, @@ -228,7 +314,9 @@ AgentRecharge, AgentRechargeQueryParams, AgentRechargeStatus, + AgentRechargeOnlinePaymentMethod, AgentRechargePaymentMethod, + AgentRechargePaymentStatusResponse, CreateAgentRechargeRequest, ConfirmOfflinePaymentRequest, RejectAgentRechargeRequest @@ -236,34 +324,94 @@ import type { SearchFormItem } from '@/types' import { useCheckedColumns } from '@/composables/useCheckedColumns' import { formatDateTime } from '@/utils/business/format' + import { + getApprovalStatusText, + getCurrentApproverSummaryText, + getProcessingStatusText + } from '@/utils/business/approvalSummary' import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business' import { useAuth } from '@/composables/useAuth' + import { useUserStore } from '@/store/modules/user' + import { AUDIT_PERMISSIONS } from '@/config/constants/audit' + import { + resolveAuditResourceTarget, + resolveFinanceAuditTarget + } from '@/utils/business/auditNavigation' + import { openAuditInvestigation } from '@/components/business/audit/investigationController' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue' + import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue' import { buildAgentRechargeActions } from './agentRechargeActions' import { formatRejectionReason } from './agentRechargeDisplay' + import { + amountYuanToFen, + createOnlineRechargeRequestId, + isOnlineRechargeTerminal + } from './agentRechargeOnline' defineOptions({ name: 'AgentRechargeList' }) const router = useRouter() const { hasAuth } = useAuth() + const userStore = useUserStore() + + const isAgentAccount = computed(() => Number(userStore.info.user_type) === 3) + const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type))) + const isRestrictedCustomerRole = computed(() => [3, 4].includes(Number(userStore.info.user_type))) + const hiddenInternalColumnProps = new Set([ + 'approval_provider', + 'submitter_name', + 'approval_status', + 'current_approver_summary', + 'processing_status_name', + 'remark', + 'rejection_reason' + ]) + const shouldShowInternalColumn = (prop?: string) => + !isRestrictedCustomerRole.value || !hiddenInternalColumnProps.has(prop || '') + const canViewRecharge = computed(() => [1, 2, 3].includes(Number(userStore.info.user_type))) + const canCreateRecharge = computed(() => isAgentAccount.value || isPlatformAccount.value) + const createMode = ref<'online' | 'offline'>(isAgentAccount.value ? 'online' : 'offline') + const createDialogTitle = computed(() => + createMode.value === 'online' ? '代理钱包在线扫码充值' : '创建平台线下代充' + ) + const createButtonLabel = computed(() => (isAgentAccount.value ? '立即充值' : '创建充值订单')) const loading = ref(false) const createLoading = ref(false) const voucherUploading = ref(false) const confirmPayLoading = ref(false) const rejectLoading = ref(false) + const triggerApprovalLoading = ref(false) const tableRef = ref() const createDialogVisible = ref(false) + const exportDialogVisible = ref(false) const confirmPayDialogVisible = ref(false) const rejectDialogVisible = ref(false) + const onlineQrDialogVisible = ref(false) const currentRecharge = ref(null) + const onlineQrRecharge = ref(null) + const onlinePaymentStatus = ref(null) + const onlineWalletBalance = ref(null) + const onlinePaymentMethods = ref([]) + const paymentMethodsLoading = ref(false) + const paymentStatusLoading = ref(false) + const qrContent = ref('') + const onlineRequestId = ref(null) + const paymentStatusTimer = ref | null>(null) + const ONLINE_MIN_RECHARGE_AMOUNT_FEN = 10000 + const ONLINE_MAX_RECHARGE_AMOUNT_FEN = 100000000 + const paymentMethodsBounds = reactive({ + min_amount: ONLINE_MIN_RECHARGE_AMOUNT_FEN, + max_amount: ONLINE_MAX_RECHARGE_AMOUNT_FEN + }) const paymentVoucherFileKeys = ref([]) // 搜索表单初始值 const initialSearchState: AgentRechargeQueryParams = { shop_id: undefined, status: undefined, + recharge_source: undefined, dateRange: [], start_date: '', end_date: '' @@ -306,51 +454,72 @@ } // 搜索表单配置 - const searchFormItems: SearchFormItem[] = [ - { - label: '店铺名称', - prop: 'shop_id', - type: 'select', - placeholder: '请选择店铺', - options: () => - shopOptions.value.map((shop) => ({ - label: shop.shop_name, - value: shop.id - })), - config: { - clearable: true, - filterable: true, - remote: true, - remoteMethod: (query: string) => searchShops(query) - } - }, - { - label: '支付状态', - prop: 'status', - type: 'select', - placeholder: '请选择状态', - options: [ - { label: '待支付', value: 1 }, - { label: '已完成', value: 2 }, - { label: '已取消', value: 3 } - ], - config: { - clearable: true - } - }, - { - label: '起止时间', - prop: 'dateRange', - type: 'date', - config: { - type: 'daterange', - rangeSeparator: '至', - startPlaceholder: '开始日期', - endPlaceholder: '结束日期', - valueFormat: 'YYYY-MM-DD' - } + const searchFormItems = computed(() => { + const items: SearchFormItem[] = [] + + if (isPlatformAccount.value) { + items.push({ + label: '店铺名称', + prop: 'shop_id', + type: 'select', + placeholder: '请选择店铺', + options: () => + shopOptions.value.map((shop) => ({ + label: shop.shop_name, + value: shop.id + })), + config: { + clearable: true, + filterable: true, + remote: true, + remoteMethod: (query: string) => searchShops(query) + } + }) } - ] + + items.push( + { + label: '充值状态', + prop: 'status', + type: 'select', + placeholder: '请选择状态', + options: [ + { label: '待支付', value: 1 }, + { label: '已支付', value: 2 }, + { label: '已完成', value: 3 }, + { label: '已关闭', value: 4 }, + { label: '已退款', value: 5 }, + { label: '已驳回', value: 6 } + ], + config: { clearable: true } + }, + { + label: '充值来源', + prop: 'recharge_source', + type: 'select', + placeholder: '请选择充值来源', + options: [ + { label: '代理在线自充', value: 'agent_online' }, + { label: '平台线下代充', value: 'platform_offline' } + ], + config: { clearable: true } + }, + { + label: '起止时间', + prop: 'dateRange', + type: 'datetimerange', + config: { + type: 'datetimerange', + rangeSeparator: '至', + startPlaceholder: '开始日期', + endPlaceholder: '结束日期', + valueFormat: 'YYYY-MM-DDTHH:mm:ssZ' + } + } + ) + + return items + }) // 分页 const pagination = reactive({ @@ -363,21 +532,72 @@ const columnOptions = [ { label: '充值单号', prop: 'recharge_no' }, { label: '店铺名称', prop: 'shop_name' }, + { label: '充值来源', prop: 'recharge_source' }, { label: '充值金额', prop: 'amount' }, { label: '状态', prop: 'status' }, + { label: '审批渠道', prop: 'approval_provider' }, + { label: '提交人', prop: 'submitter_name' }, + { label: '审批状态', prop: 'approval_status' }, + { label: '当前审批人摘要', prop: 'current_approver_summary' }, + { label: '业务处理状态', prop: 'processing_status_name' }, { label: '支付方式', prop: 'payment_method' }, { label: '支付通道', prop: 'payment_channel' }, + { label: '运营备注', prop: 'remark' }, { label: '驳回原因', prop: 'rejection_reason' }, { label: '创建时间', prop: 'created_at' }, { label: '支付时间', prop: 'paid_at' }, - { label: '完成时间', prop: 'completed_at' } - ] + { label: '完成时间', prop: 'completed_at' }, + { label: '更新时间', prop: 'updated_at' } + ].filter(({ prop }) => shouldShowInternalColumn(prop)) const createFormRef = ref() const confirmPayFormRef = ref() const rejectFormRef = ref() const uploadRef = ref>() - const MIN_RECHARGE_AMOUNT = 0.01 + const OFFLINE_MIN_RECHARGE_AMOUNT = 0.01 + const OFFLINE_MAX_RECHARGE_AMOUNT = 1_000_000 + + const minimumAmountYuan = computed(() => + createMode.value === 'online' + ? paymentMethodsBounds.min_amount / 100 + : OFFLINE_MIN_RECHARGE_AMOUNT + ) + const maximumAmountYuan = computed(() => + createMode.value === 'online' + ? paymentMethodsBounds.max_amount / 100 + : OFFLINE_MAX_RECHARGE_AMOUNT + ) + const availablePaymentMethodOptions = computed(() => { + if (createMode.value === 'offline') return [{ label: '线下转账', value: 'offline' }] + + return onlinePaymentMethods.value.map((method) => ({ + label: method === 'wechat' ? '微信支付' : '支付宝', + value: method + })) + }) + const createSubmitDisabled = computed( + () => + createLoading.value || + voucherUploading.value || + paymentMethodsLoading.value || + (createMode.value === 'online' && onlinePaymentMethods.value.length === 0) + ) + const onlinePaymentMethodLabel = computed(() => { + const method = onlineQrRecharge.value?.payment_method + if (method === 'wechat') return '微信支付' + if (method === 'alipay') return '支付宝' + return '' + }) + const onlinePaymentStatusMessage = computed(() => { + const status = onlinePaymentStatus.value?.status ?? onlineQrRecharge.value?.status + if (status === 1) return `请使用${onlinePaymentMethodLabel.value || '对应支付方式'}扫码完成支付` + if (status === 2) return '支付已成功,钱包正在入账,请稍候' + if (status === 3) return '充值成功,钱包已到账' + if (status === 4) return '充值订单已关闭,请重新发起充值' + if (status === 5) return '充值订单已退款' + if (status === 6) return '充值订单已驳回' + return '正在获取支付状态' + }) const createRules = computed(() => { const rules: FormRules = { @@ -389,8 +609,16 @@ callback() return } - if (Number(value) < MIN_RECHARGE_AMOUNT) { - callback(new Error(`充值金额最小为 ¥${MIN_RECHARGE_AMOUNT.toFixed(2)}`)) + if (Number(value) < minimumAmountYuan.value) { + callback(new Error(`充值金额最小为 ¥${minimumAmountYuan.value.toFixed(2)}`)) + return + } + if (Number(value) > maximumAmountYuan.value) { + callback( + new Error( + `充值金额最大为 ¥${maximumAmountYuan.value.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}` + ) + ) return } callback() @@ -401,9 +629,10 @@ payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }], shop_id: [{ required: true, message: '请选择目标店铺', trigger: 'change' }] } - if (createForm.payment_method === 'offline') { + if (createMode.value === 'offline') { rules.payment_voucher_key = [{ required: true, message: '请上传支付凭证', trigger: 'change' }] } + if (createMode.value === 'online') delete rules.shop_id return rules }) @@ -418,13 +647,13 @@ }) const createForm = reactive<{ - amount: number - payment_method: string + amount: number | null + payment_method: AgentRechargePaymentMethod | '' shop_id: number | null payment_voucher_key: string[] remark: string }>({ - amount: MIN_RECHARGE_AMOUNT, + amount: OFFLINE_MIN_RECHARGE_AMOUNT, payment_method: '', shop_id: null, payment_voucher_key: [], @@ -455,124 +684,180 @@ 2: 'success', // 已支付 3: 'success', // 已完成 4: 'info', // 已关闭 - 5: 'danger' // 已退款 + 5: 'danger', // 已退款 + 6: 'danger' // 已驳回 } return statusMap[status] || 'info' } - // 获取状态文本(优先使用 status_name,否则使用本地映射) - const getStatusText = (status: AgentRechargeStatus, statusName?: string): string => { - if (statusName) return statusName - const statusMap: Record = { - 1: '待支付', - 2: '已支付', - 3: '已完成', - 4: '已关闭', - 5: '已退款' - } - return statusMap[status] || '-' - } - // 获取支付方式文本 const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => { const methodMap: Record = { wechat: '微信在线支付', + alipay: '支付宝在线支付', offline: '线下转账' } return methodMap[method] || method } + const getRechargeSourceText = (row: AgentRecharge): string => { + if (row.recharge_source_name) return row.recharge_source_name + if (row.recharge_source === 'agent_online') return '代理在线自充' + if (row.recharge_source === 'platform_offline') return '平台线下代充' + return '-' + } + // 动态列配置 - const { columnChecks, columns } = useCheckedColumns(() => [ - { - prop: 'recharge_no', - label: '充值单号', - minWidth: 240, - formatter: (row: AgentRecharge) => { - return h( - 'span', - { - style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;', - onClick: (e: MouseEvent) => { - e.stopPropagation() - handleNameClick(row) - } - }, - row.recharge_no - ) + const { columnChecks, columns } = useCheckedColumns(() => + [ + { + prop: 'recharge_no', + label: '充值单号', + minWidth: 240, + formatter: (row: AgentRecharge) => { + return h( + 'span', + { + style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;', + onClick: (e: MouseEvent) => { + e.stopPropagation() + handleNameClick(row) + } + }, + row.recharge_no + ) + } + }, + { + prop: 'shop_name', + label: '店铺名称', + minWidth: 150, + showOverflowTooltip: true + }, + { + prop: 'recharge_source', + label: '充值来源', + width: 140, + formatter: (row: AgentRecharge) => getRechargeSourceText(row) + }, + { + prop: 'amount', + label: '充值金额', + width: 120, + formatter: (row: AgentRecharge) => formatCurrency(row.amount) + }, + { + prop: 'status', + label: '状态', + width: 100, + formatter: (row: AgentRecharge) => { + return h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '-') + } + }, + { + prop: 'approval_provider', + label: '审批渠道', + width: 110, + formatter: (row: AgentRecharge) => { + if (row.approval_provider === 'wecom' || row.approval_source === 'wecom') return '企微' + if (row.approval_source === 'legacy') return '历史审批' + return row.approval_provider || row.approval_source || '-' + } + }, + { + prop: 'submitter_name', + label: '提交人', + width: 120, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => row.submitter_name || '-' + }, + { + prop: 'approval_status', + label: '审批状态', + width: 120, + formatter: (row: AgentRecharge) => getApprovalStatusText(row) + }, + { + prop: 'current_approver_summary', + label: '当前审批人摘要', + minWidth: 180, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => getCurrentApproverSummaryText(row) + }, + { + prop: 'processing_status_name', + label: '业务处理状态', + width: 140, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => getProcessingStatusText(row) + }, + { + prop: 'payment_method', + label: '支付方式', + width: 140, + formatter: (row: AgentRecharge) => getPaymentMethodText(row.payment_method) + }, + { + prop: 'payment_channel', + label: '支付通道', + width: 120, + formatter: (row: AgentRecharge) => row.payment_channel || '-' + }, + { + prop: 'remark', + label: '运营备注', + minWidth: 180, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => row.remark || '-' + }, + { + prop: 'rejection_reason', + label: '驳回原因', + minWidth: 180, + showOverflowTooltip: true, + formatter: (row: AgentRecharge) => formatRejectionReason(row.rejection_reason) + }, + { + prop: 'created_at', + label: '创建时间', + width: 180, + formatter: (row: AgentRecharge) => formatDateTime(row.created_at) + }, + { + prop: 'paid_at', + label: '支付时间', + width: 180, + formatter: (row: AgentRecharge) => (row.paid_at ? formatDateTime(row.paid_at) : '-') + }, + { + prop: 'completed_at', + label: '完成时间', + width: 180, + formatter: (row: AgentRecharge) => + row.completed_at ? formatDateTime(row.completed_at) : '-' + }, + { + prop: 'updated_at', + label: '更新时间', + width: 180, + formatter: (row: AgentRecharge) => formatDateTime(row.updated_at) } - }, - { - prop: 'shop_name', - label: '店铺名称', - minWidth: 150, - showOverflowTooltip: true - }, - { - prop: 'amount', - label: '充值金额', - width: 120, - formatter: (row: AgentRecharge) => formatCurrency(row.amount) - }, - { - prop: 'status', - label: '状态', - width: 100, - formatter: (row: AgentRecharge) => { - return h(ElTag, { type: getStatusType(row.status) }, () => - getStatusText(row.status, row.status_name) - ) - } - }, - { - prop: 'payment_method', - label: '支付方式', - width: 120, - formatter: (row: AgentRecharge) => getPaymentMethodText(row.payment_method) - }, - { - prop: 'payment_channel', - label: '支付通道', - width: 120, - formatter: (row: AgentRecharge) => row.payment_channel || '-' - }, - { - prop: 'remark', - label: '运营备注', - minWidth: 180, - showOverflowTooltip: true, - formatter: (row: AgentRecharge) => row.remark || '-' - }, - { - prop: 'rejection_reason', - label: '驳回原因', - minWidth: 180, - showOverflowTooltip: true, - formatter: (row: AgentRecharge) => formatRejectionReason(row.rejection_reason) - }, - { - prop: 'created_at', - label: '创建时间', - width: 180, - formatter: (row: AgentRecharge) => formatDateTime(row.created_at) - }, - { - prop: 'paid_at', - label: '支付时间', - width: 180, - formatter: (row: AgentRecharge) => (row.paid_at ? formatDateTime(row.paid_at) : '-') - }, - { - prop: 'completed_at', - label: '完成时间', - width: 180, - formatter: (row: AgentRecharge) => (row.completed_at ? formatDateTime(row.completed_at) : '-') - } - ]) + ].filter(({ prop }) => shouldShowInternalColumn(prop)) + ) onMounted(() => { getTableData() - loadShops() + if (isPlatformAccount.value) loadShops() + document.addEventListener('visibilitychange', handleDocumentVisibilityChange) + }) + + onBeforeUnmount(() => { + stopPaymentStatusPolling() + document.removeEventListener('visibilitychange', handleDocumentVisibilityChange) + }) + + onDeactivated(() => { + stopPaymentStatusPolling() }) let isFirstActivation = true @@ -580,6 +865,9 @@ if (!isFirstActivation) { getTableData() } + if (onlineQrDialogVisible.value && document.visibilityState === 'visible') { + startPaymentStatusPolling() + } isFirstActivation = false }) @@ -630,13 +918,20 @@ // 获取充值订单列表 const getTableData = async () => { + if (!canViewRecharge.value) { + rechargeList.value = [] + pagination.total = 0 + return + } + loading.value = true try { const params: AgentRechargeQueryParams = { page: pagination.page, page_size: pagination.page_size, - shop_id: searchForm.shop_id, + shop_id: isPlatformAccount.value ? searchForm.shop_id : undefined, status: searchForm.status, + recharge_source: searchForm.recharge_source, start_date: searchForm.start_date || undefined, end_date: searchForm.end_date || undefined } @@ -673,6 +968,14 @@ getTableData() } + const exportQuery = computed(() => ({ + shop_id: isPlatformAccount.value ? searchForm.shop_id : undefined, + status: searchForm.status, + recharge_source: searchForm.recharge_source, + start_date: searchForm.start_date || searchForm.dateRange?.[0], + end_date: searchForm.end_date || searchForm.dateRange?.[1] + })) + // 刷新表格 const handleRefresh = () => { getTableData() @@ -691,26 +994,62 @@ // 显示创建订单对话框 const showCreateDialog = async () => { - // 重新加载店铺列表,确保获取最新数据 - await loadShops() - createForm.payment_method = '' + createMode.value = isAgentAccount.value ? 'online' : 'offline' + resetCreateForm() + + if (createMode.value === 'online') { + await loadPaymentMethods() + } else { + // 重新加载店铺列表,确保获取最新数据 + await loadShops() + } + createDialogVisible.value = true } + const resetCreateForm = () => { + createForm.amount = minimumAmountYuan.value + createForm.payment_method = '' + createForm.shop_id = null + createForm.payment_voucher_key = [] + createForm.remark = '' + onlineRequestId.value = null + voucherUploading.value = false + uploadRef.value?.clearFiles(false) + } + // 对话框关闭后的清理 const handleCreateDialogClosed = () => { createFormRef.value?.resetFields() - createForm.amount = MIN_RECHARGE_AMOUNT - createForm.payment_method = '' - createForm.shop_id = null - createForm.payment_voucher_key = [] - createForm.remark = '' - voucherUploading.value = false - uploadRef.value?.clearFiles(false) + resetCreateForm() + } + + // 加载代理在线充值可用支付方式 + const loadPaymentMethods = async () => { + paymentMethodsLoading.value = true + onlinePaymentMethods.value = [] + try { + const res = await AgentRechargeService.getPaymentMethods() + if (res.code === 0) { + onlinePaymentMethods.value = Array.isArray(res.data?.methods) ? res.data.methods : [] + paymentMethodsBounds.min_amount = ONLINE_MIN_RECHARGE_AMOUNT_FEN + paymentMethodsBounds.max_amount = + Number(res.data?.max_amount) || ONLINE_MAX_RECHARGE_AMOUNT_FEN + createForm.amount = minimumAmountYuan.value + } else { + ElMessage.warning(res.msg || '当前暂无可用在线支付方式') + } + } catch (error) { + console.error('加载在线支付方式失败:', error) + } finally { + paymentMethodsLoading.value = false + } } // 创建充值订单 const handleCreateRecharge = async () => { + if (createLoading.value) return + const formRef = createFormRef.value if (!formRef) return if (voucherUploading.value) { @@ -718,36 +1057,181 @@ return } - await formRef.validate(async (valid) => { - if (valid) { - createLoading.value = true - try { - const data: CreateAgentRechargeRequest = { - amount: Math.round(createForm.amount * 100), // 元转分 - payment_method: createForm.payment_method as AgentRechargePaymentMethod, - shop_id: createForm.shop_id!, - remark: createForm.remark || undefined - } + createLoading.value = true + try { + const valid = await formRef.validate().catch(() => false) + if (!valid || createForm.amount == null || !createForm.payment_method) return - if ( - createForm.payment_method === 'offline' && - hasVoucherKeys(createForm.payment_voucher_key) - ) { - data.payment_voucher_key = toVoucherKeyList(createForm.payment_voucher_key) - } + if (createMode.value === 'online') { + await handleOnlineRechargeCreate(createForm.amount, createForm.payment_method) + return + } - await AgentRechargeService.createAgentRecharge(data) - ElMessage.success('充值订单创建成功') - createDialogVisible.value = false - formRef.resetFields() - await getTableData() - } catch (error) { - console.error(error) - } finally { - createLoading.value = false + if (!createForm.shop_id) { + ElMessage.warning('请选择目标店铺') + return + } + + const voucherKeys = toVoucherKeyList(createForm.payment_voucher_key) + if (!hasVoucherKeys(voucherKeys)) { + ElMessage.warning('请上传支付凭证') + return + } + + const data: CreateAgentRechargeRequest = { + amount: amountYuanToFen(createForm.amount), + payment_method: 'offline', + shop_id: createForm.shop_id, + payment_voucher_key: voucherKeys, + remark: createForm.remark || undefined + } + + const res = await AgentRechargeService.createAgentRecharge(data) + if (res.code !== 0) { + ElMessage.error(res.msg || '充值订单创建失败') + return + } + + ElMessage.success('充值订单创建成功') + createDialogVisible.value = false + await getTableData() + } catch (error) { + console.error(error) + } finally { + createLoading.value = false + } + } + + const handleOnlineRechargeCreate = async ( + amountYuan: number, + paymentMethod: AgentRechargePaymentMethod + ) => { + if (paymentMethod === 'offline') return + + const requestId = onlineRequestId.value || createOnlineRechargeRequestId() + onlineRequestId.value = requestId + + const request: CreateAgentRechargeRequest = { + amount: amountYuanToFen(amountYuan), + payment_method: paymentMethod, + request_id: requestId + } + + try { + const res = await AgentRechargeService.createAgentRecharge(request) + if (res.code !== 0) { + ElMessage.error(res.msg || '在线充值创建失败') + onlineRequestId.value = null + return + } + + if (!res.data?.qr_content) { + ElMessage.error('支付接口未返回有效二维码,请刷新后重试') + return + } + + onlineQrRecharge.value = res.data + onlinePaymentStatus.value = null + qrContent.value = res.data.qr_content + onlineQrDialogVisible.value = true + createDialogVisible.value = false + onlineRequestId.value = null + await getTableData() + } catch { + onlineRequestId.value = null + } + } + + const stopPaymentStatusPolling = () => { + if (paymentStatusTimer.value) { + clearInterval(paymentStatusTimer.value) + paymentStatusTimer.value = null + } + } + + const loadOnlinePaymentStatus = async () => { + const rechargeId = onlineQrRecharge.value?.id + if (!rechargeId || !onlineQrDialogVisible.value || document.visibilityState !== 'visible') + return + + paymentStatusLoading.value = true + try { + const res = await AgentRechargeService.getPaymentStatus(rechargeId) + if (res.code !== 0) return + + onlinePaymentStatus.value = res.data + if (onlineQrRecharge.value) { + onlineQrRecharge.value = { + ...onlineQrRecharge.value, + status: res.data.status, + status_name: res.data.status_name || onlineQrRecharge.value.status_name, + paid_at: res.data.paid_at, + completed_at: res.data.completed_at } } - }) + + if (isOnlineRechargeTerminal(res.data.status)) { + stopPaymentStatusPolling() + if (res.data.status === 3) { + await Promise.all([getTableData(), refreshAgentWalletBalance()]) + } + } + } catch (error) { + console.error('查询在线充值状态失败:', error) + } finally { + paymentStatusLoading.value = false + } + } + + const startPaymentStatusPolling = () => { + stopPaymentStatusPolling() + void loadOnlinePaymentStatus() + paymentStatusTimer.value = setInterval(() => { + if (!onlineQrDialogVisible.value || document.visibilityState !== 'visible') { + stopPaymentStatusPolling() + return + } + void loadOnlinePaymentStatus() + }, 3000) + } + + const handleDocumentVisibilityChange = () => { + if (document.visibilityState === 'visible' && onlineQrDialogVisible.value) { + startPaymentStatusPolling() + } else if (document.visibilityState === 'hidden') { + stopPaymentStatusPolling() + } + } + + watch(onlineQrDialogVisible, (visible) => { + if (visible) { + startPaymentStatusPolling() + } else { + stopPaymentStatusPolling() + } + }) + + const handleQrDialogClosed = () => { + stopPaymentStatusPolling() + qrContent.value = '' + onlineQrRecharge.value = null + onlinePaymentStatus.value = null + onlineWalletBalance.value = null + paymentStatusLoading.value = false + } + + const refreshAgentWalletBalance = async () => { + if (!isAgentAccount.value) return + + try { + const res = await CommissionService.getShopFundSummary({ page: 1, page_size: 100 }) + const currentShopId = Number(userStore.info.shop_id) + const currentShop = + res.code === 0 ? res.data.items?.find((item) => item.shop_id === currentShopId) : null + if (currentShop) onlineWalletBalance.value = currentShop.main_balance + } catch (error) { + console.error('刷新代理钱包余额失败:', error) + } } // 显示确认支付对话框 @@ -827,6 +1311,37 @@ }) } + // 补发历史线下代理充值审批 + const handleTriggerApproval = (row: AgentRecharge) => { + if (triggerApprovalLoading.value) return + + ElMessageBox.confirm(`确定要为充值单号 ${row.recharge_no} 补发企微审批吗?`, '补发审批', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }) + .then(async () => { + triggerApprovalLoading.value = true + try { + const res = await AgentRechargeService.triggerApproval(row.id) + if (res.code !== 0) { + ElMessage.error(res.msg || '补发审批失败') + return + } + ElMessage.success('补发审批成功') + await getTableData() + } catch (error) { + console.error(error) + ElMessage.error('补发审批失败') + } finally { + triggerApprovalLoading.value = false + } + }) + .catch(() => { + // 用户取消 + }) + } + // 处理名称点击 const handleNameClick = (row: AgentRecharge) => { if (hasAuth('agent_recharge:detail_page')) { @@ -846,12 +1361,54 @@ // 获取操作按钮 const getActions = (row: AgentRecharge) => { - return buildAgentRechargeActions(row, { + const actions = buildAgentRechargeActions(row, { hasAuth, onViewPaymentVoucher: handleViewPaymentVoucher, onConfirmPayment: handleShowConfirmPay, - onReject: handleShowReject + onReject: handleShowReject, + onTriggerApproval: handleTriggerApproval }) + if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.agentRechargeEntry)) { + const resourceTarget = resolveAuditResourceTarget({ + resourceType: 'agent_recharge', + internalId: row.id + }) + if (resourceTarget) + actions.unshift({ + label: '审计记录', + handler: () => openAuditInvestigation(resourceTarget), + type: 'primary' + }) + } + if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.agentRechargeFinanceEntry)) { + const financeTarget = resolveFinanceAuditTarget('recharge_id', row.id) + if (financeTarget) + actions.unshift({ + label: '资金链路', + handler: () => openAuditInvestigation(financeTarget), + type: 'primary' + }) + } + if (isPlatformAccount.value && hasAuth(AUDIT_PERMISSIONS.agentRechargeApprovalEntry)) { + const approvalTarget = resolveAuditResourceTarget({ + resourceType: 'approval_instance', + internalId: row.approval_instance_id + }) + if (approvalTarget) + actions.unshift({ + label: '审批审计', + handler: () => openAuditInvestigation(approvalTarget), + type: 'primary' + }) + } + + const paymentVoucherIndex = actions.findIndex((action) => action.label === '支付凭证') + if (paymentVoucherIndex > 0) { + const [paymentVoucherAction] = actions.splice(paymentVoucherIndex, 1) + actions.unshift(paymentVoucherAction) + } + + return actions } // 查看支付凭证 @@ -865,4 +1422,49 @@ .agent-recharge-page { height: 100%; } + + .online-recharge-empty-hint { + margin-top: 8px; + font-size: 12px; + line-height: 1.5; + color: var(--el-color-danger); + } + + .online-recharge-qr-dialog { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + + &__code-surface { + display: flex; + padding: 20px; + background: #fff; + border-radius: 8px; + } + + &__method { + font-weight: 600; + } + + &__summary { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + width: 100%; + font-size: 14px; + color: var(--el-text-color-regular); + text-align: center; + } + + &__hint { + color: var(--el-text-color-secondary); + } + + &__loading { + font-size: 12px; + color: var(--el-color-primary); + } + } diff --git a/src/views/finance/refund/detail.vue b/src/views/finance/refund/detail.vue index 0b3ff29..9f94e74 100644 --- a/src/views/finance/refund/detail.vue +++ b/src/views/finance/refund/detail.vue @@ -10,6 +10,13 @@ 返回

{{ pageTitle }}

+ + 重新申请 + @@ -24,90 +31,10 @@ - - - - - {{ refund?.refund_no }} - - - {{ formatCurrency(refund?.requested_refund_amount) }} - - - -
- 不填则默认使用申请金额 -
-
- - - -
- -
- - - - - - {{ refund?.refund_no }} - - - - - - - - @@ -157,6 +84,7 @@ voucher-name="退款凭证" @uploading-change="resubmitVoucherUploading = $event" @change="resubmitFormRef?.validateField('refund_voucher_key')" + @files-change="resubmitForm.attachments = $event" /> @@ -169,7 +97,7 @@ :loading="resubmitLoading || resubmitVoucherUploading" :disabled="resubmitVoucherUploading" > - {{ resubmitVoucherUploading ? '凭证上传中...' : '确认提交' }} + {{ resubmitVoucherUploading ? '凭证上传中...' : '确认申请' }} @@ -180,27 +108,26 @@ diff --git a/src/views/order-management/order-list/detail.vue b/src/views/order-management/order-list/detail.vue index 3b89d0c..39dfbf7 100644 --- a/src/views/order-management/order-list/detail.vue +++ b/src/views/order-management/order-list/detail.vue @@ -104,6 +104,22 @@ return type === 'single_card' ? '单卡购买' : '设备购买' } + const getAssetTypeText = (type?: string): string => { + if (type === 'card') return 'IoT卡' + if (type === 'device') return '设备' + return '-' + } + + const getPaymentMethodText = (method?: string): string => { + const methodMap: Record = { + wallet: '钱包', + wechat: '微信', + alipay: '支付宝', + offline: '线下' + } + return method ? methodMap[method] || method : '-' + } + // 获取买家类型文本 const getBuyerTypeText = (type: string): string => { return type === 'personal' ? '个人客户' : '代理商' @@ -174,10 +190,19 @@ label: '订单类型', formatter: (_: any, data: any) => getOrderTypeText(data.order_type) }, + { + label: '资产类型', + formatter: (_, data) => getAssetTypeText(data.asset_type) + }, { label: '支付状态', formatter: (_: any, data: any) => data.payment_status_text || '-' }, + { + label: '支付方式', + prop: 'payment_method', + formatter: (value) => getPaymentMethodText(value) + }, { label: '订单金额', prop: 'total_amount', @@ -253,7 +278,12 @@ formatter: (_, data) => (data.is_purchased_by_parent ? '是' : '否') }, { - label: 'ICCID/VirtualNo', + label: '是否代购', + formatter: (_, data) => + data.is_purchase_on_behalf === undefined ? '-' : data.is_purchase_on_behalf ? '是' : '否' + }, + { + label: '资产标识符', prop: 'asset_identifier', formatter: (value) => value || '-' }, diff --git a/src/views/order-management/order-list/index.vue b/src/views/order-management/order-list/index.vue index 26b2f20..684011d 100644 --- a/src/views/order-management/order-list/index.vue +++ b/src/views/order-management/order-list/index.vue @@ -22,6 +22,13 @@ {{ '创建订单' }} + + 批量订购 + {{ '导出' }} @@ -52,7 +59,7 @@ @@ -156,7 +163,7 @@ @@ -45,6 +48,14 @@ + +
缩减比例:{{ realDataGb }}×(1-{{ virtualRatioPercent }}%) = - {{ calculatedVirtualDataGb }}GB + {{ formatTwoDecimals(calculatedVirtualDataGb) }}GB
- 增长比例:{{ realDataGb }}GB/{{ calculatedVirtualDataGb }}GB = - {{ calculatedGrowthRatio * 100 }}% + 增长比例:{{ realDataGb }}GB/{{ + formatTwoDecimals(calculatedVirtualDataGb) + }}GB = {{ formatTwoDecimals(calculatedGrowthRatio * 100) }}%
@@ -312,21 +324,6 @@ - - - - - - - -
-