Compare commits

...

6 Commits

Author SHA1 Message Date
09225dd8bc 更新 Dockerfile
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m9s
2026-07-20 15:33:35 +08:00
563804f67f 更新 docker-compose.prod.yml
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m29s
2026-07-20 14:46:19 +08:00
luo
e95dc8e4f5 feat:代理现金余额不足100元展示、店铺业务员选择展示与筛选
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m34s
2026-07-20 12:17:02 +08:00
luo
ca338dd345 fix: 测试环境显示develop分支
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 6m20s
2026-07-20 11:02:05 +08:00
luo
fac05db39a feat: 换货新资产归属继承提示 2026-07-20 10:50:23 +08:00
luo
2c8524485b fix: 资产信息字段显示隐藏根据角色
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m24s
2026-07-13 16:40:35 +08:00
33 changed files with 1283 additions and 70 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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:
- 顶部通知抽屉、站内通知中心及其他公共站内通知功能的开发或改造
- 任意余额预警阈值配置能力

View File

@@ -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

View File

@@ -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`

View File

@@ -0,0 +1,44 @@
# Change: 更新资产信息账号类型字段可见性与运营商账户展示
## Why
资产信息页当前在卡/设备基础信息区域存在两类与最新业务口径不一致的问题:
- 卡资产“卡信息”中的 `实名认证策略` 仍会对 `user_type=3`(代理账号)和 `user_type=4`(企业账号)显示。
- 设备资产“设备信息”中的 `实名认证策略``制造商` 仍会对 `user_type=3/4` 显示。
- 卡资产“运营商账户”当前展示的是 `carrier_name`,而最新要求需要改为展示 `carrier_type`,并按运营商管理中的映射规则转成中文,例如 `CTCC -> 中国电信`
如果不统一这些展示规则,不同账号在资产信息页会继续看到不应暴露的字段,同时“运营商账户”的显示口径也会与系统内其他模块不一致。
## What Changes
- 更新卡资产“卡信息”区域:
- 当当前登录账号 `user_type``3``4` 时,隐藏 `实名认证策略` 字段。
- 当当前登录账号 `user_type``3``4` 时,`运营商账户` 字段改为展示 `carrier_type` 的中文映射值,而不是 `carrier_name`
- 当当前登录账号 `user_type` 不是 `3` 且不是 `4` 时,`运营商账户` 字段继续保持当前 `carrier_name` 展示口径。
- 已知运营商类型映射沿用运营商管理口径:`CMCC -> 中国移动``CUCC -> 中国联通``CTCC -> 中国电信``CBN -> 中国广电`
-`carrier_type` 为空时显示 `-`;若返回未知编码,则保留原编码展示,避免吞掉上游信息。
- 更新设备资产“设备信息”区域:
- 当当前登录账号 `user_type``3``4` 时,隐藏 `实名认证策略` 字段。
- 当当前登录账号 `user_type``3``4` 时,隐藏 `制造商` 字段。
- 范围约束:
- 本提案仅调整资产信息基础描述区字段展示。
- 本提案不改动实名认证策略相关操作按钮的权限和可见性。
- 本提案不改动设备详情“绑定卡列表”中的列展示规则,除非后续另行提出变更。
## Impact
- Affected specs:
- `asset-information`
- Affected code:
- `src/views/asset-management/asset-information/components/BasicInfoCard.vue`
- `src/views/asset-management/asset-information/composables/useAssetFormatters.ts`
- `src/views/asset-management/asset-information/types.ts`
- `src/views/asset-management/asset-information/composables/useAssetInfo.ts`
- `src/config/constants/carrierTypes.ts`
- Dependencies:
- 依赖当前登录账号 `user_type` 可从用户状态中稳定读取
- 依赖资产详情接口继续返回 `carrier_type`
- 运营商类型中文映射应与运营商管理模块保持一致
- Breaking changes:
- 无外部接口破坏性变更;本次仅调整后台页面展示契约和字段可见性

View File

@@ -0,0 +1,123 @@
## ADDED Requirements
### Requirement: Admin Card Asset Carrier Type Contract
The admin asset information integration SHALL accept and preserve `carrier_type` from `GET /api/admin/assets/resolve/:identifier` for card assets so the card information section can render the carrier account value from the carrier type code.
#### Scenario: Card asset detail preserves carrier_type from resolve response
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** `GET /api/admin/assets/resolve/:identifier` 返回了 `carrier_type`
- **WHEN** 前端解析卡资产详情响应
- **THEN** 页面状态 MUST 保留该 `carrier_type` 原始值
- **AND** 后续“卡信息”区域的 `运营商账户` 展示 MUST 基于该状态值进行格式化
#### Scenario: Missing carrier_type does not break card asset detail parsing
- **GIVEN** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** `GET /api/admin/assets/resolve/:identifier` 未返回 `carrier_type` 或该字段为空
- **WHEN** 前端解析卡资产详情响应
- **THEN** 页面 MUST 继续保留其他卡资产字段的解析行为
- **AND** MUST NOT 因 `carrier_type` 缺失导致“卡信息”区域渲染失败
### Requirement: Admin Card Asset Carrier Account Display
The admin asset information view SHALL render the card information field `运营商账户` according to the current logged-in account type.
For logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号), the field MUST use `carrier_type` and the same carrier-type-to-Chinese mapping used by carrier management.
Known mappings MUST include `CMCC -> 中国移动`, `CUCC -> 中国联通`, `CTCC -> 中国电信`, and `CBN -> 中国广电`.
For other logged-in account types, the field MUST keep the existing `carrier_name` display behavior.
If `carrier_type` is empty or missing, the page MUST display `-`. If `carrier_type` contains an unknown code, the page MUST preserve and display the original code.
#### Scenario: Show China Telecom label for CTCC in card information for agent or enterprise account
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_type``CTCC`
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 显示 `中国电信`
#### Scenario: Show placeholder when carrier_type is empty for agent or enterprise account
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_type` 为空、缺失或仅包含空白字符
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 显示 `-`
#### Scenario: Preserve unknown carrier_type code for agent or enterprise account
- **GIVEN** 当前登录账号的 `user_type``3``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_type` 为未收录的编码
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 直接显示该原始编码
- **AND** MUST NOT 错误映射为其他运营商中文名称
#### Scenario: Keep carrier_name display for non-agent non-enterprise account
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **AND** 当前卡资产详情状态中的 `carrier_name` 为有效值
- **WHEN** 页面渲染“卡信息”区域中的 `运营商账户`
- **THEN** 页面 MUST 显示该 `carrier_name`
### Requirement: Admin Card Realname Policy Visibility By Account Type
The admin asset information view SHALL hide the card information field `实名认证策略` for logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号).
For other logged-in account types, the page SHALL keep the existing card information field visibility behavior for `实名认证策略`.
#### Scenario: Hide card realname policy for agent account
- **GIVEN** 当前登录账号的 `user_type``3`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
#### Scenario: Hide card realname policy for enterprise account
- **GIVEN** 当前登录账号的 `user_type``4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
#### Scenario: Keep card realname policy visible for non-agent non-enterprise account
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
- **AND** 用户正在查看后台“资产信息”中的卡资产详情
- **WHEN** 页面渲染“卡信息”区域
- **THEN** 页面 MUST 继续按现有规则渲染 `实名认证策略` 字段
### Requirement: Admin Device Sensitive Field Visibility By Account Type
The admin asset information view SHALL hide the device information fields `实名认证策略` and `制造商` for logged-in accounts whose `user_type` is `3` (代理账号) or `4` (企业账号).
For other logged-in account types, the page SHALL keep the existing device information field visibility behavior for both fields.
#### Scenario: Hide device realname policy and manufacturer for agent account
- **GIVEN** 当前登录账号的 `user_type``3`
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
- **AND** 页面 MUST NOT 显示 `制造商` 字段
#### Scenario: Hide device realname policy and manufacturer for enterprise account
- **GIVEN** 当前登录账号的 `user_type``4`
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST NOT 显示 `实名认证策略` 字段
- **AND** 页面 MUST NOT 显示 `制造商` 字段
#### Scenario: Keep device realname policy and manufacturer visible for non-agent non-enterprise account
- **GIVEN** 当前登录账号的 `user_type` 不是 `3` 也不是 `4`
- **AND** 用户正在查看后台“资产信息”中的设备资产详情
- **WHEN** 页面渲染“设备信息”区域
- **THEN** 页面 MUST 继续按现有规则渲染 `实名认证策略` 字段
- **AND** 页面 MUST 继续按现有规则渲染 `制造商` 字段

View File

@@ -0,0 +1,20 @@
## 1. Contract And Formatting
- [x] 1.1 补齐资产信息页面本地类型/状态中的 `carrier_type` 字段,确保卡资产详情可稳定保留该值。
- [x] 1.2 复用运营商管理中的运营商类型映射规则,为资产信息页提供 `carrier_type` 到中文文案的格式化能力。
## 2. UI Visibility
- [x] 2.1 调整卡资产“卡信息”区域的 `运营商账户` 字段:`user_type=3/4` 展示 `carrier_type` 中文映射,其他账号继续展示原来的 `carrier_name`
- [x] 2.2 当当前登录账号 `user_type``3``4` 时,隐藏卡资产“卡信息”区域的 `实名认证策略` 字段。
- [x] 2.3 当当前登录账号 `user_type``3``4` 时,隐藏设备资产“设备信息”区域的 `实名认证策略``制造商` 字段。
- [x] 2.4 确认本次变更仅影响基础信息描述区,不改动实名认证策略操作按钮和设备绑定卡列表列。
## 3. Verification
- [ ] 3.1 验证 `user_type=3/4` 查看卡资产且返回 `carrier_type=CTCC` 时,“运营商账户”显示为 `中国电信`
- [ ] 3.2 验证 `user_type=3/4` 查看卡资产且返回 `carrier_type=CMCC/CUCC/CBN` 时,“运营商账户”分别显示与运营商管理一致的中文文案;空值显示 `-`
- [ ] 3.3 验证 `user_type=3/4` 查看卡资产且返回未知 `carrier_type` 编码时,“运营商账户”保留原编码展示。
- [ ] 3.4 验证 `user_type=3``user_type=4` 查看卡资产时,不显示 `实名认证策略` 字段。
- [ ] 3.5 验证 `user_type=3``user_type=4` 查看设备资产时,不显示 `实名认证策略``制造商` 字段。
- [ ] 3.6 验证其他账号类型查看卡资产时,“运营商账户”继续显示原来的 `carrier_name`;其余上述字段继续按现有规则显示。

View File

@@ -0,0 +1,39 @@
# Change: 允许企业账号登录后台管理系统
## Why
当前前端登录流程会在登录接口成功返回后,基于 `user_type=4` 对企业账号做前端拦截,并提示“企业账号无法登录后台管理系统,请使用平台/代理账号登录”。这与最新业务要求不一致,导致企业账号即使后端已返回有效 token、菜单和权限也无法进入后台。
如果只保留现状,企业账号无法访问后台中已经按权限和菜单控制开放给它们的页面,也会与近期多项针对企业账号后台可见性和资产能力的变更方向相冲突。
## What Changes
- 移除前端登录流程中对 `user_type=4` 企业账号的后台登录拦截。
- 企业账号登录成功后,前端应与其他账号类型一致地:
- 保存 token
- 保存用户信息
- 保存权限、菜单和按钮数据
- 在存在可用后台菜单和权限时,继续进入后台默认首页/重定向目标页
- 当登录接口成功但 `menus` 为空或 `permissions` 为空时:
- 前端仅提示“当前账号暂无任何权限,请联系管理员分配角色和权限”
- 不继续进入后台首页或动态路由注册流程
- 不作为这条提示的后续动作调用退出登录接口
- 登录成功后的页面访问边界继续由现有菜单、路由权限、按钮权限和后端接口权限共同控制。
- 本提案不要求本次同步放开所有企业账号受限接口或页面;仅调整“企业账号能否进入后台”的前端准入逻辑。
- 本提案不修改登录接口路径、请求参数或返回结构。
## Impact
- Affected specs:
- `admin-authentication`
- Affected code:
- `src/composables/useLogin.ts`
- `src/router/guards/beforeEach.ts`
- `src/router/guards/permission.ts`
- `src/App.vue`
- 其他依赖当前登录态、菜单和权限进行页面准入的后台入口逻辑
- Dependencies:
- 后端登录接口继续允许企业账号返回有效 `access_token``user``permissions``menus``buttons`
- 企业账号需要至少具备一部分后台菜单或权限,才能进入可访问页面
- Breaking changes:
- 无外部接口破坏性变更;本次为前端登录准入行为放宽

View File

@@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Enterprise Account Admin Login Admission
The admin login flow SHALL allow enterprise accounts whose `user_type` is `4` to sign in to the admin system when the login API returns a successful response with valid authentication data.
The frontend MUST NOT reject admin login solely because `user_type` equals `4`.
#### Scenario: Enterprise account is allowed to complete admin login
- **GIVEN** 用户使用企业账号凭证登录后台管理系统
- **AND** 登录接口返回成功状态
- **AND** 返回数据包含有效 `access_token`
- **AND** 返回数据中的 `user.user_type``4`
- **WHEN** 前端处理登录成功响应
- **THEN** 前端 MUST 继续执行后台登录成功流程
- **AND** MUST NOT 弹出“企业账号无法登录后台管理系统,请使用平台/代理账号登录”提示
#### Scenario: Enterprise account login still fails when login API itself fails
- **GIVEN** 用户使用企业账号凭证登录后台管理系统
- **AND** 登录接口返回失败状态,或未返回有效 `access_token`
- **WHEN** 前端处理登录响应
- **THEN** 前端 MUST 继续按现有登录失败流程处理
- **AND** MUST NOT 将“允许企业账号登录后台”解释为绕过登录接口失败
### Requirement: Enterprise Account Login State Persistence
The admin login flow SHALL persist enterprise-account login state using the same token, user, permissions, menus, and buttons handling used for other successful admin logins only when the login response provides usable admin-access data.
#### Scenario: Enterprise account saves auth state after successful login with usable admin access data
- **GIVEN** 用户使用企业账号成功登录后台管理系统
- **AND** 登录返回中包含 `access_token``refresh_token``user``permissions``menus``buttons`
- **AND** `permissions` 非空
- **AND** `menus` 非空
- **WHEN** 前端写入登录态
- **THEN** 前端 MUST 保存 token
- **AND** MUST 保存用户信息
- **AND** MUST 保存权限、菜单和按钮数据
#### Scenario: Enterprise account shows no-permission warning without logout follow-up
- **GIVEN** 用户使用企业账号成功登录后台管理系统
- **AND** 登录返回中的 `permissions` 为空,或 `menus` 为空
- **WHEN** 前端处理登录成功响应
- **THEN** 前端 MUST 继续显示现有“当前账号暂无任何权限,请联系管理员分配角色和权限”提示
- **AND** MUST NOT 因为空权限或空菜单回退为企业账号登录拦截
- **AND** MUST NOT 继续进入后台首页或动态路由注册流程
- **AND** MUST NOT 因这条无权限提示而调用退出登录接口
### Requirement: Enterprise Account Admin Entry Uses Existing Authorization Boundaries
Allowing enterprise accounts to sign in to the admin system SHALL NOT by itself grant additional page or API access beyond the existing menu, route-permission, button-permission, and backend authorization boundaries.
#### Scenario: Enterprise account enters admin and follows existing route registration flow
- **GIVEN** 用户使用企业账号成功登录后台管理系统
- **AND** 登录返回中的 `permissions` 非空
- **AND** 登录返回中的 `menus` 非空
- **WHEN** 前端进入登录后的路由注册和首页跳转流程
- **THEN** 前端 MUST 继续按现有菜单和动态路由注册逻辑处理
- **AND** MUST NOT 因 `user_type=4` 在该阶段额外拒绝用户进入后台
#### Scenario: Enterprise account page access remains permission-controlled
- **GIVEN** 用户已使用企业账号登录后台管理系统
- **WHEN** 用户尝试访问某个后台页面或触发某个后台操作
- **THEN** 页面访问和操作可用性 MUST 继续由现有菜单、路由权限、按钮权限和后端接口鉴权决定
- **AND** 允许登录后台本身 MUST NOT 被解释为默认拥有全部后台页面和操作权限

View File

@@ -0,0 +1,17 @@
## 1. Login Flow
- [x] 1.1 移除登录成功后对 `user_type=4` 企业账号的前端拒绝逻辑和错误提示。
- [x] 1.2 确保企业账号在登录成功后与其他账号一样完成 token、用户信息、权限、菜单、按钮的保存流程。
- [x] 1.3 当登录成功但 `menus``permissions` 为空时,仅提示“当前账号暂无任何权限,请联系管理员分配角色和权限”并停止后续登录流程,不调用退出登录接口。
## 2. Navigation And Access
- [x] 2.1 确保企业账号在具备可用后台菜单和权限时,继续走现有后台首页跳转和路由注册流程,不因账号类型被额外拦截。
- [x] 2.2 确认企业账号后台访问范围仍由现有菜单、路由权限和后端接口权限控制,而不是在登录阶段一刀切拒绝。
## 3. Verification
- [ ] 3.1 验证企业账号登录成功后不再出现“企业账号无法登录后台管理系统,请使用平台/代理账号登录”提示。
- [ ] 3.2 验证企业账号在具备可用后台菜单和权限时,能正常保存登录态并进入后台首页或重定向目标页。
- [ ] 3.3 验证企业账号在菜单为空或权限为空时,仅提示“暂无任何权限”,且不会继续调用退出登录接口。
- [ ] 3.4 验证平台账号和代理账号登录流程不受影响。

View File

@@ -0,0 +1,22 @@
# Change: 换货新资产归属继承提示
## Why
当前换货流程在选择新资产后,页面没有明确告知换货完成后的最终归属店铺,运营人员容易误以为可以手工选择目标店铺,进而对换货结果产生误判。
## What Changes
- 在换货创建入口、发货确认入口、换货详情页补充旧资产所属店铺和新资产继承归属店铺的展示。
- 保持现有换货创建、发货、确认完成接口和入参语义不变,不新增目标店铺选择控件或目标店铺提交字段。
- 扩展换货响应数据,返回 `inherited_shop_id``inherited_shop_name` 供前端统一展示最终归属店铺。
- 在创建、发货、确认完成流程中补充提交中禁用、防重复提交、后端归属校验失败提示,以及成功后的详情刷新。
## 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`
- 相关旧资产/新资产搜索结果类型或映射逻辑(如需补充店铺名称展示数据)

View File

@@ -0,0 +1,88 @@
## 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 能够手工覆盖继承归属店铺
### 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 在新资产归属校验失败时直接展示后端错误,并在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交,同时保证失败后可以重试。
#### Scenario: 新资产属于其他店铺时展示后端错误
- **GIVEN** 用户选择了不允许用于当前换货流程的新资产
- **WHEN** 后端返回“新资产属于其他店铺”或等价归属校验错误
- **THEN** 系统 MUST 直接展示后端错误信息
- **AND** 系统 MUST NOT 提供手工修改目标店铺的替代入口
#### Scenario: 提交期间禁用按钮防止重复提交
- **WHEN** 用户提交创建、发货或确认完成操作
- **THEN** 当前操作按钮 MUST 进入禁用或 loading 状态直到请求结束
- **AND** 在请求完成前的重复点击 MUST NOT 触发第二次提交
#### Scenario: 请求失败后恢复可重试状态
- **WHEN** 创建、发货或确认完成请求失败
- **THEN** 系统 MUST 恢复当前操作按钮可用状态
- **AND** 页面 MUST 保留当前已展示的资产与归属提示,便于用户修正后重试

View File

@@ -0,0 +1,22 @@
## 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 确保上述页面不新增目标店铺选择控件,也不允许手工覆盖继承归属。
## 3. Exchange Detail And Submission States
- [x] 3.1 在换货详情页展示继承后的店铺名称,并在发货或确认完成成功后重新拉取详情。
- [x] 3.2 在创建、发货、确认完成提交期间禁用操作按钮,防止重复提交。
- [x] 3.3 在新资产归属校验失败时展示后端错误,并在失败后恢复可重试状态。
## 4. Verification
- [x] 4.1 验证创建、发货确认、详情三个入口均能正确展示旧资产所属店铺和继承归属店铺。
- [x] 4.2 验证加载中、失败提示、重复提交防护和成功后详情刷新的交互状态。
- [x] 4.3 运行相关前端校验,并执行 `openspec validate update-exchange-inherited-shop-hint --strict`

View File

@@ -0,0 +1,29 @@
# 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/accounts?account_type=platform&status=1`

View File

@@ -0,0 +1,80 @@
## 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 仅查询 `account_type=platform``status=1` 的账号
- **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_name``business_owner_phone_masked`
#### 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_name``business_owner_phone_masked`
### 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 继续使用详情响应中的 `business_owner_name``business_owner_phone_masked` 展示历史业务员信息
#### Scenario: Do not add distribution or commission semantics
- **WHEN** 用户查看店铺新建、编辑、列表或详情页面
- **THEN** 页面 MUST NOT 将平台业务员描述为分销关系、佣金关系或发展层级

View File

@@ -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 复用启用平台账号查询接口,读取 `account_id``account_name``phone_masked` 作为候选项。
## 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
- [ ] 4.1 验证创建、编辑、清空业务员和无可选账号状态。
- [ ] 4.2 验证列表业务员展示和按业务员筛选正确。
- [ ] 4.3 验证详情对历史停用业务员仍能展示名称和手机号摘要。
- [x] 4.4 确认页面不引入分销、佣金或发展层级文案。
- [x] 4.5 运行相关前端校验,并执行 `openspec validate update-shop-salesperson-selection --strict`

View File

@@ -6,6 +6,8 @@ import { BaseService } from '../BaseService'
import type {
PlatformAccount,
AccountQueryParams,
PlatformSalesperson,
PlatformSalespersonQueryParams,
CreatePlatformAccountParams,
BaseResponse,
PaginationResponse
@@ -23,6 +25,16 @@ export class AccountService extends BaseService {
return this.getPage<PlatformAccount>('/api/admin/accounts', params)
}
/**
* 获取启用的平台业务员候选
* GET /api/admin/accounts?account_type=platform&status=1
*/
static getPlatformSalespeople(
params: PlatformSalespersonQueryParams
): Promise<PaginationResponse<PlatformSalesperson>> {
return this.getPage<PlatformSalesperson>('/api/admin/accounts', params)
}
/**
* 创建账号
* POST /api/admin/accounts

View File

@@ -54,9 +54,14 @@ export class AssetService extends BaseService {
*/
static resolveAsset(
identifier: string,
params?: AssetResolveParams
params?: AssetResolveParams,
config?: Record<string, any>
): Promise<BaseResponse<AssetResolveResponse>> {
return this.getOne<AssetResolveResponse>(`/api/admin/assets/resolve/${identifier}`, params)
return this.getOne<AssetResolveResponse>(
`/api/admin/assets/resolve/${identifier}`,
params,
config
)
}
/**

View File

@@ -47,6 +47,8 @@ export interface ExchangeResponse {
recipient_address?: string
express_company?: string
express_no?: string
inherited_shop_id?: number | null
inherited_shop_name?: string | null
remark?: string
created_at: string
updated_at: string
@@ -114,8 +116,8 @@ export class ExchangeService extends BaseService {
* POST /api/admin/exchanges/{id}/complete
* @param id 换货单ID
*/
static completeExchange(id: number): Promise<BaseResponse> {
return this.post<BaseResponse>(`/api/admin/exchanges/${id}/complete`, {})
static completeExchange(id: number): Promise<BaseResponse<ExchangeResponse>> {
return this.post<BaseResponse<ExchangeResponse>>(`/api/admin/exchanges/${id}/complete`, {})
}
/**

View File

@@ -3,18 +3,17 @@
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElNotification, ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { HOME_PAGE, RoutesAlias } from '@/router/routesAlias'
import { RoutesAlias } from '@/router/routesAlias'
import AppConfig from '@/config'
import { AuthService } from '@/api/modules'
import { ApiStatus } from '@/utils/http/status'
import { saveCredentials, getRememberedCredentials } from '@/utils/auth/rememberPassword'
import { usernameRules, passwordRules } from '@/utils/auth/loginValidation'
import { getRedirectPath } from '@/router/guards/permission'
/**
* 登录表单数据
@@ -28,7 +27,6 @@ interface LoginForm {
export function useLogin() {
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 表单引用
@@ -108,9 +106,24 @@ export function useLogin() {
return
}
// 检查用户类型 - 禁止企业账号登录
if (response.data.user && response.data.user.user_type === 4) {
ElMessage.error('企业账号无法登录后台管理系统,请使用平台/代理账号登录')
if (!response.data.user) {
ElMessage.error(t('login.error.getUserInfoFailed'))
return
}
// 登录成功但没有后台菜单或权限时,仅提示并停止后续流程。
// 这里不保存登录态,也不触发后续跳转,避免进入路由守卫后再连续调用退出接口。
const hasPermissions =
Array.isArray(response.data.permissions) && response.data.permissions.length > 0
const hasMenus = Array.isArray(response.data.menus) && response.data.menus.length > 0
if (!hasPermissions || !hasMenus) {
ElMessage.warning({
message: '当前账号暂无任何权限,请联系管理员分配角色和权限',
duration: 4000,
showClose: true
})
saveCredentials(formData.username, formData.password, formData.rememberPassword)
return
}
@@ -118,13 +131,8 @@ export function useLogin() {
userStore.setToken(response.data.access_token, response.data.refresh_token)
// 保存用户信息
if (response.data.user) {
userStore.setUserInfo(response.data.user)
userStore.setLoginStatus(true)
} else {
ElMessage.error(t('login.error.getUserInfoFailed'))
return
}
userStore.setUserInfo(response.data.user)
userStore.setLoginStatus(true)
// 保存权限、菜单和按钮
if (response.data.permissions) {
@@ -137,19 +145,6 @@ export function useLogin() {
userStore.setButtons(response.data.buttons)
}
// 检查权限和菜单是否为空
const hasPermissions = response.data.permissions && response.data.permissions.length > 0
const hasMenus = response.data.menus && response.data.menus.length > 0
if (!hasPermissions || !hasMenus) {
ElMessage.warning({
message: '当前账号暂无任何权限,请联系管理员分配角色和权限',
duration: 4000,
showClose: true
})
// 虽然没有权限,但仍然允许登录,让用户看到空白页面而不是停留在登录页
}
// 保存记住密码
saveCredentials(formData.username, formData.password, formData.rememberPassword)

View File

@@ -107,6 +107,20 @@ export interface AccountQueryParams extends PaginationParams {
enterprise_id?: number // 按企业ID筛选
}
// 平台业务员候选账号
export interface PlatformSalesperson {
account_id: number
account_name: string
phone_masked: string
}
// 平台业务员候选查询参数
export interface PlatformSalespersonQueryParams extends PaginationParams {
account_type: 'platform'
status: AccountStatus
account_name?: string
}
// 代理商查询参数
export interface AgentQueryParams extends PaginationParams {
keyword?: string

View File

@@ -211,6 +211,10 @@ export interface ShopFundSummaryItem {
phone?: string // 主账号手机号
main_balance: number // 预充值余额(分)
main_frozen_balance: number // 预充值冻结余额(分)
balance: number // 现金余额(分)
frozen_balance: number // 现金冻结金额(分)
cash_available: number // 现金可用余额(分)
low_balance_warning: boolean // 现金可用余额不足100元预警
total_commission: number // 总佣金(分)
available_commission: number // 可提现佣金(分)
frozen_commission: number // 冻结中佣金(分)

View File

@@ -21,6 +21,9 @@ export interface ShopResponse {
contact_name: string // 联系人姓名
contact_phone: string // 联系人电话
status: number // 状态 (0:禁用, 1:启用)
business_owner_account_id?: number | null // 平台业务员账号ID
business_owner_name?: string // 平台业务员名称
business_owner_phone_masked?: string // 平台业务员脱敏手机号
}
// 店铺列表查询参数
@@ -32,6 +35,7 @@ export interface ShopQueryParams extends PaginationParams {
parent_id?: number | null // 上级店铺ID
level?: number | null // 店铺层级 (1-7级)
status?: number | null // 状态 (0:禁用, 1:启用)
business_owner_account_id?: number | null // 平台业务员账号ID
page?: number // 页码
page_size?: number // 每页数量
}
@@ -50,6 +54,7 @@ export interface CreateShopParams {
address?: string // 详细地址
contact_name?: string // 联系人姓名
contact_phone?: string // 联系人电话
business_owner_account_id?: number | null // 平台业务员账号ID
}
// 更新店铺参数
@@ -62,6 +67,7 @@ export interface UpdateShopParams {
address?: string // 详细地址
contact_name?: string // 联系人姓名
contact_phone?: string // 联系人电话
business_owner_account_id?: number | null // 平台业务员账号ID
}
// 店铺列表分页响应

View File

@@ -9,7 +9,7 @@ const axiosInstance = axios.create({
baseURL: import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL, // 开发服务器使用代理其他模式使用完整URL
withCredentials: false, // 异步请求携带cookie
transformRequest: [
(data, headers) => {
(data) => {
// 如果是 FormData,不进行转换
if (data instanceof FormData) {
return data
@@ -72,6 +72,7 @@ let failedQueue: Array<{
resolve: (value?: any) => void
reject: (reason?: any) => void
}> = [] // 失败队列,存储因 token 过期而失败的请求
let isHandlingAuthExpiry = false
// 处理失败队列
const processQueue = (error: any, token: string | null = null) => {
@@ -222,6 +223,11 @@ function handleErrorMessage(
const backendMessage = error.response?.data?.msg
const httpStatus = error.response?.status
// 401 过期类错误由 clearLocalStateAndRedirect 统一提示,避免重复弹出后端报错。
if (httpStatus === 401 || error.response?.data?.code === ApiStatus.unauthorized) {
return
}
// 如果是404错误且未配置显示404错误则不显示任何提示
if (httpStatus === 404 && !show404Error) {
return
@@ -306,9 +312,13 @@ const api = {
// 仅清理本地状态(不调用接口)- 用于401等情况
const clearLocalStateAndRedirect = () => {
if (isHandlingAuthExpiry) return
isHandlingAuthExpiry = true
ElMessage.error('登录已过期,请重新登录')
setTimeout(() => {
useUserStore().clearLocalState()
isHandlingAuthExpiry = false
}, 1000)
}

View File

@@ -32,15 +32,13 @@
<ElDescriptionsItem label="IMEI">{{
cardInfo?.gateway_card_imei || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{
cardInfo?.carrier_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商账户">{{ carrierAccountDisplay }}</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="getRealNameStatusType(cardInfo?.real_name_status)" size="small">
{{ getRealNameStatusName(cardInfo?.real_name_status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="实名认证策略">
<ElDescriptionsItem v-if="canShowSensitiveFields" label="实名认证策略">
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
</ElDescriptionsItem>
@@ -157,11 +155,13 @@
<ElDescriptionsItem label="最大卡槽数">{{
cardInfo?.max_sim_slots || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="实名认证策略">
<ElDescriptionsItem v-if="canShowSensitiveFields" label="实名认证策略">
{{ getRealnamePolicyName(cardInfo?.realname_policy) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ cardInfo?.device_type || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ cardInfo?.manufacturer || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem v-if="canShowSensitiveFields" label="制造商">{{
cardInfo?.manufacturer || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最后在线时间">{{
formatDateTime(cardInfo?.last_online_time) || '-'
}}</ElDescriptionsItem>
@@ -524,6 +524,7 @@
iccid?: string
imei?: string
msisdn?: string
carrier_type?: string
carrier_name?: string
real_name_status?: number
activation_status_name?: string
@@ -643,6 +644,22 @@
return ![3, 4].includes(userType) && hasAuth(CARD_MONTH_USAGE_PERMISSION)
})
const isAgentOrEnterpriseAccount = computed(() => {
const userType = Number(userStore.info.user_type ?? 0)
return [3, 4].includes(userType)
})
const canShowSensitiveFields = computed(() => {
return !isAgentOrEnterpriseAccount.value
})
const carrierAccountDisplay = computed(() => {
if (isAgentOrEnterpriseAccount.value) {
return getCarrierTypeName(props.cardInfo?.carrier_type)
}
return props.cardInfo?.carrier_name || '-'
})
const descriptionsColumn = computed(() => {
if (width.value <= 640) return 1
if (width.value <= 1024) return 2
@@ -679,6 +696,7 @@
getRealNameStatusName,
getRealNameStatusType,
getRealnamePolicyName,
getCarrierTypeName,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,

View File

@@ -2,6 +2,9 @@
* 资产信息格式化工具函数
*/
import { getCarrierTypeLabel } from '@/config/constants/carrierTypes'
import type { CarrierType } from '@/types/api'
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
export function useAssetFormatters() {
@@ -76,6 +79,13 @@ export function useAssetFormatters() {
return policyMap[policy] || '未知'
}
// 获取运营商类型名称
const getCarrierTypeName = (carrierType?: string | null) => {
const normalizedCarrierType = carrierType?.trim()
if (!normalizedCarrierType) return '-'
return getCarrierTypeLabel(normalizedCarrierType as CarrierType)
}
// 获取资产状态名称
const getAssetStatusName = (status?: number) => {
if (status === undefined || status === null) return '未知'
@@ -252,6 +262,7 @@ export function useAssetFormatters() {
getRealNameStatusName,
getRealNameStatusType,
getRealnamePolicyName,
getCarrierTypeName,
getAssetStatusName,
getAssetStatusType,
getOnlineStatusName,

View File

@@ -14,6 +14,7 @@ export interface AssetInfo {
iccid?: string
imei?: string
msisdn?: string
carrier_type?: string
carrier_name?: string
real_name_status?: number
activation_status_name?: string

View File

@@ -45,6 +45,12 @@
const exchangeDetail = ref<ExchangeResponse | null>(null)
const exchangeId = ref<number>(0)
const formatExchangeShopName = (shopName?: string | null, shopId?: number | null) => {
if (shopName) return shopName
if (shopId === null || shopId === 0) return '平台'
return '--'
}
const getStatusType = (status: number): TagProps['type'] => {
const types: Record<number, TagProps['type']> = {
1: 'warning',
@@ -96,6 +102,11 @@
label: '新资产标识符',
formatter: (value) => value || '--',
prop: 'new_asset_identifier'
},
{
label: '继承归属店铺',
formatter: (_, data) =>
formatExchangeShopName(data.inherited_shop_name, data.inherited_shop_id)
}
]
})

View File

@@ -170,6 +170,9 @@
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="createOldAssetShopDisplay" label="旧资产所属店铺">
<ElInput :model-value="createOldAssetShopDisplay" readonly disabled />
</ElFormItem>
<ElFormItem
v-if="createForm.flow_type === 'direct' && createForm.old_asset_type === 'iot_card'"
label="新资产标识符"
@@ -232,6 +235,9 @@
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="createDirectInheritedShopHint" label="归属提示">
<div class="exchange-shop-hint">{{ createDirectInheritedShopHint }}</div>
</ElFormItem>
<ElFormItem v-if="createForm.flow_type === 'direct'" label="是否迁移数据">
<ElSwitch v-model="createForm.migrate_data" />
<div style="margin-top: 4px; margin-left: 8px; font-size: 12px; color: #909399">
@@ -250,7 +256,9 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="createDialogVisible = false">取消</ElButton>
<ElButton @click="createDialogVisible = false" :disabled="createLoading"
>取消</ElButton
>
<ElButton type="primary" @click="handleConfirmCreate" :loading="createLoading">
确认创建
</ElButton>
@@ -267,11 +275,70 @@
@closed="handleCloseShipDialog"
>
<ElForm ref="shipFormRef" :model="shipForm" :rules="shipRules" label-width="120px">
<ElFormItem label="新资产标识符" prop="new_identifier">
<ElInput
<ElFormItem
v-if="shipAssetType === 'iot_card'"
label="新资产标识符"
prop="new_identifier"
>
<ElSelect
v-model="shipForm.new_identifier"
placeholder="请输入新资产标识符(ICCID/虚拟号/IMEI/SN)"
/>
filterable
remote
reserve-keyword
placeholder="请输入ICCID搜索"
:remote-method="searchShipNewIotCards"
:loading="newCardSearchLoading"
style="width: 100%"
clearable
>
<ElOption
v-for="card in newIotCardOptions"
:key="card.id"
:label="`${card.iccid} (${card.msisdn || '无接入号'})`"
:value="card.iccid"
>
<div style="display: flex; justify-content: space-between">
<span>{{ card.iccid }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ card.msisdn || '无接入号' }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem
v-else-if="shipAssetType === 'device'"
label="新资产标识符"
prop="new_identifier"
>
<ElSelect
v-model="shipForm.new_identifier"
filterable
remote
reserve-keyword
placeholder="请输入虚拟号或IMEI搜索"
:remote-method="searchShipNewDevices"
:loading="newDeviceSearchLoading"
style="width: 100%"
clearable
>
<ElOption
v-for="device in newDeviceOptions"
:key="device.id"
:label="`${device.virtual_no} (${device.device_name || device.imei})`"
:value="device.virtual_no"
>
<div style="display: flex; justify-content: space-between">
<span>{{ device.virtual_no }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ device.device_name || device.imei }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="shipInheritedShopDisplayText" label="归属提示">
<div class="exchange-shop-hint">{{ shipInheritedShopDisplayText }}</div>
</ElFormItem>
<ElFormItem label="快递公司" prop="express_company">
<ElInput v-model="shipForm.express_company" placeholder="请输入快递公司" />
@@ -289,7 +356,7 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="shipDialogVisible = false">取消</ElButton>
<ElButton @click="shipDialogVisible = false" :disabled="shipLoading">取消</ElButton>
<ElButton type="primary" @click="handleConfirmShip" :loading="shipLoading">
确认发货
</ElButton>
@@ -411,6 +478,7 @@
const newCardSearchLoading = ref(false)
const newDeviceOptions = ref<Device[]>([])
const newDeviceSearchLoading = ref(false)
const completeLoadingId = ref<number | null>(null)
// 发货表单
const shipForm = reactive({
@@ -426,6 +494,88 @@
express_no: [{ required: true, message: '请输入快递单号', trigger: 'blur' }]
})
type ShopSelectableOption = {
shop_id?: number | null
shop_name?: string | null
}
const getShopDisplayName = (option?: ShopSelectableOption | null) => {
if (!option) return ''
if (option.shop_name) return option.shop_name
if (option.shop_id === null || option.shop_id === 0) return '平台'
return ''
}
const createSelectedOldAsset = computed<ShopSelectableOption | null>(() => {
if (!createForm.old_identifier) return null
if (createForm.old_asset_type === 'iot_card') {
return (
oldIotCardOptions.value.find((card) => card.iccid === createForm.old_identifier) || null
)
}
if (createForm.old_asset_type === 'device') {
return (
oldDeviceOptions.value.find((device) => device.virtual_no === createForm.old_identifier) ||
null
)
}
return null
})
const createSelectedNewAsset = computed<ShopSelectableOption | null>(() => {
if (!createForm.new_identifier) return null
if (createForm.old_asset_type === 'iot_card') {
return (
newIotCardOptions.value.find((card) => card.iccid === createForm.new_identifier) || null
)
}
if (createForm.old_asset_type === 'device') {
return (
newDeviceOptions.value.find((device) => device.virtual_no === createForm.new_identifier) ||
null
)
}
return null
})
const shipAssetType = computed(() => currentExchangeRow.value?.old_asset_type || '')
const shipSelectedNewAsset = computed<ShopSelectableOption | null>(() => {
if (!shipForm.new_identifier) return null
if (shipAssetType.value === 'iot_card') {
return newIotCardOptions.value.find((card) => card.iccid === shipForm.new_identifier) || null
}
if (shipAssetType.value === 'device') {
return (
newDeviceOptions.value.find((device) => device.virtual_no === shipForm.new_identifier) ||
null
)
}
return null
})
const createOldAssetShopDisplay = computed(() => getShopDisplayName(createSelectedOldAsset.value))
const createDirectInheritedShopHint = computed(() => {
const shopName = getShopDisplayName(createSelectedNewAsset.value)
return shopName ? `换货完成后将归属:${shopName}` : ''
})
const shipInheritedShopDisplayText = computed(() => {
const shopName = getShopDisplayName(shipSelectedNewAsset.value)
return shopName ? `换货完成后将归属:${shopName}` : ''
})
// 分页
const pagination = reactive({
page: 1,
@@ -732,11 +882,25 @@
}
}
const refreshExchangeDetail = async (id: number) => {
try {
const res = await ExchangeService.getExchangeDetail(id)
if (res.code === 0 && res.data) {
return res.data
}
} catch (error) {
console.error('刷新换货详情失败:', error)
}
return null
}
// 加载默认IoT卡列表
const loadDefaultIotCards = async () => {
oldCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
page: 1,
page_size: 10
})
@@ -787,6 +951,7 @@
oldCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
keyword: query,
page: 1,
page_size: 20
@@ -845,6 +1010,36 @@
newCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
keyword: query,
page: 1,
page_size: 20
})
if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || []
if (newIotCardOptions.value.length === 0) {
ElMessage.info('未找到匹配的IoT卡')
}
}
} catch (error) {
console.error('搜索IoT卡失败:', error)
newIotCardOptions.value = []
} finally {
newCardSearchLoading.value = false
}
}
const searchShipNewIotCards = async (query: string) => {
if (!query) {
await loadDefaultShipNewIotCards()
return
}
newCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
keyword: query,
page: 1,
page_size: 20
@@ -868,6 +1063,27 @@
newCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
page: 1,
page_size: 10
})
if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || []
}
} catch (error) {
console.error('加载IoT卡列表失败:', error)
newIotCardOptions.value = []
} finally {
newCardSearchLoading.value = false
}
}
const loadDefaultShipNewIotCards = async () => {
newCardSearchLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
page: 1,
page_size: 10
})
@@ -911,6 +1127,34 @@
}
}
const searchShipNewDevices = async (query: string) => {
if (!query) {
await loadDefaultShipNewDevices()
return
}
newDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
keyword: query,
page: 1,
page_size: 20
})
if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || []
if (newDeviceOptions.value.length === 0) {
ElMessage.info('未找到匹配的设备')
}
}
} catch (error) {
console.error('搜索设备失败:', error)
newDeviceOptions.value = []
} finally {
newDeviceSearchLoading.value = false
}
}
// 加载默认新设备列表
const loadDefaultNewDevices = async () => {
newDeviceSearchLoading.value = true
@@ -930,10 +1174,29 @@
}
}
const loadDefaultShipNewDevices = async () => {
newDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
page: 1,
page_size: 10
})
if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || []
}
} catch (error) {
console.error('加载设备列表失败:', error)
newDeviceOptions.value = []
} finally {
newDeviceSearchLoading.value = false
}
}
// 确认创建
const handleConfirmCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
if (!valid || createLoading.value) return
createLoading.value = true
try {
@@ -961,7 +1224,10 @@
ElMessage.success('换货单创建成功')
createDialogVisible.value = false
await loadExchangeList()
return
}
ElMessage.error(res.msg || '换货单创建失败')
} catch (error) {
console.error('创建换货单失败:', error)
} finally {
@@ -1081,29 +1347,44 @@
}
// 发货
const handleShipExchange = (row: ExchangeResponse) => {
const handleShipExchange = async (row: ExchangeResponse) => {
currentExchangeRow.value = row
// 重置表单
shipForm.new_identifier = ''
shipForm.express_company = ''
shipForm.express_no = ''
shipForm.migrate_data = true
newIotCardOptions.value = []
newDeviceOptions.value = []
shipDialogVisible.value = true
if (row.old_asset_type === 'iot_card') {
await loadDefaultShipNewIotCards()
} else if (row.old_asset_type === 'device') {
await loadDefaultShipNewDevices()
}
}
// 确认发货
const handleConfirmShip = () => {
shipFormRef.value?.validate(async (valid) => {
if (!valid || !currentExchangeRow.value) return
if (!valid || !currentExchangeRow.value || shipLoading.value) return
shipLoading.value = true
try {
const res = await ExchangeService.shipExchange(currentExchangeRow.value.id, shipForm)
if (res.code === 0) {
const latestDetail = await refreshExchangeDetail(currentExchangeRow.value.id)
if (latestDetail) {
currentExchangeRow.value = latestDetail
}
ElMessage.success('发货成功')
shipDialogVisible.value = false
await loadExchangeList()
return
}
ElMessage.error(res.msg || '发货失败')
} catch (error) {
console.error('发货失败:', error)
} finally {
@@ -1115,25 +1396,52 @@
// 关闭发货对话框
const handleCloseShipDialog = () => {
shipFormRef.value?.resetFields()
currentExchangeRow.value = null
newIotCardOptions.value = []
newDeviceOptions.value = []
}
// 确认完成
const handleCompleteExchange = (row: ExchangeResponse) => {
if (completeLoadingId.value === row.id) return
completeLoadingId.value = row.id
ElMessageBox.confirm('确定要确认换货完成吗?', '确认操作', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
type: 'warning',
beforeClose: async (action, instance, done) => {
if (action !== 'confirm') {
done()
return
}
instance.confirmButtonLoading = true
instance.confirmButtonText = '提交中...'
try {
const res = await ExchangeService.completeExchange(row.id)
if (res.code === 0) {
ElMessage.success('操作成功')
await refreshExchangeDetail(row.id)
await loadExchangeList()
ElMessage.success('操作成功')
done()
return
}
ElMessage.error(res.msg || '确认完成失败')
} catch (error) {
console.error('确认完成失败:', error)
} finally {
instance.confirmButtonLoading = false
instance.confirmButtonText = '确定'
}
}
})
.then(() => {})
.finally(() => {
completeLoadingId.value = null
})
.catch(() => {})
}
@@ -1168,5 +1476,15 @@
<style lang="scss" scoped>
.exchange-management-page {
height: 100%;
.exchange-shop-hint {
width: 100%;
padding: 10px 12px;
line-height: 1.5;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 6px;
}
}
</style>

View File

@@ -550,7 +550,9 @@
{ label: '店铺名称', prop: 'shop_name' },
{ label: '用户名', prop: 'username' },
{ label: '手机号', prop: 'phone' },
{ label: '预充值余额', prop: 'main_balance' },
{ label: '现金余额', prop: 'balance' },
{ label: '冻结金额', prop: 'frozen_balance' },
{ label: '余额预警', prop: 'low_balance_warning' },
{ label: '总佣金', prop: 'total_commission' },
{ label: '可提现', prop: 'available_commission' },
{ label: '冻结中', prop: 'frozen_commission' },
@@ -637,17 +639,33 @@
width: 120
},
{
prop: 'main_balance',
label: '预充值余额',
prop: 'balance',
label: '现金余额',
minWidth: 130,
formatter: (row: ShopFundSummaryItem) => {
return h(
'span',
{ style: 'color: var(--el-color-primary); font-weight: 500' },
formatMoney(row.main_balance)
formatMoney(row.balance)
)
}
},
{
prop: 'frozen_balance',
label: '冻结金额',
minWidth: 120,
formatter: (row: ShopFundSummaryItem) => formatMoney(row.frozen_balance)
},
{
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: '总佣金',

View File

@@ -126,6 +126,37 @@
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="平台业务员">
<ElSelect
v-model="formData.business_owner_account_id"
filterable
remote
clearable
placeholder="请选择或搜索平台业务员"
:remote-method="searchPlatformSalespeople"
:loading="salespersonLoading"
style="width: 100%"
>
<ElOption
v-for="salesperson in salespersonOptions"
:key="salesperson.account_id"
:label="salesperson.account_name"
:value="salesperson.account_id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ salesperson.account_name }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ salesperson.phone_masked }}
</span>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<!-- 新增店铺时的初始账号信息 -->
<template v-if="dialogType === 'add'">
<div class="form-section-title">
@@ -207,6 +238,16 @@
</template>
</ElDialog>
<ElDialog v-model="detailDialogVisible" title="店铺详情" width="500px">
<ElDescriptions v-if="detailShop" :column="1" border>
<ElDescriptionsItem label="店铺名称">{{ detailShop.shop_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺编号">{{ detailShop.shop_code }}</ElDescriptionsItem>
<ElDescriptionsItem label="平台业务员">
{{ formatBusinessOwner(detailShop) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
<!-- 店铺默认角色管理对话框 -->
<ElDialog
v-model="defaultRolesDialogVisible"
@@ -287,10 +328,10 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { generateShopCode } from '@/utils/codeGenerator'
import { ShopService, RoleService } from '@/api/modules'
import { ShopService, RoleService, AccountService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
import type { ShopResponse, ShopRoleResponse } from '@/types/api'
import { RoleType, RoleStatus } from '@/types/api'
import type { PlatformSalesperson, ShopResponse, ShopRoleResponse } from '@/types/api'
import { AccountStatus, RoleType, RoleStatus } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants'
import { regionData } from '@/utils/constants/regionData'
@@ -307,6 +348,10 @@
const submitLoading = ref(false)
const defaultRoleLoading = ref(false)
const defaultRoleList = ref<any[]>([])
const salespersonLoading = ref(false)
const salespersonOptions = ref<PlatformSalesperson[]>([])
const detailDialogVisible = ref(false)
const detailShop = ref<ShopResponse | null>(null)
// 级联选择相关
const parentShopCascadeOptions = ref<any[]>([])
@@ -370,6 +415,39 @@
}
}
const searchPlatformSalespeople = async (query: string) => {
salespersonLoading.value = true
try {
const res = await AccountService.getPlatformSalespeople({
page: 1,
page_size: 20,
account_type: 'platform',
status: AccountStatus.ENABLED,
account_name: query || undefined
})
if (res.code === 0) {
salespersonOptions.value = res.data.items || []
}
} catch (error) {
console.error('获取平台业务员列表失败:', error)
salespersonOptions.value = []
} finally {
salespersonLoading.value = false
}
}
const formatBusinessOwner = (shop: ShopResponse) => {
if (!shop.business_owner_name) return '-'
return shop.business_owner_phone_masked
? `${shop.business_owner_name} (${shop.business_owner_phone_masked})`
: shop.business_owner_name
}
const showDetail = (row: ShopResponse) => {
detailShop.value = row
detailDialogVisible.value = true
}
// 定义表单搜索初始值
const initialSearchState = {
shop_name: '',
@@ -377,7 +455,8 @@
parent_shop_name: '',
parent_id: undefined as number | undefined,
level: undefined as number | undefined,
status: undefined as number | undefined
status: undefined as number | undefined,
business_owner_account_id: undefined as number | undefined
}
// 响应式表单数据
@@ -457,6 +536,23 @@
clearable: true,
placeholder: '请选择状态'
}
},
{
label: '平台业务员',
prop: 'business_owner_account_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => searchPlatformSalespeople(query),
placeholder: '请选择或搜索平台业务员'
},
options: () =>
salespersonOptions.value.map((salesperson) => ({
label: `${salesperson.account_name} (${salesperson.phone_masked})`,
value: salesperson.account_id
}))
}
])
@@ -468,6 +564,7 @@
{ label: '所在地区', prop: 'region' },
{ label: '联系人', prop: 'contact_name' },
{ label: '联系电话', prop: 'contact_phone' },
{ label: '平台业务员', prop: 'business_owner_name' },
...(canModifyShopStatus ? [{ label: '状态', prop: 'status' }] : []),
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
@@ -497,6 +594,7 @@
formData.address = row.address || ''
formData.contact_name = row.contact_name || ''
formData.contact_phone = row.contact_phone || ''
formData.business_owner_account_id = row.business_owner_account_id ?? null
formData.status = row.status
formData.init_username = ''
formData.init_password = ''
@@ -514,6 +612,7 @@
formData.address = ''
formData.contact_name = ''
formData.contact_phone = ''
formData.business_owner_account_id = null
formData.status = CommonStatus.ENABLED
formData.init_username = ''
formData.init_password = ''
@@ -597,6 +696,13 @@
label: '联系电话',
width: 130
},
{
prop: 'business_owner_name',
label: '平台业务员',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ShopResponse) => formatBusinessOwner(row)
},
...(canModifyShopStatus
? [
{
@@ -630,6 +736,12 @@
const getActions = (row: ShopResponse) => {
const actions: any[] = []
actions.push({
label: '详情',
handler: () => showDetail(row),
type: 'primary'
})
if (hasAuth('shop:look_customer')) {
actions.push({
label: '账号列表',
@@ -685,7 +797,8 @@
init_username: '',
init_password: '',
init_phone: '',
default_role_id: undefined as number | undefined
default_role_id: undefined as number | undefined,
business_owner_account_id: null as number | null
})
// 处理编码生成
@@ -766,6 +879,7 @@
loadTopLevelShops() // 加载顶级店铺用于级联选择
searchDefaultRoles('') // 加载初始默认角色列表
searchParentShops('') // 加载上级店铺选项
searchPlatformSalespeople('') // 加载启用的平台业务员选项
})
// 获取店铺列表
@@ -780,7 +894,8 @@
parent_shop_name: searchForm.parent_shop_name || undefined,
parent_id: searchForm.parent_id,
level: searchForm.level,
status: searchForm.status
status: searchForm.status,
business_owner_account_id: searchForm.business_owner_account_id
}
const res = await ShopService.getShops(params)
if (res.code === 0) {
@@ -850,7 +965,8 @@
init_username: formData.init_username,
init_password: formData.init_password,
init_phone: formData.init_phone,
default_role_id: formData.default_role_id
default_role_id: formData.default_role_id,
business_owner_account_id: formData.business_owner_account_id
}
// 可选字段 - parent_id 可能是数组(级联选择器)或数字
@@ -871,7 +987,8 @@
} else {
const data: any = {
shop_name: formData.shop_name,
status: formData.status
status: formData.status,
business_owner_account_id: formData.business_owner_account_id
}
// 可选字段