diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/openspec/changes/update-asset-current-package-expiry-base-display/proposal.md b/openspec/changes/update-asset-current-package-expiry-base-display/proposal.md new file mode 100644 index 0000000..679fc16 --- /dev/null +++ b/openspec/changes/update-asset-current-package-expiry-base-display/proposal.md @@ -0,0 +1,29 @@ +# Change: 更新资产信息当前生效套餐的到期计时基准展示 + +## Why + +资产信息页的“当前生效套餐”依赖 `GET /api/admin/assets/:identifier/current-package` 获取套餐详情。后端现已在该接口中新增返回 `expiry_base` 字段,用于标识套餐到期时间的计时基准,但当前前端侧还没有建立对应的契约与展示规范。 + +如果不先补齐这份 spec,后续实现时容易出现类型未对齐、数据映射丢失,或页面根据开始/到期时间自行推断口径的情况,导致运营人员无法直接判断套餐是“实名激活时起算”还是“购买时起算”。 + +## What Changes + +- 为资产信息页“当前生效套餐”补充 `expiry_base` 契约规范: + - `GET /api/admin/assets/:identifier/current-package` 支持返回 `expiry_base` + - 前端当前套餐类型与页面状态需要保留该字段 +- 为资产信息页“当前生效套餐”补充“到期计时基准”展示规范: + - `from_activation` 显示为“实名激活时起算” + - `from_purchase` 显示为“购买时起算” + - 字段为空或未知时显示稳定占位内容 +- 明确页面必须优先使用后端返回的 `expiry_base`,不得根据 `start_time`、`expire_time` 等字段自行推断 + +## Impact + +- Affected specs: + - `asset-information` +- Affected code: + - `src/types/api/asset.ts` + - `src/views/asset-management/asset-information/composables/useAssetInfo.ts` + - `src/views/asset-management/asset-information/components/CurrentPackageCard.vue` +- Dependencies: + - `GET /api/admin/assets/:identifier/current-package` 已新增返回 `expiry_base` diff --git a/openspec/changes/update-asset-current-package-expiry-base-display/specs/asset-information/spec.md b/openspec/changes/update-asset-current-package-expiry-base-display/specs/asset-information/spec.md new file mode 100644 index 0000000..aa16aee --- /dev/null +++ b/openspec/changes/update-asset-current-package-expiry-base-display/specs/asset-information/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Admin Current Package Expiry Base Contract + +The admin asset information integration SHALL accept and preserve `expiry_base` from `GET /api/admin/assets/:identifier/current-package`. + +#### Scenario: Accept additive expiry_base from current-package API + +- **GIVEN** 用户正在查看后台“资产信息”中的“当前生效套餐” +- **AND** `GET /api/admin/assets/:identifier/current-package` 返回当前套餐数据 +- **WHEN** 响应中包含 `expiry_base` +- **THEN** 前端类型契约 MUST 支持 `expiry_base` +- **AND** 页面状态 MUST 保留该字段原始值 +- **AND** 支持的取值 MUST 包含 `from_activation` 与 `from_purchase` + +#### Scenario: Missing expiry_base does not break current package parsing + +- **GIVEN** `GET /api/admin/assets/:identifier/current-package` 返回当前套餐数据 +- **AND** 响应中未返回 `expiry_base` 或该字段为空 +- **WHEN** 前端解析当前套餐响应 +- **THEN** 页面 MUST 继续保留现有套餐字段的解析行为 +- **AND** MUST NOT 因缺少 `expiry_base` 导致“当前生效套餐”整体渲染失败 + +### Requirement: Admin Current Package Expiry Base Display + +The admin asset information view SHALL display `expiry_base` as the expiry countdown base in the `当前生效套餐` card and MUST use the backend-provided value instead of inferring from time fields. + +#### Scenario: Show activation-based expiry text on current package card + +- **GIVEN** 用户正在查看后台“资产信息”中的“当前生效套餐” +- **AND** 当前套餐返回 `expiry_base` 为 `from_activation` +- **WHEN** 页面渲染套餐详情表格 +- **THEN** 页面 MUST 显示“到期计时基准” +- **AND** 该字段的展示文案 MUST 为“实名激活时起算” + +#### Scenario: Show purchase-based expiry text on current package card + +- **GIVEN** 用户正在查看后台“资产信息”中的“当前生效套餐” +- **AND** 当前套餐返回 `expiry_base` 为 `from_purchase` +- **WHEN** 页面渲染套餐详情表格 +- **THEN** 页面 MUST 显示“到期计时基准” +- **AND** 该字段的展示文案 MUST 为“购买时起算” + +#### Scenario: Show placeholder when expiry_base is empty or unknown + +- **GIVEN** 用户正在查看后台“资产信息”中的“当前生效套餐” +- **AND** 当前套餐的 `expiry_base` 为空、缺失或不是已知取值 +- **WHEN** 页面渲染套餐详情表格 +- **THEN** 页面 MUST 在“到期计时基准”位置显示稳定占位内容 +- **AND** 页面 MUST NOT 根据 `start_time`、`expire_time` 或其他时间字段自行推断展示文案 diff --git a/openspec/changes/update-asset-current-package-expiry-base-display/tasks.md b/openspec/changes/update-asset-current-package-expiry-base-display/tasks.md new file mode 100644 index 0000000..8c8c04a --- /dev/null +++ b/openspec/changes/update-asset-current-package-expiry-base-display/tasks.md @@ -0,0 +1,16 @@ +## 1. Contract Alignment + +- [x] 1.1 扩展资产当前生效套餐相关类型,支持 `expiry_base` +- [x] 1.2 调整 `useAssetInfo.ts` 中当前生效套餐的数据映射,确保页面状态保留 `expiry_base` + +## 2. UI Display + +- [x] 2.1 在资产信息页“当前生效套餐”卡片中新增“到期计时基准”展示项 +- [x] 2.2 按统一文案映射 `expiry_base`:`from_activation` -> “实名激活时起算”,`from_purchase` -> “购买时起算” +- [x] 2.3 当 `expiry_base` 为空、缺失或未知时,显示稳定占位内容,不自行推断 + +## 3. Verification + +- [x] 3.1 验证 `current-package` 返回 `expiry_base=from_activation` 时,页面正确显示“实名激活时起算” +- [x] 3.2 验证 `current-package` 返回 `expiry_base=from_purchase` 时,页面正确显示“购买时起算” +- [x] 3.3 验证 `expiry_base` 缺失或为空时,页面仍能正常渲染其余套餐信息且显示占位内容 diff --git a/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/proposal.md b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/proposal.md new file mode 100644 index 0000000..bb636aa --- /dev/null +++ b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/proposal.md @@ -0,0 +1,40 @@ +# Change: 为 IoT 卡管理补齐店铺名称筛选并拆分当前生效套餐流量权限 + +## Why + +当前 IoT 卡管理页虽然列表接口与前端类型已经支持 `shop_id` 查询,但搜索栏没有暴露“店铺名称”筛选,导致用户无法像设备管理页那样按店铺快速定位 IoT 卡。 + +同时,资产信息页“当前生效套餐”中的“真流量使用”和“虚流量使用”目前没有独立权限控制。业务现在要求将这两个展示块拆成两个可单独授权的权限点,便于按角色控制可见范围。 + +## What Changes + +- 在 IoT 卡管理页搜索栏新增与设备管理页一致的“店铺名称”筛选: + - 采用远程可搜索下拉选择 + - 通过 `GET /api/admin/shops` 按 `shop_name` 查询候选店铺 + - 选中后向 `GET /api/admin/iot-cards/standalone` 提交 `shop_id` +- 在资产信息页中,为以下两个流量展示权限增加独立控制,并同时作用于“当前生效套餐”与“套餐列表”的管理员双区块流量视图: + - `asset_info:view_current_package_real_usage` + - `asset_info:view_current_package_virtual_usage` +- `asset_info:view_current_package_real_usage` 用于控制: + - “当前生效套餐”中的“真流量使用” + - “套餐列表”中的“已使用真流量 / 真流量总量 / 真流量剩余” +- `asset_info:view_current_package_virtual_usage` 用于控制: + - “当前生效套餐”中的“虚流量使用” + - “套餐列表”中的“已使用虚流量 / 停机阈值” +- 非平台/非超管账号继续沿用现有的合并流量摘要逻辑。 +- 平台/超管账号若缺少上述一个或两个权限,只能看到被授权的拆分流量块;若两个权限都没有,则不得回退显示“已使用 / 总量 / 剩余 / 百分比”这类仅面向非平台/非超管账号的合并流量摘要。 +- 本次变更不修改现有后端接口字段契约。 + +## Impact + +- Affected specs: + - `iot-card-management` + - `asset-package-traffic-permissions` +- Affected code: + - `src/views/asset-management/iot-card-management/index.vue` + - `src/views/asset-management/asset-information/components/CurrentPackageCard.vue` + - `src/views/asset-management/asset-information/components/PackageListCard.vue` + - 角色/权限树配置中与新增权限编码相关的注册位置 +- Dependencies: + - `GET /api/admin/shops` + - `GET /api/admin/iot-cards/standalone` diff --git a/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/specs/asset-package-traffic-permissions/spec.md b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/specs/asset-package-traffic-permissions/spec.md new file mode 100644 index 0000000..f670b5c --- /dev/null +++ b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/specs/asset-package-traffic-permissions/spec.md @@ -0,0 +1,88 @@ +## ADDED Requirements + +### Requirement: Asset Package Traffic Breakdown Permissions + +The asset information page SHALL gate the real-traffic and virtual-traffic breakdown sections in both the `当前生效套餐` card and the `套餐列表` traffic area with separate permissions. + +The unified traffic summary (`已使用 / 总量 / 剩余 / 百分比`) MUST remain exclusive to non-platform/non-super-admin accounts and MUST NOT be used as a fallback for platform/admin accounts when one or both breakdown permissions are missing. + +When the current-package card renders the platform/admin dual-track traffic view, the `真流量使用` section MUST require `asset_info:view_current_package_real_usage`, and the `虚流量使用` section MUST require `asset_info:view_current_package_virtual_usage`. + +When the package-list card renders the platform/admin dual-track traffic view, the following items MUST require `asset_info:view_current_package_real_usage`: + +- `已使用真流量` +- `真流量总量` +- `真流量剩余` + +When the package-list card renders the platform/admin dual-track traffic view, the following items MUST require `asset_info:view_current_package_virtual_usage`: + +- `已使用虚流量` +- `停机阈值` + +#### Scenario: Show both traffic sections when both permissions are granted + +- **GIVEN** 当前用户进入的是资产信息页的管理员双区块流量视图 +- **AND** 用户拥有 `asset_info:view_current_package_real_usage` +- **AND** 用户拥有 `asset_info:view_current_package_virtual_usage` +- **WHEN** 页面渲染 `当前生效套餐` 或 `套餐列表` +- **THEN** 页面 MUST 显示受真流量权限控制的展示块 +- **AND** 页面 MUST 显示受虚流量权限控制的展示块 + +#### Scenario: Hide real traffic section without the real-usage permission + +- **GIVEN** 当前用户进入的是资产信息页的管理员双区块流量视图 +- **AND** 用户不拥有 `asset_info:view_current_package_real_usage` +- **AND** 用户拥有 `asset_info:view_current_package_virtual_usage` +- **WHEN** 页面渲染 `当前生效套餐` 或 `套餐列表` +- **THEN** 页面 MUST NOT 显示受真流量权限控制的展示块 +- **AND** 页面 MUST 继续显示受虚流量权限控制的展示块 + +#### Scenario: Hide virtual traffic section without the virtual-usage permission + +- **GIVEN** 当前用户进入的是资产信息页的管理员双区块流量视图 +- **AND** 用户拥有 `asset_info:view_current_package_real_usage` +- **AND** 用户不拥有 `asset_info:view_current_package_virtual_usage` +- **WHEN** 页面渲染 `当前生效套餐` 或 `套餐列表` +- **THEN** 页面 MUST 显示受真流量权限控制的展示块 +- **AND** 页面 MUST NOT 显示受虚流量权限控制的展示块 + +#### Scenario: Hide both traffic sections without either permission + +- **GIVEN** 当前用户进入的是资产信息页的管理员双区块流量视图 +- **AND** 用户不拥有 `asset_info:view_current_package_real_usage` +- **AND** 用户不拥有 `asset_info:view_current_package_virtual_usage` +- **WHEN** 页面渲染 `当前生效套餐` 或 `套餐列表` +- **THEN** 页面 MUST NOT 显示受真流量权限控制的展示块 +- **AND** 页面 MUST NOT 显示受虚流量权限控制的展示块 +- **AND** 页面 MUST NOT 显示 `已使用 / 总量 / 剩余 / 百分比` 的合并流量摘要 +- **AND** 页面 MUST 继续显示当前套餐或套餐记录的非流量基础信息 + +#### Scenario: Non-platform users keep the unified current-package summary + +- **GIVEN** 当前登录账号的 `user_type` 不是 `1` 也不是 `2` +- **WHEN** 页面渲染 `当前生效套餐` 或 `套餐列表` +- **THEN** 页面 MUST 继续沿用现有的合并流量摘要展示 +- **AND** 页面 MUST NOT 依赖上述两个新增权限才能渲染该摘要 + +#### Scenario: Platform users never fall back to the unified traffic summary + +- **GIVEN** 当前登录账号的 `user_type` 是 `1` 或 `2` +- **AND** 用户缺少 `asset_info:view_current_package_real_usage`、`asset_info:view_current_package_virtual_usage` 中的一个或多个权限 +- **WHEN** 页面渲染 `当前生效套餐` 或 `套餐列表` +- **THEN** 页面 MUST 仅按已授权的拆分流量块进行展示 +- **AND** 页面 MUST NOT 回退显示仅面向非平台/非超管账号的合并流量摘要 + +#### Scenario: Package list real traffic metrics use the real-usage permission + +- **GIVEN** 当前用户进入的是 `套餐列表` 的管理员双区块流量视图 +- **WHEN** 页面渲染某条套餐记录的流量信息 +- **THEN** `已使用真流量` MUST 使用 `asset_info:view_current_package_real_usage` 控制 +- **AND** `真流量总量` MUST 使用 `asset_info:view_current_package_real_usage` 控制 +- **AND** `真流量剩余` MUST 使用 `asset_info:view_current_package_real_usage` 控制 + +#### Scenario: Package list virtual traffic metrics use the virtual-usage permission + +- **GIVEN** 当前用户进入的是 `套餐列表` 的管理员双区块流量视图 +- **WHEN** 页面渲染某条套餐记录的流量信息 +- **THEN** `已使用虚流量` MUST 使用 `asset_info:view_current_package_virtual_usage` 控制 +- **AND** `停机阈值` MUST 使用 `asset_info:view_current_package_virtual_usage` 控制 diff --git a/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/specs/iot-card-management/spec.md b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/specs/iot-card-management/spec.md new file mode 100644 index 0000000..fba3f6e --- /dev/null +++ b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/specs/iot-card-management/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: IoT Card Management Shop Filter + +The IoT card management page SHALL provide a `店铺名称` filter consistent with the device management page. + +The `店铺名称` filter MUST be implemented as a searchable remote selector that displays `shop_name` and submits the selected shop `id` as `shop_id`. + +#### Scenario: Search shop options by shop name + +- **GIVEN** 用户正在使用 IoT 卡管理页的搜索栏 +- **WHEN** 用户在 `店铺名称` 筛选项中输入关键字进行搜索 +- **THEN** 页面 MUST 调用 `GET /api/admin/shops` +- **AND** 请求 MAY 携带 `shop_name` 作为关键字 +- **AND** 返回结果 MUST 使用 `shop_name` 作为展示文案 +- **AND** 返回结果 MUST 使用店铺 `id` 作为可提交值 + +#### Scenario: Submit selected shop as shop_id + +- **GIVEN** 用户已经在 `店铺名称` 筛选项中选中某个店铺 +- **WHEN** 用户执行 IoT 卡列表查询 +- **THEN** 页面 MUST 调用 `GET /api/admin/iot-cards/standalone` +- **AND** 请求 MUST 将该店铺的 `id` 作为 `shop_id` 提交 + +#### Scenario: Reset clears shop filter + +- **GIVEN** `店铺名称` 筛选项已经选中了某个店铺 +- **WHEN** 用户重置搜索栏 +- **THEN** 页面 MUST 清空当前选中的店铺 +- **AND** 后续列表请求 MUST NOT 再携带 `shop_id` diff --git a/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/tasks.md b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/tasks.md new file mode 100644 index 0000000..0893426 --- /dev/null +++ b/openspec/changes/update-iot-card-shop-filter-and-current-package-usage-permissions/tasks.md @@ -0,0 +1,11 @@ +## 1. Implementation + +- [x] 1.1 在 IoT 卡管理搜索栏增加与设备管理一致的“店铺名称”远程筛选,并将选中项提交为 `shop_id` +- [x] 1.2 确保 IoT 卡管理页的重置、翻页与刷新流程正确保留或清空 `shop_id` 筛选状态 +- [x] 1.3 在资产信息页“当前生效套餐”中为“真流量使用”接入权限 `asset_info:view_current_package_real_usage` +- [x] 1.4 在资产信息页“当前生效套餐”中为“虚流量使用”接入权限 `asset_info:view_current_package_virtual_usage` +- [x] 1.5 在资产信息页“套餐列表”中为“已使用真流量 / 真流量总量 / 真流量剩余”接入权限 `asset_info:view_current_package_real_usage` +- [x] 1.6 在资产信息页“套餐列表”中为“已使用虚流量 / 停机阈值”接入权限 `asset_info:view_current_package_virtual_usage` +- [x] 1.7 确认非平台/非超管账号的合并流量摘要展示不受新增权限影响 +- [ ] 1.8 在权限树/角色配置中补充两个新增权限编码,并完成联调验证 +- [x] 1.9 修正平台/超管账号在缺少上述一个或两个权限时错误回退显示合并流量摘要的问题,确保仅非平台/非超管账号显示该摘要 diff --git a/openspec/changes/update-list-status-hide-without-permission/proposal.md b/openspec/changes/update-list-status-hide-without-permission/proposal.md new file mode 100644 index 0000000..42980a0 --- /dev/null +++ b/openspec/changes/update-list-status-hide-without-permission/proposal.md @@ -0,0 +1,47 @@ +# Change: 列表状态字段无权限时改为隐藏而不是禁用 + +## Why + +当前多个列表页对“状态 / 上架状态 / 激活状态”采用 `disabled` 开关来处理无权限用户。这样虽然阻止了正常点击,但仍然暴露了前端交互控件,既不符合“无权限不应展示操作入口”的权限体验,也会引发对 DOM 篡改后是否还能触发接口调用的安全疑虑。 + +业务现在明确要求:对于列表中的状态类交互字段,如果用户没有对应权限,则前端直接隐藏该字段,不再显示禁用态开关。 + +## What Changes + +- 将以下列表页中的状态类交互字段统一从“无权限时禁用”改为“无权限时隐藏”: + - 角色管理:`role:update_status` + - 运营商管理:`carrier:update_status` + - 套餐系列:`package_series:update_status` + - 套餐列表-状态:`package:update_status` + - 套餐列表-上架状态:`package:update_away` + - 代理系列授权:`series_grants:update_status` + - 店铺列表:`shop:modify_status` + - 账号列表:`account:modify_status` + - 企业客户:`enterprise_customer:status` + - 微信配置-激活状态:`wechat_config:status` +- 对于无权限用户: + - 前端列表中不再渲染对应状态字段/列 + - 前端不再渲染 disabled 的 `ElSwitch` 或其他禁用态交互控件作为占位 +- 对于有权限用户: + - 保持现有状态切换交互与接口调用行为不变 +- 套餐列表需要将“状态”和“上架状态”视为两个独立权限入口分别控制显示 +- 本次仅调整列表页中的状态类交互字段展示策略,不修改搜索栏状态筛选项,也不扩展到详情页或新增/编辑弹窗中的表单字段 +- 前端隐藏仅作为 UI 层权限收敛,不能替代后端接口鉴权;后端仍需继续拒绝无权限的直接状态更新请求 + +## Impact + +- Affected specs: + - `list-status-permission-visibility` +- Affected code: + - `src/views/system/role/index.vue` + - `src/views/system/carrier-management/index.vue` + - `src/views/package-management/package-series/index.vue` + - `src/views/package-management/package-list/index.vue` + - `src/views/package-management/series-grants/index.vue` + - `src/views/shop-management/list/index.vue` + - `src/views/account-management/account/index.vue` + - `src/views/account-management/enterprise-customer/index.vue` + - `src/views/settings/wechat-config/index.vue` +- Dependencies: + - 现有 `useAuth` / `hasAuth` 权限判断 + - 现有后端状态更新接口权限校验 diff --git a/openspec/changes/update-list-status-hide-without-permission/specs/list-status-permission-visibility/spec.md b/openspec/changes/update-list-status-hide-without-permission/specs/list-status-permission-visibility/spec.md new file mode 100644 index 0000000..83a0fe9 --- /dev/null +++ b/openspec/changes/update-list-status-hide-without-permission/specs/list-status-permission-visibility/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: List Status Controls Must Be Hidden Without Permission + +The system SHALL hide list-level status mutation fields from users who do not have the corresponding update permission, instead of rendering disabled switches or other disabled interactive controls. + +This rule MUST apply to the following list pages and permission mappings: + +- `角色管理` -> `role:update_status` -> `状态` +- `运营商管理` -> `carrier:update_status` -> `状态` +- `套餐系列` -> `package_series:update_status` -> `状态` +- `套餐列表` -> `package:update_status` -> `状态` +- `套餐列表` -> `package:update_away` -> `上架状态` +- `代理系列授权` -> `series_grants:update_status` -> `状态` +- `店铺列表` -> `shop:modify_status` -> `状态` +- `账号列表` -> `account:modify_status` -> `状态` +- `企业客户` -> `enterprise_customer:status` -> `状态` +- `微信配置` -> `wechat_config:status` -> `激活状态` + +When the current user lacks the corresponding permission on one of the pages above, the page MUST NOT render the corresponding status field/column, and MUST NOT render a disabled `ElSwitch` or equivalent disabled interactive control in its place. + +When the current user has the corresponding permission, the page MUST continue to render the existing status control and MUST preserve the current status update workflow. + +This requirement only applies to list-level interactive status fields. Search filters, detail pages, and create/edit dialogs are outside the scope of this change. + +Frontend hiding MUST NOT be treated as sufficient authorization. The system MUST continue to rely on backend permission checks for any status update API request. + +#### Scenario: Render status field when the user has permission + +- **GIVEN** 用户进入上述任一列表页 +- **AND** 用户拥有该页面对应的状态更新权限 +- **WHEN** 页面渲染列表 +- **THEN** 页面 MUST 显示对应的状态类交互字段 +- **AND** 页面 MUST 继续允许用户按现有流程发起状态更新 + +#### Scenario: Hide status field instead of disabling it when the user lacks permission + +- **GIVEN** 用户进入 `角色管理`、`运营商管理`、`套餐系列`、`代理系列授权`、`店铺列表`、`账号列表`、`企业客户` 或 `微信配置` 列表页之一 +- **AND** 用户不拥有该页面对应的状态更新权限 +- **WHEN** 页面渲染列表 +- **THEN** 页面 MUST NOT 显示对应的状态字段或列 +- **AND** 页面 MUST NOT 显示 disabled 的状态开关作为占位 + +#### Scenario: Package list hides only the unauthorized status field + +- **GIVEN** 用户进入 `套餐列表` +- **WHEN** 页面渲染列表 +- **THEN** 若用户缺少 `package:update_status`,页面 MUST 隐藏 `状态` 字段 +- **AND** 若用户缺少 `package:update_away`,页面 MUST 隐藏 `上架状态` 字段 +- **AND** 页面 MUST 仅显示用户已被授权的状态类字段 +- **AND** 页面 MUST NOT 用 disabled 开关展示未授权字段 + +#### Scenario: Backend authorization remains mandatory + +- **GIVEN** 用户在前端列表页看不到某个状态更新入口 +- **AND** 用户仍尝试通过直接构造请求或篡改页面 DOM 发起状态更新 +- **WHEN** 请求到达状态更新接口 +- **THEN** 系统 MUST 继续依赖后端权限校验来决定是否放行 +- **AND** 前端隐藏行为 MUST NOT 被视为接口层放行依据 diff --git a/openspec/changes/update-list-status-hide-without-permission/tasks.md b/openspec/changes/update-list-status-hide-without-permission/tasks.md new file mode 100644 index 0000000..bee1341 --- /dev/null +++ b/openspec/changes/update-list-status-hide-without-permission/tasks.md @@ -0,0 +1,14 @@ +## 1. Implementation + +- [x] 1.1 梳理 9 个页面当前状态字段的 disabled 实现入口,统一改造为按权限动态隐藏字段/列 +- [x] 1.2 在角色管理页中,无 `role:update_status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.3 在运营商管理页中,无 `carrier:update_status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.4 在套餐系列页中,无 `package_series:update_status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.5 在套餐列表页中,无 `package:update_status` 时隐藏“状态”字段;无 `package:update_away` 时隐藏“上架状态”字段 +- [x] 1.6 在代理系列授权页中,无 `series_grants:update_status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.7 在店铺列表页中,无 `shop:modify_status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.8 在账号列表页中,无 `account:modify_status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.9 在企业客户页中,无 `enterprise_customer:status` 时隐藏状态字段,不再渲染禁用态开关 +- [x] 1.10 在微信配置页中,无 `wechat_config:status` 时隐藏“激活状态”字段,不再渲染禁用态开关 +- [x] 1.11 验证各页面在有权限时现有状态切换与接口调用行为保持不变 +- [ ] 1.12 验证各页面在无权限时不再出现 disabled 状态控件,且前端隐藏不会被当作后端鉴权的替代 diff --git a/package.json b/package.json index 5dce799..8d3923b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:default": "vue-tsc --noEmit && vite build --mode default", "build:development": "vue-tsc --noEmit && vite build --mode development", "build:production": "vue-tsc --noEmit && vite build --mode production", + "check:encoding": "node scripts/check-encoding.mjs", "np": "vite build --mode production", "nd": "vite build --mode development", "serve": "vite preview", @@ -28,6 +29,9 @@ } }, "lint-staged": { + "*.{js,ts,mjs,mts,tsx,cjs,json,jsonc,vue,html,htm,css,less,scss,md,mdx,yaml,yml}": [ + "node scripts/check-encoding.mjs" + ], "*.{js,ts,mjs,mts,tsx}": [ "eslint --fix", "prettier --write" diff --git a/scripts/check-encoding.mjs b/scripts/check-encoding.mjs new file mode 100644 index 0000000..12de7ea --- /dev/null +++ b/scripts/check-encoding.mjs @@ -0,0 +1,133 @@ +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const repoRoot = path.resolve(__dirname, '..') +const decoder = new TextDecoder('utf-8', { fatal: true }) + +const textExtensions = new Set([ + '.js', + '.cjs', + '.mjs', + '.ts', + '.tsx', + '.json', + '.jsonc', + '.css', + '.less', + '.scss', + '.vue', + '.html', + '.htm', + '.md', + '.mdx', + '.yml', + '.yaml', + '.svg' +]) + +const textFileNames = new Set([ + 'Dockerfile', + 'AGENTS.md', + 'CLAUDE.md', + 'LICENSE', + '.env', + '.env.development', + '.env.production', + '.gitignore', + '.gitattributes', + '.prettierignore', + '.prettierrc', + '.stylelintignore', + '.stylelintrc.cjs' +]) + +const ignoredDirs = new Set(['.git', 'node_modules', 'dist']) + +function isTextFile(filePath) { + const baseName = path.basename(filePath) + + if (textFileNames.has(baseName)) { + return true + } + + return textExtensions.has(path.extname(baseName).toLowerCase()) +} + +function collectFiles(dirPath) { + const files = [] + + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (ignoredDirs.has(entry.name)) { + continue + } + + files.push(...collectFiles(path.join(dirPath, entry.name))) + continue + } + + const fullPath = path.join(dirPath, entry.name) + + if (isTextFile(fullPath)) { + files.push(fullPath) + } + } + + return files +} + +function toAbsolutePath(targetPath) { + if (path.isAbsolute(targetPath)) { + return targetPath + } + + return path.resolve(process.cwd(), targetPath) +} + +function describeIssue(relativePath, message) { + return `${relativePath}: ${message}` +} + +function checkFile(filePath) { + const relativePath = path.relative(repoRoot, filePath) + const buffer = fs.readFileSync(filePath) + + try { + const text = decoder.decode(buffer) + + if (text.includes('\uFFFD')) { + return describeIssue(relativePath, '包含 Unicode 替换字符,疑似已出现乱码') + } + + return null + } + catch { + return describeIssue(relativePath, '不是有效的 UTF-8 编码') + } +} + +const inputFiles = process.argv.slice(2) +const filesToCheck = inputFiles.length > 0 + ? inputFiles + .map(toAbsolutePath) + .filter((filePath) => fs.existsSync(filePath) && fs.statSync(filePath).isFile()) + : collectFiles(repoRoot) + +const issues = filesToCheck + .filter(isTextFile) + .map(checkFile) + .filter(Boolean) + +if (issues.length > 0) { + console.error('检测到编码问题:') + for (const issue of issues) { + console.error(`- ${issue}`) + } + process.exit(1) +} + +console.log(`编码检查通过,共检查 ${filesToCheck.length} 个文件。`) diff --git a/src/types/api/asset.ts b/src/types/api/asset.ts index 8783909..51431cc 100644 --- a/src/types/api/asset.ts +++ b/src/types/api/asset.ts @@ -6,6 +6,8 @@ // ========== 资产类型枚举 ========== // 资产类型 +type ExpiryBase = 'from_activation' | 'from_purchase' + export type AssetType = 'card' | 'device' // 网络状态 @@ -238,6 +240,10 @@ export interface AssetPackageUsageRecord { * 资产套餐列表分页响应 * 对应接口:GET /api/admin/assets/:asset_type/:id/packages?page=1&page_size=50 */ +export interface AssetPackageUsageRecord { + expiry_base?: ExpiryBase | null // 鍒版湡璁℃椂鍩哄噯 +} + export interface AssetPackageListResponse { total: number // 总记录数 page: number // 当前页码 diff --git a/src/views/account-management/account/index.vue b/src/views/account-management/account/index.vue index 9522415..0366d6d 100644 --- a/src/views/account-management/account/index.vue +++ b/src/views/account-management/account/index.vue @@ -246,6 +246,7 @@ defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制 const { hasAuth } = useAuth() + const canModifyAccountStatus = hasAuth('account:modify_status') const route = useRoute() const dialogType = ref('add') @@ -399,7 +400,7 @@ { label: '账号类型', prop: 'user_type' }, { label: '店铺名称', prop: 'shop_name' }, { label: '企业名称', prop: 'enterprise_name' }, - { label: '状态', prop: 'status' }, + ...(canModifyAccountStatus ? [{ label: '状态', prop: 'status' }] : []), { label: '创建时间', prop: 'created_at' } ] @@ -507,24 +508,27 @@ return row.enterprise_name || '-' } }, - { - prop: 'status', - label: '状态', - width: 100, - formatter: (row: any) => { - return h(ElSwitch, { - modelValue: row.status, - activeValue: CommonStatus.ENABLED, - inactiveValue: CommonStatus.DISABLED, - activeText: getStatusText(CommonStatus.ENABLED), - inactiveText: getStatusText(CommonStatus.DISABLED), - inlinePrompt: true, - disabled: !hasAuth('account:modify_status'), - 'onUpdate:modelValue': (val: string | number | boolean) => - handleStatusChange(row, val as number) - }) - } - }, + ...(canModifyAccountStatus + ? [ + { + prop: 'status', + label: '状态', + width: 100, + formatter: (row: any) => { + return h(ElSwitch, { + modelValue: row.status, + activeValue: CommonStatus.ENABLED, + inactiveValue: CommonStatus.DISABLED, + activeText: getStatusText(CommonStatus.ENABLED), + inactiveText: getStatusText(CommonStatus.DISABLED), + inlinePrompt: true, + 'onUpdate:modelValue': (val: string | number | boolean) => + handleStatusChange(row, val as number) + }) + } + } + ] + : []), { prop: 'created_at', label: '创建时间', diff --git a/src/views/account-management/enterprise-customer/index.vue b/src/views/account-management/enterprise-customer/index.vue index 8ecfbb4..8ac4af5 100644 --- a/src/views/account-management/enterprise-customer/index.vue +++ b/src/views/account-management/enterprise-customer/index.vue @@ -220,6 +220,7 @@ defineOptions({ name: 'EnterpriseCustomer' }) const { hasAuth } = useAuth() + const canUpdateEnterpriseCustomerStatus = hasAuth('enterprise_customer:status') const userStore = useUserStore() const router = useRouter() @@ -344,7 +345,7 @@ { label: '登录手机号', prop: 'login_phone' }, { label: '所在地区', prop: 'address' }, { label: '归属店铺', prop: 'owner_shop_name' }, - { label: '状态', prop: 'status' }, + ...(canUpdateEnterpriseCustomerStatus ? [{ label: '状态', prop: 'status' }] : []), { label: '创建时间', prop: 'created_at' }, { label: '操作', prop: 'operation' } ] @@ -457,24 +458,27 @@ showOverflowTooltip: true, formatter: (row: EnterpriseItem) => row.owner_shop_name || '平台' }, - { - prop: 'status', - label: '状态', - width: 100, - formatter: (row: EnterpriseItem) => { - return h(ElSwitch, { - modelValue: row.status, - activeValue: 1, - inactiveValue: 0, - activeText: '启用', - inactiveText: '禁用', - inlinePrompt: true, - disabled: !hasAuth('enterprise_customer:status'), - 'onUpdate:modelValue': (val: string | number | boolean) => - handleStatusChange(row, val as number) - }) - } - }, + ...(canUpdateEnterpriseCustomerStatus + ? [ + { + prop: 'status', + label: '状态', + width: 100, + formatter: (row: EnterpriseItem) => { + return h(ElSwitch, { + modelValue: row.status, + activeValue: 1, + inactiveValue: 0, + activeText: '启用', + inactiveText: '禁用', + inlinePrompt: true, + 'onUpdate:modelValue': (val: string | number | boolean) => + handleStatusChange(row, val as number) + }) + } + } + ] + : []), { prop: 'created_at', label: '创建时间', diff --git a/src/views/asset-management/asset-information/components/AssetSearchCard.vue b/src/views/asset-management/asset-information/components/AssetSearchCard.vue index 08bd42a..d5b6f68 100644 --- a/src/views/asset-management/asset-information/components/AssetSearchCard.vue +++ b/src/views/asset-management/asset-information/components/AssetSearchCard.vue @@ -34,7 +34,7 @@
diff --git a/src/views/asset-management/asset-information/components/CurrentPackageCard.vue b/src/views/asset-management/asset-information/components/CurrentPackageCard.vue index 0fb2649..de46472 100644 --- a/src/views/asset-management/asset-information/components/CurrentPackageCard.vue +++ b/src/views/asset-management/asset-information/components/CurrentPackageCard.vue @@ -31,12 +31,12 @@ -