Compare commits

..

21 Commits

Author SHA1 Message Date
luo
d115363115 fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 59s
2026-07-03 10:50:40 +08:00
luo
808e7e40dd fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 57s
2026-07-03 10:43:14 +08:00
luo
6d324214c8 fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 58s
2026-07-03 10:39:17 +08:00
luo
2352419dfd fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 59s
2026-07-03 10:36:02 +08:00
luo
0930d7b49a fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m24s
2026-07-03 10:28:05 +08:00
luo
7721539306 fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 57s
2026-07-03 10:25:09 +08:00
luo
6b6cc4ca9f fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 57s
2026-07-03 10:22:43 +08:00
luo
8cb820c9f9 fix: h5
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m0s
2026-07-03 10:20:09 +08:00
luo
1f058b3f4d fix: bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 59s
2026-07-03 10:13:54 +08:00
luo
ced2344d14 fix: bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m28s
2026-07-03 09:49:51 +08:00
luo
aca24f734f fix: bug
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 4s
2026-07-03 09:47:51 +08:00
luo
95d3df5803 fix: bug
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 8s
2026-07-03 09:27:24 +08:00
sexygoat
4226824be9 fix: 单卡设备显示字段
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m6s
2026-05-21 14:43:11 +08:00
sexygoat
3e42c4e98c feat: 资产管理新增字段
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m47s
2026-05-20 11:06:57 +08:00
sexygoat
f9fe76ff99 fix:bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m5s
2026-05-14 09:50:34 +08:00
sexygoat
7f15843da0 fix: bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 1m29s
2026-05-11 15:22:23 +08:00
sexygoat
08f70dccf4 fix: bug
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 4s
2026-05-11 15:17:27 +08:00
sexygoat
1304c93af3 fix: bug
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-05-11 15:15:14 +08:00
sexygoat
52311fae0a fix: bug
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Has been cancelled
2026-05-11 15:14:38 +08:00
sexygoat
28246f5345 fix: 优化体验
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 3h10m20s
2026-05-09 16:13:39 +08:00
sexygoat
b54d8f5b32 fix: 流量保留两位小数 2026-05-08 14:54:36 +08:00
68 changed files with 4560 additions and 4725 deletions

18
AGENTS.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

View File

@@ -10,7 +10,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
WORKDIR /build
# 安装 pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
# 设置 npm 镜像源
RUN pnpm config set registry https://registry.npmmirror.com
@@ -24,8 +24,8 @@ RUN pnpm install --frozen-lockfile
# 复制源代码
COPY . .
# 构建 H5 生产版本
RUN pnpm build:h5-prod
# 构建 H5 测试版本
RUN pnpm build:h5-test
# ================================
# 阶段 2: 运行阶段

View File

@@ -24,7 +24,7 @@ pnpm dev:h5
http://localhost:5173/
```
**默认入口页面**: `pages/agent-system/index`
**默认入口页面**: `pages/agent-system/home/index`
---
@@ -85,7 +85,10 @@ http://localhost:5173/
```
src/pages/agent-system/
── index.vue # 主页面 (所有功能都在这里)
── home/index.vue # 当前首页入口
├── commission-center/index.vue # 佣金中心
├── withdraw/index.vue # 提现管理
└── ... # 其他独立业务页面
.claude/
├── AGENT-SYSTEM-DESIGN.md # 完整设计规范 (50+ 页)
@@ -135,7 +138,7 @@ src/pages/agent-system/
### 添加新菜单项
修改 `src/pages/agent-system/index.vue`:
当前实现已拆分为多页面,请修改对应业务页,例如 `src/pages/agent-system/home/index.vue`:
```vue
<script setup>

View File

@@ -1,5 +1,5 @@
const fs = require('fs')
const content = fs.readFileSync('src/pages/agent-system/asset-search/index.vue', 'utf8')
const content = fs.readFileSync('src/pages/agent-system/asset-detail/index.vue', 'utf8')
const lines = content.split('\n')
let depth = 0

View File

@@ -1,5 +1,5 @@
server {
server_name cmp-api.boss160.cn;
server_name cmp-api.xm-iot.cn;
location / {
proxy_pass http://127.0.0.1:3000;
@@ -26,8 +26,8 @@ server {
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/cmp-api.boss160.cn/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/cmp-api.boss160.cn/privkey.pem; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/cmp-api.xm-iot.cn/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/cmp-api.xm-iot.cn/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
@@ -171,12 +171,12 @@ server {
}
server {
if ($host = cmp-api.boss160.cn) {
if ($host = cmp-api.xm-iot.cn) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name cmp-api.boss160.cn;
server_name cmp-api.xm-iot.cn;
listen 80;
return 404; # managed by Certbot

View File

@@ -18,7 +18,11 @@ server {
}
location /h5/ {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /h5/index.html;
}
location = /h5 {
return 301 /h5/;
}
location = / {
@@ -30,6 +34,4 @@ server {
add_header Cache-Control "public, immutable";
access_log off;
}
error_page 404 /index.html;
}

View File

@@ -0,0 +1,207 @@
# 代理首页佣金展示 Spec
## 1. 背景
代理首页当前已经会请求 `getFundSummary()`,但佣金区域使用了 `v-if="isAgent && commissionSummary"`
这会带来两个问题:
- 只要 `commissionSummary` 为空,首页代理佣金模块就整块不显示
- 首页顶部还保留了“佣金概览”标题文案,这个文案本次不再需要
本次需求明确为:
- 首页里去掉“佣金概览”这个标题文案
- 只要当前账号是代理账号,就显示首页代理佣金区域
- 不管接口有没有值,模块都要显示
---
## 2. 目标
- 首页代理佣金区域的外层显示条件改为仅判断 `isAgent`
- 首页不再展示“佣金概览”标题
- 当佣金概览数据未返回、为空或请求失败时,佣金卡片仍然保留,金额位置显示占位内容
---
## 3. 范围
### 页面
- `src/pages/agent-system/home/index.vue`
### 用户
- 代理账号显示:`user_type === 3`
- 企业账号不显示:`user_type === 4`
---
## 4. 数据来源与约束
### 数据来源
- 接口:`GET /api/admin/shops/fund-summary`
- 方法:`getFundSummary()`
- 现有状态:`commissionSummary`
### 关键字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `available_commission` | number | 可提现佣金,单位分 |
| `total_commission` | number | 累计佣金,单位分 |
| `withdrawn_commission` | number | 已提现佣金,单位分 |
| `frozen_commission` | number | 冻结佣金,单位分 |
### 约束
1. 首页不得新增新的佣金概览接口请求
2. 首页直接复用现有 `commissionSummary`
3. 本次不改后端接口、不改接口字段
---
## 5. 展示规则
### 5.1 外层显示条件
首页代理佣金区域显示条件改为:
- `isAgent === true`
不再要求:
- `commissionSummary` 必须有值
### 5.2 标题文案
首页移除“佣金概览”标题文案,包括:
- 不再在卡片上方展示“佣金概览”
- 保留卡片内部的“可提现佣金”主标题
### 5.3 金额展示
佣金卡片继续展示以下内容:
- `可提现佣金`
- `累计佣金`
- `已提现`
- `冻结`
展示规则:
- 有值时,按现有金额格式化规则显示
- 无值时,显示 `--`
- 不允许因为空值显示错误金额
### 5.4 今日佣金区域
“今日佣金”区域继续跟随代理佣金模块一起显示。
展示规则保持现状:
- 日期无值显示 `--`
- 佣金金额无值显示 `¥0.00`
- 订单笔数无值显示 `0`
- 无当日数据时显示“今日暂无佣金数据”
---
## 6. 交互与状态
### 6.1 跳转
点击佣金卡片后跳转到:
- `/pages/agent-system/commission-center/index`
### 6.2 加载态
`commissionSummary` 还未返回时:
- 佣金卡片仍然显示
- 金额区域显示 `--`
### 6.3 异常态
`getFundSummary()` 请求失败时:
- 佣金卡片仍然显示
- 金额区域显示 `--`
- 不影响首页其他模块渲染
---
## 7. 非目标
- 不修改佣金中心页面结构
- 不修改提现管理页面结构
- 不新增首页专用佣金接口
- 不修改企业账号首页展示逻辑
---
## 8. 验收标准
### 用例 1代理账号且接口有值
前置条件:
- `user_type === 3`
- `commissionSummary.available_commission = 85000`
期望:
- 首页显示代理佣金卡片
- 首页不显示“佣金概览”标题
- 主金额显示 `¥850.00`
### 用例 2代理账号但接口无值
前置条件:
- `user_type === 3`
- `commissionSummary = null`
期望:
- 首页仍显示代理佣金卡片
- `可提现佣金 / 累计佣金 / 已提现 / 冻结` 均显示 `--`
- 今日佣金区域仍显示
### 用例 3代理账号且接口失败
前置条件:
- `user_type === 3`
- `getFundSummary()` 请求失败
期望:
- 首页仍显示代理佣金卡片
- 金额不显示错误数据
- 页面其他内容正常渲染
### 用例 4企业账号
前置条件:
- `user_type === 4`
期望:
- 首页不显示代理佣金区域
### 用例 5不重复请求
前置条件:
- 首页已执行 `getFundSummary()`
期望:
- 不因本次需求新增第二次佣金接口请求
- 页面继续复用 `commissionSummary`

View File

@@ -132,7 +132,7 @@ POST /api/admin/assets/device/12345/stop
## 服务器地址
- **测试环境**https://cmp-api.boss160.cn
- **测试环境**https://cmp-api.xm-iot.cn
# 设备复机
@@ -232,7 +232,7 @@ POST /api/admin/assets/device/12345/start
## 服务器地址
- **测试环境**https://cmp-api.boss160.cn
- **测试环境**https://cmp-api.xm-iot.cn
# 单卡停机
@@ -332,7 +332,7 @@ POST /api/admin/assets/card/89860123456789012345/stop
## 服务器地址
- **测试环境**https://cmp-api.boss160.cn
- **测试环境**https://cmp-api.xm-iot.cn
# 单卡复机
@@ -424,4 +424,4 @@ POST /api/admin/assets/card/89860123456789012345/start
## 服务器地址
- **测试环境**https://cmp-api.boss160.cn
- **测试环境**https://cmp-api.xm-iot.cn

21
docs/权限按钮.md Normal file
View File

@@ -0,0 +1,21 @@
/api/auth/login 登录接口返回了权限按钮
user:{
"id": 144,
"username": "MLLxp",
"phone": "15571055000",
"user_type": 3,
"user_type_name": "代理账号",
"shop_id": 1
},
"buttons": [
"device:clear_series",
]
这个地方有返回按钮的权限
<!-- IoT卡操作按钮 -->
<!-- 设备操作按钮 -->
资产列表里面要加这个权限编码,然后判断按钮是否显示

View File

@@ -195,7 +195,7 @@ GET /api/admin/assets/card/12345/packages?page=1&page_size=50
## 服务器地址
- **测试环境**https://cmp-api.boss160.cn
- **测试环境**https://cmp-api.xm-iot.cn
# 当前生效套餐
@@ -330,4 +330,4 @@ GET /api/admin/assets/card/12345/current-package
## 服务器地址
- **测试环境**https://cmp-api.boss160.cn
- **测试环境**https://cmp-api.xm-iot.cn

View File

@@ -0,0 +1,60 @@
1. 资产列表页面的IoT卡, 响应新增了返回了一个device_virtual_no这个字段
如果这个字段为空显示的就是未绑定设备, 如果是有值就显示已绑定设备
2.资产列表里面加一个店铺联级查询条件
/api/admin/iot-cards/standalone?shop_id=xxxx
# 店铺联级查询
## 基本信息
| 项目 | 内容 |
|------|------|
| 接口地址 | `GET /api/admin/shops/cascade` |
| 认证方式 | Bearer Token (JWT) |
| 所属模块 | 店铺管理 |
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `shop_name` | string | 否 | 店铺名称(模糊查询),最大长度 100 |
| `parent_id` | integer | 否 | 上级店铺 ID不传则查询顶级店铺 |
## 响应参数
### 成功响应200
{
"code": 0,
"data": [
{
"id": 1,
"shop_name": "总店",
"has_children": true
},
{
"id": 2,
"shop_name": "分店 A",
"has_children": false
}
],
"msg": "success",
"timestamp": "2026-05-09T14:28:00Z"
}
#### DtoShopCascadeItem
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | integer | 店铺 ID |
| `shop_name` | string | 店铺名称 |
| `has_children` | boolean | 是否有下级店铺 |
3. 资产列表是设备的情况下让默认选择的搜索类型是虚拟号, 并把这个虚拟号改成设备号, 然后那个选择搜索类型的不要了,如果是IoT卡的时候就是ICCID, 也不要那个选择搜索类型去掉
4. 在资产详情里面制造商,批次号字段去掉, 然后绑定的IoT卡里面电话改成MSISDN, 实名改显眼一点, 加一个运营商(carrier_name)返回里面有返回这个字段
5. 设备实时状态里面的下载流量,上传流量去掉.
6. 套餐列表里面的生肖中的那xxx至xxx 时间要明显一点
7. 创建时间和网关同步删掉, 把最后在线改成最后上线时间放到设备实时状态里面

View File

@@ -0,0 +1,227 @@
# 资产详情流量展示 Spec
## 1. 背景
原始说明见 `docs/资产详情流量.md`
本次需要统一资产详情中套餐流量的展示口径,避免“当前生效套餐”和“套餐列表”出现字段选择不一致、剩余流量计算不一致的问题。
本 Spec 仅定义前端展示和计算规则,不涉及后端接口改造。
---
## 2. 适用范围
### 页面范围
- 资产详情页:`src/pages/agent-system/asset-detail/index.vue`
### 联动范围
- 资产详情页中的同类套餐展示:`src/pages/agent-system/asset-detail/index.vue`
### 本次覆盖的 UI 区块
- 当前生效套餐卡片
- 套餐列表中的流量摘要
- 套餐进度条文案与进度百分比
### 不在本次范围内
- 后端接口字段定义
- 其他页面中与套餐无关的流量汇总展示
---
## 3. 数据来源
### 当前生效套餐
- 接口:`GET /api/admin/assets/:identifier/current-package`
### 套餐列表
- 接口:`GET /api/admin/assets/:identifier/packages`
### 本次展示使用的关键字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `real_total_mb` | number | 真流量总量 |
| `real_used_mb` | number | 真流量已使用 |
| `virtual_used_mb` | number | 虚流量已使用 |
| `enable_virtual_data` | boolean | 是否按虚流量口径展示已使用 |
| `paid_amount` | number | 套餐金额,单位分 |
说明:
- 本次总流量固定使用 `real_total_mb`
- 本次已使用根据 `enable_virtual_data``virtual_used_mb``real_used_mb` 间切换
- 本次剩余流量以前端计算结果为准,不直接取其他剩余字段
---
## 4. 统一展示口径
| 展示项 | 计算规则 | 备注 |
|------|------|------|
| 总流量 | `real_total_mb` | 固定使用该字段 |
| 已使用 | `enable_virtual_data === true ? virtual_used_mb : real_used_mb` | 仅做字段切换,不做额外换算 |
| 剩余 | `max(总流量 - 已使用, 0)` | 与页面展示的“已使用”保持同口径 |
| 进度百分比 | `total <= 0 ? 0 : min(已使用 / 总流量 * 100, 100)` | 进度条与文案必须一致 |
### 明确约束
1. 不使用 `virtual_total_mb` 作为“总流量”展示值。
2. 不使用 `real_used_mb` 单独计算“剩余”,必须使用页面实际展示的“已使用”值参与计算。
3. 不因为字段名差异而在前端自行做虚实流量换算。
---
## 5. 页面展示要求
### 5.1 当前生效套餐
当前卡片中的以下区域必须使用统一口径:
- `总量`:显示 `real_total_mb`
- `已使用`:根据 `enable_virtual_data` 切换展示
- `剩余`:使用 `总量 - 已使用`
- 进度条文案:`已使用 / 总量`
- 进度条宽度:使用同一组 `已使用``总量` 计算
- `总量``已使用``剩余` 的展示值统一保留两位小数
### 5.2 套餐列表
套餐列表每一项中当前展示的 `已用 / 总量` 必须使用相同口径:
- `已用`:根据 `enable_virtual_data` 切换
- `总量`:固定使用 `real_total_mb`
- `剩余`:显示 `总量 - 已使用`
- `金额`:显示 `paid_amount / 100` 后的元金额
- `总量``已用``剩余` 的展示值统一保留两位小数
如果后续套餐列表新增“剩余”字段,必须沿用本 Spec 中的剩余计算规则,不得单独定义。
---
## 6. 边界处理
### 空值处理
- `null``undefined``NaN``0` 处理
### 超限处理
-`已使用 > 总流量` 时:
- `剩余` 显示 `0`
- 进度条按 `100%` 展示
### 总量为 0
- `总量 = 0` 时:
- `已使用` 按规则正常显示
- `剩余` 显示 `0`
- 进度条显示 `0%`
---
## 7. 实现要求
### 统一计算入口
建议统一封装以下计算函数,并在相关页面复用:
- `getPackageTotalMb`
- `getPackageUsedMb`
- `getPackageRemainMb`
- `getPackageUsagePercent`
### 关键实现要求
- `getPackageRemainMb` 必须改为基于“当前展示的已使用值”计算
- `asset-detail` 页面内不同区块不允许维护不同口径的同名逻辑
- 格式化继续沿用现有 `formatDataSize`,本次不单独引入新的单位换算规则
---
## 8. 验收标准
### 验收用例 1`enable_virtual_data = true`
输入:
```json
{
"real_total_mb": 1024000,
"real_used_mb": 7848,
"virtual_used_mb": 11211.425208,
"enable_virtual_data": true
}
```
期望:
- 总流量 = `1024000`
- 已使用 = `11211.425208`
- 剩余 = `1012788.574792`
- 进度百分比约 = `1.09%`
格式化后参考展示:
- 总量:`1000.00GB`
- 已使用:`10.95GB`
- 剩余:`989.05GB`
### 验收用例 2`enable_virtual_data = false`
输入:
```json
{
"real_total_mb": 1024000,
"real_used_mb": 7848,
"virtual_used_mb": 11211.425208,
"enable_virtual_data": false
}
```
期望:
- 总流量 = `1024000`
- 已使用 = `7848`
- 剩余 = `1016152`
### 验收用例 3超限
输入:
```json
{
"real_total_mb": 100,
"real_used_mb": 120,
"virtual_used_mb": 120,
"enable_virtual_data": false
}
```
期望:
- 已使用 = `120`
- 剩余 = `0`
- 进度条 = `100%`
### 验收用例 4跨页面一致性
- 同一套餐数据在 `asset-detail` 页面各区块中的展示结果一致
- 当前生效套餐卡片与套餐列表摘要使用同一套字段口径
---
## 9. 结论
本次变更的核心是统一三件事:
1. 总流量固定取 `real_total_mb`
2. 已使用根据 `enable_virtual_data` 选择 `virtual_used_mb``real_used_mb`
3. 剩余必须使用 `总流量 - 已使用` 计算,不能再固定减 `real_used_mb`

View File

@@ -0,0 +1,34 @@
/api/admin/assets/862639075995821/current-package
响应:
{
"code": 0,
"data": {
"package_usage_id": 2,
"package_id": 2,
"package_name": "年套餐",
"package_type": "formal",
"order_id": 2,
"order_no": "ORD20260428105811360190",
"usage_type": "device",
"status": 1,
"status_name": "生效中",
"real_total_mb": 1024000,
"real_used_mb": 7848,
"virtual_total_mb": 716800,
"virtual_used_mb": 11211.425208,
"reduction_pct": 0.42857100000000004,
"enable_virtual_data": true,
"activated_at": "2026-04-28T10:58:11.377734+08:00",
"expires_at": "2027-04-30T23:59:59+08:00",
"priority": 1,
"paid_amount": 19900,
"created_at": "2026-04-28T10:58:11.554516+08:00"
},
"msg": "success",
"timestamp": "2026-05-08T10:00:55+08:00"
}
资产详情里面的,当前生效套餐和套餐列表里面的流量
总流量用的就是real_total_mb,
已使用: 如果 enable_virtual_data: true 显示:virtual_used_mb false:real_used_mb,
剩余: 总流量-已使用

2
env/.env.production vendored
View File

@@ -2,7 +2,7 @@
VITE_APP_ENV=production
# 接口地址
VITE_API_BASE_URL=https://cmp-api.boss160.cn
VITE_API_BASE_URL=https://cmp-api.xm-iot.cn
# 是否使用代理 (生产环境不使用代理)
VITE_APP_PROXY=false

456
openspec/AGENTS.md Normal file
View File

@@ -0,0 +1,456 @@
# OpenSpec Instructions
Instructions for AI coding assistants using OpenSpec for spec-driven development.
## TL;DR Quick Checklist
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
- Decide scope: new capability vs modify existing capability
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
- Validate: `openspec validate [change-id] --strict` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
### Stage 1: Creating Changes
Create proposal when you need to:
- Add features or functionality
- Make breaking changes (API, schema)
- Change architecture or patterns
- Optimize performance (changes behavior)
- Update security patterns
Triggers (examples):
- "Help me create a change proposal"
- "Help me plan a change"
- "Help me create a proposal"
- "I want to create a spec proposal"
- "I want to create a spec"
Loose matching guidance:
- Contains one of: `proposal`, `change`, `spec`
- With one of: `create`, `plan`, `make`, `start`, `help`
Skip proposal for:
- Bug fixes (restore intended behavior)
- Typos, formatting, comments
- Dependency updates (non-breaking)
- Configuration changes
- Tests for existing behavior
**Workflow**
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/``changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
- Run `openspec validate --strict` to confirm the archived change passes checks
## Before Any Task
**Context Checklist:**
- [ ] Read relevant specs in `specs/[capability]/spec.md`
- [ ] Check pending changes in `changes/` for conflicts
- [ ] Read `openspec/project.md` for conventions
- [ ] Run `openspec list` to see active changes
- [ ] Run `openspec list --specs` to see existing capabilities
**Before Creating Specs:**
- Always check if capability already exists
- Prefer modifying existing specs over creating duplicates
- Use `openspec show [spec]` to review current state
- If request is ambiguous, ask 12 clarifying questions before scaffolding
### Search Guidance
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
- Show details:
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
- Change: `openspec show <change-id> --json --deltas-only`
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
## Quick Start
### CLI Commands
```bash
# Essential commands
openspec list # List active changes
openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec validate [item] # Validate changes or specs
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
openspec update [path] # Update instruction files
# Interactive mode
openspec show # Prompts for selection
openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
```
### Command Flags
- `--json` - Machine-readable output
- `--type change|spec` - Disambiguate items
- `--strict` - Comprehensive validation
- `--no-interactive` - Disable prompts
- `--skip-specs` - Archive without spec updates
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
```
openspec/
├── project.md # Project conventions
├── specs/ # Current truth - what IS built
│ └── [capability]/ # Single focused capability
│ ├── spec.md # Requirements and scenarios
│ └── design.md # Technical patterns
├── changes/ # Proposals - what SHOULD change
│ ├── [change-name]/
│ │ ├── proposal.md # Why, what, impact
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional; see criteria)
│ │ └── specs/ # Delta changes
│ │ └── [capability]/
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
│ └── archive/ # Completed changes
```
## Creating Change Proposals
### Decision Tree
```
New request?
├─ Bug fix restoring spec behavior? → Fix directly
├─ Typo/format/comment? → Fix directly
├─ New feature/capability? → Create proposal
├─ Breaking change? → Create proposal
├─ Architecture change? → Create proposal
└─ Unclear? → Create proposal (safer)
```
### Proposal Structure
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
2. **Write proposal.md:**
```markdown
# Change: [Brief description of change]
## Why
[1-2 sentences on problem/opportunity]
## What Changes
- [Bullet list of changes]
- [Mark breaking changes with **BREAKING**]
## Impact
- Affected specs: [list capabilities]
- Affected code: [key files/systems]
```
3. **Create spec deltas:** `specs/[capability]/spec.md`
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL provide...
#### Scenario: Success case
- **WHEN** user performs action
- **THEN** expected result
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement]
## REMOVED Requirements
### Requirement: Old Feature
**Reason**: [Why removing]
**Migration**: [How to handle]
```
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
4. **Create tasks.md:**
```markdown
## 1. Implementation
- [ ] 1.1 Create database schema
- [ ] 1.2 Implement API endpoint
- [ ] 1.3 Add frontend component
- [ ] 1.4 Write tests
```
5. **Create design.md when needed:**
Create `design.md` if any of the following apply; otherwise omit it:
- Cross-cutting change (multiple services/modules) or a new architectural pattern
- New external dependency or significant data model changes
- Security, performance, or migration complexity
- Ambiguity that benefits from technical decisions before coding
Minimal `design.md` skeleton:
```markdown
## Context
[Background, constraints, stakeholders]
## Goals / Non-Goals
- Goals: [...]
- Non-Goals: [...]
## Decisions
- Decision: [What and why]
- Alternatives considered: [Options + rationale]
## Risks / Trade-offs
- [Risk] → Mitigation
## Migration Plan
[Steps, rollback]
## Open Questions
- [...]
```
## Spec File Format
### Critical: Scenario Formatting
**CORRECT** (use #### headers):
```markdown
#### Scenario: User login success
- **WHEN** valid credentials provided
- **THEN** return JWT token
```
**WRONG** (don't use bullets or bold):
```markdown
- **Scenario: User login** ❌
**Scenario**: User login ❌
### Scenario: User login ❌
```
Every requirement MUST have at least one scenario.
### Requirement Wording
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
### Delta Operations
- `## ADDED Requirements` - New capabilities
- `## MODIFIED Requirements` - Changed behavior
- `## REMOVED Requirements` - Deprecated features
- `## RENAMED Requirements` - Name changes
Headers matched with `trim(header)` - whitespace ignored.
#### When to use ADDED vs MODIFIED
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you arent explicitly changing the existing requirement, add a new requirement under ADDED instead.
Authoring a MODIFIED requirement correctly:
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
Example for RENAMED:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Login`
- TO: `### Requirement: User Authentication`
```
## Troubleshooting
### Common Errors
**"Change must have at least one delta"**
- Check `changes/[name]/specs/` exists with .md files
- Verify files have operation prefixes (## ADDED Requirements)
**"Requirement must have at least one scenario"**
- Check scenarios use `#### Scenario:` format (4 hashtags)
- Don't use bullet points or bold for scenario headers
**Silent scenario parsing failures**
- Exact format required: `#### Scenario: Name`
- Debug with: `openspec show [change] --json --deltas-only`
### Validation Tips
```bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
# Check specific requirement
openspec show [spec] --json -r 1
```
## Happy Path Script
```bash
# 1) Explore current state
openspec spec list --long
openspec list
# Optional full-text search:
# rg -n "Requirement:|Scenario:" openspec/specs
# rg -n "^#|Requirement:" openspec/changes
# 2) Choose change id and scaffold
CHANGE=add-two-factor-auth
mkdir -p openspec/changes/$CHANGE/{specs/auth}
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
# 3) Add deltas (example)
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
## ADDED Requirements
### Requirement: Two-Factor Authentication
Users MUST provide a second factor during login.
#### Scenario: OTP required
- **WHEN** valid credentials are provided
- **THEN** an OTP challenge is required
EOF
# 4) Validate
openspec validate $CHANGE --strict
```
## Multi-Capability Example
```
openspec/changes/add-2fa-notify/
├── proposal.md
├── tasks.md
└── specs/
├── auth/
│ └── spec.md # ADDED: Two-Factor Authentication
└── notifications/
└── spec.md # ADDED: OTP email notification
```
auth/spec.md
```markdown
## ADDED Requirements
### Requirement: Two-Factor Authentication
...
```
notifications/spec.md
```markdown
## ADDED Requirements
### Requirement: OTP Email Notification
...
```
## Best Practices
### Simplicity First
- Default to <100 lines of new code
- Single-file implementations until proven insufficient
- Avoid frameworks without clear justification
- Choose boring, proven patterns
### Complexity Triggers
Only add complexity with:
- Performance data showing current solution too slow
- Concrete scale requirements (>1000 users, >100MB data)
- Multiple proven use cases requiring abstraction
### Clear References
- Use `file.ts:42` format for code locations
- Reference specs as `specs/auth/spec.md`
- Link related changes and PRs
### Capability Naming
- Use verb-noun: `user-auth`, `payment-capture`
- Single purpose per capability
- 10-minute understandability rule
- Split if description needs "AND"
### Change ID Naming
- Use kebab-case, short and descriptive: `add-two-factor-auth`
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
## Tool Selection Guide
| Task | Tool | Why |
|------|------|-----|
| Find files by pattern | Glob | Fast pattern matching |
| Search code content | Grep | Optimized regex search |
| Read specific files | Read | Direct file access |
| Explore unknown scope | Task | Multi-step investigation |
## Error Recovery
### Change Conflicts
1. Run `openspec list` to see active changes
2. Check for overlapping specs
3. Coordinate with change owners
4. Consider combining proposals
### Validation Failures
1. Run with `--strict` flag
2. Check JSON output for details
3. Verify spec file format
4. Ensure scenarios properly formatted
### Missing Context
1. Read project.md first
2. Check related specs
3. Review recent archives
4. Ask for clarification
## Quick Reference
### Stage Indicators
- `changes/` - Proposed, not yet built
- `specs/` - Built and deployed
- `archive/` - Completed changes
### File Purposes
- `proposal.md` - Why and what
- `tasks.md` - Implementation steps
- `design.md` - Technical decisions
- `spec.md` - Requirements and behavior
### CLI Essentials
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec validate --strict # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.

View File

@@ -0,0 +1,25 @@
# Change: Update agent asset action button permissions
## Why
The login response already returns button-level permission codes, but the agent asset list still renders IoT card and device action buttons without using those codes. This leaves action visibility disconnected from backend authorization and makes button exposure inconsistent with the authenticated session.
## What Changes
- Define agent asset-list behavior for consuming button permission codes returned from `POST /api/auth/login`
- Require each IoT card action button and device action button in the agent asset list to be bound to an explicit H5 permission code:
- IoT卡停机`asset:card_stop_h5`
- IoT卡复机`asset:card_start_h5`
- 设备停机:`asset:device_stop_h5`
- 设备复机:`asset:device_start_h5`
- Hide action buttons when the current session does not contain the corresponding permission code
- Keep asset status text and detail-page entry visible even when action buttons are hidden
## Impact
- Affected specs:
- `agent-asset-list`
- Affected code:
- `src/api/auth.ts`
- `src/pages/common/login/index.vue`
- `src/pages/agent-system/assets/index.vue`

View File

@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: Agent Asset Action Buttons SHALL Respect Session Button Permissions
The agent asset list SHALL use the backend-provided button permission codes from the authenticated session to determine whether IoT card and device action buttons are rendered. The page SHALL use the following H5-specific permission-code mapping:
- IoT卡停机`asset:card_stop_h5`
- IoT卡复机`asset:card_start_h5`
- 设备停机:`asset:device_stop_h5`
- 设备复机:`asset:device_start_h5`
#### Scenario: Login response returns button permission codes
- **WHEN** `POST /api/auth/login` succeeds
- **THEN** the frontend SHALL treat the response `buttons` array as the source of button-level permissions for the authenticated session
- **AND** the session data used by the agent asset list SHALL preserve those button permission codes
#### Scenario: Asset action has its mapped H5 permission code and the code is present
- **WHEN** an IoT card or device action button in the agent asset list has one of the mapped H5 permission codes
- **AND** that permission code exists in the current session `buttons` array
- **THEN** the corresponding action button SHALL be rendered
#### Scenario: Asset action has its mapped H5 permission code and the code is absent
- **WHEN** an IoT card or device action button in the agent asset list has one of the mapped H5 permission codes
- **AND** that permission code does not exist in the current session `buttons` array
- **THEN** the corresponding action button SHALL NOT be rendered
#### Scenario: Session includes only a subset of asset action permissions
- **WHEN** the current session `buttons` array contains permission codes for only some of the asset actions
- **THEN** only the matching asset action buttons SHALL be shown
- **AND** non-matching asset action buttons SHALL remain hidden
#### Scenario: Session includes unrelated button codes
- **WHEN** the current session `buttons` array includes unrelated button codes such as `device:clear_series`
- **THEN** no asset action button SHALL become visible unless its own mapped H5 permission code is also present
#### Scenario: Action permissions are absent but the list item remains accessible
- **WHEN** the current session does not grant any asset action button permissions for a list item
- **THEN** the asset status text SHALL still be shown
- **AND** the detail-page entry affordance for the list item SHALL remain available
- **AND** the page SHALL NOT render placeholder or disabled action buttons in place of the hidden ones

View File

@@ -0,0 +1,16 @@
## 1. Spec
- [x] 1.1 Review `docs/权限按钮.md` and confirm the login response is the source of button permission codes
- [x] 1.2 Add OpenSpec proposal, tasks, and `agent-asset-list` delta spec for action-button permissions
- [x] 1.3 Validate the new change with strict OpenSpec validation
## 2. Implementation
- [x] 2.1 Extend login response typing and session persistence for the backend-provided `buttons` array
- [x] 2.2 Define explicit H5 permission-code mapping for agent asset-list IoT card and device action buttons:
- `asset:card_stop_h5`
- `asset:card_start_h5`
- `asset:device_stop_h5`
- `asset:device_start_h5`
- [x] 2.3 Gate asset-list action-button rendering by the current session button permissions
- [x] 2.4 Verify card and device action visibility for sessions with and without matching button permission codes

View File

@@ -0,0 +1,33 @@
# Change: Update agent asset list and detail display
## Why
The current agent-side asset experience exposes too much low-value information, hides some high-value state, and requires unnecessary search-type switching. The latest requests also require shop-based filtering, clearer detail presentation, explicit pagination, and a fixed top control area so search and filters remain available while the user browses long lists.
## What Changes
- Add IoT card device-binding status to the agent asset list
- Add cascaded shop filtering for the agent IoT card and device lists
- Remove the manual search-type selector and fix search semantics by active tab
- Keep the search input and filter controls fixed at the top while the asset list scrolls underneath
- Replace bottom-reached auto pagination with an explicit load-more button for both asset tabs
- Simplify asset detail basic fields and strengthen bound-card status presentation
- Remove `保护状态`
- Remove `切卡模式`
- Move the bound-card real-name status badge into the slot-title row
- Remove low-value device realtime traffic items and move "last online" into the realtime block as `最后上线时间`
- Make package effective-period text more prominent in asset detail package lists
- Only active packages should show the effective-period line
- Adjust the current active package header content
- Remove the order number line below the package name
- Show the active period there as `生效期:开始时间 - 到期时间`
## Impact
- Affected specs:
- `agent-asset-list`
- `agent-asset-detail`
- Affected code:
- `src/pages/agent-system/assets/index.vue`
- `src/pages/agent-system/asset-detail/index.vue`
- `src/api/assets.ts`

View File

@@ -0,0 +1,108 @@
## ADDED Requirements
### Requirement: Agent Asset Detail SHALL Remove Redundant Basic Fields
The agent asset detail experience SHALL remove fields that are no longer required from the basic information area.
#### Scenario: Viewing an IoT card detail
- **WHEN** the current asset is an IoT card
- **THEN** the basic information section SHALL NOT show `批次号`
#### Scenario: Viewing a device detail
- **WHEN** the current asset is a device
- **THEN** the basic information section SHALL NOT show `制造商`
- **AND** the basic information section SHALL NOT show `批次号`
- **AND** the basic information section SHALL NOT show `保护状态`
- **AND** the basic information section SHALL NOT show `切卡模式`
### Requirement: Agent Asset Detail SHALL Emphasize Bound IoT Card Identity And Status
The agent device detail view SHALL make bound IoT card identity and real-name status easier to scan.
#### Scenario: Rendering a bound IoT card item
- **WHEN** a device detail contains a bound IoT card entry
- **THEN** the label `电话` SHALL be renamed to `MSISDN`
- **AND** the item SHALL display `carrier_name` as `运营商`
- **AND** missing `msisdn` or `carrier_name` values SHALL render as `-`
#### Scenario: Rendering real-name status
- **WHEN** a bound IoT card has a `real_name_status`
- **THEN** the UI SHALL render that status with stronger visual emphasis than ordinary low-contrast metadata text
- **AND** the meaning of existing status values SHALL remain unchanged
#### Scenario: Rendering the bound-card header row
- **WHEN** a bound IoT card item is rendered in the device detail page
- **THEN** the real-name status badge SHALL be placed in the same row as the slot title
- **AND** the badge SHALL appear after the slot title text
- **AND** the lower metadata area SHALL NOT render a second standalone real-name field for the same card
### Requirement: Agent Device Realtime Status SHALL Prioritize High-Value Realtime Information
The agent device detail realtime block SHALL remove low-value traffic rows and absorb the "last online" field as a realtime-oriented field.
#### Scenario: Rendering device realtime traffic statistics
- **WHEN** the device realtime block is shown
- **THEN** it SHALL continue to show `今日用量`
- **AND** it SHALL continue to show `限速`
- **AND** it SHALL NOT show `下载流量`
- **AND** it SHALL NOT show `上传流量`
#### Scenario: Rendering last online time
- **WHEN** a device detail page has a realtime section
- **THEN** the page SHALL show `最后上线时间` inside the realtime section
- **AND** it SHALL prefer `realtimeStatus.device_realtime.last_online_time`
- **AND** it SHALL fall back to `deviceInfo.last_online_time` when the realtime value is absent
#### Scenario: Rendering the legacy time-information block
- **WHEN** the device detail page previously showed `创建时间`, `最后在线`, or `网关同步` in a separate time-information block
- **THEN** that block SHALL no longer show `创建时间`
- **AND** that block SHALL no longer show `最后在线`
- **AND** that block SHALL no longer show `网关同步`
### Requirement: Agent Asset Detail Package List SHALL Emphasize Effective Period
The package list in agent asset detail SHALL make the effective period line visually prominent.
#### Scenario: Package has both start and end time
- **WHEN** a package list item has both `activated_at` and `expires_at`
- **AND** the package is in active status
- **THEN** the page SHALL render a dedicated effective-period line with the meaning `生效期:开始时间 至 结束时间`
- **AND** that line SHALL have stronger visual prominence than the current weak auxiliary timestamp style
#### Scenario: Package is missing part of the effective period
- **WHEN** either `activated_at` or `expires_at` is missing
- **AND** the package is in active status
- **THEN** the effective-period line SHALL still be rendered
- **AND** the missing value SHALL display as `-`
#### Scenario: Package is not in active status
- **WHEN** a package list item is not in active status
- **THEN** the page SHALL NOT render the dedicated `生效期` line for that package
### Requirement: Agent Asset Detail Current Package SHALL Prioritize Effective Period Over Order Number
The current active package card in agent asset detail SHALL remove the order number from the package header area and use that space to surface the package active period.
#### Scenario: Rendering the current active package card
- **WHEN** the current active package card is rendered
- **THEN** the package name SHALL remain visible
- **AND** the order number line below the package name SHALL NOT be shown
- **AND** the card SHALL show a labeled `生效期:开始时间 - 到期时间` line in that position instead
#### Scenario: Current package is missing part of the active period
- **WHEN** either the current package `activated_at` or `expires_at` is missing
- **THEN** the labeled `生效期:开始时间 - 到期时间` line SHALL still be rendered
- **AND** the missing value SHALL display as `-`

View File

@@ -0,0 +1,123 @@
## ADDED Requirements
### Requirement: Agent IoT Card List SHALL Surface Device Binding Status
The agent-side IoT card list SHALL derive a device-binding status from the `device_virtual_no` field returned by `GET /api/admin/iot-cards/standalone` and show that status in each IoT card list item.
#### Scenario: Card is not bound to a device
- **WHEN** an IoT card item has `device_virtual_no` equal to `null`, `undefined`, an empty string, or a whitespace-only string
- **THEN** the list item SHALL show a status tag with `未绑定设备`
- **AND** the UI SHALL NOT display a fabricated device identifier
- **AND** the UI SHALL NOT prepend an extra `设备绑定` label before the tag
#### Scenario: Card is bound to a device
- **WHEN** an IoT card item has a non-empty `device_virtual_no`
- **THEN** the list item SHALL show a status tag with `已绑定设备`
- **AND** the UI MAY keep the raw `device_virtual_no` hidden
### Requirement: Agent Asset Lists SHALL Support Cascaded Shop Filtering
The agent-side asset lists SHALL provide a cascaded shop filter that is backed by `GET /api/admin/shops/cascade` and applies the selected shop ID to the active list request.
#### Scenario: User chooses a shop node under the IoT card tab
- **WHEN** the user opens the shop filter under the `IoT卡` tab and confirms a shop node
- **THEN** the selected node `id` SHALL be sent as `shop_id` in the subsequent IoT card list request
- **AND** the list SHALL refresh using that shop filter
#### Scenario: User chooses a shop node under the device tab
- **WHEN** the user opens the shop filter under the `设备` tab and confirms a shop node
- **THEN** the selected node `id` SHALL be sent as `shop_id` in the subsequent device list request
- **AND** the device list SHALL refresh using that shop filter
- **AND** the device list request SHALL keep its existing search semantics and append `shop_id` in addition to them
#### Scenario: Selected shop has children
- **WHEN** a shop node returned from `GET /api/admin/shops/cascade` has `has_children = true`
- **THEN** the UI SHALL allow the user to continue drilling down to child shops
- **AND** the UI SHALL use `parent_id` to load the next level
#### Scenario: User clears the shop filter
- **WHEN** the user clears the shop filter
- **THEN** subsequent IoT card list requests SHALL omit `shop_id`
- **AND** subsequent device list requests SHALL omit `shop_id`
#### Scenario: Device tab is active
- **WHEN** the active asset tab is `设备`
- **THEN** the shop filter SHALL still be shown
- **AND** device list requests SHALL support `shop_id`
### Requirement: Agent Asset Search Input SHALL Be Fixed By Active Tab
The agent asset list SHALL remove the manual search-type selector and bind search semantics directly to the active tab.
#### Scenario: IoT card tab search
- **WHEN** the active tab is `IoT卡`
- **THEN** the search field SHALL target `iccid`
- **AND** the search placeholder SHALL be `搜索ICCID`
- **AND** the UI SHALL NOT show a search-type switcher
#### Scenario: Device tab search
- **WHEN** the active tab is `设备`
- **THEN** the search field SHALL target `virtual_no`
- **AND** the user-facing search label SHALL be `设备号`
- **AND** the search placeholder SHALL be `搜索设备号`
- **AND** the UI SHALL NOT show a search-type switcher
### Requirement: Agent Asset List Controls SHALL Stay Fixed At The Top
The agent-side asset list page SHALL keep the search input and the filter/tab control area fixed at the top of the page while the list content scrolls independently underneath.
#### Scenario: User scrolls a long IoT card list
- **WHEN** the active tab is `IoT卡` and the user scrolls the list
- **THEN** the search input SHALL remain visible at the top
- **AND** the filter and tab controls directly below it SHALL remain visible at the top
- **AND** only the list content area SHALL scroll
#### Scenario: User scrolls a long device list
- **WHEN** the active tab is `设备` and the user scrolls the list
- **THEN** the search input SHALL remain visible at the top
- **AND** the filter and tab controls directly below it SHALL remain visible at the top
- **AND** only the list content area SHALL scroll
#### Scenario: Fixed controls do not cover list content
- **WHEN** the fixed top control area is rendered
- **THEN** the list content SHALL start below that area
- **AND** the first list item SHALL remain fully visible and operable
### Requirement: Agent Asset Lists SHALL Use Explicit Load-More Pagination
The agent-side IoT card list and device list SHALL stop automatic pagination triggered by scrolling to the bottom and SHALL use an explicit footer button to load the next page.
#### Scenario: IoT card tab still has more pages
- **WHEN** the active tab is `IoT卡` and the current IoT card result set has more pages available
- **THEN** the footer SHALL show a button labeled `加载更多`
- **AND** the next page SHALL NOT be requested until the user clicks that button
#### Scenario: Device tab still has more pages
- **WHEN** the active tab is `设备` and the current device result set has more pages available
- **THEN** the footer SHALL show a button labeled `加载更多`
- **AND** the next page SHALL NOT be requested until the user clicks that button
#### Scenario: User clicks the load-more button
- **WHEN** the user clicks the `加载更多` button for the active tab
- **THEN** the UI SHALL request the next page for that tab using the existing active filters and search parameters
- **AND** the returned items SHALL append to the existing list instead of replacing it
#### Scenario: No more pages remain
- **WHEN** the active list has no more pages available
- **THEN** the `加载更多` button SHALL NOT be shown

View File

@@ -0,0 +1,20 @@
## 1. Spec
- [x] 1.1 Initialize OpenSpec in the repository
- [x] 1.2 Capture project context in `openspec/project.md`
- [x] 1.3 Create change proposal and delta specs for asset list and asset detail
- [x] 1.4 Review and approve the proposal before implementation
## 2. Implementation
- [x] 2.1 Extend asset list typing and request parameters for `device_virtual_no` and `shop_id`
- [x] 2.2 Update agent asset list UI for binding status, shop filter, and fixed search semantics
- [x] 2.3 Update asset detail UI for field removal, bound-card emphasis, and realtime-status changes
- [x] 2.4 Update package-list date presentation in detail pages
- [ ] 2.5 Verify card and device flows manually after implementation
- [x] 2.6 Remove `保护状态` and `切卡模式`, and move bound-card real-name status next to the slot title
- [x] 2.7 Restrict package effective-period display to packages in active status only
- [x] 2.8 Remove the current-package order number and replace it with a `生效期:开始时间 - 到期时间` line
- [x] 2.9 Apply the cascaded shop filter to the device list request and device tab UI
- [x] 2.10 Replace bottom-reached auto pagination with an explicit `加载更多` button for the IoT card and device lists
- [x] 2.11 Keep the asset-list search input and filter controls fixed at the top while the list scrolls underneath

View File

@@ -0,0 +1,51 @@
## Context
The project already contains a theme file at `src/static/styles/theme.scss`, but the actual app styling is inconsistent:
- `theme.scss` defines `--brand-*`, text, background, border, and utility classes
- `src/uni.scss` and `uno.config.ts` still reference `--theme-*` variables
The requested change is a cross-cutting design-system alignment focused on the project-wide theme contract.
## Goals / Non-Goals
- Goals:
- Define a single source of truth for the core project palette
- Use exactly three core brand colors for layout-level styling:
- Primary: `#2F7D32`
- Secondary: `#1F2937`
- Tertiary: `#F4FBF5`
- Ensure shared consumers such as UnoCSS utilities and uView theme variables resolve from the same theme source
- Non-Goals:
- Full repo-wide page cleanup in one implementation pass
- Redefining semantic success, warning, and error states for every page
- Removing light/dark theme support
## Decisions
- Decision: `src/static/styles/theme.scss` will remain the only file that contains literal core brand values.
- Why: this file already acts as the closest thing to a design-token source, and centralizing values here avoids duplicating palette values across styling systems.
- Decision: `src/uni.scss` and `uno.config.ts` will consume compatibility aliases exported from the same theme source rather than define their own brand colors.
- Why: the current `--theme-*` and `--brand-*` split is inconsistent and invites drift. Compatibility aliases let existing consumers keep working while the project converges on one source.
- Decision: layout-level accents in touched agent-facing pages and shared components will be limited to the approved primary, secondary, tertiary, and neutral tokens.
- Why: the project had mixed blue, green, orange, red, purple, and cyan across normal layout decoration, which weakens hierarchy and visual cohesion.
- Decision: semantic success, warning, and error colors may remain for exceptional states only.
- Why: status badges, destructive actions, and warnings still need semantic distinction, but these colors should not become the default decorative palette for dashboards and cards.
## Risks / Trade-offs
- Risk: changing the core palette may alter the look of existing shared utilities.
- Mitigation: keep the implementation scoped to shared tokens plus touched pages, and validate affected agent pages after the token update.
## Migration Plan
1. Define the approved brand palette and compatibility aliases in `src/static/styles/theme.scss`
2. Update `src/uni.scss` and `uno.config.ts` to reference the shared aliases
3. Verify touched shared utilities and agent pages for palette consistency
## Open Questions
- Which additional agent-facing pages should be migrated to the shared token set in the next cleanup pass

View File

@@ -0,0 +1,25 @@
# Change: Update shared theme tokens
## Why
The project currently has inconsistent theme entry points: `src/static/styles/theme.scss` defines `--brand-*` variables, while `src/uni.scss` and `uno.config.ts` still reference `--theme-*` variables. This prevents a clean single-source design system and makes brand updates drift across pages.
## What Changes
- Establish a single theme source for the project color system in `src/static/styles/theme.scss`
- Re-define the core project palette as:
- Primary: `#2F7D32`
- Secondary: `#1F2937`
- Tertiary: `#F4FBF5`
- Require page and component layout accents to consume shared theme tokens instead of page-local hard-coded hex values
- Align UnoCSS and uView theme mappings with the shared theme source
## Impact
- Affected specs:
- `design-theme-tokens`
- Affected code:
- `src/static/styles/theme.scss`
- `src/uni.scss`
- `uno.config.ts`
- shared agent-facing components that currently rely on page-local brand hex colors

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Project Brand Tokens SHALL Be Defined In One Theme Source
The project SHALL define its core layout brand palette in `src/static/styles/theme.scss` as the single source of truth. The core brand palette MUST be:
- Primary: `#2F7D32`
- Secondary: `#1F2937`
- Tertiary: `#F4FBF5`
Compatibility aliases and helper tokens MAY exist, but they MUST be declared from the same theme source and MUST NOT introduce unrelated page-local brand colors.
#### Scenario: Shared brand emphasis resolves from one source
- **GIVEN** a page or component uses shared brand emphasis such as `text-brand`, `bg-brand`, `border-brand`, UnoCSS theme colors, or uView primary-theme values
- **WHEN** the app renders those styles
- **THEN** they resolve from the palette defined in `src/static/styles/theme.scss`
- **AND** they do not define a separate hard-coded primary hex outside that theme source
#### Scenario: A core brand value changes
- **GIVEN** a developer updates one of the core brand values in `src/static/styles/theme.scss`
- **WHEN** the project rebuilds
- **THEN** shared brand utilities and theme consumers reflect the updated value without needing page-level color edits
### Requirement: Layout-Level Brand Usage SHALL Consume Shared Tokens
Agent-facing pages and shared components SHALL use shared theme tokens for layout-level accents instead of page-local hard-coded brand hex values. Semantic success, warning, and error colors MAY remain for exceptional states, destructive actions, or state badges, but SHALL NOT be used as the default decorative palette for normal dashboard and card layouts.
#### Scenario: Agent-facing layout colors are reviewed
- **GIVEN** an agent-facing page or shared component is reviewed for colors
- **WHEN** it renders navigation states, buttons, highlight cards, decorative surfaces, and metric blocks
- **THEN** those layout accents use the primary, secondary, tertiary, or neutral theme tokens
- **AND** normal layout decoration does not depend on page-local blue, orange, purple, cyan, or red accent values
#### Scenario: Shared theme consumers remain aligned
- **GIVEN** `src/uni.scss` and `uno.config.ts` provide theme mappings for components and utilities
- **WHEN** they expose primary colors to downstream styles
- **THEN** they reference the shared theme source rather than maintaining independent brand values

View File

@@ -0,0 +1,12 @@
## 1. Spec
- [x] 1.1 Review the existing theme/token entry points
- [x] 1.2 Draft proposal, design notes, and spec deltas for centralized theme tokens
- [x] 1.3 Review and approve the proposal before implementation
## 2. Implementation
- [x] 2.1 Update `src/static/styles/theme.scss` to define the approved primary, secondary, and tertiary palette and expose compatibility aliases from the same file
- [x] 2.2 Align `src/uni.scss` and `uno.config.ts` with the shared theme source
- [x] 2.3 Replace touched page-local brand hex values with shared tokens where this change already updated agent-facing views
- [x] 2.4 Verify that touched layouts and components use the shared theme source and preserve mobile readability

View File

@@ -0,0 +1,57 @@
## Context
The agent home page and the commission center both render a withdrawable-commission summary card from the same `GET /api/admin/shops/fund-summary` response, but they currently duplicate the markup separately.
That duplication has already caused drift:
- the home page added an inline `佣金中心` text-and-chevron chip inside the card header
- the two cards can diverge visually even though they show the same summary concept
- new summary fields such as `main_balance` need to be maintained in two places
The requested change is small in scope, but it introduces a presentation-level reuse rule that should be documented before implementation.
## Goals / Non-Goals
- Goals:
- Ensure the home page and commission center render the withdrawable-commission summary with the same visible structure
- Remove the extra inline `佣金中心` chip from the home-page card
- Centralize the repeated card markup into one shared component
- Keep data fetching and navigation ownership in the page containers
- Non-Goals:
- Redesigning commission-center record lists or search/filter areas
- Changing the fund-summary API contract beyond the already approved username/main-balance behavior
- Moving commission business logic out of the page containers
## Decisions
- Decision: implement one shared presentational component for the withdrawable-commission summary card.
- Why: the two pages describe the same summary object and should not maintain separate card markup.
- Decision: the shared component should own display structure only; parent pages should continue to own data loading and page navigation.
- Why: the card is reused across pages, but fetch timing and navigation context are still page-specific concerns.
- Decision: the home-page card must not render a separate inline `佣金中心` chip/chevron affordance inside the card header.
- Why: the product explicitly wants that duplicate callout removed, and the surrounding page already provides navigation context.
- Decision: the shared summary icon should render directly in the header layout without an extra boxed wrapper surface.
- Why: the product explicitly wants the icon treatment simplified, and keeping the icon unboxed reduces visual noise while preserving recognition.
## Risks / Trade-offs
- Risk: extracting a shared component can accidentally drop one page-specific field or visual state.
- Mitigation: the spec requires the same core title, amount, and summary metric set on both pages.
- Risk: if navigation behavior is embedded into the shared component, reuse may become rigid.
- Mitigation: keep click handling and routing in the parent pages.
## Migration Plan
1. Extract the repeated withdrawable-commission card markup into one shared component
2. Replace the duplicated home-page and commission-center card markup with the shared component
3. Remove the extra inline `佣金中心` chip from the home-page rendering
4. Verify that both pages still show the same summary fields and formatting
## Open Questions
- Whether the shared component should live in `src/components/` or in an `agent-system`-scoped components directory

View File

@@ -0,0 +1,28 @@
# Change: Update fund-summary username filter and shared summary card display
## Why
The frontend originally sent the current agent username through the `shop_name` query parameter when calling `GET /api/admin/shops/fund-summary`, which no longer matched the backend contract. That response also includes `main_balance`, which must be shown in both the agent home page and commission center withdrawable-commission cards.
The two withdrawable-commission cards are also duplicated between the home page and commission center. The home page currently renders an extra inline `佣金中心` chip inside the card header, which the product no longer wants. The two pages should render the same summary card through one shared component.
## What Changes
- Update the frontend contract for `GET /api/admin/shops/fund-summary` so self-scoped requests send the logged-in agent username under `username`, not `shop_name`
- Keep the query value unchanged; only the parameter name changes
- Show the backend `main_balance` field in the agent home page and commission center withdrawable-commission cards with the label `预充值余额`
- Preserve the existing amount formatting and empty-state behavior for the summary cards
- Remove the extra inline `佣金中心` text-and-chevron chip from the home-page withdrawable-commission card
- Standardize the home page and commission center withdrawable-commission cards behind one shared component so the visible summary structure stays aligned
- Remove the extra decorative icon wrapper so the summary icon is not rendered inside a separate boxed container
## Impact
- Affected specs:
- `agent-fund-summary`
- Affected code:
- `src/api/commission.ts`
- shared fund-summary card component to be introduced for agent pages
- `src/pages/agent-system/home/index.vue`
- `src/pages/agent-system/commission-center/index.vue`
- `src/pages/agent-system/withdraw/index.vue`

View File

@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: Agent Fund Summary Requests SHALL Filter By Username
The frontend SHALL send the current logged-in agent account identifier under the `username` query parameter when requesting self-scoped data from `GET /api/admin/shops/fund-summary`. The frontend MUST NOT send that same identifier under `shop_name`.
#### Scenario: Agent home page requests its own fund summary
- **GIVEN** the logged-in agent has `username = "agent_a"`
- **WHEN** the home page requests `GET /api/admin/shops/fund-summary`
- **THEN** the request includes `username=agent_a`
- **AND** the request does not send `shop_name=agent_a`
#### Scenario: Commission pages request the current agent fund summary
- **GIVEN** the logged-in agent has `username = "agent_a"`
- **WHEN** the commission center page or withdrawal page requests `GET /api/admin/shops/fund-summary`
- **THEN** the request includes `username=agent_a`
- **AND** the value is passed through unchanged from the logged-in user information
#### Scenario: No username is available from login state
- **GIVEN** the current login state does not provide a username
- **WHEN** the frontend requests `GET /api/admin/shops/fund-summary`
- **THEN** the frontend omits the username filter
- **AND** the frontend does not substitute `shop_name` as a fallback filter key
### Requirement: Agent Fund Summary Cards SHALL Show Main Balance
The agent home page and commission center withdrawable-commission summary cards SHALL display the backend `main_balance` field using the label `预充值余额`. The displayed amount MUST use the same currency-formatting rules as the other fund summary amounts.
#### Scenario: Home page renders main balance
- **GIVEN** the fund summary response contains `main_balance = 200000`
- **WHEN** the agent home page renders the withdrawable-commission card
- **THEN** the card shows a metric labeled `预充值余额`
- **AND** the amount is rendered using the existing summary amount formatter
#### Scenario: Commission center renders main balance
- **GIVEN** the fund summary response contains `main_balance = 200000`
- **WHEN** the commission center page renders the withdrawable-commission card
- **THEN** the card shows a metric labeled `预充值余额`
- **AND** the amount is rendered using the existing summary amount formatter
#### Scenario: Main balance is unavailable
- **GIVEN** the fund summary response is missing `main_balance` or the summary has not loaded yet
- **WHEN** either summary card renders
- **THEN** the `预充值余额` label remains visible
- **AND** the corresponding amount renders as `--`
### Requirement: Agent Fund Summary Card Presentation SHALL Stay Consistent Across Pages
The agent home page and commission center SHALL render the withdrawable-commission summary through one shared card presentation so the title, primary amount area, summary icon, and summary metric blocks stay visually consistent across both pages. The home-page card MUST NOT render a separate inline `佣金中心` text-and-chevron chip inside the card header. The summary icon MUST NOT be rendered inside a separate boxed wrapper surface.
#### Scenario: Home page and commission center share the same summary structure
- **GIVEN** the fund summary response has loaded on both the home page and the commission center page
- **WHEN** the withdrawable-commission summary card renders on each page
- **THEN** both pages show the same card title, primary amount area, summary icon treatment, and summary metric block order
- **AND** both pages use the same summary-card presentation rather than maintaining separate duplicated markup
#### Scenario: Home page removes the extra commission-center chip
- **GIVEN** the agent home page renders the withdrawable-commission summary card
- **WHEN** the user scans the card header area
- **THEN** the card does not display a separate inline `佣金中心` text-and-chevron chip
- **AND** the rest of the card content remains available
#### Scenario: Summary icon is not boxed
- **GIVEN** either summary card renders on the home page or commission center page
- **WHEN** the user views the summary icon area
- **THEN** the icon is displayed without an additional boxed wrapper container
- **AND** the icon remains visually aligned with the shared card header

View File

@@ -0,0 +1,15 @@
## 1. Spec
- [x] 1.1 Create a focused OpenSpec proposal for the fund-summary contract and card display change
- [x] 1.2 Review and approve the proposal before implementation
## 2. Implementation
- [x] 2.1 Update `FundSummaryParams` and every `getFundSummary` caller to send `username` instead of `shop_name`
- [x] 2.2 Display `main_balance` as `预充值余额` in the agent home withdrawable-commission card
- [x] 2.3 Display `main_balance` as `预充值余额` in the commission center withdrawable-commission card
- [x] 2.4 Verify request payloads and empty-state rendering for all fund-summary callers
- [x] 2.5 Extract the home-page and commission-center withdrawable-commission summary into one shared component
- [x] 2.6 Remove the extra inline `佣金中心` chip from the home-page summary card
- [x] 2.7 Remove the extra boxed wrapper around the summary icon in the shared card
- [x] 2.8 Verify that both pages render the same summary card structure and field order

67
openspec/project.md Normal file
View File

@@ -0,0 +1,67 @@
# Project Context
## Purpose
This project is a Uni-app based agent and enterprise management frontend for IoT assets. It provides asset list, asset search/detail, package display, commission, withdrawal, and related operational pages for two user roles: agent accounts and enterprise accounts.
## Tech Stack
- TypeScript
- Vue 3 with `<script setup>`
- Uni-app
- Vite
- UnoCSS
- Pinia
- uview-plus
## Project Conventions
### Code Style
- Use Composition API and `<script setup lang="ts">`.
- Keep API types close to the request layer in `src/api/*.ts`.
- Prefer small, direct page-level logic over premature abstraction.
- Existing pages use UnoCSS utility classes heavily; UI changes should preserve the current visual language.
- User-facing copy is primarily Chinese.
### Architecture Patterns
- Business pages live under `src/pages/agent-system/`.
- Backend calls are wrapped in `src/api/*.ts`.
- Shared formatting helpers live in `src/utils/format` and nearby page helpers.
- Asset pages distinguish between two primary resource types: `card` (IoT card) and `device`.
### Testing Strategy
- Run targeted validation with `pnpm type-check`, lint, and manual page checks when implementation begins.
- For spec work, validate proposals with `openspec validate --strict`.
- User-visible asset changes should be verified on both card and device flows.
### Git Workflow
- The repository may contain unrelated user changes; do not revert them.
- Use OpenSpec change proposals for user-visible feature changes before implementation.
- Keep proposals focused and capability-scoped.
## Domain Context
- Assets are mainly IoT cards and devices.
- Asset identifiers may be ICCID or VirtualNo depending on the resource and entry point.
- Agent-side pages currently include:
- asset list: `src/pages/agent-system/assets/index.vue`
- asset detail: `src/pages/agent-system/asset-detail/index.vue`
- Package display and realtime status are important parts of the asset detail experience.
## Important Constraints
- Agent and enterprise views are not identical; requirements in one role should not be assumed to apply globally.
- Existing backend contracts are documented partly in `docs/*.md` and partly in the TypeScript API layer.
- UI changes should preserve current behavior unless the proposal explicitly changes it.
- Search semantics, display labels, and field visibility are product requirements and must be documented precisely.
## External Dependencies
- Backend admin APIs under `/api/admin/...`
- Shop cascade API for hierarchical shop filtering
- Asset resolve, realtime status, current package, and package list APIs
- Uni-app runtime and uview-plus components

View File

@@ -86,11 +86,11 @@
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
"dayjs": "^1.11.19",
"pinia": "2.2.4",
"pinia-plugin-persistedstate": "4.1.3",
"destr": "^2.0.5",
"deep-pick-omit": "^1.2.1",
"defu": "^6.1.4",
"destr": "^2.0.5",
"pinia": "2.2.4",
"pinia-plugin-persistedstate": "4.1.3",
"uview-plus": "^3.7.13",
"vue": "3.4.21",
"vue-i18n": "9.1.9",
@@ -106,6 +106,7 @@
"@esbuild/darwin-arm64": "0.25.1",
"@esbuild/darwin-x64": "0.25.1",
"@iconify-json/mdi": "^1.2.3",
"@iconify/utils": "^3.1.3",
"@rollup/rollup-darwin-arm64": "4.38.0",
"@rollup/rollup-darwin-x64": "4.38.0",
"@types/node": "^22.19.3",
@@ -140,11 +141,6 @@
"vite-plugin-restart": "^1.0.0",
"vue-tsc": "^2.2.12"
},
"pnpm": {
"overrides": {
"vue": "3.4.21"
}
},
"simple-git-hooks": {
"pre-commit": "npx lint-staged",
"commit-msg": "node ./scripts/verify-commit.js"

708
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,9 @@
allowBuilds:
'@swc/core': true
'@uni-helper/unocss-preset-uni': true
core-js: true
esbuild: true
less: true
protobufjs: true
simple-git-hooks: true
vue-demi: true

View File

@@ -47,7 +47,8 @@ export interface AssetResolveResponse {
cards?: Array<{
card_id: number
iccid: string
msisdn: string
msisdn?: string | null
carrier_name?: string | null
network_status: number
real_name_status: number
slot_position: number
@@ -74,6 +75,7 @@ export interface CardInfo {
msisdn?: string // 电话号码
virtual_no?: string // 虚拟号
virtual_number?: string // 虚拟号 (别名兼容)
device_virtual_no?: string | null
imei?: string
carrier_id?: number // 运营商ID
carrier_name?: string // 运营商名称
@@ -246,7 +248,8 @@ export interface AssetRealtimeStatus {
card_id: number
iccid: string
is_current: boolean
msisdn: string
msisdn?: string | null
carrier_name?: string | null
network_status: number
real_name_status: number
slot_position: number

View File

@@ -9,6 +9,7 @@ import { get, post, put } from '@/utils/request'
export interface LoginParams {
username: string
password: string
device?: 'h5'
}
/**
@@ -18,6 +19,7 @@ export interface LoginResponse {
access_token: string
refresh_token: string
expires_in: number
buttons?: string[]
user: {
id: number
username: string
@@ -47,6 +49,7 @@ export interface UserInfo {
status?: 'active' | 'inactive'
create_time?: string
last_login_time?: string
buttons?: string[]
}
/**
@@ -62,7 +65,12 @@ export interface ChangePasswordParams {
* POST /api/auth/login
*/
export function login(data: LoginParams) {
return post<LoginResponse>('/api/auth/login', { data })
return post<LoginResponse>('/api/auth/login', {
data: {
...data,
device: data.device ?? 'h5',
},
})
}
/**

View File

@@ -1,7 +1,7 @@
/**
* 佣金模块 API (代理端)
*/
import { get, post } from '@/utils/request'
import { get } from '@/utils/request'
/**
* 资金概览 (代理端主页用)
@@ -141,7 +141,7 @@ export interface ListResponse<T> {
}
export interface FundSummaryParams {
shop_name?: string
username?: string
}
/**
@@ -151,7 +151,7 @@ export interface FundSummaryParams {
export function getFundSummary(params?: FundSummaryParams) {
return get<{ items: FundSummary[] }>(
'/api/admin/shops/fund-summary',
params?.shop_name ? { params } : undefined
params?.username ? { params } : undefined
).then(res => res.items?.[0])
}

View File

@@ -26,6 +26,7 @@ export interface EnterpriseCardsParams {
carrier_id?: number // 运营商ID
iccid?: string // ICCID (模糊查询)
virtual_no?: string // 虚拟号 (模糊查询)
msisdn?: string // 接入号 (模糊查询)
}
/**
@@ -34,7 +35,10 @@ export interface EnterpriseCardsParams {
export interface EnterpriseDevicesParams {
page?: number
page_size?: number
status?: number
device_name?: string // 设备号 / 设备名称 (模糊搜索)
virtual_no?: string // 虚拟号 (模糊搜索)
imei?: string // IMEI (模糊搜索)
}
/**

16
src/api/shop.ts Normal file
View File

@@ -0,0 +1,16 @@
import { get } from '@/utils/request'
export interface ShopCascadeParams {
parent_id?: number
shop_name?: string
}
export interface ShopCascadeItem {
id: number
shop_name: string
has_children: boolean
}
export function getShopCascade(params?: ShopCascadeParams) {
return get<ShopCascadeItem[]>('/api/admin/shops/cascade', { params })
}

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FundSummary } from '@/api/commission'
type SummaryMetric = {
label: string
amount?: number | null
cardClass: string
labelClass: string
}
const props = defineProps<{
summary?: FundSummary | null
}>()
const metrics = computed<SummaryMetric[]>(() => [
{
label: '预充值余额',
amount: props.summary?.main_balance,
cardClass: 'bg-[#f4fbf5] border border-[#e3f2e6]',
labelClass: 'text-[#2f7d32]',
},
{
label: '累计佣金',
amount: props.summary?.total_commission,
cardClass: 'bg-[#f8fafc] border border-[#eef2f6]',
labelClass: 'text-[#6b7280]',
},
{
label: '已提现',
amount: props.summary?.withdrawn_commission,
cardClass: 'bg-[#f8fafc] border border-[#eef2f6]',
labelClass: 'text-[#6b7280]',
},
{
label: '冻结',
amount: props.summary?.frozen_commission,
cardClass: 'bg-[#f8fafc] border border-[#eef2f6]',
labelClass: 'text-[#6b7280]',
},
])
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `¥${parts.join('.')}`
}
function formatAmountDisplay(amount?: number | null) {
return typeof amount === 'number' ? formatAmount(amount) : '--'
}
</script>
<template>
<view class="bg-white rounded-20px shadow-[0_8px_24px_rgba(15,23,42,0.06)] p-5">
<view class="rounded-18px bg-gradient-to-br from-[#f5fbf5] via-white to-[#f8fafc] p-4">
<view class="flex items-start justify-between gap-3">
<view class="min-w-0 flex-1">
<view class="inline-flex items-center gap-1 rounded-full bg-[#e8f5e9] px-2.5 py-1">
<i class="i-mdi-wallet-outline text-13px text-[#2f7d32]" />
<text class="text-11px font-600 text-[#2f7d32]">可提现佣金</text>
</view>
<text class="text-34px font-700 leading-none text-[#1f2937] block mt-3 break-all">
{{ formatAmountDisplay(summary?.available_commission) }}
</text>
</view>
<image src="@/static/icons/佣金中心.png" class="w-16 h-16 shrink-0" mode="aspectFit" />
</view>
</view>
<view class="grid grid-cols-2 gap-3 mt-4">
<view
v-for="metric in metrics"
:key="metric.label"
:class="metric.cardClass"
class="rounded-16px px-3 py-3"
>
<text :class="metric.labelClass" class="text-11px block">
{{ metric.label }}
</text>
<text class="text-15px font-700 text-[#1f2937] block mt-2 break-all">
{{ formatAmountDisplay(metric.amount) }}
</text>
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getShopCascade } from '@/api/shop'
import type { ShopCascadeItem } from '@/api/shop'
interface SelectedShop {
id: number
shop_name: string
}
interface Props {
show: boolean
selectedShop?: SelectedShop | null
title?: string
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'confirm', shop: SelectedShop | null): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '选择店铺',
selectedShop: null,
})
const emit = defineEmits<Emits>()
const loading = ref(false)
const shopOptions = ref<ShopCascadeItem[]>([])
const breadcrumbs = ref<ShopCascadeItem[]>([])
const selectedShop = ref<SelectedShop | null>(null)
async function loadShopOptions(parentId?: number) {
loading.value = true
try {
const response = await getShopCascade(parentId !== undefined ? { parent_id: parentId } : undefined)
shopOptions.value = response || []
}
catch (error) {
console.error('加载店铺级联数据失败:', error)
shopOptions.value = []
}
finally {
loading.value = false
}
}
async function openPicker() {
selectedShop.value = props.selectedShop ? { ...props.selectedShop } : null
breadcrumbs.value = []
await loadShopOptions()
}
function close() {
emit('update:show', false)
}
function handleCancel() {
emit('cancel')
close()
}
function selectShop(option: ShopCascadeItem) {
selectedShop.value = {
id: option.id,
shop_name: option.shop_name,
}
}
async function enterChildren(option: ShopCascadeItem) {
const nextPath = [...breadcrumbs.value, option]
breadcrumbs.value = nextPath
await loadShopOptions(option.id)
}
async function goToBreadcrumb(index: number) {
if (index < 0) {
breadcrumbs.value = []
await loadShopOptions()
return
}
const nextPath = breadcrumbs.value.slice(0, index + 1)
breadcrumbs.value = nextPath
await loadShopOptions(nextPath[index]?.id)
}
function handleConfirm() {
emit('confirm', selectedShop.value)
close()
}
watch(
() => props.show,
async (show) => {
if (show) {
await openPicker()
}
},
)
</script>
<template>
<view
v-if="show"
class="shop-picker-overlay"
@click="handleCancel"
>
<view
class="shop-picker-content"
@click.stop
>
<view class="shop-picker-header">
<view class="shop-picker-action" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
</view>
<view class="shop-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
</view>
<view class="shop-picker-action text-right" @click="handleConfirm">
<text class="text-sm text-brand font-600">确定</text>
</view>
</view>
<scroll-view
class="shop-picker-breadcrumb"
scroll-x
>
<view class="shop-picker-breadcrumb-list">
<text
class="shop-picker-breadcrumb-item"
:class="{ 'shop-picker-breadcrumb-item-active': breadcrumbs.length === 0 }"
@click="goToBreadcrumb(-1)"
>
顶级店铺
</text>
<template
v-for="(item, index) in breadcrumbs"
:key="item.id"
>
<text class="shop-picker-breadcrumb-separator">/</text>
<text
class="shop-picker-breadcrumb-item"
:class="{ 'shop-picker-breadcrumb-item-active': index === breadcrumbs.length - 1 }"
@click="goToBreadcrumb(index)"
>
{{ item.shop_name }}
</text>
</template>
</view>
</scroll-view>
<view v-if="selectedShop" class="shop-picker-selection">
<text class="text-12px text-[#666]">
已选店铺
</text>
<text class="text-13px font-600 text-[#212121]">
{{ selectedShop.shop_name }}
</text>
</view>
<scroll-view
class="shop-picker-list"
scroll-y
>
<view v-if="loading" class="shop-picker-empty">
<i class="i-mdi-loading animate-spin text-18px text-brand" />
<text class="text-13px text-[#64748b]">加载中...</text>
</view>
<view v-else-if="shopOptions.length === 0" class="shop-picker-empty">
<text class="text-13px text-[#94a3b8]">暂无下级店铺</text>
</view>
<template v-else>
<view
v-for="option in shopOptions"
:key="option.id"
class="shop-picker-item"
:class="{ 'shop-picker-item-active': selectedShop?.id === option.id }"
>
<view
class="shop-picker-item-main"
@click="selectShop(option)"
>
<text class="shop-picker-item-text">{{ option.shop_name }}</text>
<i
v-if="selectedShop?.id === option.id"
class="i-mdi-check text-16px text-brand"
/>
</view>
<view
v-if="option.has_children"
class="shop-picker-next"
@click="enterChildren(option)"
>
<text class="text-12px text-[#999]">下级</text>
<i class="i-mdi-chevron-right text-14px text-[#999]" />
</view>
</view>
</template>
</scroll-view>
</view>
</view>
</template>
<style scoped>
.shop-picker-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.5);
}
.shop-picker-content {
display: flex;
flex-direction: column;
max-height: 72vh;
background: #fff;
border-radius: 16px 16px 0 0;
}
.shop-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f1f5f9;
}
.shop-picker-action {
min-width: 60px;
}
.shop-picker-title {
flex: 1;
text-align: center;
}
.shop-picker-breadcrumb {
white-space: nowrap;
border-bottom: 1px solid #f8fafc;
}
.shop-picker-breadcrumb-list {
display: inline-flex;
align-items: center;
min-width: 100%;
padding: 12px 16px;
}
.shop-picker-breadcrumb-item {
font-size: 12px;
color: #64748b;
}
.shop-picker-breadcrumb-item-active {
color: var(--brand-primary, #1677ff);
font-weight: 600;
}
.shop-picker-breadcrumb-separator {
margin: 0 8px;
font-size: 12px;
color: #cbd5e1;
}
.shop-picker-selection {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: #f8fafc;
}
.shop-picker-list {
flex: 1;
max-height: calc(72vh - 114px);
}
.shop-picker-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 16px;
}
.shop-picker-item {
display: flex;
align-items: stretch;
border-bottom: 1px solid #f8fafc;
}
.shop-picker-item-active {
background: #fef3f2;
}
.shop-picker-item-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
}
.shop-picker-item-text {
flex: 1;
font-size: 15px;
color: #1e293b;
}
.shop-picker-item-active .shop-picker-item-text {
color: var(--brand-primary, #1677ff);
font-weight: 600;
}
.shop-picker-next {
display: flex;
align-items: center;
gap: 2px;
padding: 0 16px 0 8px;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -10,6 +10,8 @@ interface Props {
show: boolean
options: PickerOption[]
title?: string
autoConfirm?: boolean
selectedValue?: any
}
interface Emits {
@@ -19,13 +21,42 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
title: '请选择'
title: '请选择',
autoConfirm: false,
})
const emit = defineEmits<Emits>()
const selectedIndex = ref(0)
function syncSelectedIndex() {
if (!props.options.length) {
selectedIndex.value = 0
return
}
const matchedIndex = props.options.findIndex(option => option.value === props.selectedValue)
selectedIndex.value = matchedIndex >= 0 ? matchedIndex : 0
}
watch(() => props.show, (show) => {
if (show) {
syncSelectedIndex()
}
}, { immediate: true })
watch(() => props.selectedValue, () => {
if (props.show) {
syncSelectedIndex()
}
})
watch(() => props.options, () => {
if (props.show) {
syncSelectedIndex()
}
}, { deep: true })
// 关闭弹窗
function close() {
emit('update:show', false)
@@ -48,6 +79,11 @@ function handleConfirm() {
// 选择选项
function selectOption(index: number) {
selectedIndex.value = index
if (props.autoConfirm && props.options[index]) {
emit('confirm', props.options[index])
close()
}
}
</script>
@@ -69,9 +105,17 @@ function selectOption(index: number) {
<view class="simple-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
</view>
<view class="simple-picker-confirm" @click="handleConfirm">
<view
v-if="!autoConfirm"
class="simple-picker-confirm"
@click="handleConfirm"
>
<text class="text-sm text-brand font-600">确定</text>
</view>
<view
v-else
class="simple-picker-confirm"
/>
</view>
<!-- 选项列表 -->

View File

@@ -70,9 +70,10 @@
},
"vueVersion" : "3",
"h5" : {
"title" : "企业代理H5",
"template" : "index.html",
"router" : {
"mode" : "hash",
"base" : "/h5/"
"mode" : "history"
}
}
}

View File

@@ -15,30 +15,6 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/agent-system/my-packages/index",
"style": {
"navigationBarTitleText": "我的套餐",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/commission/index",
"style": {
"navigationBarTitleText": "我的佣金",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/packages/index",
"style": {
"navigationBarTitleText": "套餐列表",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/assets/index",
"style": {
@@ -47,22 +23,6 @@
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/tags/index",
"style": {
"navigationBarTitleText": "标签管理",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/asset-search/index",
"style": {
"navigationBarTitleText": "资产详情",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/agent-system/asset-detail/index",
"style": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
import { stopAsset, startAsset } from '@/api/assets'
import Skeleton from '@/components/Skeleton.vue'
import SimplePicker from '@/components/SimplePicker.vue'
import { formatTime, formatDataSize } from '@/utils/format'
import ShopCascadePicker from '@/components/ShopCascadePicker.vue'
import { get } from '@/utils/request'
// 卡片信息接口
@@ -18,6 +18,7 @@
imsi ?: string
msisdn ?: string
virtual_no ?: string
device_virtual_no ?: string | null
virtual_number ?: string // 企业端字段
status ?: number
status_name ?: string
@@ -70,14 +71,63 @@
// 用户信息
const userInfo = ref<UserInfo | null>(null)
const sessionButtonCodes = ref<string[]>([])
const ASSET_ACTION_BUTTON_CODES = {
cardStop: 'asset:card_stop_h5',
cardStart: 'asset:card_start_h5',
deviceStop: 'asset:device_stop_h5',
deviceStart: 'asset:device_start_h5',
} as const
type CardSearchType = 'iccid' | 'virtual_no' | 'msisdn'
type DeviceSearchType = 'device_name' | 'virtual_no' | 'imei'
interface SearchTypeOption {
label : string
value : string
}
interface StandaloneCardsParams {
page : number
page_size : number
carrier_id ?: number
status ?: number
iccid ?: string
virtual_no ?: string
msisdn ?: string
shop_id ?: number
}
interface DevicesParams {
page : number
page_size : number
status ?: number
device_name ?: string
virtual_no ?: string
imei ?: string
shop_id ?: number
}
const cardSearchTypeOptions : SearchTypeOption[] = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual_no' },
{ label: '接入号', value: 'msisdn' },
]
const deviceSearchTypeOptions : SearchTypeOption[] = [
{ label: '设备号', value: 'device_name' },
{ label: '虚拟号', value: 'virtual_no' },
{ label: 'IMEI', value: 'imei' },
]
// 获取单卡列表(代理端)
function getStandaloneCards(params : any) {
function getStandaloneCards(params : StandaloneCardsParams) {
return get<{ items : CardInfo[], page : number, size : number, total : number }>('/api/admin/iot-cards/standalone', { params })
}
// 获取设备列表(代理端)
function getDevices(params : any) {
function getDevices(params : DevicesParams) {
return get<{ items : DeviceInfo[], page : number, size : number, total : number }>('/api/admin/devices', { params })
}
@@ -95,9 +145,15 @@
status : string
statusColor : string
detail : string
bindingStatus ?: string
raw : CardInfo | DeviceInfo
}
interface SelectedShopFilter {
id : number
shop_name : string
}
// 当前标签
const activeTab = ref<'card' | 'device'>('card')
// 资产列表
@@ -112,15 +168,69 @@
const deviceHasMore = ref(true)
const pageSize = 20
// 搜索和筛选
const searchType = ref<'iccid' | 'virtual'>('iccid')
const searchKeyword = ref('')
const carrierFilter = ref<number | undefined>(undefined)
const statusFilter = ref<number | undefined>(undefined)
const selectedShopFilter = ref<SelectedShopFilter | null>(null)
const cardSearchType = ref<CardSearchType>('iccid')
const deviceSearchType = ref<DeviceSearchType>('device_name')
const showCarrierPicker = ref(false)
const showStatusPicker = ref(false)
const showSearchTypePicker = ref(false)
const showStatusPicker = ref(false)
const showShopPicker = ref(false)
// 运营商列表
const carrierList = ref<CarrierInfo[]>([])
const isAgent = computed(() => userInfo.value?.user_type === 3)
const currentSearchType = computed(() => activeTab.value === 'card' ? cardSearchType.value : deviceSearchType.value)
const searchTypeOptions = computed(() => activeTab.value === 'card' ? cardSearchTypeOptions : deviceSearchTypeOptions)
const selectedSearchTypeLabel = computed(() => {
return searchTypeOptions.value.find(option => option.value === currentSearchType.value)?.label
|| (activeTab.value === 'card' ? 'ICCID' : '设备号')
})
const isDefaultSearchType = computed(() => {
return activeTab.value === 'card' ? cardSearchType.value === 'iccid' : deviceSearchType.value === 'device_name'
})
const searchPlaceholder = computed(() => {
if (activeTab.value === 'card') {
if (cardSearchType.value === 'virtual_no') return '搜索虚拟号'
if (cardSearchType.value === 'msisdn') return '搜索接入号'
return '搜索ICCID'
}
if (deviceSearchType.value === 'imei') return '搜索IMEI'
if (deviceSearchType.value === 'virtual_no') return '搜索虚拟号'
return '搜索设备号'
})
const canShowDeviceStopButton = computed(() => hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.deviceStop))
const canShowDeviceStartButton = computed(() => hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.deviceStart))
function hasStatusCode(error : unknown) {
return typeof error === 'object' && error !== null && 'statusCode' in error
}
function getSessionButtonCodes() {
const cachedUserInfo = uni.getStorageSync('user_info')
if (!cachedUserInfo || typeof cachedUserInfo !== 'object' || !Array.isArray((cachedUserInfo as UserInfo).buttons)) {
return []
}
return (cachedUserInfo as UserInfo).buttons!.filter((code) => {
return typeof code === 'string' && code.trim().length > 0
})
}
function hasActionButtonPermission(code : string) {
return sessionButtonCodes.value.includes(code)
}
function canShowCardStopButton(card : CardInfo) {
return card.network_status !== 0 && hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.cardStop)
}
function canShowCardStartButton(card : CardInfo) {
return card.network_status !== 1 && hasActionButtonPermission(ASSET_ACTION_BUTTON_CODES.cardStart)
}
// 运营商选择器选项
const carrierOptions = computed(() => [
{ label: '全部运营商', value: undefined },
@@ -134,11 +244,44 @@
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
// 搜索类型选项
const searchTypeOptions = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual' }
]
function getCardBindingStatus(card : CardInfo) {
return card.device_virtual_no?.trim() ? '已绑定设备' : '未绑定设备'
}
function getCardBindingTagClass(bindingStatus ?: string) {
return bindingStatus === '已绑定设备'
? 'bg-[#e8f5e9] text-[#2e7d32]'
: 'bg-[#fff7e6] text-[#d46b08]'
}
function applyCardSearchKeyword(params : StandaloneCardsParams, keyword : string) {
if (cardSearchType.value === 'virtual_no') {
params.virtual_no = keyword
return
}
if (cardSearchType.value === 'msisdn') {
params.msisdn = keyword
return
}
params.iccid = keyword
}
function applyDeviceSearchKeyword(params : DevicesParams, keyword : string) {
if (deviceSearchType.value === 'imei') {
params.imei = keyword
return
}
if (deviceSearchType.value === 'device_name') {
params.device_name = keyword
return
}
params.virtual_no = keyword
}
// 合并后的资产列表
const assetList = computed<AssetItem[]>(() => {
@@ -150,7 +293,11 @@
status: card.network_status_name || (card.network_status === 1 ? '正常' : card.network_status === 0 ? '停机' : card.status_name || '未知'),
statusColor: (card.network_status === 1) ? '#52c41a' : (card.network_status === 0) ? '#999' : '#ff4d4f',
// 兼容企业端和代理端的字段
detail: `虚拟号: ${card.virtual_no || card.virtual_number || '-'} | 运营商: ${card.carrier_name || card.operator || '-'}`,
detail: `${[
`虚拟号: ${card.virtual_no || card.virtual_number || '-'}`,
`运营商: ${card.carrier_name || card.operator || '-'}`,
].filter(Boolean).join(' | ')}`,
bindingStatus: isAgent.value ? getCardBindingStatus(card) : undefined,
raw: card,
}))
@@ -203,7 +350,7 @@
cardHasMore.value = true
}
const params : any = {
const params : StandaloneCardsParams = {
page: cardPage.value,
page_size: pageSize,
}
@@ -215,12 +362,11 @@
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
if (searchKeyword.value) {
if (searchType.value === 'iccid') {
params.iccid = searchKeyword.value
} else {
params.virtual_no = searchKeyword.value
}
if (searchKeyword.value.trim()) {
applyCardSearchKeyword(params, searchKeyword.value.trim())
}
if (userInfo.value?.user_type === 3 && selectedShopFilter.value) {
params.shop_id = selectedShopFilter.value.id
}
let cardsData : any
@@ -249,7 +395,7 @@
}
catch (error : any) {
console.error('加载IoT卡列表失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
if (error instanceof Error && error.message && !hasStatusCode(error)) {
uni.$u.toast(error.message)
}
}
@@ -271,17 +417,20 @@
deviceHasMore.value = true
}
const params : any = {
const params : DevicesParams = {
page: devicePage.value,
page_size: pageSize,
}
if (searchKeyword.value) {
params.virtual_no = searchKeyword.value
if (searchKeyword.value.trim()) {
applyDeviceSearchKeyword(params, searchKeyword.value.trim())
}
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
if (userInfo.value?.user_type === 3 && selectedShopFilter.value) {
params.shop_id = selectedShopFilter.value.id
}
let devicesData : any
@@ -309,7 +458,7 @@
}
catch (error : any) {
console.error('加载设备列表失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
if (error instanceof Error && error.message && !hasStatusCode(error)) {
uni.$u.toast(error.message)
}
}
@@ -339,7 +488,7 @@
// 加载运营商列表
async function loadCarriers() {
try {
const response = await getCarriers({ page: 1, page_size: 100 })
const response = await getCarriers({ page: 1, size: 100 })
carrierList.value = response?.items || []
} catch (error) {
console.error('加载运营商列表失败:', error)
@@ -358,9 +507,14 @@
refreshList()
}
// 选择搜索类型
function onSearchTypeConfirm(option : { label : string, value : any }) {
searchType.value = option.value
function onSearchTypeConfirm(option : SearchTypeOption) {
if (activeTab.value === 'card') {
cardSearchType.value = option.value as CardSearchType
} else {
deviceSearchType.value = option.value as DeviceSearchType
}
refreshList()
}
// 获取当前选中的运营商名称
@@ -377,17 +531,26 @@
return option?.label || '状态'
})
const selectedShopName = computed(() => selectedShopFilter.value?.shop_name || '店铺')
const activeTabHasMore = computed(() => activeTab.value === 'card' ? cardHasMore.value : deviceHasMore.value)
function onShopConfirm(shop : SelectedShopFilter | null) {
if (!shop) return
selectedShopFilter.value = shop
refreshList()
}
// 查看资产详情
function viewAssetDetail(asset : AssetItem) {
if (asset.type === 'card') {
const card = asset.raw as CardInfo
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
url: `/pages/agent-system/asset-detail/index?iccid=${card.iccid}`,
})
} else {
const device = asset.raw as DeviceInfo
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?virtual_no=${device.virtual_no}`,
url: `/pages/agent-system/asset-detail/index?virtual_no=${device.virtual_no}`,
})
}
}
@@ -397,13 +560,7 @@
if (activeTab.value === tab) return
activeTab.value = tab
// 切换后加载对应数据
if (tab === 'card' && cardList.value.length === 0) {
loadCards(true)
} else if (tab === 'device' && deviceList.value.length === 0) {
loadDevices(true)
}
refreshList()
}
// 设备停机
@@ -411,8 +568,9 @@
event.stopPropagation()
const device = asset.raw as DeviceInfo
const identifier = device.virtual_no
if (!device.virtual_no) {
if (!identifier) {
uni.$u.toast('设备虚拟号不存在')
return
}
@@ -424,7 +582,7 @@
if (res.confirm) {
uni.showLoading({ title: '停机中...' })
try {
const result = await stopAsset(device.virtual_no)
await stopAsset(identifier)
uni.hideLoading()
// 显示停机结果 (统一接口不返回详情,仅提示成功)
@@ -452,8 +610,9 @@
event.stopPropagation()
const device = asset.raw as DeviceInfo
const identifier = device.virtual_no
if (!device.virtual_no) {
if (!identifier) {
uni.$u.toast('设备虚拟号不存在')
return
}
@@ -465,7 +624,7 @@
if (res.confirm) {
uni.showLoading({ title: '复机中...' })
try {
await startAsset(device.virtual_no)
await startAsset(identifier)
uni.hideLoading()
uni.$u.toast('复机成功')
// 刷新列表
@@ -546,20 +705,12 @@
})
}
// 滚动事件处理
function onScroll(e : any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
// 当滚动到底部附近时加载更多
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
const statusBarHeight = ref(0)
onMounted(async () => {
// 1. 获取状态栏高度
statusBarHeight.value = uni.getSystemInfoSync().statusBarHeight || 20
sessionButtonCodes.value = getSessionButtonCodes()
// 2. 加载用户信息(判断是企业端还是代理端)
try {
@@ -578,14 +729,14 @@
</script>
<template>
<view class="bg-page min-h-screen flex flex-col">
<view class="bg-page h-screen flex flex-col overflow-hidden">
<!-- 搜索区域 -->
<view class="bg-white px-4 pt-4 pb-3 mb-3">
<view class="bg-white px-4 pt-4 pb-3 shrink-0 shadow-[0_4px_18px_rgba(15,23,42,0.06)] z-10">
<!-- 搜索框 -->
<view class="relative flex items-center">
<input v-model="searchKeyword"
class="flex-1 h-10 bg-[#f1f5f9] rounded-full pl-4 pr-12 text-sm text-[#1e293b] placeholder-[#94a3b8]"
:placeholder="searchType === 'iccid' ? '搜索ICCID' : '搜索虚拟号'" @confirm="refreshList" />
:placeholder="searchPlaceholder" @confirm="refreshList" />
<view class="absolute right-1 flex items-center">
<view v-if="searchKeyword" class="w-8 h-8 flex items-center justify-center text-[#94a3b8]"
@click="searchKeyword = ''">
@@ -611,10 +762,10 @@
</view>
</view>
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0"
<view class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0"
@click="showSearchTypePicker = true">
<text class="text-12px" :class="searchType !== 'iccid' ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ searchType === 'iccid' ? 'ICCID' : '虚拟号' }}
<text class="text-12px" :class="isDefaultSearchType ? 'text-[#64748b]' : 'text-brand font-medium'">
{{ selectedSearchTypeLabel }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
@@ -636,9 +787,18 @@
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view v-if="carrierFilter !== undefined || statusFilter !== undefined"
<view v-if="isAgent"
class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0 max-w-36"
@click="showShopPicker = true">
<text class="text-12px truncate" :class="selectedShopFilter ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ selectedShopName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8] flex-shrink-0" />
</view>
<view v-if="carrierFilter !== undefined || statusFilter !== undefined || selectedShopFilter"
class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f] flex-shrink-0"
@click="carrierFilter = undefined; statusFilter = undefined; refreshList()">
@click="carrierFilter = undefined; statusFilter = undefined; selectedShopFilter = null; refreshList()">
<i class="i-mdi-close text-12px" />
<text class="text-12px font-medium">清除</text>
</view>
@@ -646,13 +806,13 @@
</view>
<!-- 列表内容区域 -->
<view class="safe-area-bottom min-h-screen overflow-y-auto">
<view class="safe-area-bottom flex-1 min-h-0 overflow-y-auto">
<!-- 加载状态 -->
<Skeleton v-if="loading && assetList.length === 0" type="list" />
<!-- 资产列表 -->
<view v-else class="flex-1 overflow-y-auto min-h-0" @scroll="onScroll">
<view v-if="assetList.length > 0" class="px-4 pb-4">
<view v-else class="min-h-full">
<view v-if="assetList.length > 0" class="px-4 pt-3 pb-4">
<view v-for="asset in assetList" :key="`${asset.type}-${asset.id}`"
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3 cursor-pointer"
@click="viewAssetDetail(asset)">
@@ -677,6 +837,14 @@
<text class="text-12px text-[#999]">
{{ asset.detail }}
</text>
<view v-if="asset.type === 'card' && asset.bindingStatus" class="mt-2">
<text :class="[
'inline-flex px-2 py-1 rounded-full text-11px font-600',
getCardBindingTagClass(asset.bindingStatus),
]">
{{ asset.bindingStatus }}
</text>
</view>
</view>
<!-- 底部区域状态标签 + 操作按钮 -->
@@ -691,42 +859,30 @@
<view class="flex items-center gap-2">
<!-- IoT卡操作按钮 -->
<template v-if="asset.type === 'card'">
<template v-if="(asset.raw as CardInfo).network_status === 0">
<view
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartCard(asset, $event)">
</view>
</template>
<template v-else-if="(asset.raw as CardInfo).network_status === 1">
<view
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopCard(asset, $event)">
停机
</view>
</template>
<template v-else>
<view
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopCard(asset, $event)">
停机
</view>
<view
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartCard(asset, $event)">
复机
</view>
</template>
<view
v-if="canShowCardStopButton(asset.raw as CardInfo)"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopCard(asset, $event)">
</view>
<view
v-if="canShowCardStartButton(asset.raw as CardInfo)"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartCard(asset, $event)">
复机
</view>
</template>
<!-- 设备操作按钮 -->
<template v-else>
<view
v-if="canShowDeviceStopButton"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#ff4d4f] text-white shadow-sm shadow-[#ff4d4f]/30 active:scale-95 transition-transform"
@click="handleStopDevice(asset, $event)">
停机
</view>
<view
v-if="canShowDeviceStartButton"
class="px-3 py-1.5 rounded-10px text-12px font-medium bg-[#52c41a] text-white shadow-sm shadow-[#52c41a]/30 active:scale-95 transition-transform"
@click="handleStartDevice(asset, $event)">
复机
@@ -738,19 +894,29 @@
</view>
</view>
<!-- 加载更多提示 -->
<view v-if="loading" class="py-4 text-center">
<i class="i-mdi-loading animate-spin text-xl text-brand mr-2" />
<text class="text-sm text-[#64748b]">加载中...</text>
<!-- 加载更多 -->
<view v-if="activeTabHasMore" class="py-4 flex justify-center">
<view
class="min-w-32 px-5 py-2.5 rounded-full text-sm font-medium transition-all flex items-center justify-center"
:class="loading
? 'bg-[#f1f5f9] text-[#94a3b8]'
: 'bg-brand text-white shadow-sm shadow-brand/30 active:scale-95'" @click="!loading && loadMore()">
<template v-if="loading">
<i class="i-mdi-loading animate-spin text-base mr-1.5" />
<text>加载中...</text>
</template>
<template v-else>
<text>加载更多</text>
</template>
</view>
</view>
<view v-else-if="!((activeTab === 'card' && cardHasMore) || (activeTab === 'device' && deviceHasMore))"
class="py-4 text-center">
<view v-else class="py-4 text-center">
<text class="text-sm text-[#94a3b8]">没有更多了</text>
</view>
</view>
<!-- 空状态 -->
<view v-else class="px-4">
<view v-else class="px-4 pt-3 pb-4">
<view class="bg-white rounded-2xl py-12 flex flex-col items-center">
<i class="i-mdi-package-variant-closed text-6xl text-[#cbd5e1] mb-3" />
<text class="text-base text-[#64748b] mb-1">暂无数据</text>
@@ -758,17 +924,23 @@
</view>
</view>
</view>
<!-- 运营商选择器 -->
<SimplePicker v-model:show="showCarrierPicker" :options="carrierOptions" title="选择运营商"
@confirm="onCarrierConfirm" />
<!-- 状态选择器 -->
<SimplePicker v-model:show="showStatusPicker" :options="statusOptions" title="选择状态" @confirm="onStatusConfirm" />
<!-- 搜索类型选择器 -->
<SimplePicker v-model:show="showSearchTypePicker" :options="searchTypeOptions" title="选择搜索类型"
@confirm="onSearchTypeConfirm" />
</view>
<!-- 运营商选择器 -->
<SimplePicker v-model:show="showCarrierPicker" :options="carrierOptions" :selected-value="carrierFilter"
auto-confirm title="选择运营商"
@confirm="onCarrierConfirm" />
<!-- 搜索字段选择器 -->
<SimplePicker v-model:show="showSearchTypePicker" :options="searchTypeOptions" :selected-value="currentSearchType"
auto-confirm title="选择搜索字段"
@confirm="onSearchTypeConfirm" />
<!-- 状态选择器 -->
<SimplePicker v-model:show="showStatusPicker" :options="statusOptions" :selected-value="statusFilter"
auto-confirm title="选择状态" @confirm="onStatusConfirm" />
<ShopCascadePicker v-model:show="showShopPicker" :selected-shop="selectedShopFilter" title="选择店铺"
@confirm="onShopConfirm" />
</view>
</template>

View File

@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { getFundSummary, getCommissionRecords, getCommissionStats } from '@/api/commission'
import type { FundSummary, CommissionRecord, CommissionStats } from '@/api/commission'
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
import SimplePicker from '@/components/SimplePicker.vue'
type LoginUserInfo = {
@@ -13,7 +14,6 @@ const commissionSummary = ref<FundSummary | null>(null)
const shopId = ref<number>(0)
const commissionRecords = ref<CommissionRecord[]>([])
const commissionStats = ref<CommissionStats | null>(null)
const loading = ref(false)
const loadingRecords = ref(false)
const page = ref(1)
const pageSize = 20
@@ -86,7 +86,7 @@ onMounted(() => {
async function loadCommissionSummary() {
try {
const userInfo = getLoginUserInfo()
commissionSummary.value = await getFundSummary(userInfo?.username ? { shop_name: userInfo.username } : undefined)
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined)
} catch (error) {
console.error('加载佣金概览失败:', error)
}
@@ -153,12 +153,6 @@ function loadMore() {
}
}
function onScroll(e: any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
</script>
<template>
@@ -205,37 +199,7 @@ function onScroll(e: any) {
<!-- 内容区域 -->
<view class="px-4 pb-4">
<!-- 概览卡片 -->
<view class="bg-white rounded-16px p-5 mt-4 shadow-[0_2px_12px_rgba(0,0,0,0.08)]">
<view class="flex items-center justify-between">
<view>
<text class="text-12px text-[#999]">可提现佣金</text>
<text class="text-32px font-700 text-[#212121] block mt-1">
{{ commissionSummary ? formatAmount(commissionSummary.available_commission) : '--' }}
</text>
</view>
<image src="@/static/icons/佣金中心.png" class="w-12 h-12 rounded-12px" mode="aspectFit" />
</view>
<view class="flex gap-3 mt-4 pt-4 border-t border-[#f5f5f5]">
<view class="flex-1 text-center">
<text class="text-11px text-[#999]">累计佣金</text>
<text class="text-14px font-600 text-[#212121] block mt-1">
{{ commissionSummary ? formatAmount(commissionSummary.total_commission) : '--' }}
</text>
</view>
<view class="flex-1 text-center border-l border-[#f5f5f5]">
<text class="text-11px text-[#999]">已提现</text>
<text class="text-14px font-600 text-[#52c41a] block mt-1">
{{ commissionSummary ? formatAmount(commissionSummary.withdrawn_commission) : '--' }}
</text>
</view>
<view class="flex-1 text-center border-l border-[#f5f5f5]">
<text class="text-11px text-[#999]">冻结</text>
<text class="text-14px font-600 text-[#ff4d4f] block mt-1">
{{ commissionSummary ? formatAmount(commissionSummary.frozen_commission) : '--' }}
</text>
</view>
</view>
</view>
<AgentFundSummaryCard class="mt-4" :summary="commissionSummary" />
<!-- 佣金统计 -->
<view class="bg-white rounded-16px p-4 mt-3 shadow-[0_2px_8px_rgba(0,0,0,0.04)]">
@@ -297,7 +261,6 @@ function onScroll(e: any) {
</view>
<view class="flex items-center justify-between text-12px text-[#999]">
<text>{{ formatTime(record.created_at) }}</text>
<text v-if="record.order_id">订单 {{ record.order_id }}</text>
</view>
</view>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
// 直接跳转到佣金中心
onMounted(() => {
uni.redirectTo({
url: '/pages/agent-system/commission-center/index',
})
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen flex items-center justify-center">
<text class="text-14px text-[#999]">跳转中...</text>
</view>
</template>

View File

@@ -54,7 +54,7 @@ const stats = computed(() => {
async function loadCarriers() {
try {
const response = await getCarriers({ page: 1, size: 50 })
const response = await getCarriers({ page: 1, size: 100 })
carrierList.value = response?.items || []
} catch (error) {
console.error('加载运营商列表失败:', error)
@@ -128,7 +128,7 @@ async function loadEnterpriseCards(isRefresh = false) {
function viewCardDetail(card: any) {
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
url: `/pages/agent-system/asset-detail/index?iccid=${card.iccid}`,
})
}

View File

@@ -74,7 +74,7 @@ async function loadEnterpriseDevices(isRefresh = false) {
function viewDeviceDetail(device: any) {
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?virtual_no=${device.virtual_no}`,
url: `/pages/agent-system/asset-detail/index?virtual_no=${device.virtual_no}`,
})
}

View File

@@ -5,7 +5,8 @@
import type { UserInfo } from '@/api/auth'
import { getFundSummary, getCommissionDailyStats } from '@/api/commission'
import type { FundSummary, DailyCommissionStats } from '@/api/commission'
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
@@ -45,78 +46,24 @@
return `${month}-${day}`
}
function getTodayDate() {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const menuItems = computed(() => {
const menus = [
{
title: '资产列表',
desc: userInfo.value?.user_type === 3 ? '代理资产列表' : '企业资产列表',
icon: '/static/icons/资产列表.png',
path: '/pages/agent-system/assets/index',
},
]
// 企业端显示授权卡和授权设备 (仅 user_type === 4)
if (userInfo.value?.user_type === 4) {
menus.push(
{
title: '授权卡列表',
desc: '企业授权IoT卡',
icon: '/static/icons/授权卡列表.png',
path: '/pages/agent-system/enterprise-cards/index',
},
{
title: '授权设备列表',
desc: '企业授权设备',
icon: '/static/icons/授权设备列表.png',
path: '/pages/agent-system/enterprise-devices/index',
}
)
}
// 仅代理端显示佣金和提现
if (userInfo.value?.user_type === 3) {
menus.push(
{
title: '佣金中心',
desc: '收益统计',
icon: '/static/icons/佣金中心.png',
path: '/pages/agent-system/commission-center/index',
},
{
title: '提现管理',
desc: '申请提现',
icon: '/static/icons/提现管理.png',
path: '/pages/agent-system/withdraw/index',
}
)
}
return menus
})
// 加载佣金数据
async function loadCommissionData(shopName?: string) {
if (!isAgent.value || !shopId.value) return
async function loadCommissionData(username?: string) {
if (!isAgent.value) return
loadingCommission.value = true
try {
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
const summary = await getFundSummary(shopName ? { shop_name: shopName } : undefined)
const summary = await getFundSummary(username ? { username } : undefined)
commissionSummary.value = summary
const today = getTodayDate()
if (!shopId.value) {
dailyStats.value = []
return
}
const todayStats = await getCommissionDailyStats(shopId.value, {
days: 1,
start_date: today,
end_date: today,
})
dailyStats.value = todayStats || []
return
@@ -143,24 +90,20 @@
userInfo.value = user
// 设置店铺ID
if (user.shop_id) {
shopId.value = user.shop_id
}
const loginUserInfo = getLoginUserInfo()
const fundSummaryShopName = loginUserInfo?.username || user.username
console.log('首页数据加载成功:', { user })
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0
const fundSummaryUsername = loginUserInfo?.username || user.username
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData(fundSummaryShopName)
await loadCommissionData(fundSummaryUsername)
}
}
catch (error : any) {
console.error('加载首页数据失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
const errorWithStatus = error as { statusCode?: number } | undefined
if (error instanceof Error && error.message && !errorWithStatus?.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
@@ -244,43 +187,19 @@
</view>
</view>
<!-- 代理端: 佣金概览 -->
<view v-if="isAgent && commissionSummary" class="px-4 pt-4">
<text class="text-16px font-700 text-[#212121] block mb-3">可提现佣金</text>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="grid grid-cols-4 gap-3">
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">累计</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.total_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">冻结</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.frozen_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">已提现</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.withdrawn_commission) }}
</text>
</view>
<view class="text-center">
<text class="text-11px text-[#999] block mb-1">可提现</text>
<text class="text-14px font-600 text-[#212121]">
{{ formatAmount(commissionSummary.available_commission) }}
</text>
</view>
</view>
</view>
<!-- 代理端: 佣金展示 -->
<view v-if="isAgent" class="px-4 pt-4">
<AgentFundSummaryCard
class="mb-3"
:summary="commissionSummary"
@click="navigateTo('/pages/agent-system/commission-center/index')"
/>
<!-- 今日佣金 -->
<!-- 最近一天佣金 -->
<view>
<view class="flex items-center justify-between mb-3">
<text class="text-16px font-700 text-[#212121]">今日佣金</text>
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">更多</text>
<text class="text-16px font-700 text-[#212121]">最近一天佣金</text>
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">明细</text>
</view>
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<view class="grid grid-cols-3 gap-3">
@@ -304,7 +223,7 @@
</view>
</view>
<view v-if="!todayStat" class="mt-3 pt-3 border-t border-[#f5f5f5] text-center">
<text class="text-12px text-[#999]">今日暂无佣金数据</text>
<text class="text-12px text-[#999]">最近一天暂无佣金数据</text>
</view>
</view>
</view>

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen flex items-center justify-center pb-safe">
<view class="px-6 text-center">
<i class="i-mdi-hammer-wrench text-64px text-[#ccc] block mb-4" />
<text class="text-18px font-600 text-[#333] block mb-2">功能开发中</text>
<text class="text-14px text-[#999] block mb-6">
我的套餐功能正在开发中敬请期待
</text>
<view
class="bg-[#212121] text-white rounded-full px-8 py-3 inline-block"
@click="goBack"
>
<text class="text-15px">返回</text>
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,44 @@
import type { AssetPackage } from '@/api/assets'
export function getPackageUsedMb(pkg?: AssetPackage | null) {
if (!pkg)
return 0
return Number(pkg.enable_virtual_data ? pkg.virtual_used_mb : pkg.real_used_mb) || 0
}
export function getPackageTotalMb(pkg?: AssetPackage | null) {
if (!pkg)
return 0
return Number(pkg.real_total_mb) || 0
}
export function getPackageRemainMb(pkg?: AssetPackage | null) {
return Math.max(0, getPackageTotalMb(pkg) - getPackageUsedMb(pkg))
}
export function getPackageUsagePercent(pkg?: AssetPackage | null) {
const total = getPackageTotalMb(pkg)
if (total <= 0)
return 0
return Math.min(100, (getPackageUsedMb(pkg) / total) * 100)
}
export function formatPackageDataSize(mb?: number | null) {
const value = Number(mb) || 0
if (value >= 1024)
return `${(value / 1024).toFixed(2)}GB`
return `${value.toFixed(2)}MB`
}
export function formatPackagePrice(amount?: number | null) {
if (amount === undefined || amount === null)
return '-'
const yuan = amount / 100
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`
}

View File

@@ -1,245 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getEnterpriseCards } from '@/api/enterprise'
import { getUserInfo } from '@/api/auth'
interface CardInfo {
id: number
iccid: string
msisdn: string
virtual_no?: string
carrier_id: number
carrier_name?: string
package_name?: string
status: number
status_name: string
network_status: number
network_status_name: string
}
// 企业ID
const enterpriseId = ref<number>(0)
// IoT卡列表
const cardList = ref<CardInfo[]>([])
// 加载状态
const loading = ref(true)
// 套餐统计
const packageStats = computed(() => {
const total = cardList.value.length
const hasPackage = cardList.value.filter(c => c.package_name).length
const noPackage = total - hasPackage
return { total, hasPackage, noPackage }
})
// 按套餐分组
const packageGroups = computed(() => {
const groups = new Map<string, CardInfo[]>()
cardList.value.forEach(card => {
const pkg = card.package_name || '无套餐'
if (!groups.has(pkg)) {
groups.set(pkg, [])
}
groups.get(pkg)!.push(card)
})
return Array.from(groups.entries()).map(([name, cards]) => ({
name,
count: cards.length,
cards,
}))
})
// 加载数据
async function loadData() {
loading.value = true
try {
// 获取企业ID
if (!enterpriseId.value) {
const userInfo = uni.getStorageSync('user_info')
if (userInfo?.enterprise_id) {
enterpriseId.value = userInfo.enterprise_id
} else {
const apiUserInfo = await getUserInfo()
if (!apiUserInfo.enterprise_id) {
throw new Error('未找到企业ID')
}
enterpriseId.value = apiUserInfo.enterprise_id
}
}
// 获取企业卡列表
const response = await getEnterpriseCards(enterpriseId.value)
cardList.value = response?.items || []
} catch (error: any) {
console.error('加载数据失败:', error)
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
} finally {
loading.value = false
}
}
// 查看IoT卡详情
function viewCardDetail(card: CardInfo) {
uni.navigateTo({
url: `/pages/agent-system/asset-search/index?iccid=${card.iccid}`,
})
}
// 获取卡片状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#f5f5f5', text: '#999' }, // 在库
2: { bg: '#e8f5e9', text: '#2e7d32' }, // 已分销
3: { bg: '#ffebee', text: '#c62828' }, // 已停用
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 获取网络状态颜色
function getNetworkStatusColor(status: number) {
return status === 1
? { bg: '#e8f5e9', text: '#2e7d32' }
: { bg: '#ffebee', text: '#c62828' }
}
onMounted(() => {
loadData()
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<!-- 顶部统计 -->
<view class="px-4 pt-4 pb-3">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<text class="text-16px font-700 text-[#212121] block mb-3">套餐统计</text>
<view class="grid grid-cols-3 gap-3">
<view class="text-center">
<text class="text-24px font-700 text-[#212121] block">{{ packageStats.total }}</text>
<text class="text-11px text-[#999] mt-1">总IoT卡</text>
</view>
<view class="text-center">
<text class="text-24px font-700 text-[var(--brand-primary)] block">{{ packageStats.hasPackage }}</text>
<text class="text-11px text-[#999] mt-1">已订购</text>
</view>
<view class="text-center">
<text class="text-24px font-700 text-[#999] block">{{ packageStats.noPackage }}</text>
<text class="text-11px text-[#999] mt-1">未订购</text>
</view>
</view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="pt-20 flex flex-col items-center">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 套餐分组列表 -->
<view v-else class="px-4 pb-4">
<view class="space-y-3">
<view
v-for="group in packageGroups"
:key="group.name"
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
<!-- 套餐头部 -->
<view class="px-4 py-3 bg-[#f8fafc] border-b-1px border-[#f1f5f9]">
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<i class="i-mdi-package-variant text-20px text-[var(--brand-primary)]" />
<text class="text-15px font-600 text-[#212121]">{{ group.name }}</text>
</view>
<text class="text-12px text-[#999]">{{ group.count }} </text>
</view>
</view>
<!-- IoT卡列表 -->
<view class="px-4 py-2">
<view
v-for="(card, index) in group.cards"
:key="card.id"
class="py-3 active:bg-[#f8fafc] transition-all"
:class="{ 'border-t-1px border-[#f1f5f9]': index > 0 }"
@click="viewCardDetail(card)"
>
<view class="flex items-start justify-between">
<view class="flex-1 min-w-0">
<!-- ICCID -->
<text class="text-13px text-[#212121] block mb-1 font-500">
{{ card.iccid }}
</text>
<!-- 电话号码 -->
<view class="flex items-center gap-2 mb-1">
<i class="i-mdi-phone text-14px text-[#999]" />
<text class="text-12px text-[#666]">
{{ card.msisdn }}
</text>
</view>
<!-- 状态标签 -->
<view class="flex items-center gap-2">
<!-- 卡片状态 -->
<view
:style="{
backgroundColor: getStatusColor(card.status).bg,
color: getStatusColor(card.status).text,
}"
class="px-2 py-0.5 rounded text-10px font-600"
>
{{ card.status_name }}
</view>
<!-- 网络状态 -->
<view
:style="{
backgroundColor: getNetworkStatusColor(card.network_status).bg,
color: getNetworkStatusColor(card.network_status).text,
}"
class="px-2 py-0.5 rounded text-10px font-600"
>
{{ card.network_status_name }}
</view>
</view>
</view>
<!-- 右侧箭头 -->
<view class="flex items-center ml-3">
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="packageGroups.length === 0" class="bg-white rounded-16px py-16 flex flex-col items-center">
<i class="i-mdi-package-variant-closed text-64px text-[#ddd] mb-3" />
<text class="text-15px text-[#999]">暂无套餐数据</text>
</view>
</view>
</view>
</template>
<style scoped>
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen flex items-center justify-center pb-safe">
<view class="px-6 text-center">
<i class="i-mdi-hammer-wrench text-64px text-[#ccc] block mb-4" />
<text class="text-18px font-600 text-[#333] block mb-2">功能开发中</text>
<text class="text-14px text-[#999] block mb-6">
标签管理功能正在开发中敬请期待
</text>
<view
class="bg-[#212121] text-white rounded-full px-8 py-3 inline-block"
@click="goBack"
>
<text class="text-15px">返回</text>
</view>
</view>
</view>
</template>

View File

@@ -43,7 +43,6 @@ async function loadUserInfo() {
try {
// 调用 API 获取用户信息
const data = await getUserInfo()
console.log('获取用户信息成功:', data)
userInfo.value = data
}
catch (error: any) {

View File

@@ -1,77 +0,0 @@
// 套餐状态枚举
export const packageStatus = {
0: { name: '待生效', color: '#666', bgColor: '#f5f5f5' },
1: { name: '生效中', color: '#212121', bgColor: '#e8e8e8' },
2: { name: '已用完', color: '#999', bgColor: '#f5f5f5' },
3: { name: '已过期', color: '#999', bgColor: '#f5f5f5' },
4: { name: '已失效', color: '#999', bgColor: '#f5f5f5' },
}
// 套餐类型
export const packageTypeMap = {
formal: '正式套餐',
addon: '加油包',
}
// 佣金来源映射
export const commissionSourceMap = {
cost_diff: '成本差价',
one_time: '一次性佣金',
tier_bonus: '层级分成',
}
// 格式化流量 (MB to GB)
export function formatDataSize(mb: number): string {
if (mb < 1024)
return `${mb}MB`
return `${(mb / 1024).toFixed(1)}GB`
}
// 计算使用百分比
export function calcUsagePercent(used: number, total: number): number {
if (total === 0)
return 0
return Math.min(Math.round((used / total) * 100), 100)
}
// 格式化时间
export function formatDate(dateStr: string | null): string {
if (!dateStr)
return '--'
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// 格式化日期时间
export function formatDateTime(dateStr: string | null): string {
if (!dateStr)
return '--'
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
// 计算剩余天数
export function calcRemainingDays(expiresAt: string | null): number | null {
if (!expiresAt)
return null
const now = new Date()
const expireDate = new Date(expiresAt)
const diff = expireDate.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
// 格式化金额 (分转元)
export function formatAmount(cents: number): string {
return (cents / 100).toFixed(2)
}
// 获取剩余天数颜色
export function getRemainingDaysColor(days: number | null): string {
if (days === null)
return '#bdbdbd'
if (days <= 7)
return '#f44336' // 红色
if (days <= 30)
return '#ff9800' // 橙色
return '#4caf50' // 绿色
}

View File

@@ -26,6 +26,7 @@ const pageSize = 20
const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const showStatusPicker = ref(false)
const MIN_WITHDRAW_AMOUNT = 100
const statusOptions = [
{ label: '全部状态', value: undefined },
@@ -41,7 +42,7 @@ const selectedStatusName = computed(() => {
return option?.label || '状态'
})
const tabs = [
const tabs: Array<{ key: 'apply' | 'records', label: string }> = [
{ key: 'apply', label: '申请提现' },
{ key: 'records', label: '提现记录' },
]
@@ -51,6 +52,8 @@ const availableAmount = computed(() => {
return commissionSummary.value.available_commission / 100
})
const canWithdrawByMinAmount = computed(() => availableAmount.value >= MIN_WITHDRAW_AMOUNT)
function formatAmount(amount: number) {
const yuan = (amount / 100).toFixed(2)
const parts = yuan.split('.')
@@ -95,7 +98,7 @@ async function loadCommissionSummary() {
loading.value = true
try {
const userInfo = getLoginUserInfo()
commissionSummary.value = await getFundSummary(userInfo?.username ? { shop_name: userInfo.username } : undefined)
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined)
withdrawAmount.value = availableAmount.value.toFixed(2)
} catch (error) {
console.error('加载佣金概览失败:', error)
@@ -144,6 +147,10 @@ async function handleSubmit() {
uni.$u.toast('请输入提现金额')
return
}
if (amount < MIN_WITHDRAW_AMOUNT) {
uni.$u.toast(`${MIN_WITHDRAW_AMOUNT}元才可提现`)
return
}
if (amount > availableAmount.value) {
uni.$u.toast('提现金额超过可用余额')
return
@@ -200,12 +207,6 @@ function loadMore() {
}
}
function onScroll(e: any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
</script>
<template>
@@ -293,10 +294,17 @@ function onScroll(e: any) {
/>
</view>
<view class="mt-6 mb-3 flex items-center gap-1.5 px-3 py-2 rounded-12px bg-[var(--color-warning-bg)]">
<i class="i-mdi-information-outline text-14px text-warning" />
<text class="text-12px" :class="canWithdrawByMinAmount ? 'text-warning' : 'text-error'">
满100元才可提现
</text>
</view>
<!-- 提交按钮 -->
<view
class="bg-brand rounded-full py-4 text-center text-16px font-600 text-white mt-6"
:class="{ 'opacity-50': submitting }"
class="bg-brand rounded-full py-4 text-center text-16px font-600 text-white"
:class="{ 'opacity-50': submitting || !canWithdrawByMinAmount }"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '确认提现' }}

View File

@@ -106,6 +106,10 @@ async function submit() {
username: account.value.trim(),
password: password.value.trim(),
});
const sessionUser = {
...res.user,
buttons: Array.isArray(res.buttons) ? res.buttons : [],
};
// 保存 access_token
setToken(res.access_token);
@@ -113,12 +117,6 @@ async function submit() {
// 保存 refresh_token 到本地存储
uni.setStorageSync('refresh_token', res.refresh_token);
// 保存用户信息到本地存储
uni.setStorageSync('user_info', res.user);
// 调试日志:查看用户信息
console.log('登录成功,用户信息:', res.user);
// 验证用户类型 (只允许 3=代理 和 4=企业 登录)
if (res.user.user_type !== 3 && res.user.user_type !== 4) {
const typeNames: Record<number, string> = {
@@ -139,9 +137,12 @@ async function submit() {
return;
}
// 保存用户信息到本地存储
uni.setStorageSync('user_info', sessionUser);
uni.showToast({
title: '登录成功',
icon: 'success',
icon: 'none',
duration: 1500,
});

View File

@@ -6,47 +6,49 @@
:root,
page {
/* === 品牌色 Brand Colors === */
--brand-primary: #1677ff; /* 主色 - 蓝色 */
--brand-primary-light: #4096ff; /* 主色浅 */
--brand-primary-dark: #0958d9; /* 主色深 */
--brand-secondary: #1e293b; /* 辅色 - 深灰/黑色 */
--brand-accent: #ffffff; /* 强调 - 白色 */
--brand-primary: #2f7d32; /* 主色 */
--brand-primary-light: #5a9f5d; /* 主色浅 */
--brand-primary-dark: #245f27; /* 主色深 */
--brand-secondary: #1f2937; /* 辅色 */
--brand-tertiary: #f4fbf5; /* 色 */
--brand-accent: #ffffff; /* 强调白 */
/* === 语义色 Semantic Colors === */
--color-success: #52c41a; /* 成功 - Green */
--color-success-bg: #f6ffed; /* 成功背景 */
--color-warning: #faad14; /* 警告 - Amber */
--color-warning-bg: #fffbe6; /* 警告背景 */
--color-error: #ff4d4f; /* 错误 - Red */
--color-error-bg: #fff2f0; /* 错误背景 */
--color-info: #1677ff; /* 信息 - Blue同主色 */
--color-info-bg: #e6f7ff; /* 信息背景 */
--color-success: #2f7d32;
--color-success-bg: #edf7ee;
--color-warning: #b7791f;
--color-warning-bg: #fff7e6;
--color-error: #c24141;
--color-error-bg: #fdf1f1;
--color-info: var(--brand-primary);
--color-info-bg: var(--brand-tertiary);
/* === 文字色阶 Text Colors === */
--text-primary: #212121; /* 标题/重要文字 */
--text-secondary: #666666; /* 正文 */
--text-tertiary: #999999; /* 辅助信息 */
--text-quaternary: #cccccc; /* 禁用/占位符 */
--text-inverse: #ffffff; /* 反色文字 */
--text-primary: #1f2937;
--text-secondary: #4b5563;
--text-tertiary: #6b7280;
--text-quaternary: #9ca3af;
--text-inverse: #ffffff;
/* === 背景色 Background Colors === */
--bg-page: #faf0f5; /* 页面背景 */
--bg-container: #ffffff; /* 容器/卡片背景 */
--bg-elevated: #ffffff; /* 悬浮层背景 */
--bg-muted: #f5f5f5; /* 静音背景 */
--bg-overlay: rgba(0, 0, 0, 0.45); /* 遮罩层 */
--bg-page: var(--brand-tertiary);
--bg-container: #ffffff;
--bg-elevated: #ffffff;
--bg-muted: #f8faf8;
--bg-overlay: rgba(31, 41, 55, 0.45);
/* === 边框色 Border Colors === */
--border-primary: #e8e8e8; /* 主边框 */
--border-secondary: #f0f0f0; /* 次边框 */
--border-light: #fafafa; /* 浅边框 */
--border-primary: #e3ece4;
--border-secondary: #edf2ee;
--border-light: #f7faf7;
/* === 阴影 Shadows === */
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px 0 rgba(0, 0, 0, 0.12);
--shadow-xl: 0 8px 24px 0 rgba(0, 0, 0, 0.15);
--shadow-brand: 0 4px 12px 0 rgba(22, 119, 255, 0.2);
--shadow-xs: 0 1px 2px 0 rgba(31, 41, 55, 0.04);
--shadow-sm: 0 1px 3px 0 rgba(31, 41, 55, 0.06);
--shadow-md: 0 4px 12px 0 rgba(31, 41, 55, 0.08);
--shadow-lg: 0 8px 20px 0 rgba(31, 41, 55, 0.12);
--shadow-xl: 0 12px 28px 0 rgba(31, 41, 55, 0.16);
--shadow-brand: 0 6px 18px 0 rgba(47, 125, 50, 0.18);
/* === 圆角 Radius === */
--radius-xs: 4px;
@@ -96,38 +98,53 @@ page {
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
/* === 兼容主题别名 Compatibility Theme Aliases === */
--theme-primary: var(--brand-primary);
--theme-primary-light: var(--brand-primary-light);
--theme-primary-dark: var(--brand-primary-dark);
--theme-success: var(--color-success);
--theme-warning: var(--color-warning);
--theme-error: var(--color-error);
--theme-main-color: var(--text-primary);
--theme-content-color: var(--text-secondary);
--theme-tips-color: var(--text-tertiary);
--theme-light-color: var(--text-quaternary);
--theme-disabled-color: var(--text-quaternary);
--theme-bg-color: var(--bg-page);
--theme-bg-color-secondary: var(--bg-container);
--theme-border-color: var(--border-primary);
}
/* === 暗色主题 Dark Theme === */
.theme-dark {
--brand-primary: #4b9cff; /* 暗色下蓝色更柔和 */
--brand-primary-light: #69b1ff;
--brand-primary-dark: #1668dc;
--color-success: #73d13d;
--color-warning: #ffc53d;
--color-error: #ff7875;
--color-info: #4b9cff;
--brand-primary-light: #74b377;
--brand-primary-dark: #245f27;
--color-warning: #f0b451;
--color-error: #ef7f7f;
--text-primary: #ffffff;
--text-secondary: #b0b8c1;
--text-tertiary: #8a8f95;
--text-quaternary: #6c757d;
--text-inverse: #1a1a1a;
--text-primary: #f8fafc;
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
--text-quaternary: #64748b;
--text-inverse: #111827;
--bg-page: #1a1a1a;
--bg-container: #2d2d2d;
--bg-elevated: #3a3a3a;
--bg-muted: #242424;
--bg-page: #111827;
--bg-container: #1f2937;
--bg-elevated: #273548;
--bg-muted: #182232;
--bg-overlay: rgba(0, 0, 0, 0.65);
--border-primary: #3a3a3a;
--border-secondary: #2d2d2d;
--border-light: #242424;
--border-primary: #314155;
--border-secondary: #273548;
--border-light: #1f2937;
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px 0 rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 16px 0 rgba(0, 0, 0, 0.5);
--shadow-xl: 0 8px 24px 0 rgba(0, 0, 0, 0.6);
--shadow-md: 0 4px 12px 0 rgba(0, 0, 0, 0.35);
--shadow-lg: 0 8px 20px 0 rgba(0, 0, 0, 0.45);
--shadow-xl: 0 12px 28px 0 rgba(0, 0, 0, 0.55);
--shadow-brand: 0 6px 18px 0 rgba(47, 125, 50, 0.22);
}
/* === 全局重置 Global Resets === */
@@ -144,7 +161,9 @@ page {
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-quaternary { color: var(--text-quaternary); }
.text-brand { color: var(--brand-primary); }
.text-brand-secondary { color: var(--brand-secondary); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
@@ -153,13 +172,15 @@ page {
.bg-container { background-color: var(--bg-container); }
.bg-muted { background-color: var(--bg-muted); }
.bg-brand { background-color: var(--brand-primary); }
.bg-brand-secondary { background-color: var(--brand-secondary); }
.bg-brand-tertiary { background-color: var(--brand-tertiary); }
.bg-brand-light { background-color: var(--brand-primary-light); }
.bg-brand-dark { background-color: var(--brand-primary-dark); }
.bg-gradient-brand { background: linear-gradient(135deg, var(--brand-primary), var(--brand-primary-light)); }
.shadow-brand-colored { box-shadow: 0 4px 16px rgba(22, 119, 255, 0.3); }
.shadow-brand-sm { box-shadow: 0 2px 12px rgba(22, 119, 255, 0.15); }
.shadow-brand-xs { box-shadow: 0 2px 8px rgba(22, 119, 255, 0.2); }
.shadow-brand-colored { box-shadow: 0 8px 22px rgba(47, 125, 50, 0.22); }
.shadow-brand-sm { box-shadow: 0 4px 14px rgba(47, 125, 50, 0.14); }
.shadow-brand-xs { box-shadow: 0 2px 8px rgba(47, 125, 50, 0.16); }
.border-brand { border-color: var(--brand-primary); }
@@ -241,7 +262,7 @@ page {
.input:focus {
border-color: var(--brand-primary);
outline: none;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
box-shadow: 0 0 0 3px rgba(47, 125, 50, 0.12);
}
.input::placeholder {

View File

@@ -2,7 +2,7 @@ import type { providerType, UserState } from './types';
import type { LoginReq } from '@/api/user/types';
import { defineStore } from 'pinia';
import { UserApi } from '@/api';
import { refreshToken } from '@/api/auth';
import { refreshToken, type UserInfo } from '@/api/auth';
import { clearToken, setToken } from '@/utils/auth';
@@ -82,12 +82,22 @@ const useUserStore = defineStore('user', {
try {
const res = await refreshToken(refresh_token);
const rawUserInfo = uni.getStorageSync('user_info');
const cachedUserInfo = rawUserInfo && typeof rawUserInfo === 'object'
? rawUserInfo as Partial<UserInfo>
: {};
const cachedButtons = Array.isArray(cachedUserInfo.buttons) ? cachedUserInfo.buttons : [];
const sessionUser = {
...cachedUserInfo,
...res.user,
buttons: Array.isArray(res.buttons) ? res.buttons : cachedButtons,
};
// 更新 access_token
setToken(res.access_token);
// 更新 refresh_token
uni.setStorageSync('refresh_token', res.refresh_token);
// 更新用户信息
uni.setStorageSync('user_info', res.user);
uni.setStorageSync('user_info', sessionUser);
return res;
}
catch (error) {

View File

@@ -4,24 +4,24 @@
/* 颜色变量 - 使用CSS变量支持主题切换 */
/* 行为相关颜色 */
$u-primary: var(--theme-primary);
$u-primary-dark: var(--theme-primary-dark);
$u-success: var(--theme-success);
$u-warning: var(--theme-warning);
$u-error: var(--theme-error);
$u-primary: var(--brand-primary);
$u-primary-dark: var(--brand-primary-dark);
$u-success: var(--color-success);
$u-warning: var(--color-warning);
$u-error: var(--color-error);
/* 文字基本颜色 */
$u-main-color: var(--theme-main-color);
$u-content-color: var(--theme-content-color);
$u-tips-color: var(--theme-tips-color);
$u-light-color: var(--theme-light-color);
$u-disabled-color: var(--theme-disabled-color);
$u-main-color: var(--text-primary);
$u-content-color: var(--text-secondary);
$u-tips-color: var(--text-tertiary);
$u-light-color: var(--text-quaternary);
$u-disabled-color: var(--text-quaternary);
/* 背景颜色 */
$u-bg-color: var(--theme-bg-color);
$u-bg-color: var(--bg-page);
/* 边框颜色 */
$u-border-color: var(--theme-border-color);
$u-border-color: var(--border-primary);
/* 尺寸变量 */

View File

@@ -29,7 +29,7 @@ export function setupRequest() {
export function request<T = any>(config: HttpRequestConfig): Promise<T> {
return new Promise((resolve, reject) => {
http.request(config).then((res: HttpResponse<IResponse<T>>) => {
console.log('[ res ] >', res);
// console.log('[ res ] >', res);
const { data } = res.data;
resolve(data as T);
}).catch((err: any) => {

View File

@@ -8,10 +8,12 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AgentFundSummaryCard: typeof import('./../src/components/AgentFundSummaryCard.vue')['default']
AgreePrivacy: typeof import('./../src/components/agree-privacy/index.vue')['default']
LangSelect: typeof import('./../src/components/lang-select/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ShopCascadePicker: typeof import('./../src/components/ShopCascadePicker.vue')['default']
SimplePicker: typeof import('./../src/components/SimplePicker.vue')['default']
Skeleton: typeof import('./../src/components/Skeleton.vue')['default']
ThemePicker: typeof import('./../src/components/theme-picker/index.vue')['default']

View File

@@ -44,7 +44,7 @@ export default defineConfig({
* @see https://github.com/unocss/unocss#shortcuts
*/
shortcuts: {
'border-base': 'border border-gray-500_10',
'border-base': 'border border-[var(--border-primary)]',
'center': 'flex justify-center items-center',
},
rules: [
@@ -53,21 +53,25 @@ export default defineConfig({
theme: {
colors: {
// 主题颜色
'primary': 'var(--theme-primary)',
'success': 'var(--theme-success)',
'warning': 'var(--theme-warning)',
'error': 'var(--theme-error)',
'primary': 'var(--brand-primary)',
'secondary': 'var(--brand-secondary)',
'tertiary': 'var(--brand-tertiary)',
'success': 'var(--color-success)',
'warning': 'var(--color-warning)',
'error': 'var(--color-error)',
// 文字颜色
'text-main': 'var(--theme-main-color)',
'text-content': 'var(--theme-content-color)',
'text-tips': 'var(--theme-tips-color)',
'text-light': 'var(--theme-light-color)',
'text-disabled': 'var(--theme-disabled-color)',
'text-main': 'var(--text-primary)',
'text-content': 'var(--text-secondary)',
'text-tips': 'var(--text-tertiary)',
'text-light': 'var(--text-quaternary)',
'text-disabled': 'var(--text-quaternary)',
// 背景颜色
'bg-main': 'var(--theme-bg-color)',
'bg-secondary': 'var(--theme-bg-color-secondary)',
'bg-main': 'var(--bg-page)',
'bg-secondary': 'var(--bg-container)',
'bg-muted': 'var(--bg-muted)',
// 边框颜色
'border-main': 'var(--theme-border-color)',
'border-main': 'var(--border-primary)',
'border-secondary': 'var(--border-secondary)',
},
},
transformers: [

View File

@@ -17,7 +17,9 @@ export default defineConfig(({ command, mode }): UserConfig => {
console.log('环境变量 env -> ', env);
const isBuild = process.env.NODE_ENV === 'production';
// const base = ['development', 'test'].includes(mode) ? '/h5/' : '/';
return {
base: '/h5/',
// 自定义env目录
envDir: './env',
resolve: {