Compare commits

..

26 Commits

Author SHA1 Message Date
luo
5216530714 fix: ui
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m38s
2026-08-24 09:37:36 +08:00
luo
ecb594dbb6 fix: config
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m0s
2026-07-29 11:22:10 +08:00
luo
f56e7cb922 fix: config
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 58s
2026-07-29 11:16:17 +08:00
luo
181ba47375 fix: config
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 57s
2026-07-29 11:13:54 +08:00
luo
e85d54683f fix: config
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m3s
2026-07-29 11:00:01 +08:00
luo
81f2724a2b fix: config
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m31s
2026-07-29 10:52:35 +08:00
luo
d060edb698 feat: add notification feature 2026-07-29 10:25:13 +08:00
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
76 changed files with 6017 additions and 6407 deletions

View File

@@ -1,9 +1,9 @@
name: 构建并部署前端到测试环境
name: 构建并部署前端到生产环境
on:
push:
branches:
- main
- develop
- dev
- test
@@ -21,13 +21,13 @@ jobs:
run: |
export PATH="$HOME/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
export GIT_SSL_NO_VERIFY=1
git clone https://git.boss160.cn/luo/agent-xm-iot.git .
git clone https://git.boss160.cn/luo/gt-agent-company.git .
git checkout ${{ github.sha }}
- name: 设置镜像标签
id: tag
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
echo "tag=dev" >> $GITHUB_OUTPUT
@@ -51,15 +51,11 @@ jobs:
docker push ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: 部署到本地(仅 main 分支)
if: github.ref == 'refs/heads/main'
- name: 部署到本地(仅 develop 分支)
if: github.ref == 'refs/heads/develop'
run: |
mkdir -p ${{ env.DEPLOY_DIR }}
echo "当前工作目录: $(pwd)"
echo "当前目录内容:"
ls -la
echo "更新部署配置文件..."
cp -v docker-compose.prod.yml ${{ env.DEPLOY_DIR }}/

View File

@@ -10,27 +10,30 @@ 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 镜像源
# 设置 pnpm 镜像源
RUN pnpm config set registry https://registry.npmmirror.com
# 复制 package.json 和 pnpm-lock.yaml利用 Docker 缓存)
COPY package.json pnpm-lock.yaml ./
# 安装依赖
# 安装所有依赖
RUN pnpm install --frozen-lockfile
# 安装 git (用于获取 commit 信息)
RUN apk add --no-cache git
# 复制源代码
COPY . .
# 构建 H5 生产版本
RUN pnpm build:h5-prod
# 使用 development 环境构建 H5
RUN pnpm run build:h5 --mode default
# ================================
# 阶段 2: 运行阶段
# ================================
FROM --platform=linux/amd64 nginx:alpine
FROM --platform=linux/amd64 nginx:1.31.2-alpine
# 使用阿里云镜像源加速
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories

View File

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

@@ -9,6 +9,9 @@ services:
- '3002:80'
networks:
- junhong-network
tmpfs:
- /run:rw
- /tmp:rw
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/health']
interval: 30s

View File

@@ -17,8 +17,22 @@ server {
add_header Content-Type text/plain;
}
location /api/ {
proxy_pass https://cmp-api.boss160.cn;
proxy_ssl_server_name on;
proxy_ssl_name cmp-api.boss160.cn;
proxy_set_header Host cmp-api.boss160.cn;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /h5/ {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /h5/index.html;
}
location = /h5 {
return 301 /h5/;
}
location = / {
@@ -30,6 +44,4 @@ server {
add_header Cache-Control "public, immutable";
access_log off;
}
error_page 404 /index.html;
}

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

@@ -18,7 +18,7 @@
### 联动范围
- 资产搜索页中的同类套餐展示:`src/pages/agent-system/asset-search/index.vue`
- 资产详情页中的同类套餐展示:`src/pages/agent-system/asset-detail/index.vue`
### 本次覆盖的 UI 区块
@@ -140,7 +140,7 @@
### 关键实现要求
- `getPackageRemainMb` 必须改为基于“当前展示的已使用值”计算
- `asset-detail` `asset-search` 不允许维护不同口径的同名逻辑
- `asset-detail` 页面内不同区块不允许维护不同口径的同名逻辑
- 格式化继续沿用现有 `formatDataSize`,本次不单独引入新的单位换算规则
---
@@ -213,7 +213,7 @@
### 验收用例 4跨页面一致性
- 同一套餐数据在 `asset-detail` `asset-search`展示结果一致
- 同一套餐数据在 `asset-detail` 页面各区块中的展示结果一致
- 当前生效套餐卡片与套餐列表摘要使用同一套字段口径
---

305
docs/通知.md Normal file
View File

@@ -0,0 +1,305 @@
# 通知接口
服务地址:`https://cmp-api.boss160.cn`
认证方式:`Bearer <JWT>`
## 通知枚举
### 通知类别
| 值 | 说明 |
| ---------- | --- |
| `approval` | 审批 |
| `expiry` | 临期 |
| `sync` | 同步 |
| `system` | 系统 |
### 通知级别
| 值 | 说明 |
| ---------- | --- |
| `info` | 提示 |
| `warning` | 警告 |
| `error` | 错误 |
| `critical` | 严重 |
## 查询通知未读数
### GET /api/admin/notifications/unread-count
查询当前认证后台账号的未过期未读通知数量,超过 99 条时显示 `99+`
#### 成功响应
```json
{
"code": 0,
"data": {
"count": 120,
"display_count": "99+"
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ------------------ | ------- | --------------------- |
| data.count | integer | 未读通知数量 |
| data.display_count | string | 徽标显示文本,超过 99 时为 `99+` |
## 查询通知未读分类汇总
### GET /api/admin/notifications/unread-summary
查询当前账号未过期通知的总未读数及分类数量。
#### 成功响应
```json
{
"code": 0,
"data": {
"approval": 2,
"expiry": 3,
"sync": 1,
"system": 0,
"total": 6
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ------------- | ------- | ------- |
| data.approval | integer | 审批类未读数量 |
| data.expiry | integer | 临期类未读数量 |
| data.sync | integer | 同步类未读数量 |
| data.system | integer | 系统类未读数量 |
| data.total | integer | 未读通知总数 |
## 查询通知列表
### GET /api/admin/notifications
查询当前认证后台账号的未过期通知,按创建时间和通知 ID 倒序返回。
#### 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------- | --- | ---------------------------------------- |
| category | string | 否 | 通知类别:`approval``expiry``sync``system` |
| type | string | 否 | 稳定通知类型 |
| severity | string | 否 | 通知级别:`info``warning``error``critical` |
| is_read | boolean | 否 | 已读状态;不传查询全部 |
| page | integer | 否 | 页码,默认 1范围 110000 |
| page_size | integer | 否 | 每页数量,默认 20范围 150 |
#### 成功响应
```json
{
"code": 0,
"data": {
"items": [
{
"id": 1,
"category": "approval",
"type": "refund_approval_pending",
"severity": "warning",
"title": "退款审批待处理",
"body": "退款单 REFUND202607250001 正在审批中",
"is_read": false,
"read_at": null,
"ref_type": "refund",
"ref_id": "100",
"ref_key": "REFUND202607250001",
"created_at": "2026-07-25T00:00:00Z"
}
],
"page": 1,
"size": 20,
"total": 1
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
#### 返回字段
| 字段 | 类型 | 说明 |
| ------------------ | ----------- | ---------------- |
| data.items | array | 通知列表 |
| data.page | integer | 当前页码 |
| data.size | integer | 每页数量 |
| data.total | integer | 总数量 |
| items[].id | integer | 通知 ID |
| items[].category | string | 通知类别 |
| items[].type | string | 稳定通知类型 |
| items[].severity | string | 通知级别 |
| items[].title | string | 纯文本标题 |
| items[].body | string | 纯文本正文 |
| items[].is_read | boolean | 是否已读 |
| items[].read_at | string/null | 首次已读时间 |
| items[].ref_type | string | 受控资源类型,不是前端路由 |
| items[].ref_id | string | 受控资源数字 ID 字符串 |
| items[].ref_key | string | 受控资源稳定 Key 或展示快照 |
| items[].created_at | string | 创建时间 |
## 标记单条通知已读
### PUT /api/admin/notifications/{id}/read
标记当前账号的一条通知为已读。通知不存在、属于其他账号或已经已读时均幂等成功。
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
| --- | ------- | --- | ------------ |
| id | integer | 是 | 通知 ID最小值为 0 |
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
| --- | ------- | --- | --------------- |
| id | integer | 是 | 通知 ID必须与路径参数一致 |
请求示例:
```json
{
"id": 1
}
```
#### 成功响应
```json
{
"code": 0,
"data": {
"success": true
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
## 批量标记通知已读
### PUT /api/admin/notifications/read-all
批量标记当前账号通知为已读。未传类别时更新全部未过期未读通知,传类别时只更新对应类别。
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
| -------- | ------ | --- | ---------------------------------------- |
| category | string | 否 | 通知类别:`approval``expiry``sync``system` |
请求示例:
```json
{
"category": "approval"
}
```
#### 成功响应
```json
{
"code": 0,
"data": {
"updated_count": 2
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ------------------ | ------- | ----------- |
| data.updated_count | integer | 本次实际更新的通知数量 |
## 解析通知受控目标
### GET /api/admin/notifications/{id}/target
校验通知属于当前账号后,复核目标资源当前权限,返回白名单结构化目标。不会返回任意 URL。
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
| --- | ------- | --- | ------------ |
| id | integer | 是 | 通知 ID最小值为 0 |
#### 成功响应
```json
{
"code": 0,
"data": {
"available": true,
"target_type": "refund_detail",
"target_id": 100,
"target_key": "REFUND202607250001"
},
"msg": "success",
"timestamp": "2026-07-25T00:00:00Z"
}
```
| 字段 | 类型 | 说明 |
| ---------------- | ------------ | ------------------ |
| data.available | boolean | 当前账号是否仍可访问目标 |
| data.target_type | string | 前端白名单目标类型,空表示不支持跳转 |
| data.target_id | integer/null | ID 型目标业务主键 |
| data.target_key | string | Key 型目标稳定定位值 |
### target_type 白名单
| 值 | 说明 |
| ----------------------- | ------- |
| `refund_detail` | 退款详情 |
| `agent_recharge_detail` | 代理充值详情 |
| `wecom_approval_detail` | 企微审批详情 |
| `iot_card_detail` | IoT 卡详情 |
| `device_detail` | 设备详情 |
| `expiring_asset_list` | 临期资产列表 |
| `shop_fund_summary` | 店铺资金概况 |
| `system_config` | 系统配置 |
## 业务规则
- 通知只返回当前认证后台账号的未过期通知。
- `ref_type``ref_id``ref_key` 是受控资源引用,不是前端 URL。
- 点击通知后必须调用目标解析接口,由 `target_type``available` 决定是否跳转。
- `available=false``target_type` 为空时,只展示通知正文,不执行跳转。
- 前端维护 `target_type` 到页面的白名单映射,不得根据通知字段拼接任意 URL。
- 已读操作必须支持幂等调用。
## 错误响应
适用于以上接口:
| HTTP 状态码 | 说明 |
| -------- | --------- |
| 400 | 请求参数错误 |
| 401 | 未认证或认证已过期 |
| 403 | 无权访问 |
| 500 | 服务器内部错误 |
错误响应示例:
```json
{
"code": 1001,
"data": {},
"msg": "参数验证失败",
"timestamp": "2026-07-25T00:00:00Z"
}
```

2
env/.env vendored
View File

@@ -5,7 +5,7 @@ VITE_APP_TITLE=代理企业管理系统
VITE_APP_ENV=development
# 接口地址
VITE_API_BASE_URL=https://cmp-api.xm-iot.cn
VITE_API_BASE_URL=https://cmp-api.boss160.cn
# 端口号
VITE_APP_PORT=9527

View File

@@ -2,7 +2,7 @@
VITE_APP_ENV=development
# 接口地址
VITE_API_BASE_URL=https://cmp-api.xm-iot.cn
VITE_API_BASE_URL=https://cmp-api.boss160.cn
# 是否使用代理 (H5开发环境使用代理解决跨域)
VITE_APP_PROXY=true

2
env/.env.test vendored
View File

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

View File

@@ -0,0 +1,29 @@
## Context
The notification API returns the response payload through the existing request wrapper, so frontend API methods should expose the typed `data` payload directly. The home page is shared by agent and enterprise accounts and is loaded after login through `/pages/agent-system/home/index`.
## Goals / Non-Goals
- Goals: expose notifications, show the newest unread message after login, and keep server read state aligned with what the user has actually seen.
- Non-Goals: notification target resolution, controlled-resource routing, push notifications, or a separate manual read button.
## Decisions
- Decision: add a dedicated notification page for the notification entry. The entry is placed in the home page's top user-information card, and the page renders notification content only; notification rows have no click navigation.
- Decision: the login page writes a short-lived pending-reminder marker after storing a valid session. The home page consumes the marker after loading the authenticated user and requests the newest unread notification.
- Decision: use `GET /api/admin/notifications/unread-count` for the entry badge and `GET /api/admin/notifications?is_read=false&page=1&page_size=1` for the login reminder. The notification page requests the newest notification page with `page_size=50`.
- Decision: call `PUT /api/admin/notifications/{id}/read` as soon as an unread notification becomes visible in the reminder or notification list, update local state optimistically, and treat the API as idempotent.
- Decision: if the unread-count or reminder request fails, keep the home page usable and do not show a fabricated notification. The normal request interceptor continues to handle authentication failures.
## Risks / Trade-offs
- Marking on render means a message can become read before the user finishes reading it; this follows the requested “seen means read” behavior.
- A notification page displays the first 50 newest records; additional pagination is outside this change unless the API data requires it.
## Migration Plan
No data migration is needed. Deploy the frontend after the notification endpoints and icon asset are available. Rolling back removes the entry and UI without changing notification records.
## Open Questions
- None for the stated behavior. The entry opens the notification page, while notification content itself never navigates to a controlled target.

View File

@@ -0,0 +1,20 @@
# Change: Add notification entry and unread reminder
## Why
The backend notification contract is documented in `docs/通知.md`, but the frontend does not expose notifications to authenticated agent or enterprise users. Users also have no immediate way to notice newly issued unread messages after signing in.
## What Changes
- Add a notification shortcut to the home page using `src/static/icons/通知.png`.
- Add a notification page that lists the current account's notifications in newest-first order.
- Show the newest unread notification in a reminder modal after a successful login redirects to the home page.
- Mark a notification as read when it is rendered as visible to the user; no manual “mark as read” action is required.
- Do not resolve `target` data and do not navigate from a notification to any controlled resource.
- Display the unread count on the home-page notification entry when it is available.
## Impact
- Affected specs: `notifications` (new capability)
- Affected code: `src/api/notifications.ts`, `src/pages/agent-system/home/index.vue`, `src/pages/agent-system/notifications/index.vue`, `src/pages.json`, and the login-to-home session flow
- Existing user-supplied assets remain in place: `docs/通知.md` and `src/static/icons/通知.png`

View File

@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: Home notification entry
The authenticated home page SHALL expose a notification entry for both supported account types. The entry MUST use `src/static/icons/通知.png`, display the current unread count when available, and open the notification page. Notification content MUST NOT resolve controlled targets or navigate to business resources.
#### Scenario: Agent sees notification entry
- **GIVEN** an authenticated agent is viewing the home page
- **WHEN** the unread-count request succeeds
- **THEN** the home page shows the notification icon and the returned unread badge text
- **AND** activating the entry opens the notification page without resolving a target
#### Scenario: Enterprise sees notification entry
- **GIVEN** an authenticated enterprise user is viewing the home page
- **WHEN** the home page renders its shortcuts
- **THEN** the notification entry is available with the same icon and behavior
#### Scenario: Unread-count failure does not block home
- **GIVEN** the authenticated home page has loaded
- **WHEN** the unread-count request fails
- **THEN** the home page remains usable
- **AND** no fabricated unread count is displayed
### Requirement: Notification list display
The notification page SHALL request the current account's non-expired notifications from `GET /api/admin/notifications`, display them newest first using the server order, and show each notification's title, body, category, severity, and creation time. Notification rows MUST be display-only and MUST NOT invoke `GET /api/admin/notifications/{id}/target` or navigate to another business page.
#### Scenario: Latest notifications are displayed
- **GIVEN** the notification page is opened
- **WHEN** the notification list request succeeds
- **THEN** the newest notification is rendered first
- **AND** each rendered notification shows its text content and read state
#### Scenario: No notifications
- **GIVEN** the notification page is opened
- **WHEN** the server returns an empty item list
- **THEN** the page shows an empty state
- **AND** it does not attempt target resolution
### Requirement: Post-login unread reminder
After a successful login redirects to the home page, the system SHALL request the newest unread notification and show it in a reminder modal when one exists. The newest unread notification MUST be the default displayed message. The reminder MUST not navigate to a controlled target.
#### Scenario: Newest unread notification is shown after login
- **GIVEN** a user has successfully logged in and is redirected to the home page
- **AND** at least one unread notification exists
- **WHEN** the home page finishes loading the authenticated session
- **THEN** a reminder modal displays the newest unread notification's title and body by default
#### Scenario: No unread notification
- **GIVEN** a user has successfully logged in and is redirected to the home page
- **WHEN** the unread notification query returns no items
- **THEN** no reminder modal is shown
#### Scenario: Reminder query failure
- **GIVEN** a user has successfully logged in and is redirected to the home page
- **WHEN** the newest unread notification query fails
- **THEN** the home page remains usable
- **AND** no reminder modal is shown
### Requirement: Read on visibility
The system SHALL mark an unread notification as read when its content becomes visible in the reminder modal or notification list. The UI MUST NOT require a manual read button, and it MUST update the local read state after initiating the read request.
#### Scenario: Reminder marks visible notification read
- **GIVEN** the newest unread notification is shown in the post-login reminder modal
- **WHEN** the modal becomes visible
- **THEN** the client calls `PUT /api/admin/notifications/{id}/read` for that notification
- **AND** the notification is treated as read locally without a manual action
#### Scenario: List marks visible unread notifications read
- **GIVEN** the notification page renders one or more unread notifications
- **WHEN** those notification rows become visible
- **THEN** the client initiates an idempotent read request for each visible unread notification
- **AND** no manual read control is rendered
#### Scenario: Read request is idempotent
- **GIVEN** a notification has already been marked read
- **WHEN** the client repeats the read request
- **THEN** the UI remains in the read state and the repeated request does not create a user-visible error

View File

@@ -0,0 +1,18 @@
## 1. API and session flow
- [x] 1.1 Add typed notification models and list, unread-count, and single-read API methods from `docs/通知.md`.
- [x] 1.2 Set a pending notification-reminder marker after a valid login session is stored.
## 2. Notification UI
- [x] 2.1 Add the notification page and register it in `src/pages.json`.
- [x] 2.2 Add the notification shortcut and unread badge to the top user-information card using `src/static/icons/通知.png`.
- [x] 2.3 Render notification content without target resolution or controlled-resource navigation.
- [x] 2.4 Show the newest unread notification in a post-login home-page reminder modal.
- [x] 2.5 Mark each notification read when it becomes visible and remove the manual read action.
## 3. Verification
- [x] 3.1 Run `pnpm type-check`.
- [x] 3.2 Run the relevant ESLint and style checks.
- [x] 3.3 Verify the login-to-home reminder, newest-first display, entry badge, and read-on-visible behavior.

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

@@ -29,6 +29,5 @@ The current agent-side asset experience exposes too much low-value information,
- `agent-asset-detail`
- Affected code:
- `src/pages/agent-system/assets/index.vue`
- `src/pages/agent-system/asset-search/index.vue`
- `src/pages/agent-system/asset-detail/index.vue`
- `src/api/assets.ts`

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

View File

@@ -49,8 +49,7 @@ This project is a Uni-app based agent and enterprise management frontend for IoT
- 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 search/detail: `src/pages/agent-system/asset-search/index.vue`
- secondary asset detail page: `src/pages/agent-system/asset-detail/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

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

@@ -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 (模糊搜索)
}
/**

72
src/api/notifications.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 通知模块 API
*/
import { get, put } from '@/utils/request';
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system';
export type NotificationSeverity = 'info' | 'warning' | 'error' | 'critical';
/**
* 通知记录
*/
export interface NotificationItem {
id: number;
category: NotificationCategory;
type: string;
severity: NotificationSeverity;
title: string;
body: string;
is_read: boolean;
read_at: string | null;
ref_type?: string | null;
ref_id?: string | null;
ref_key?: string | null;
created_at: string;
}
export interface NotificationListParams {
category?: NotificationCategory;
type?: string;
severity?: NotificationSeverity;
is_read?: boolean;
page?: number;
page_size?: number;
}
export interface NotificationListResponse {
items: NotificationItem[];
page: number;
size: number;
total: number;
}
export interface NotificationUnreadCountResponse {
count: number;
display_count: string;
}
/**
* 查询通知列表
* GET /api/admin/notifications
*/
export function getNotifications(params?: NotificationListParams) {
return get<NotificationListResponse>('/api/admin/notifications', { params });
}
/**
* 查询通知未读数
* GET /api/admin/notifications/unread-count
*/
export function getNotificationUnreadCount() {
return get<NotificationUnreadCountResponse>('/api/admin/notifications/unread-count');
}
/**
* 标记单条通知已读
* PUT /api/admin/notifications/{id}/read
*/
export function markNotificationRead(id: number) {
return put<{ success: boolean }>(`/api/admin/notifications/${id}/read`, {
data: { id },
});
}

View File

@@ -0,0 +1,184 @@
<template>
<view
v-if="variant === 'wallet'"
class="relative overflow-hidden rounded-16px from-[#8fc0ff] to-[#66a6ff] bg-gradient-to-br px-4 py-4 text-white"
>
<i class="i-mdi-wallet-outline pointer-events-none absolute top-9 text-112px text-white opacity-10 -right-7" />
<view class="relative flex items-center justify-between">
<view class="flex items-center gap-1.5">
<text class="text-13px text-white font-600">
我的钱包
</text>
<view class="h-7 w-7 center" @click.stop="toggleAmountsVisibility">
<i v-if="amountsVisible" class="i-mdi-eye-outline text-14px text-white/80" />
<i v-else class="i-mdi-eye-off-outline text-14px text-white/80" />
</view>
</view>
<view
class="flex items-center rounded-full bg-white px-3 py-1.5"
@click.stop="handleWithdraw"
>
<text class="text-11px text-[#66a6ff] font-600">
去提现
</text>
<image src="@/static/icons/右边.png" class="ml-1 h-12px w-12px" mode="aspectFit" />
</view>
</view>
<view class="relative grid grid-cols-2 mt-4 gap-5">
<view>
<text class="block text-10px text-white/70">
可提现佣金
</text>
<text class="mt-1 block break-all text-22px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.available_commission) }}
</text>
</view>
<view>
<text class="block text-10px text-white/70">
预充值余额
</text>
<text class="mt-1 block break-all text-22px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.main_balance) }}
</text>
</view>
</view>
<view class="relative grid grid-cols-3 mt-4 gap-2 border-t border-white/15 pt-3.5">
<view>
<text class="block text-10px text-white/70">
累计佣金
</text>
<text class="mt-1 block break-all text-16px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.total_commission) }}
</text>
</view>
<view>
<text class="block text-10px text-white/70">
已提现
</text>
<text class="mt-1 block break-all text-16px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.withdrawn_commission) }}
</text>
</view>
<view>
<text class="block text-10px text-white/70">
冻结
</text>
<text class="mt-1 block break-all text-16px text-white font-600 leading-tight">
{{ formatWalletAmount(summary?.frozen_commission) }}
</text>
</view>
</view>
</view>
<view v-else class="rounded-20px bg-white p-5">
<view class="rounded-18px from-[#f5fbf5] via-white to-[#f8fafc] bg-gradient-to-br 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-[#edf5ff] px-2.5 py-1">
<i class="i-mdi-wallet-outline text-brand text-13px" />
<text class="text-brand text-11px font-600">
可提现佣金
</text>
</view>
<text class="mt-3 block break-all text-34px text-[#1f2937] font-700 leading-none">
{{ formatAmountDisplay(summary?.available_commission) }}
</text>
</view>
<image src="@/static/icons/佣金中心.png" class="h-16 w-16 shrink-0" mode="aspectFit" />
</view>
</view>
<view class="grid grid-cols-2 mt-4 gap-3">
<view
v-for="metric in metrics"
:key="metric.label"
:class="metric.cardClass"
class="rounded-16px px-3 py-3"
>
<text :class="metric.labelClass" class="block text-11px">
{{ metric.label }}
</text>
<text class="mt-2 block break-all text-15px text-[#1f2937] font-700">
{{ formatAmountDisplay(metric.amount) }}
</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { FundSummary } from '@/api/commission';
import { computed, ref } from 'vue';
interface SummaryMetric {
label: string;
amount?: number | null;
cardClass: string;
labelClass: string;
}
const props = defineProps<{
summary?: FundSummary | null;
variant?: 'default' | 'wallet';
}>();
const emit = defineEmits<{
withdraw: [];
}>();
const amountsVisible = ref(true);
const metrics = computed<SummaryMetric[]>(() => [
{
label: '预充值余额',
amount: props.summary?.main_balance,
cardClass: 'bg-[#f1f6fc] border border-[#e1ecfa]',
labelClass: 'text-[var(--brand-primary-dark)]',
},
{
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) : '--';
}
function formatWalletAmount(amount?: number | null) {
if (!amountsVisible.value) return '***';
return typeof amount === 'number' ? formatAmount(amount).slice(1) : '--';
}
function toggleAmountsVisibility() {
amountsVisible.value = !amountsVisible.value;
}
function handleWithdraw() {
emit('withdraw');
}
</script>

View File

@@ -1,107 +1,3 @@
<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"
@@ -114,13 +10,19 @@ watch(
>
<view class="shop-picker-header">
<view class="shop-picker-action" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
<text class="text-sm text-[#666]">
取消
</text>
</view>
<view class="shop-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
<text class="text-base text-[#1e293b] font-600">
{{ title }}
</text>
</view>
<view class="shop-picker-action text-right" @click="handleConfirm">
<text class="text-sm text-brand font-600">确定</text>
<text class="text-brand text-sm font-600">
确定
</text>
</view>
</view>
@@ -140,7 +42,9 @@ watch(
v-for="(item, index) in breadcrumbs"
:key="item.id"
>
<text class="shop-picker-breadcrumb-separator">/</text>
<text class="shop-picker-breadcrumb-separator">
/
</text>
<text
class="shop-picker-breadcrumb-item"
:class="{ 'shop-picker-breadcrumb-item-active': index === breadcrumbs.length - 1 }"
@@ -156,7 +60,7 @@ watch(
<text class="text-12px text-[#666]">
已选店铺
</text>
<text class="text-13px font-600 text-[#212121]">
<text class="text-13px text-[#212121] font-600">
{{ selectedShop.shop_name }}
</text>
</view>
@@ -166,12 +70,16 @@ watch(
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>
<i class="i-mdi-loading text-brand animate-spin text-18px" />
<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>
<text class="text-13px text-[#94a3b8]">
暂无下级店铺
</text>
</view>
<template v-else>
@@ -185,10 +93,12 @@ watch(
class="shop-picker-item-main"
@click="selectShop(option)"
>
<text class="shop-picker-item-text">{{ option.shop_name }}</text>
<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"
class="i-mdi-check text-brand text-16px"
/>
</view>
<view
@@ -196,7 +106,9 @@ watch(
class="shop-picker-next"
@click="enterChildren(option)"
>
<text class="text-12px text-[#999]">下级</text>
<text class="text-12px text-[#999]">
下级
</text>
<i class="i-mdi-chevron-right text-14px text-[#999]" />
</view>
</view>
@@ -206,6 +118,110 @@ watch(
</view>
</template>
<script setup lang="ts">
import type { ShopCascadeItem } from '@/api/shop';
import { ref, watch } from 'vue';
import { getShopCascade } 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>
<style scoped>
.shop-picker-overlay {
position: fixed;
@@ -260,7 +276,7 @@ watch(
}
.shop-picker-breadcrumb-item-active {
color: var(--brand-primary, #1677ff);
color: var(--brand-primary, #66a6ff);
font-weight: 600;
}
@@ -318,7 +334,7 @@ watch(
}
.shop-picker-item-active .shop-picker-item-text {
color: var(--brand-primary, #1677ff);
color: var(--brand-primary, #66a6ff);
font-weight: 600;
}

View File

@@ -1,56 +1,3 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
interface PickerOption {
label: string
value: any
}
interface Props {
show: boolean
options: PickerOption[]
title?: string
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'confirm', option: PickerOption): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '请选择'
})
const emit = defineEmits<Emits>()
const selectedIndex = ref(0)
// 关闭弹窗
function close() {
emit('update:show', false)
}
// 取消
function handleCancel() {
emit('cancel')
close()
}
// 确认
function handleConfirm() {
if (props.options.length > 0) {
emit('confirm', props.options[selectedIndex.value])
}
close()
}
// 选择选项
function selectOption(index: number) {
selectedIndex.value = index
}
</script>
<template>
<view
v-if="show"
@@ -64,14 +11,28 @@ function selectOption(index: number) {
<!-- 头部 -->
<view class="simple-picker-header">
<view class="simple-picker-cancel" @click="handleCancel">
<text class="text-sm text-[#666]">取消</text>
<text class="text-sm text-[#666]">
取消
</text>
</view>
<view class="simple-picker-title">
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
<text class="text-base text-[#1e293b] font-600">
{{ title }}
</text>
</view>
<view class="simple-picker-confirm" @click="handleConfirm">
<text class="text-sm text-brand font-600">确定</text>
<view
v-if="!autoConfirm"
class="simple-picker-confirm"
@click="handleConfirm"
>
<text class="text-brand text-sm font-600">
确定
</text>
</view>
<view
v-else
class="simple-picker-confirm"
/>
</view>
<!-- 选项列表 -->
@@ -86,10 +47,12 @@ function selectOption(index: number) {
:class="{ 'simple-picker-item-active': selectedIndex === index }"
@click="selectOption(index)"
>
<text class="simple-picker-item-text">{{ option.label }}</text>
<text class="simple-picker-item-text">
{{ option.label }}
</text>
<i
v-if="selectedIndex === index"
class="i-mdi-check text-lg text-brand"
class="i-mdi-check text-brand text-lg"
/>
</view>
</scroll-view>
@@ -97,6 +60,95 @@ function selectOption(index: number) {
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
interface PickerOption {
label: string;
value: any;
}
interface Props {
show: boolean;
options: PickerOption[];
title?: string;
autoConfirm?: boolean;
selectedValue?: any;
}
interface Emits {
(e: 'update:show', value: boolean): void;
(e: 'confirm', option: PickerOption): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
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);
}
// 取消
function handleCancel() {
emit('cancel');
close();
}
// 确认
function handleConfirm() {
if (props.options.length > 0) {
emit('confirm', props.options[selectedIndex.value]);
}
close();
}
// 选择选项
function selectOption(index: number) {
selectedIndex.value = index;
if (props.autoConfirm && props.options[index]) {
emit('confirm', props.options[index]);
close();
}
}
</script>
<style scoped>
.simple-picker-overlay {
position: fixed;
@@ -176,7 +228,7 @@ function selectOption(index: number) {
}
.simple-picker-item-active .simple-picker-item-text {
color: var(--brand-primary, #1677ff);
color: var(--brand-primary, #66a6ff);
font-weight: 600;
}
</style>

View File

@@ -3,12 +3,12 @@
<view v-if="type === 'home'" class="skeleton-home">
<!-- 顶部用户信息骨架 -->
<view class="px-4 pt-4">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] px-4 py-4">
<view class="rounded-16px bg-white px-4 py-4">
<view class="flex items-center">
<view class="skeleton-circle w-56px h-56px mr-4" />
<view class="skeleton-circle mr-4 h-56px w-56px" />
<view class="flex-1">
<view class="skeleton-block h-5 w-32 mb-2" />
<view class="skeleton-block h-3 w-16 mb-2" />
<view class="skeleton-block mb-2 h-5 w-32" />
<view class="skeleton-block mb-2 h-3 w-16" />
<view class="skeleton-block h-3 w-24" />
</view>
</view>
@@ -17,19 +17,19 @@
<!-- 数据卡片骨架 -->
<view class="px-4 pt-4">
<view class="skeleton-block h-5 w-24 mb-3" />
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
<view class="skeleton-block mb-3 h-5 w-24" />
<view class="rounded-16px bg-white p-4">
<view class="flex justify-between">
<view>
<view class="skeleton-block h-3 w-20 mb-1" />
<view class="skeleton-block mb-1 h-3 w-20" />
<view class="skeleton-block h-5 w-24" />
</view>
<view>
<view class="skeleton-block h-3 w-16 mb-1" />
<view class="skeleton-block mb-1 h-3 w-16" />
<view class="skeleton-block h-5 w-20" />
</view>
<view class="text-right">
<view class="skeleton-block h-3 w-20 mb-1" />
<view class="skeleton-block mb-1 h-3 w-20" />
<view class="skeleton-block h-5 w-24" />
</view>
</view>
@@ -37,13 +37,13 @@
</view>
<!-- 快捷功能骨架 -->
<view class="px-4 pt-6 pb-4">
<view class="skeleton-block h-5 w-24 mb-3" />
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<view class="px-4 pb-4 pt-6">
<view class="skeleton-block mb-3 h-5 w-24" />
<view class="overflow-hidden rounded-16px bg-white">
<view v-for="i in 5" :key="i" class="flex items-center px-4 py-4" :class="{ 'border-t border-[#f1f5f9]': i > 1 }">
<view class="skeleton-circle w-10 h-10 mr-3" />
<view class="skeleton-circle mr-3 h-10 w-10" />
<view class="flex-1">
<view class="skeleton-block h-4 w-20 mb-1" />
<view class="skeleton-block mb-1 h-4 w-20" />
<view class="skeleton-block h-3 w-32" />
</view>
</view>
@@ -53,15 +53,15 @@
<view v-else-if="type === 'list'" class="skeleton-list">
<view class="px-4 pt-4">
<view v-for="i in 3" :key="i" class="bg-white rounded-2xl p-4 mb-3">
<view class="flex items-start justify-between mb-3">
<view v-for="i in 3" :key="i" class="mb-3 rounded-2xl bg-white p-4">
<view class="mb-3 flex items-start justify-between">
<view class="flex-1">
<view class="skeleton-block h-4 w-40 mb-2" />
<view class="skeleton-block mb-2 h-4 w-40" />
<view class="skeleton-block h-3 w-48" />
</view>
<view class="skeleton-block h-5 w-12 rounded-full" />
</view>
<view class="flex items-center justify-between pt-3 border-t border-[#f1f5f9]">
<view class="flex items-center justify-between border-t border-[#f1f5f9] pt-3">
<view class="skeleton-block h-3 w-16" />
<view class="skeleton-block h-4 w-4" />
</view>
@@ -73,8 +73,8 @@
<script setup lang="ts">
defineProps<{
type?: 'home' | 'list'
}>()
type?: 'home' | 'list';
}>();
</script>
<style scoped>

View File

@@ -16,25 +16,9 @@
}
},
{
"path": "pages/agent-system/my-packages/index",
"path": "pages/agent-system/notifications/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": "套餐列表",
"navigationBarTitleText": "通知",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
@@ -47,22 +31,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

@@ -1,105 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAssetWallet } from '@/api/wallet'
import type { AssetWalletInfo, AssetType } from '@/api/wallet'
import { onLoad } from '@dcloudio/uni-app'
// 资产类型和ID (从URL参数获取)
const assetType = ref<AssetType>('card')
const assetId = ref<number>(0)
// 钱包信息
const walletInfo = ref<AssetWalletInfo | null>(null)
// 加载状态
const loading = ref(false)
// 格式化金额(分转元)
function formatAmount(amount : number) {
return `¥${(amount / 100).toFixed(2)}`
}
// 获取钱包状态文本
function getStatusText(status : number, statusText ?: string) {
if (statusText) return statusText
const map : Record<number, string> = {
1: '正常',
2: '冻结',
3: '已停用',
}
return map[status] || '未知'
}
// 获取钱包状态颜色
function getStatusColor(status : number) {
const map : Record<number, { bg : string, text : string }> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 正常
2: { bg: '#fff3e0', text: '#e65100' }, // 冻结
3: { bg: '#f5f5f5', text: '#999' }, // 已停用
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
// 格式化时间
function formatTime(time : string) {
if (!time) return '-'
const date = new Date(time)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 页面加载时获取参数
onLoad((options) => {
if (options.asset_type) {
assetType.value = options.asset_type as AssetType
}
if (options.asset_id) {
assetId.value = Number(options.asset_id)
}
})
// 页面挂载
onMounted(() => {
if (assetId.value) {
loadWalletInfo()
}
})
// 加载钱包信息
async function loadWalletInfo() {
if (!assetId.value) {
uni.$u.toast('缺少资产ID')
return
}
loading.value = true
try {
const wallet = await getAssetWallet(assetType.value, assetId.value)
walletInfo.value = wallet
}
catch (error) {
console.error('加载资产钱包信息失败:', error)
}
finally {
loading.value = false
}
}
// 返回上一页
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<view class="min-h-screen bg-[#fafafa] pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<view v-if="loading" class="center pt-20">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
@@ -107,34 +9,41 @@
<view v-else-if="walletInfo" class="px-4 pt-4">
<!-- 余额卡片 -->
<view
class="bg-gradient-to-r from-[var(--brand-primary)] to-[var(--brand-primary-light)] rounded-16px shadow-brand-colored p-4 mb-3">
<view class="flex items-center justify-between mb-3">
class="mb-3 rounded-16px from-[var(--brand-primary)] to-[var(--brand-primary-light)] bg-gradient-to-r p-4"
>
<view class="mb-3 flex items-center justify-between">
<view>
<text class="text-13px text-white/70 block mb-1">
<text class="mb-1 block text-13px text-white/70">
可用余额
</text>
<text class="text-32px font-700 text-white block">
<text class="block text-32px text-white font-700">
{{ formatAmount(walletInfo.available_balance) }}
</text>
</view>
<view :style="{
<view
:style="{
backgroundColor: getStatusColor(walletInfo.status).bg,
color: getStatusColor(walletInfo.status).text,
}" class="px-3 py-1 rounded-8px text-12px font-600">
}" class="rounded-8px px-3 py-1 text-12px font-600"
>
{{ getStatusText(walletInfo.status, walletInfo.status_text) }}
</view>
</view>
<view class="grid grid-cols-2 gap-3 pt-3 border-t-1px border-white/20">
<view class="grid grid-cols-2 gap-3 border-t-1px border-white/20 pt-3">
<view>
<text class="text-11px text-white/70 block mb-1">总余额</text>
<text class="text-14px font-600 text-white">
<text class="mb-1 block text-11px text-white/70">
总余额
</text>
<text class="text-14px text-white font-600">
{{ formatAmount(walletInfo.balance) }}
</text>
</view>
<view>
<text class="text-11px text-white/70 block mb-1">冻结金额</text>
<text class="text-14px font-600 text-white">
<text class="mb-1 block text-11px text-white/70">
冻结金额
</text>
<text class="text-14px text-white font-600">
{{ formatAmount(walletInfo.frozen_balance) }}
</text>
</view>
@@ -142,52 +51,64 @@
</view>
<!-- 钱包详情 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
<view class="flex items-center gap-2 mb-3">
<view class="mb-3 rounded-16px bg-white p-4">
<view class="mb-3 flex items-center gap-2">
<i class="i-mdi-information-outline text-20px text-[var(--brand-primary)]" />
<text class="text-15px font-600 text-[#212121]">
<text class="text-15px text-[#212121] font-600">
钱包详情
</text>
</view>
<view class="space-y-2">
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">钱包ID</text>
<text class="text-13px text-[#666]">
钱包ID
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.wallet_id }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">资产ID</text>
<text class="text-13px text-[#666]">
资产ID
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.resource_id }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">资产类型</text>
<text class="text-13px text-[#666]">
资产类型
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.resource_type === 'iot_card' ? 'IoT卡' : '设备' }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">币种</text>
<text class="text-13px text-[#666]">
币种
</text>
<text class="text-13px text-[#212121]">
{{ walletInfo.currency }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">创建时间</text>
<text class="text-13px text-[#666]">
创建时间
</text>
<text class="text-11px text-[#999]">
{{ formatTime(walletInfo.created_at) }}
</text>
</view>
<view class="flex items-center justify-between">
<text class="text-13px text-[#666]">更新时间</text>
<text class="text-13px text-[#666]">
更新时间
</text>
<text class="text-11px text-[#999]">
{{ formatTime(walletInfo.updated_at) }}
</text>
@@ -196,11 +117,11 @@
</view>
<!-- 温馨提示 -->
<view class="bg-[#fff3e0] rounded-12px p-3 mb-3">
<view class="mb-3 rounded-12px bg-[#fff3e0] p-3">
<view class="flex items-start gap-2">
<i class="i-mdi-information text-16px text-[#e65100] mt-0.5" />
<i class="i-mdi-information mt-0.5 text-16px text-[#e65100]" />
<view class="flex-1">
<text class="text-12px text-[#666] block">
<text class="block text-12px text-[#666]">
此钱包为资产专属钱包,余额仅可用于该资产相关的费用支付
</text>
</view>
@@ -209,9 +130,9 @@
</view>
<!-- 空状态 -->
<view v-else class="pt-20 center flex-col">
<i class="i-mdi-wallet-outline text-60px text-[#ddd] mb-3" />
<text class="text-14px text-[#999] mb-2">
<view v-else class="center flex-col pt-20">
<i class="i-mdi-wallet-outline mb-3 text-60px text-[#ddd]" />
<text class="mb-2 text-14px text-[#999]">
暂无钱包信息
</text>
<text class="text-12px text-[#ccc]">
@@ -224,6 +145,104 @@
</view>
</template>
<script setup lang="ts">
import type { AssetType, AssetWalletInfo } from '@/api/wallet';
import { onLoad } from '@dcloudio/uni-app';
import { onMounted, ref } from 'vue';
import { getAssetWallet } from '@/api/wallet';
// 资产类型和ID (从URL参数获取)
const assetType = ref<AssetType>('card');
const assetId = ref<number>(0);
// 钱包信息
const walletInfo = ref<AssetWalletInfo | null>(null);
// 加载状态
const loading = ref(false);
// 格式化金额(分转元)
function formatAmount(amount: number) {
return `¥${(amount / 100).toFixed(2)}`;
}
// 获取钱包状态文本
function getStatusText(status: number, statusText?: string) {
if (statusText) return statusText;
const map: Record<number, string> = {
1: '正常',
2: '冻结',
3: '已停用',
};
return map[status] || '未知';
}
// 获取钱包状态颜色
function getStatusColor(status: number) {
const map: Record<number, { bg: string; text: string }> = {
1: { bg: '#e8f5e9', text: '#2e7d32' }, // 正常
2: { bg: '#fff3e0', text: '#e65100' }, // 冻结
3: { bg: '#f5f5f5', text: '#999' }, // 已停用
};
return map[status] || { bg: '#f5f5f5', text: '#999' };
}
// 格式化时间
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
// 页面加载时获取参数
onLoad((options) => {
if (options.asset_type) {
assetType.value = options.asset_type as AssetType;
}
if (options.asset_id) {
assetId.value = Number(options.asset_id);
}
});
// 页面挂载
onMounted(() => {
if (assetId.value) {
loadWalletInfo();
}
});
// 加载钱包信息
async function loadWalletInfo() {
if (!assetId.value) {
uni.$u.toast('缺少资产ID');
return;
}
loading.value = true;
try {
const wallet = await getAssetWallet(assetType.value, assetId.value);
walletInfo.value = wallet;
}
catch (error) {
console.error('加载资产钱包信息失败:', error);
}
finally {
loading.value = false;
}
}
// 返回上一页
function goBack() {
uni.navigateBack();
}
</script>
<style scoped>
.center {
display: flex;

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +1,50 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getFundSummary, getCommissionRecords, getCommissionStats } from '@/api/commission'
import type { FundSummary, CommissionRecord, CommissionStats } from '@/api/commission'
import SimplePicker from '@/components/SimplePicker.vue'
type LoginUserInfo = {
shop_id?: number
username?: string
}
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
const total = ref(0)
const searchKeyword = ref('')
const sourceFilter = ref<string | undefined>(undefined)
const showSourcePicker = ref(false)
const showSearchTypePicker = ref(false)
const sourceOptions = [
{ label: '全部来源', value: undefined },
{ label: '成本价差', value: 'cost_diff' },
{ label: '一次性佣金', value: 'one_time' },
]
const searchType = ref<'iccid' | 'virtual_no' | 'order_no'>('iccid')
const searchTypeOptions = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual_no' },
{ label: '订单号', value: 'order_no' }
]
const selectedSourceName = computed(() => {
if (sourceFilter.value === undefined) return '来源'
const option = sourceOptions.find(o => o.value === sourceFilter.value)
return option?.label || '来源'
})
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 getSourceText(source: string) {
const map: Record<string, string> = {
cost_diff: '成本价差',
one_time: '一次性佣金',
tier_bonus: '梯度奖励',
}
return map[source] || source
}
function getStatusText(status: number) {
return status === 1 ? '已入账' : '已失效'
}
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
return `${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')}`
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined
}
onMounted(() => {
const userInfo = getLoginUserInfo()
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id
}
loadCommissionSummary()
loadCommissionStats()
loadCommissionRecords()
})
async function loadCommissionSummary() {
try {
const userInfo = getLoginUserInfo()
commissionSummary.value = await getFundSummary(userInfo?.username ? { shop_name: userInfo.username } : undefined)
} catch (error) {
console.error('加载佣金概览失败:', error)
}
}
async function loadCommissionStats() {
if (!shopId.value) return
try {
commissionStats.value = await getCommissionStats(shopId.value)
} catch (error) {
console.error('加载佣金统计失败:', error)
}
}
async function loadCommissionRecords(isRefresh = false) {
if (!shopId.value) return
if (isRefresh) {
page.value = 1
commissionRecords.value = []
}
loadingRecords.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (sourceFilter.value) {
params.commission_source = sourceFilter.value
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.trim()
if (searchType.value === 'iccid') params.iccid = keyword
else if (searchType.value === 'virtual_no') params.virtual_no = keyword
else params.order_no = keyword
}
const response = await getCommissionRecords(shopId.value, params)
if (page.value === 1) {
commissionRecords.value = response.items || []
} else {
commissionRecords.value.push(...(response.items || []))
}
total.value = response.total || 0
} catch (error) {
console.error('加载佣金明细失败:', error)
} finally {
loadingRecords.value = false
}
}
function handleSearch() {
loadCommissionRecords(true)
}
function onSourceConfirm(option: { label: string, value: any }) {
sourceFilter.value = option.value
loadCommissionRecords(true)
}
function onSearchTypeConfirm(option: { label: string, value: any }) {
searchType.value = option.value
}
function loadMore() {
if (commissionRecords.value.length < total.value) {
page.value++
loadCommissionRecords()
}
}
function onScroll(e: any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
</script>
<template>
<view class="bg-page min-h-screen">
<!-- 顶部搜索区域 -->
<view class="bg-white px-4 pt-4 pb-3">
<view class="bg-white px-4 pb-3 pt-4">
<!-- 搜索框 -->
<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]"
class="h-10 flex-1 rounded-full bg-[#f1f5f9] pl-4 pr-12 text-sm text-[#1e293b] placeholder-[#94a3b8]"
:placeholder="searchType === 'iccid' ? '搜索ICCID' : searchType === 'virtual_no' ? '搜索虚拟号' : '搜索订单号'"
@confirm="handleSearch"
/>
>
<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 = ''">
<view
v-if="searchKeyword" class="h-8 w-8 flex items-center justify-center text-[#94a3b8]"
@click="searchKeyword = ''"
>
<i class="i-mdi-close-circle text-lg" />
</view>
<view class="w-8 h-8 flex items-center justify-center text-brand" @click="handleSearch">
<view class="text-brand h-8 w-8 flex items-center justify-center" @click="handleSearch">
<i class="i-mdi-magnify text-xl" />
</view>
</view>
</view>
<!-- 筛选器 -->
<view class="flex items-center gap-2 mt-3">
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full" @click="showSearchTypePicker = true">
<text class="text-12px text-[#64748b]">{{ searchType === 'iccid' ? 'ICCID' : searchType === 'virtual_no' ? '虚拟号' : '订单号' }}</text>
<view class="mt-3 flex items-center gap-2">
<view class="flex items-center gap-1 rounded-full bg-[#f8fafc] px-3 py-1.5" @click="showSearchTypePicker = true">
<text class="text-12px text-[#64748b]">
{{ searchType === 'iccid' ? 'ICCID' : searchType === 'virtual_no' ? '虚拟号' : '订单号' }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full" @click="showSourcePicker = true">
<text class="text-12px" :class="sourceFilter !== undefined ? 'text-brand font-medium' : 'text-[#64748b]'">{{ selectedSourceName }}</text>
<view class="flex items-center gap-1 rounded-full bg-[#f8fafc] px-3 py-1.5" @click="showSourcePicker = true">
<text class="text-12px" :class="sourceFilter !== undefined ? 'text-brand font-medium' : 'text-[#64748b]'">
{{ selectedSourceName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view v-if="sourceFilter !== undefined" class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f]"
@click="sourceFilter = undefined; loadCommissionRecords(true)">
<view
v-if="sourceFilter !== undefined" class="flex items-center gap-1 rounded-full bg-[#ff4d4f]/10 px-2 py-1.5 text-[#ff4d4f]"
@click="sourceFilter = undefined; loadCommissionRecords(true)"
>
<i class="i-mdi-close text-12px" />
<text class="text-12px font-medium">清除</text>
<text class="text-12px font-medium">
清除
</text>
</view>
</view>
</view>
@@ -205,114 +52,117 @@ 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)]">
<text class="text-14px font-600 text-[#212121]">佣金统计</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-14px text-[#212121] font-600">
佣金统计
</text>
<view class="mt-3 space-y-3">
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<view class="w-2 h-2 rounded-full bg-[#2196f3]" />
<text class="text-13px text-[#666]">成本价差</text>
<view class="h-2 w-2 rounded-full bg-[#2196f3]" />
<text class="text-13px text-[#666]">
成本价差
</text>
</view>
<view class="flex items-center gap-2">
<span class="text-11px text-[#999]">{{ commissionStats ? (commissionStats.cost_diff_percent / 10).toFixed(1) + '%' : '--' }}</span>
<text class="text-14px font-600 text-[#212121]">{{ commissionStats ? formatAmount(commissionStats.cost_diff_amount) : '--' }}</text>
<span class="text-11px text-[#999]">{{ commissionStats ? `${(commissionStats.cost_diff_percent / 10).toFixed(1)}%` : '--' }}</span>
<text class="text-14px text-[#212121] font-600">
{{ commissionStats ? formatAmount(commissionStats.cost_diff_amount) : '--' }}
</text>
</view>
</view>
<view class="flex items-center justify-between">
<view class="flex items-center gap-2">
<view class="w-2 h-2 rounded-full bg-[#ff9800]" />
<text class="text-13px text-[#666]">一次性佣金</text>
<view class="h-2 w-2 rounded-full bg-[#ff9800]" />
<text class="text-13px text-[#666]">
一次性佣金
</text>
</view>
<view class="flex items-center gap-2">
<span class="text-11px text-[#999]">{{ commissionStats ? (commissionStats.one_time_percent / 10).toFixed(1) + '%' : '--' }}</span>
<text class="text-14px font-600 text-[#212121]">{{ commissionStats ? formatAmount(commissionStats.one_time_amount) : '--' }}</text>
<span class="text-11px text-[#999]">{{ commissionStats ? `${(commissionStats.one_time_percent / 10).toFixed(1)}%` : '--' }}</span>
<text class="text-14px text-[#212121] font-600">
{{ commissionStats ? formatAmount(commissionStats.one_time_amount) : '--' }}
</text>
</view>
</view>
<view class="flex items-center justify-between pt-3 border-t border-[#f0f0f0]">
<text class="text-13px font-600 text-[#212121]">总计</text>
<text class="text-16px font-700 text-brand">{{ commissionStats ? formatAmount(commissionStats.total_amount) : '--' }}</text>
<view class="flex items-center justify-between border-t border-[#f0f0f0] pt-3">
<text class="text-13px text-[#212121] font-600">
总计
</text>
<text class="text-brand text-16px font-700">
{{ commissionStats ? formatAmount(commissionStats.total_amount) : '--' }}
</text>
</view>
</view>
</view>
<!-- 佣金明细 -->
<view class="mt-4">
<view class="flex items-center justify-between mb-3">
<text class="text-14px font-600 text-[#212121]">佣金明细</text>
<text class="text-12px text-[#999]"> {{ total }} </text>
<view class="mb-3 flex items-center justify-between">
<text class="text-14px text-[#212121] font-600">
佣金明细
</text>
<text class="text-12px text-[#999]">
{{ total }}
</text>
</view>
<view v-if="loadingRecords && page === 1" class="pt-20 flex justify-center">
<text class="text-14px text-[#64748b]">加载中...</text>
<view v-if="loadingRecords && page === 1" class="flex justify-center pt-20">
<text class="text-14px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="commissionRecords.length > 0" class="space-y-2">
<view
v-for="record in commissionRecords"
:key="record.id"
class="bg-white rounded-12px p-4"
class="rounded-12px bg-white p-4"
>
<view class="flex items-center justify-between mb-2">
<view class="mb-2 flex items-center justify-between">
<view class="flex items-center gap-2">
<text class="text-14px font-500 text-[#212121]">{{ getSourceText(record.commission_source) }}</text>
<view :class="record.status === 1 ? 'bg-[#e8f5e9] text-[#2e7d32]' : 'bg-[#f5f5f5] text-[#999]'"
class="px-2 py-0.5 rounded text-11px">
<text class="text-14px text-[#212121] font-500">
{{ getSourceText(record.commission_source) }}
</text>
<view
:class="record.status === 1 ? 'bg-[#e8f5e9] text-[#2e7d32]' : 'bg-[#f5f5f5] text-[#999]'"
class="rounded px-2 py-0.5 text-11px"
>
{{ getStatusText(record.status) }}
</view>
</view>
<text class="text-16px font-600 text-[#52c41a]">+{{ formatAmount(record.amount) }}</text>
<text class="text-16px text-[#52c41a] font-600">
+{{ formatAmount(record.amount) }}
</text>
</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 v-if="commissionRecords.length < total" class="py-4 text-center">
<text v-if="loadingRecords" class="text-12px text-[#64748b]">加载中...</text>
<text v-else class="text-12px text-brand" @click="loadMore">加载更多</text>
<text v-if="loadingRecords" class="text-12px text-[#64748b]">
加载中...
</text>
<text v-else class="text-brand text-12px" @click="loadMore">
加载更多
</text>
</view>
<view v-else class="py-4 text-center">
<text class="text-12px text-[#cbd5e1]">没有更多了</text>
<text class="text-12px text-[#cbd5e1]">
没有更多了
</text>
</view>
</view>
<view v-else class="pt-16 flex flex-col items-center">
<i class="i-mdi-file-document-outline text-5xl text-[#e0e0e0] mb-3" />
<text class="text-14px text-[#999]">{{ searchKeyword ? '未找到相关结果' : '暂无佣金记录' }}</text>
<view v-else class="flex flex-col items-center pt-16">
<i class="i-mdi-file-document-outline mb-3 text-5xl text-[#e0e0e0]" />
<text class="text-14px text-[#999]">
{{ searchKeyword ? '未找到相关结果' : '暂无佣金记录' }}
</text>
</view>
</view>
</view>
@@ -333,6 +183,167 @@ function onScroll(e: any) {
</view>
</template>
<script setup lang="ts">
import type { CommissionRecord, CommissionStats, FundSummary } from '@/api/commission';
import { computed, onMounted, ref } from 'vue';
import { getCommissionRecords, getCommissionStats, getFundSummary } from '@/api/commission';
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue';
import SimplePicker from '@/components/SimplePicker.vue';
interface LoginUserInfo {
shop_id?: number;
username?: string;
}
const commissionSummary = ref<FundSummary | null>(null);
const shopId = ref<number>(0);
const commissionRecords = ref<CommissionRecord[]>([]);
const commissionStats = ref<CommissionStats | null>(null);
const loadingRecords = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const searchKeyword = ref('');
const sourceFilter = ref<string | undefined>(undefined);
const showSourcePicker = ref(false);
const showSearchTypePicker = ref(false);
const sourceOptions = [
{ label: '全部来源', value: undefined },
{ label: '成本价差', value: 'cost_diff' },
{ label: '一次性佣金', value: 'one_time' },
];
const searchType = ref<'iccid' | 'virtual_no' | 'order_no'>('iccid');
const searchTypeOptions = [
{ label: 'ICCID', value: 'iccid' },
{ label: '虚拟号', value: 'virtual_no' },
{ label: '订单号', value: 'order_no' },
];
const selectedSourceName = computed(() => {
if (sourceFilter.value === undefined) return '来源';
const option = sourceOptions.find(o => o.value === sourceFilter.value);
return option?.label || '来源';
});
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 getSourceText(source: string) {
const map: Record<string, string> = {
cost_diff: '成本价差',
one_time: '一次性佣金',
tier_bonus: '梯度奖励',
};
return map[source] || source;
}
function getStatusText(status: number) {
return status === 1 ? '已入账' : '已失效';
}
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
return `${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')}`;
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined;
}
onMounted(() => {
const userInfo = getLoginUserInfo();
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id;
}
loadCommissionSummary();
loadCommissionStats();
loadCommissionRecords();
});
async function loadCommissionSummary() {
try {
const userInfo = getLoginUserInfo();
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined);
}
catch (error) {
console.error('加载佣金概览失败:', error);
}
}
async function loadCommissionStats() {
if (!shopId.value) return;
try {
commissionStats.value = await getCommissionStats(shopId.value);
}
catch (error) {
console.error('加载佣金统计失败:', error);
}
}
async function loadCommissionRecords(isRefresh = false) {
if (!shopId.value) return;
if (isRefresh) {
page.value = 1;
commissionRecords.value = [];
}
loadingRecords.value = true;
try {
const params: any = { page: page.value, page_size: pageSize };
if (sourceFilter.value) {
params.commission_source = sourceFilter.value;
}
if (searchKeyword.value) {
const keyword = searchKeyword.value.trim();
if (searchType.value === 'iccid') params.iccid = keyword;
else if (searchType.value === 'virtual_no') params.virtual_no = keyword;
else params.order_no = keyword;
}
const response = await getCommissionRecords(shopId.value, params);
if (page.value === 1) {
commissionRecords.value = response.items || [];
}
else {
commissionRecords.value.push(...(response.items || []));
}
total.value = response.total || 0;
}
catch (error) {
console.error('加载佣金明细失败:', error);
}
finally {
loadingRecords.value = false;
}
}
function handleSearch() {
loadCommissionRecords(true);
}
function onSourceConfirm(option: { label: string; value: any }) {
sourceFilter.value = option.value;
loadCommissionRecords(true);
}
function onSearchTypeConfirm(option: { label: string; value: any }) {
searchType.value = option.value;
}
function loadMore() {
if (commissionRecords.value.length < total.value) {
page.value++;
loadCommissionRecords();
}
}
</script>
<style scoped>
.space-y-2 > view:not(:last-child) {
margin-bottom: 8px;

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

@@ -1,400 +1,162 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
import { getFundSummary, getCommissionDailyStats } from '@/api/commission'
import type { FundSummary, DailyCommissionStats } from '@/api/commission'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 佣金概览
const commissionSummary = ref<FundSummary | null>(null)
// 每日佣金统计 (最近7天)
const dailyStats = ref<DailyCommissionStats[]>([])
// 加载状态
const loading = ref(true)
const loadingCommission = ref(false)
// 店铺ID
const shopId = ref<number>(0)
// 是否为代理账号
const isAgent = computed(() => userInfo.value?.user_type === 3)
const todayStat = computed(() => dailyStats.value[0] || null)
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as Partial<UserInfo> | undefined
}
// 格式化金额(分转元,带千分号)
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) : '--'
}
// 格式化日期 (MM-DD)
// 根据用户类型动态生成菜单
function formatDate(dateStr : string) {
const date = new Date(dateStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
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) return
loadingCommission.value = true
try {
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
const summary = await getFundSummary(shopName ? { shop_name: shopName } : undefined)
commissionSummary.value = summary
if (!shopId.value) {
dailyStats.value = []
return
}
const today = getTodayDate()
const todayStats = await getCommissionDailyStats(shopId.value, {
days: 1,
start_date: today,
end_date: today,
})
dailyStats.value = todayStats || []
return
// 2. 加载最近30天佣金统计
const stats = await getCommissionDailyStats(shopId.value, { days: 30 })
dailyStats.value = stats || []
}
catch (error) {
console.error('加载佣金数据失败:', error)
}
finally {
loadingCommission.value = false
}
}
// 加载首页数据
async function loadHomeData() {
loading.value = true
try {
// 加载用户信息
const user = await getUserInfo()
userInfo.value = user
// 设置店铺ID
const loginUserInfo = getLoginUserInfo()
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0
const fundSummaryShopName = loginUserInfo?.username || user.username
console.log('首页数据加载成功:', { user })
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData(fundSummaryShopName)
}
}
catch (error : any) {
console.error('加载首页数据失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 跳转页面
function navigateTo(path : string) {
uni.navigateTo({ url: path })
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi()
}
catch (error) {
console.error('登出接口调用失败:', error)
}
finally {
// 清除本地登录状态
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
})
}
}
},
})
}
onMounted(() => {
loadHomeData()
})
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<view class="bg-page min-h-screen pb-safe">
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<text class="text-14px text-[#999]">加载中...</text>
<view v-if="loading" class="center pt-20">
<text class="text-14px text-[#999]">
加载中...
</text>
</view>
<!-- 实际内容 -->
<view v-else>
<!-- 顶部用户信息卡片 -->
<view class="px-4 pt-4">
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] px-4 py-4">
<view class="flex items-center">
<!-- 左侧头像 -->
<view class="w-56px h-56px rounded-full bg-[#f5f5f5] center mr-4"
@click="navigateTo('/pages/agent-system/user-info/index')">
<text class="text-14px text-[#666] font-700">
{{ userInfo?.user_type === 3 ? '代理' : '企业' }}
</text>
</view>
<!-- 顶部用户信息 -->
<view class="flex items-center from-[#dfeafb] via-[#f1f6fc] to-[#f4f6fa] bg-gradient-to-b px-4 pb-4 pt-4">
<view
class="mr-4 h-56px w-56px center rounded-full bg-white"
@click="navigateTo('/pages/agent-system/user-info/index')"
>
<text class="text-14px text-[#666] font-700">
{{ userInitial }}
</text>
</view>
<!-- 右侧用户信息 -->
<view class="flex-1">
<text class="text-18px font-700 text-[#212121] block mb-1">
{{ userInfo?.username || '欢迎回来' }}
</text>
<text class="text-13px text-[#999]">
{{ userInfo?.phone || '-' }}
</text>
</view>
<view class="flex-1">
<text class="mb-1 block text-18px text-[#212121] font-700">
{{ userInfo?.username || '欢迎回来' }}
</text>
<text class="text-13px text-[#999]">
{{ userInfo?.phone || '-' }}
</text>
</view>
<!-- 通知入口 -->
<view class="relative ml-3 h-44px w-44px flex items-center justify-center" @click="navigateTo('/pages/agent-system/notifications/index')">
<image src="@/static/icons/通知.png" class="h-22px w-22px" mode="aspectFit" />
<view v-if="notificationUnreadDisplayCount" class="absolute h-5 min-w-5 flex items-center justify-center rounded-full bg-[#ff4d4f] px-1 -right-1 -top-1">
<text class="text-10px text-white leading-none">
{{ notificationUnreadDisplayCount }}
</text>
</view>
</view>
</view>
<!-- 代理端: 佣金展示 -->
<view v-if="isAgent" class="px-4 pt-4">
<view
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-5 mb-3"
<AgentFundSummaryCard
class="mb-3"
:summary="commissionSummary"
variant="wallet"
@click="navigateTo('/pages/agent-system/commission-center/index')"
>
<view class="flex items-center justify-between gap-3">
<view class="min-w-0 flex-1">
<text class="text-12px text-[#999]">可提现佣金</text>
<text class="text-32px font-700 text-[#212121] block mt-1 break-all">
{{ formatAmountDisplay(commissionSummary?.available_commission) }}
</text>
<text class="text-11px text-[#999] block mt-2">
当前可直接发起提现的佣金金额
</text>
</view>
<view class="flex flex-col items-end gap-2">
<image src="@/static/icons/佣金中心.png" class="w-12 h-12 rounded-12px" mode="aspectFit" />
<view class="flex items-center gap-1">
<text class="text-12px text-[#999]">佣金中心</text>
<i class="i-mdi-chevron-right text-16px text-[#cbd5e1]" />
</view>
</view>
</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">
{{ formatAmountDisplay(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">
{{ formatAmountDisplay(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">
{{ formatAmountDisplay(commissionSummary?.frozen_commission) }}
</text>
</view>
</view>
</view>
@withdraw="navigateTo('/pages/agent-system/withdraw/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>
<!-- 最近一天佣金 -->
<view class="rounded-16px bg-white px-4 py-4">
<view class="border-b border-[#f1f3f5] pb-3">
<text class="text-14px text-[#212121] font-700">
最近一天佣金
</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">
<view class="text-center">
<text class="text-12px text-[#999] block mb-1">日期</text>
<text class="text-14px font-600 text-[#212121]">
{{ todayStat ? formatDate(todayStat.date) : '--' }}
</text>
</view>
<view class="text-center">
<text class="text-12px text-[#999] block mb-1">佣金金额</text>
<text class="text-14px font-600 text-[#212121]">
<view class="grid grid-cols-3 pt-3">
<view class="min-h-14 flex flex-col items-center text-center">
<text class="block text-11px text-[#8f98a6] leading-none">
佣金金额
</text>
<view class="mt-2 h-7 center">
<text class="text-brand block text-20px font-700">
{{ formatAmount(todayStat?.total_amount || 0) }}
</text>
</view>
<view class="text-center">
<text class="text-12px text-[#999] block mb-1">订单笔数</text>
<text class="text-14px font-600 text-[#212121]">
</view>
<view class="min-h-14 flex flex-col items-center border-l border-[#edf0f3] text-center">
<text class="block text-11px text-[#8f98a6] leading-none">
订单笔数
</text>
<view class="mt-2 h-7 center">
<text class="block text-20px text-[#212121] font-600">
{{ todayStat?.total_count || 0 }}
</text>
</view>
</view>
<view v-if="!todayStat" class="mt-3 pt-3 border-t border-[#f5f5f5] text-center">
<text class="text-12px text-[#999]">今日暂无佣金数据</text>
<view
class="min-h-14 flex flex-col items-center border-l border-[#edf0f3] text-center"
@click="navigateTo('/pages/agent-system/commission-center/index')"
>
<text class="block text-11px text-[#8f98a6] leading-none">
查看明细
</text>
<view class="mt-2 h-7 center">
<image src="@/static/icons/右边.png" class="h-12px w-12px" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
<!-- 快捷功能区 -->
<view class="px-4 pt-4">
<text class="text-16px font-700 text-[#212121] block mb-3">快捷功能</text>
<view class="px-4 pb-6 pt-4">
<view class="overflow-hidden rounded-16px bg-white">
<text class="block px-4 pt-4 text-16px text-[#212121] font-700">
常用功能
</text>
<view class="grid grid-cols-3 gap-3 mb-3">
<!-- 资产列表 -->
<view class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/assets/index')">
<image src="@/static/icons/资产列表.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">资产列表</text>
</view>
<!-- 企业端: 授权卡列表 -->
<view v-if="userInfo?.user_type === 4" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/enterprise-cards/index')">
<image src="@/static/icons/授权卡列表.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">授权卡</text>
</view>
<!-- 企业端: 授权设备列表 -->
<view v-if="userInfo?.user_type === 4" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/enterprise-devices/index')">
<image src="@/static/icons/授权设备列表.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">授权设备</text>
</view>
<!-- 代理端: 佣金中心 -->
<view v-if="userInfo?.user_type === 3" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/commission-center/index')">
<image src="@/static/icons/佣金中心.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">佣金中心</text>
</view>
<!-- 代理端: 提现管理 -->
<view v-if="userInfo?.user_type === 3" class="bg-white rounded-16px p-3 flex flex-col items-center shadow-[0_2px_12px_rgba(0,0,0,0.06)] aspect-square" @click="navigateTo('/pages/agent-system/withdraw/index')">
<image src="@/static/icons/提现管理.png" class="w-12 h-12 rounded-12px mb-2" mode="aspectFit" />
<text class="text-13px font-500 text-[#212121]">提现管理</text>
</view>
</view>
</view>
<!-- 设置区 -->
<view class="px-4 pt-4 pb-6">
<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)] overflow-hidden">
<!-- 修改密码 -->
<view class="flex items-center px-4 py-4 active:bg-[#f8fafc] transition-all duration-200" @click="navigateTo('/pages/agent-system/change-password/index')">
<view class="w-40px h-40px rounded-12px bg-[#f0f5ff] flex items-center justify-center mr-3">
<i class="i-mdi-lock-reset text-20px text-brand" />
<view class="grid grid-cols-4">
<!-- 资产列表 -->
<view class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/assets/index')">
<image src="@/static/icons/资产列表.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
资产列表
</text>
</view>
<view class="flex-1">
<text class="text-15px text-[#212121] font-500 block">修改密码</text>
<text class="text-12px text-[#999] block mt-0.5">更新登录密码</text>
</view>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
<!-- 退出登录 -->
<view class="flex items-center px-4 py-4 border-t border-[#f5f5f5] active:bg-[#f8fafc] transition-all duration-200" @click="logout">
<view class="w-40px h-40px rounded-12px bg-[#fff2f0] flex items-center justify-center mr-3">
<i class="i-mdi-logout text-20px text-[#ff4d4f]" />
<!-- 企业端: 授权卡列表 -->
<view v-if="userInfo?.user_type === 4" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/enterprise-cards/index')">
<image src="@/static/icons/授权卡列表.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
授权卡
</text>
</view>
<view class="flex-1">
<text class="text-15px text-[#212121] font-500 block">退出登录</text>
<text class="text-12px text-[#999] block mt-0.5">退出当前账号</text>
<!-- 企业端: 授权设备列表 -->
<view v-if="userInfo?.user_type === 4" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/enterprise-devices/index')">
<image src="@/static/icons/授权设备列表.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
授权设备
</text>
</view>
<!-- 代理端: 佣金中心 -->
<view v-if="userInfo?.user_type === 3" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/commission-center/index')">
<image src="@/static/icons/佣金中心.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
佣金中心
</text>
</view>
<!-- 代理端: 提现管理 -->
<view v-if="userInfo?.user_type === 3" class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/withdraw/index')">
<image src="@/static/icons/提现管理.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
提现管理
</text>
</view>
<!-- 修改密码 -->
<view class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="navigateTo('/pages/agent-system/change-password/index')">
<image src="@/static/icons/修改密码.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
修改密码
</text>
</view>
<!-- 退出登录 -->
<view class="aspect-square flex flex-col items-center justify-center px-1 py-3" @click="logout">
<image src="@/static/icons/退出登录.png" class="mb-1.5 h-8 w-8 rounded-8px" mode="aspectFit" />
<text class="text-12px text-[#212121] font-500">
退出登录
</text>
</view>
<i class="i-mdi-chevron-right text-20px text-[#cbd5e1]" />
</view>
</view>
</view>
@@ -402,6 +164,222 @@
</view>
</template>
<script setup lang="ts">
import type { UserInfo } from '@/api/auth';
import type { DailyCommissionStats, FundSummary } from '@/api/commission';
import { computed, nextTick, onMounted, ref } from 'vue';
import { getUserInfo, logout as logoutApi } from '@/api/auth';
import { getCommissionDailyStats, getFundSummary } from '@/api/commission';
import { getNotifications, getNotificationUnreadCount, markNotificationRead } from '@/api/notifications';
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue';
import { clearToken } from '@/utils/auth';
const NOTIFICATION_REMINDER_PENDING_KEY = 'notification_reminder_pending';
// 用户信息
const userInfo = ref<UserInfo | null>(null);
// 佣金概览
const commissionSummary = ref<FundSummary | null>(null);
// 每日佣金统计 (最近7天)
const dailyStats = ref<DailyCommissionStats[]>([]);
// 加载状态
const loading = ref(true);
const loadingCommission = ref(false);
// 店铺ID
const shopId = ref<number>(0);
// 通知未读数
const notificationUnreadCount = ref<number | null>(null);
// 是否为代理账号
const isAgent = computed(() => userInfo.value?.user_type === 3);
const userInitial = computed(() => Array.from(userInfo.value?.username?.trim() || '')[0] || '?');
const todayStat = computed(() => dailyStats.value[0] || null);
const notificationUnreadDisplayCount = computed(() => {
if (!notificationUnreadCount.value) return '';
return notificationUnreadCount.value > 99 ? '99+' : String(notificationUnreadCount.value);
});
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as Partial<UserInfo> | undefined;
}
// 格式化金额(分转元,带千分号)
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('.');
}
// 加载佣金数据
async function loadCommissionData(username?: string) {
if (!isAgent.value) return;
loadingCommission.value = true;
try {
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
const summary = await getFundSummary(username ? { username } : undefined);
commissionSummary.value = summary;
if (!shopId.value) {
dailyStats.value = [];
return;
}
const todayStats = await getCommissionDailyStats(shopId.value, {
days: 1,
});
dailyStats.value = todayStats || [];
return;
// 2. 加载最近30天佣金统计
const stats = await getCommissionDailyStats(shopId.value, { days: 30 });
dailyStats.value = stats || [];
}
catch (error) {
console.error('加载佣金数据失败:', error);
}
finally {
loadingCommission.value = false;
}
}
// 加载首页数据
async function loadHomeData() {
loading.value = true;
try {
// 加载用户信息
const user = await getUserInfo();
userInfo.value = user;
// 设置店铺ID
const loginUserInfo = getLoginUserInfo();
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0;
const fundSummaryUsername = loginUserInfo?.username || user.username;
// 如果是代理账号,加载佣金数据
if (user.user_type === 3) {
await loadCommissionData(fundSummaryUsername);
}
}
catch (error: any) {
console.error('加载首页数据失败:', error);
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
const errorWithStatus = error as { statusCode?: number } | undefined;
if (error instanceof Error && error.message && !errorWithStatus?.statusCode) {
uni.$u.toast(error.message);
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false;
}
}
async function loadNotificationUnreadCount() {
try {
const response = await getNotificationUnreadCount();
notificationUnreadCount.value = Math.max(response.count || 0, 0);
}
catch (error) {
console.error('加载通知未读数失败:', error);
}
}
function decreaseNotificationUnreadCount() {
if (notificationUnreadCount.value && notificationUnreadCount.value > 0) {
notificationUnreadCount.value -= 1;
}
}
async function showUnreadNotificationReminder() {
if (uni.getStorageSync(NOTIFICATION_REMINDER_PENDING_KEY) !== true) return;
// 一次登录只消费一次提醒标记,避免首页重复展示同一条提醒。
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY);
try {
const response = await getNotifications({
is_read: false,
page: 1,
page_size: 1,
});
const notification = response.items?.[0];
if (!notification) return;
uni.showModal({
title: notification.title || '通知提醒',
content: notification.body || '你有一条新的未读通知',
showCancel: false,
confirmText: '知道了',
});
// 弹窗已经把通知内容呈现给用户,立即同步已读状态,不要求额外点击“已读”。
decreaseNotificationUnreadCount();
void markNotificationRead(notification.id).catch((error) => {
console.error(`标记通知 ${notification.id} 已读失败:`, error);
});
}
catch (error) {
console.error('加载未读通知提醒失败:', error);
}
}
async function loadNotificationData() {
await Promise.all([
loadNotificationUnreadCount(),
showUnreadNotificationReminder(),
]);
}
// 跳转页面
function navigateTo(path: string) {
uni.navigateTo({ url: path });
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi();
}
catch (error) {
console.error('登出接口调用失败:', error);
}
finally {
// 清除本地登录状态
clearToken();
uni.removeStorageSync('refresh_token');
uni.removeStorageSync('user_info');
uni.removeStorageSync(NOTIFICATION_REMINDER_PENDING_KEY);
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
});
}
}
},
});
}
onMounted(async () => {
await loadHomeData();
if (userInfo.value) {
await nextTick();
await loadNotificationData();
}
});
</script>
<style scoped>
.center {
display: flex;

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,153 @@
<template>
<view class="min-h-screen bg-[#f7f8fa] px-4 py-4">
<view v-if="loading" class="flex justify-center pt-20">
<text class="text-14px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="notifications.length" class="space-y-3">
<view
v-for="notification in notifications"
:key="notification.id"
class="rounded-16px bg-white p-4"
>
<view class="flex items-start justify-between gap-3">
<view class="min-w-0 flex flex-1 items-center gap-2">
<text class="truncate text-15px text-[#212121] font-600">
{{ notification.title }}
</text>
<view
v-if="!notification.is_read"
class="flex-shrink-0 rounded-4px bg-[#fff1f0] px-1.5 py-0.5 text-10px text-[#f5222d]"
>
未读
</view>
</view>
<text class="flex-shrink-0 text-11px text-[#999]">
{{ formatTime(notification.created_at) }}
</text>
</view>
<text class="mt-3 break-all text-13px text-[#666] leading-6">
{{ notification.body }}
</text>
<view class="mt-3 flex items-center gap-2">
<view class="rounded-4px bg-[#f5f5f5] px-2 py-0.5 text-11px text-[#666]">
{{ categoryLabels[notification.category] || notification.category }}
</view>
<view
class="rounded-4px px-2 py-0.5 text-11px"
:class="getSeverityClass(notification.severity)"
>
{{ severityLabels[notification.severity] || notification.severity }}
</view>
<text class="ml-auto text-11px text-[#b5b5b5]">
{{ notification.is_read ? '已读' : '未读' }}
</text>
</view>
</view>
</view>
<view v-else class="flex flex-col items-center pt-24">
<image src="@/static/icons/通知.png" class="h-18 w-18 opacity-40" mode="aspectFit" />
<text class="mt-4 text-14px text-[#999]">
暂无通知
</text>
</view>
</view>
</template>
<script setup lang="ts">
import type {
NotificationCategory,
NotificationItem,
NotificationSeverity,
} from '@/api/notifications';
import { nextTick, onMounted, ref } from 'vue';
import {
getNotifications,
markNotificationRead,
} from '@/api/notifications';
const notifications = ref<NotificationItem[]>([]);
const loading = ref(true);
const categoryLabels: Record<NotificationCategory, string> = {
approval: '审批',
expiry: '临期',
sync: '同步',
system: '系统',
};
const severityLabels: Record<NotificationSeverity, string> = {
info: '提示',
warning: '警告',
error: '错误',
critical: '严重',
};
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
if (Number.isNaN(date.getTime())) return time;
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')}`;
}
function getSeverityClass(severity: NotificationSeverity) {
const classes: Record<NotificationSeverity, string> = {
info: 'bg-[#edf5ff] text-[#66a6ff]',
warning: 'bg-[#fff7e6] text-[#d46b08]',
error: 'bg-[#fff2f0] text-[#cf1322]',
critical: 'bg-[#fff1f0] text-[#a8071a]',
};
return classes[severity];
}
async function markVisibleNotificationsAsRead() {
await nextTick();
const unreadNotifications = notifications.value.filter(notification => !notification.is_read);
unreadNotifications.forEach((notification) => {
notification.is_read = true;
notification.read_at = new Date().toISOString();
void markNotificationRead(notification.id).catch((error) => {
console.error(`标记通知 ${notification.id} 已读失败:`, error);
});
});
}
async function loadNotifications() {
loading.value = true;
try {
const response = await getNotifications({
page: 1,
page_size: 50,
});
notifications.value = response.items || [];
loading.value = false;
await markVisibleNotificationsAsRead();
}
catch (error) {
console.error('加载通知失败:', error);
}
finally {
loading.value = false;
}
}
onMounted(() => {
loadNotifications();
});
</script>
<style scoped>
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;
}
</style>

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

@@ -1,129 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getUserInfo, logout as logoutApi } from '@/api/auth'
import { clearToken } from '@/utils/auth'
import type { UserInfo } from '@/api/auth'
// 用户信息
const userInfo = ref<UserInfo | null>(null)
// 加载状态
const loading = ref(false)
// 用户类型文本(优先使用接口返回的 user_type_name
const userTypeText = computed(() => {
if (!userInfo.value)
return ''
return userInfo.value.user_type_name || '未知'
})
// 用户类型颜色
const userTypeColor = computed(() => {
if (!userInfo.value)
return { bg: '#f5f5f5', text: '#999' }
const colorMap: Record<number, { bg: string, text: string }> = {
1: { bg: '#ffebee', text: '#c62828' },
2: { bg: '#e3f2fd', text: '#1976d2' },
3: { bg: '#e8f5e9', text: '#2e7d32' },
4: { bg: '#fff3e0', text: '#e65100' },
}
return colorMap[userInfo.value.user_type] || { bg: '#f5f5f5', text: '#999' }
})
// 页面加载
onMounted(() => {
loadUserInfo()
})
// 加载用户信息
async function loadUserInfo() {
loading.value = true
try {
// 调用 API 获取用户信息
const data = await getUserInfo()
console.log('获取用户信息成功:', data)
userInfo.value = data
}
catch (error: any) {
console.error('获取用户信息失败:', error)
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message)
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false
}
}
// 修改密码
function changePassword() {
uni.navigateTo({
url: '/pages/agent-system/change-password/index',
})
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi()
}
catch (error) {
console.error('登出接口调用失败:', error)
}
finally {
// 清除本地登录状态
clearToken()
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('user_info')
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
})
}
}
},
})
}
// 复制文本
function copyText(text: string, label: string) {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({
title: `已复制${label}`,
icon: 'success',
})
},
})
}
</script>
<template>
<view class="bg-[#fafafa] min-h-screen pb-safe">
<view class="min-h-screen bg-[#fafafa] pb-safe">
<!-- 顶部背景区域 -->
<view class="bg-[#212121]">
<!-- 用户头像卡片 -->
<view v-if="userInfo" class="px-4 pt-5 pb-6">
<view v-if="userInfo" class="px-4 pb-6 pt-5">
<view class="center flex-col">
<!-- 头像 -->
<view class="w-80px h-80px rounded-full bg-[#ffffff25] center mb-3">
<view class="mb-3 h-80px w-80px center rounded-full bg-[#ffffff25]">
<i class="i-mdi-account text-48px text-white" />
</view>
<!-- 用户名 -->
<text class="text-20px font-700 text-white block mb-1">
<text class="mb-1 block text-20px text-white font-700">
{{ userInfo.username }}
</text>
@@ -133,7 +21,7 @@ function copyText(text: string, label: string) {
backgroundColor: userTypeColor.bg,
color: userTypeColor.text,
}"
class="px-3 py-1 rounded-8px text-12px font-600"
class="rounded-8px px-3 py-1 text-12px font-600"
>
{{ userTypeText }}
</view>
@@ -142,16 +30,16 @@ function copyText(text: string, label: string) {
</view>
<!-- 加载中 -->
<view v-if="loading" class="pt-20 center">
<view v-if="loading" class="center pt-20">
<i class="i-mdi-loading animate-spin text-40px text-[#999]" />
</view>
<!-- 内容区域 -->
<view v-else-if="userInfo" class="px-4 pt-4">
<!-- 个人信息 -->
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-3">
<view class="px-4 py-3 border-b-1px border-[#f5f5f5]">
<text class="text-14px font-600 text-[#212121]">
<view class="mb-3 overflow-hidden rounded-16px bg-white">
<view class="border-b-1px border-[#f5f5f5] px-4 py-3">
<text class="text-14px text-[#212121] font-600">
个人信息
</text>
</view>
@@ -159,37 +47,47 @@ function copyText(text: string, label: string) {
<view class="px-4 py-2">
<!-- 用户名 -->
<view
class="py-3 flex items-center justify-between border-b-1px border-[#f5f5f5]"
class="flex items-center justify-between border-b-1px border-[#f5f5f5] py-3"
@click="copyText(userInfo.username, '用户名')"
>
<text class="text-14px text-[#999]">用户名</text>
<text class="text-14px text-[#999]">
用户名
</text>
<view class="flex items-center gap-2">
<text class="text-14px text-[#212121]">{{ userInfo.username }}</text>
<text class="text-14px text-[#212121]">
{{ userInfo.username }}
</text>
<i class="i-mdi-content-copy text-16px text-[#999]" />
</view>
</view>
<!-- 手机号 -->
<view
class="py-3 flex items-center justify-between border-b-1px border-[#f5f5f5]"
class="flex items-center justify-between border-b-1px border-[#f5f5f5] py-3"
@click="copyText(userInfo.phone, '手机号')"
>
<text class="text-14px text-[#999]">手机号</text>
<text class="text-14px text-[#999]">
手机号
</text>
<view class="flex items-center gap-2">
<text class="text-14px text-[#212121]">{{ userInfo.phone }}</text>
<text class="text-14px text-[#212121]">
{{ userInfo.phone }}
</text>
<i class="i-mdi-content-copy text-16px text-[#999]" />
</view>
</view>
<!-- 用户类型 -->
<view class="py-3 flex items-center justify-between">
<text class="text-14px text-[#999]">用户类型</text>
<view class="flex items-center justify-between py-3">
<text class="text-14px text-[#999]">
用户类型
</text>
<view
:style="{
backgroundColor: userTypeColor.bg,
color: userTypeColor.text,
}"
class="px-3 py-1 rounded-6px text-12px font-600"
class="rounded-6px px-3 py-1 text-12px font-600"
>
{{ userInfo.user_type_name }}
</view>
@@ -201,32 +99,22 @@ function copyText(text: string, label: string) {
<view class="space-y-3">
<!-- 修改密码 -->
<view
class="
bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)]
py-3 center gap-2
transition-all duration-200
active:bg-[#f5f5f5]
"
class="center gap-2 rounded-16px bg-white py-3 transition-all duration-200 active:bg-[#f5f5f5]"
@click="changePassword"
>
<i class="i-mdi-lock-reset text-18px text-[#666]" />
<text class="text-15px font-600 text-[#666]">
<text class="text-15px text-[#666] font-600">
修改密码
</text>
</view>
<!-- 退出登录 -->
<view
class="
bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)]
py-3 center gap-2
transition-all duration-200
active:bg-[#ffebee]
"
class="center gap-2 rounded-16px bg-white py-3 transition-all duration-200 active:bg-[#ffebee]"
@click="logout"
>
<i class="i-mdi-logout text-18px text-[#c62828]" />
<text class="text-15px font-600 text-[#c62828]">
<text class="text-15px text-[#c62828] font-600">
退出登录
</text>
</view>
@@ -238,6 +126,117 @@ function copyText(text: string, label: string) {
</view>
</template>
<script setup lang="ts">
import type { UserInfo } from '@/api/auth';
import { computed, onMounted, ref } from 'vue';
import { getUserInfo, logout as logoutApi } from '@/api/auth';
import { clearToken } from '@/utils/auth';
// 用户信息
const userInfo = ref<UserInfo | null>(null);
// 加载状态
const loading = ref(false);
// 用户类型文本(优先使用接口返回的 user_type_name
const userTypeText = computed(() => {
if (!userInfo.value)
return '';
return userInfo.value.user_type_name || '未知';
});
// 用户类型颜色
const userTypeColor = computed(() => {
if (!userInfo.value)
return { bg: '#f5f5f5', text: '#999' };
const colorMap: Record<number, { bg: string; text: string }> = {
1: { bg: '#ffebee', text: '#c62828' },
2: { bg: '#e3f2fd', text: '#1976d2' },
3: { bg: '#e8f5e9', text: '#2e7d32' },
4: { bg: '#fff3e0', text: '#e65100' },
};
return colorMap[userInfo.value.user_type] || { bg: '#f5f5f5', text: '#999' };
});
// 页面加载
onMounted(() => {
loadUserInfo();
});
// 加载用户信息
async function loadUserInfo() {
loading.value = true;
try {
// 调用 API 获取用户信息
const data = await getUserInfo();
userInfo.value = data;
}
catch (error: any) {
console.error('获取用户信息失败:', error);
// 如果是前端逻辑错误(非 API 错误),需要手动显示提示
if (error instanceof Error && error.message && !error.statusCode) {
uni.$u.toast(error.message);
}
// API 请求错误已由拦截器统一处理
}
finally {
loading.value = false;
}
}
// 修改密码
function changePassword() {
uni.navigateTo({
url: '/pages/agent-system/change-password/index',
});
}
// 退出登录
function logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 调用登出接口
await logoutApi();
}
catch (error) {
console.error('登出接口调用失败:', error);
}
finally {
// 清除本地登录状态
clearToken();
uni.removeStorageSync('refresh_token');
uni.removeStorageSync('user_info');
// 跳转到登录页
uni.reLaunch({
url: '/pages/common/login/index',
});
}
}
},
});
}
// 复制文本
function copyText(text: string, label: string) {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({
title: `已复制${label}`,
icon: 'success',
});
},
});
}
</script>
<style scoped>
.center {
display: flex;

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

@@ -1,222 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getFundSummary } from '@/api/commission'
import type { FundSummary } from '@/api/commission'
import { createWithdrawal, getWithdrawalRecords } from '@/api/withdrawal'
import type { WithdrawalRecord } from '@/api/withdrawal'
import SimplePicker from '@/components/SimplePicker.vue'
type LoginUserInfo = {
shop_id?: number
username?: string
}
const commissionSummary = ref<FundSummary | null>(null)
const shopId = ref<number>(0)
const withdrawAmount = ref('')
const accountName = ref('')
const accountNumber = ref('')
const withdrawRecords = ref<WithdrawalRecord[]>([])
const activeTab = ref<'apply' | 'records'>('apply')
const loading = ref(false)
const submitting = ref(false)
const loadingRecords = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const statusFilter = ref<number | undefined>(undefined)
const showStatusPicker = ref(false)
const statusOptions = [
{ label: '全部状态', value: undefined },
{ label: '审核中', value: 1 },
{ label: '已通过', value: 2 },
{ label: '已拒绝', value: 3 },
{ label: '已到账', value: 4 },
]
const selectedStatusName = computed(() => {
if (statusFilter.value === undefined) return '状态'
const option = statusOptions.find(o => o.value === statusFilter.value)
return option?.label || '状态'
})
const tabs = [
{ key: 'apply', label: '申请提现' },
{ key: 'records', label: '提现记录' },
]
const availableAmount = computed(() => {
if (!commissionSummary.value) return 0
return commissionSummary.value.available_commission / 100
})
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 getStatusText(status: number, statusName?: string) {
return statusName || (status === 1 ? '审核中' : status === 2 ? '已通过' : status === 3 ? '已拒绝' : status === 4 ? '已到账' : '未知')
}
function getStatusColor(status: number) {
const map: Record<number, { bg: string, text: string }> = {
1: { bg: '#fff3e0', text: '#e65100' },
2: { bg: '#e8f5e9', text: '#2e7d32' },
3: { bg: '#ffebee', text: '#c62828' },
4: { bg: '#e3f2fd', text: '#1565c0' },
}
return map[status] || { bg: '#f5f5f5', text: '#999' }
}
function formatTime(time: string) {
if (!time) return '-'
const date = new Date(time)
return `${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')}`
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined
}
onMounted(() => {
const userInfo = getLoginUserInfo()
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id
}
loadCommissionSummary()
loadWithdrawRecords()
})
async function loadCommissionSummary() {
loading.value = true
try {
const userInfo = getLoginUserInfo()
commissionSummary.value = await getFundSummary(userInfo?.username ? { shop_name: userInfo.username } : undefined)
withdrawAmount.value = availableAmount.value.toFixed(2)
} catch (error) {
console.error('加载佣金概览失败:', error)
} finally {
loading.value = false
}
}
async function loadWithdrawRecords(isRefresh = false) {
if (!shopId.value) return
if (isRefresh) {
page.value = 1
withdrawRecords.value = []
}
loadingRecords.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (statusFilter.value !== undefined) {
params.status = statusFilter.value
}
const response = await getWithdrawalRecords(shopId.value, params)
if (page.value === 1) {
withdrawRecords.value = response.items || []
} else {
withdrawRecords.value.push(...(response.items || []))
}
total.value = response.total || 0
} catch (error) {
console.error('加载提现记录失败:', error)
} finally {
loadingRecords.value = false
}
}
function setQuickAmount(amount: number) {
withdrawAmount.value = amount.toString()
}
function setAllAmount() {
withdrawAmount.value = availableAmount.value.toFixed(2)
}
async function handleSubmit() {
const amount = parseFloat(withdrawAmount.value)
if (!amount || amount <= 0) {
uni.$u.toast('请输入提现金额')
return
}
if (amount > availableAmount.value) {
uni.$u.toast('提现金额超过可用余额')
return
}
const name = accountName.value.trim()
if (!name) {
uni.$u.toast('请输入收款人姓名')
return
}
const account = accountNumber.value.trim()
if (!account) {
uni.$u.toast('请输入支付宝账号')
return
}
submitting.value = true
try {
await createWithdrawal(shopId.value, {
account_name: name,
account_number: account,
amount: Math.round(amount * 100),
withdrawal_method: 'alipay',
})
uni.$u.toast('提现申请已提交')
withdrawAmount.value = ''
accountName.value = ''
accountNumber.value = ''
await loadCommissionSummary()
page.value = 1
await loadWithdrawRecords()
activeTab.value = 'records'
} catch (error) {
console.error('提现申请失败:', error)
} finally {
submitting.value = false
}
}
function handleTabChange(tab: 'apply' | 'records') {
activeTab.value = tab
if (tab === 'records') {
loadWithdrawRecords(true)
}
}
function onStatusConfirm(option: { label: string, value: any }) {
statusFilter.value = option.value
loadWithdrawRecords(true)
}
function loadMore() {
if (withdrawRecords.value.length < total.value) {
page.value++
loadWithdrawRecords()
}
}
function onScroll(e: any) {
const { scrollTop, scrollHeight, clientHeight } = e.target
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore()
}
}
</script>
<template>
<view class="bg-page min-h-screen">
<!-- 顶部搜索区域 -->
<view class="bg-white px-4 pt-4 pb-3">
<view class="bg-white px-4 pb-3 pt-4">
<view class="flex rounded-full bg-[#f1f5f9] p-1">
<view
v-for="tab in tabs"
:key="tab.key"
class="flex-1 text-center py-2 rounded-full text-14px font-medium transition-all"
class="flex-1 rounded-full py-2 text-center text-14px font-medium transition-all"
:class="activeTab === tab.key ? 'bg-white text-[#212121] shadow-sm' : 'text-[#999]'"
@click="handleTabChange(tab.key)"
>
@@ -230,42 +20,48 @@ function onScroll(e: any) {
<!-- 申请提现 -->
<view v-if="activeTab === 'apply'">
<!-- 可提现金额卡片 -->
<view class="bg-white rounded-16px p-5 mt-4 shadow-[0_2px_12px_rgba(0,0,0,0.08)]">
<view class="mt-4 rounded-16px bg-white p-5">
<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">
<text class="text-12px text-[#999]">
可提现金额
</text>
<text class="mt-1 block text-32px text-[#212121] font-700">
¥{{ availableAmount.toFixed(2) }}
</text>
</view>
<image src="@/static/icons/提现管理.png" class="w-12 h-12 rounded-12px" mode="aspectFit" />
<image src="@/static/icons/提现管理.png" class="h-12 w-12 rounded-12px" mode="aspectFit" />
</view>
</view>
<!-- 提现金额输入 -->
<view class="bg-white rounded-16px p-4 mt-3">
<text class="text-12px text-[#999]">提现金额</text>
<view class="flex items-center mt-2">
<text class="text-24px text-[#212121] mr-2">¥</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-12px text-[#999]">
提现金额
</text>
<view class="mt-2 flex items-center">
<text class="mr-2 text-24px text-[#212121]">
¥
</text>
<input
v-model="withdrawAmount"
type="digit"
class="flex-1 text-24px text-[#212121]"
placeholder="0.00"
/>
>
</view>
<view class="flex gap-2 mt-3">
<view class="mt-3 flex gap-2">
<view
v-for="amount in [100, 500, 1000]"
:key="amount"
class="px-4 py-2 rounded-full text-13px"
class="rounded-full px-4 py-2 text-13px"
:class="withdrawAmount == amount.toString() ? 'bg-brand text-white' : 'bg-[#f1f5f9] text-[#666]'"
@click="setQuickAmount(amount)"
>
¥{{ amount }}
</view>
<view
class="px-4 py-2 rounded-full text-13px"
class="rounded-full px-4 py-2 text-13px"
:class="withdrawAmount == availableAmount.toFixed(2) ? 'bg-brand text-white' : 'bg-[#f1f5f9] text-[#666]'"
@click="setAllAmount"
>
@@ -275,28 +71,39 @@ function onScroll(e: any) {
</view>
<!-- 收款信息 -->
<view class="bg-white rounded-16px p-4 mt-3">
<text class="text-12px text-[#999]">收款人姓名</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-12px text-[#999]">
收款人姓名
</text>
<input
v-model="accountName"
class="w-full text-16px text-[#212121] mt-2"
class="mt-2 w-full text-16px text-[#212121]"
placeholder="请输入姓名"
/>
>
</view>
<view class="bg-white rounded-16px p-4 mt-3">
<text class="text-12px text-[#999]">支付宝账号</text>
<view class="mt-3 rounded-16px bg-white p-4">
<text class="text-12px text-[#999]">
支付宝账号
</text>
<input
v-model="accountNumber"
class="w-full text-16px text-[#212121] mt-2"
class="mt-2 w-full text-16px text-[#212121]"
placeholder="请输入账号"
/>
>
</view>
<view class="mb-3 mt-6 flex items-center gap-1.5 rounded-12px bg-[var(--color-warning-bg)] px-3 py-2">
<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 text-white font-600"
:class="{ 'opacity-50': submitting || !canWithdrawByMinAmount }"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '确认提现' }}
@@ -305,61 +112,83 @@ function onScroll(e: any) {
<!-- 提现记录 -->
<view v-else>
<view class="flex items-center gap-2 mt-4 mb-3">
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full" @click="showStatusPicker = true">
<text class="text-12px text-[#64748b]">{{ selectedStatusName }}</text>
<view class="mb-3 mt-4 flex items-center gap-2">
<view class="flex items-center gap-1 rounded-full bg-[#f8fafc] px-3 py-1.5" @click="showStatusPicker = true">
<text class="text-12px text-[#64748b]">
{{ selectedStatusName }}
</text>
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
</view>
<view
v-if="statusFilter !== undefined"
class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f]"
class="flex items-center gap-1 rounded-full bg-[#ff4d4f]/10 px-2 py-1.5 text-[#ff4d4f]"
@click="statusFilter = undefined; loadWithdrawRecords(true)"
>
<i class="i-mdi-close text-12px" />
<text class="text-12px font-medium">清除</text>
<text class="text-12px font-medium">
清除
</text>
</view>
</view>
<view v-if="loadingRecords && page === 1" class="pt-20 flex justify-center">
<text class="text-14px text-[#64748b]">加载中...</text>
<view v-if="loadingRecords && page === 1" class="flex justify-center pt-20">
<text class="text-14px text-[#64748b]">
加载中...
</text>
</view>
<view v-else-if="withdrawRecords.length > 0" class="space-y-3">
<view
v-for="record in withdrawRecords"
:key="record.id"
class="bg-white rounded-12px p-4"
class="rounded-12px bg-white p-4"
>
<view class="flex items-center justify-between mb-2">
<text class="text-12px text-[#999]">{{ formatTime(record.created_at) }}</text>
<view class="mb-2 flex items-center justify-between">
<text class="text-12px text-[#999]">
{{ formatTime(record.created_at) }}
</text>
<view
:style="{ backgroundColor: getStatusColor(record.status).bg, color: getStatusColor(record.status).text }"
class="px-2 py-0.5 rounded text-11px font-500"
class="rounded px-2 py-0.5 text-11px font-500"
>
{{ getStatusText(record.status, record.status_name) }}
</view>
</view>
<view class="flex items-center justify-between">
<text class="text-14px text-[#666]">{{ record.account_name }}</text>
<text class="text-18px font-600 text-[#ff4d4f]">-{{ formatAmount(record.amount) }}</text>
<text class="text-14px text-[#666]">
{{ record.account_name }}
</text>
<text class="text-18px text-[#ff4d4f] font-600">
-{{ formatAmount(record.amount) }}
</text>
</view>
<view v-if="record.status === 3 && record.reject_reason" class="mt-2 pt-2 border-t border-[#f0f0f0]">
<text class="text-12px text-[#ff4d4f]">拒绝原因: {{ record.reject_reason }}</text>
<view v-if="record.status === 3 && record.reject_reason" class="mt-2 border-t border-[#f0f0f0] pt-2">
<text class="text-12px text-[#ff4d4f]">
拒绝原因: {{ record.reject_reason }}
</text>
</view>
</view>
<view v-if="withdrawRecords.length < total" class="py-4 text-center">
<text v-if="loadingRecords" class="text-12px text-[#64748b]">加载中...</text>
<text v-else class="text-12px text-brand" @click="loadMore">加载更多</text>
<text v-if="loadingRecords" class="text-12px text-[#64748b]">
加载中...
</text>
<text v-else class="text-brand text-12px" @click="loadMore">
加载更多
</text>
</view>
<view v-else class="py-4 text-center">
<text class="text-12px text-[#cbd5e1]">没有更多了</text>
<text class="text-12px text-[#cbd5e1]">
没有更多了
</text>
</view>
</view>
<view v-else class="pt-16 flex flex-col items-center">
<i class="i-mdi-file-document-outline text-5xl text-[#e0e0e0] mb-3" />
<text class="text-14px text-[#999]">暂无提现记录</text>
<view v-else class="flex flex-col items-center pt-16">
<i class="i-mdi-file-document-outline mb-3 text-5xl text-[#e0e0e0]" />
<text class="text-14px text-[#999]">
暂无提现记录
</text>
</view>
</view>
</view>
@@ -373,6 +202,223 @@ function onScroll(e: any) {
</view>
</template>
<script setup lang="ts">
import type { FundSummary } from '@/api/commission';
import type { WithdrawalRecord } from '@/api/withdrawal';
import { computed, onMounted, ref } from 'vue';
import { getFundSummary } from '@/api/commission';
import { createWithdrawal, getWithdrawalRecords } from '@/api/withdrawal';
import SimplePicker from '@/components/SimplePicker.vue';
interface LoginUserInfo {
shop_id?: number;
username?: string;
}
const commissionSummary = ref<FundSummary | null>(null);
const shopId = ref<number>(0);
const withdrawAmount = ref('');
const accountName = ref('');
const accountNumber = ref('');
const withdrawRecords = ref<WithdrawalRecord[]>([]);
const activeTab = ref<'apply' | 'records'>('apply');
const loading = ref(false);
const submitting = ref(false);
const loadingRecords = ref(false);
const page = ref(1);
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 },
{ label: '审核中', value: 1 },
{ label: '已通过', value: 2 },
{ label: '已拒绝', value: 3 },
{ label: '已到账', value: 4 },
];
const selectedStatusName = computed(() => {
if (statusFilter.value === undefined) return '状态';
const option = statusOptions.find(o => o.value === statusFilter.value);
return option?.label || '状态';
});
const tabs: Array<{ key: 'apply' | 'records'; label: string }> = [
{ key: 'apply', label: '申请提现' },
{ key: 'records', label: '提现记录' },
];
const availableAmount = computed(() => {
if (!commissionSummary.value) return 0;
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('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `¥${parts.join('.')}`;
}
function getStatusText(status: number, statusName?: string) {
return statusName || (status === 1 ? '审核中' : status === 2 ? '已通过' : status === 3 ? '已拒绝' : status === 4 ? '已到账' : '未知');
}
function getStatusColor(status: number) {
const map: Record<number, { bg: string; text: string }> = {
1: { bg: '#fff3e0', text: '#e65100' },
2: { bg: '#e8f5e9', text: '#2e7d32' },
3: { bg: '#ffebee', text: '#c62828' },
4: { bg: '#e3f2fd', text: '#1565c0' },
};
return map[status] || { bg: '#f5f5f5', text: '#999' };
}
function formatTime(time: string) {
if (!time) return '-';
const date = new Date(time);
return `${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')}`;
}
function getLoginUserInfo() {
return uni.getStorageSync('user_info') as LoginUserInfo | undefined;
}
onMounted(() => {
const userInfo = getLoginUserInfo();
if (userInfo?.shop_id) {
shopId.value = userInfo.shop_id;
}
loadCommissionSummary();
loadWithdrawRecords();
});
async function loadCommissionSummary() {
loading.value = true;
try {
const userInfo = getLoginUserInfo();
commissionSummary.value = await getFundSummary(userInfo?.username ? { username: userInfo.username } : undefined);
withdrawAmount.value = availableAmount.value.toFixed(2);
}
catch (error) {
console.error('加载佣金概览失败:', error);
}
finally {
loading.value = false;
}
}
async function loadWithdrawRecords(isRefresh = false) {
if (!shopId.value) return;
if (isRefresh) {
page.value = 1;
withdrawRecords.value = [];
}
loadingRecords.value = true;
try {
const params: any = { page: page.value, page_size: pageSize };
if (statusFilter.value !== undefined) {
params.status = statusFilter.value;
}
const response = await getWithdrawalRecords(shopId.value, params);
if (page.value === 1) {
withdrawRecords.value = response.items || [];
}
else {
withdrawRecords.value.push(...(response.items || []));
}
total.value = response.total || 0;
}
catch (error) {
console.error('加载提现记录失败:', error);
}
finally {
loadingRecords.value = false;
}
}
function setQuickAmount(amount: number) {
withdrawAmount.value = amount.toString();
}
function setAllAmount() {
withdrawAmount.value = availableAmount.value.toFixed(2);
}
async function handleSubmit() {
const amount = Number.parseFloat(withdrawAmount.value);
if (!amount || amount <= 0) {
uni.$u.toast('请输入提现金额');
return;
}
if (amount < MIN_WITHDRAW_AMOUNT) {
uni.$u.toast(`${MIN_WITHDRAW_AMOUNT}元才可提现`);
return;
}
if (amount > availableAmount.value) {
uni.$u.toast('提现金额超过可用余额');
return;
}
const name = accountName.value.trim();
if (!name) {
uni.$u.toast('请输入收款人姓名');
return;
}
const account = accountNumber.value.trim();
if (!account) {
uni.$u.toast('请输入支付宝账号');
return;
}
submitting.value = true;
try {
await createWithdrawal(shopId.value, {
account_name: name,
account_number: account,
amount: Math.round(amount * 100),
withdrawal_method: 'alipay',
});
uni.$u.toast('提现申请已提交');
withdrawAmount.value = '';
accountName.value = '';
accountNumber.value = '';
await loadCommissionSummary();
page.value = 1;
await loadWithdrawRecords();
activeTab.value = 'records';
}
catch (error) {
console.error('提现申请失败:', error);
}
finally {
submitting.value = false;
}
}
function handleTabChange(tab: 'apply' | 'records') {
activeTab.value = tab;
if (tab === 'records') {
loadWithdrawRecords(true);
}
}
function onStatusConfirm(option: { label: string; value: any }) {
statusFilter.value = option.value;
loadWithdrawRecords(true);
}
function loadMore() {
if (withdrawRecords.value.length < total.value) {
page.value++;
loadWithdrawRecords();
}
}
</script>
<style scoped>
.space-y-3 > view:not(:last-child) {
margin-bottom: 12px;

View File

@@ -12,8 +12,12 @@
<!-- 顶部品牌区 -->
<view class="brand-header">
<view class="welcome-info">
<view class="welcome-title">欢迎回来</view>
<view class="welcome-desc">请输入您的账号密码进行登录</view>
<view class="welcome-title">
欢迎回来
</view>
<view class="welcome-desc">
请输入您的账号密码进行登录
</view>
</view>
</view>
@@ -64,9 +68,9 @@
</template>
<script setup lang="ts">
import { HOME_PATH, isTabBarPath, LOGIN_PATH, removeQueryString } from '@/router';
import { setToken } from '@/utils/auth';
import { login } from '@/api/auth';
import { HOME_PATH, LOGIN_PATH, removeQueryString } from '@/router';
import { setToken } from '@/utils/auth';
const account = ref<string>('');
const password = ref<string>('');
@@ -74,6 +78,7 @@ const accountFocused = ref<boolean>(false);
const passwordFocused = ref<boolean>(false);
const showPassword = ref<boolean>(false);
const loading = ref<boolean>(false);
const NOTIFICATION_REMINDER_PENDING_KEY = 'notification_reminder_pending';
let redirect = HOME_PATH;
const isFormValid = computed(() => {
@@ -106,6 +111,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 +122,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 +142,13 @@ async function submit() {
return;
}
// 保存用户信息到本地存储
uni.setStorageSync('user_info', sessionUser);
uni.setStorageSync(NOTIFICATION_REMINDER_PENDING_KEY, true);
uni.showToast({
title: '登录成功',
icon: 'success',
icon: 'none',
duration: 1500,
});
@@ -152,11 +159,13 @@ async function submit() {
url: '/pages/agent-system/home/index',
});
}, 1500);
} catch (error: any) {
}
catch (error: any) {
console.error('登录失败:', error);
// 拦截器已经显示了后端返回的错误信息,这里不需要再次提示
// 如果需要额外处理,可以访问 error.data.msg 获取错误信息
} finally {
}
finally {
loading.value = false;
}
}
@@ -202,7 +211,7 @@ onLoad((options: any) => {
width: 100%;
height: 100%;
background:
linear-gradient(90deg, transparent 0%, rgba(22, 119, 255, 0.02) 50%, transparent 100%);
linear-gradient(90deg, transparent 0%, rgba(102, 166, 255, 0.02) 50%, transparent 100%);
animation: scanline 8s linear infinite;
}
@@ -215,7 +224,7 @@ onLoad((options: any) => {
height: 800rpx;
top: -300rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(22, 119, 255, 0.08), transparent 60%);
background: radial-gradient(circle, rgba(102, 166, 255, 0.08), transparent 60%);
border-radius: 50%;
animation: pulse1 10s infinite ease-in-out;
}
@@ -225,7 +234,7 @@ onLoad((options: any) => {
height: 600rpx;
bottom: -200rpx;
left: -150rpx;
background: radial-gradient(circle, rgba(22, 119, 255, 0.06), transparent 60%);
background: radial-gradient(circle, rgba(102, 166, 255, 0.06), transparent 60%);
border-radius: 50%;
animation: pulse2 12s infinite ease-in-out;
}
@@ -235,7 +244,7 @@ onLoad((options: any) => {
height: 400rpx;
top: 50%;
right: -100rpx;
background: radial-gradient(circle, rgba(22, 119, 255, 0.04), transparent 60%);
background: radial-gradient(circle, rgba(102, 166, 255, 0.04), transparent 60%);
border-radius: 50%;
animation: pulse3 14s infinite ease-in-out;
}
@@ -291,7 +300,6 @@ onLoad((options: any) => {
padding: 60rpx 48rpx;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08), 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
z-index: 1;
animation: cardFadeIn 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@@ -385,7 +393,7 @@ onLoad((options: any) => {
&.focused {
background: #ffffff;
border-color: var(--brand-primary);
box-shadow: 0 0 0 4rpx rgba(22, 119, 255, 0.08);
box-shadow: 0 0 0 4rpx rgba(102, 166, 255, 0.08);
transform: translateY(-2rpx);
}
@@ -440,14 +448,14 @@ onLoad((options: any) => {
font-size: 32rpx;
font-weight: 500;
color: #fff !important;
background: #ff7c24 !important;
background: #66a6ff !important;
border: none;
border-radius: 25rpx;
letter-spacing: 2rpx;
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(255, 124, 36, 0.3);
box-shadow: 0 4rpx 16rpx rgba(102, 166, 255, 0.3);
padding: 0;
margin: 0;
line-height: 1;
@@ -468,7 +476,7 @@ onLoad((options: any) => {
&:active:not([disabled]) {
transform: scale(0.98);
box-shadow: 0 2rpx 10rpx rgba(255, 124, 36, 0.35);
box-shadow: 0 2rpx 10rpx rgba(102, 166, 255, 0.35);
}
&::after {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
src/static/icons/右边.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
src/static/icons/复制.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
src/static/icons/通知.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

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: #66a6ff; /* 主色 */
--brand-primary-light: #8fc0ff; /* 主色浅 */
--brand-primary-dark: #408de8; /* 主色深 */
--brand-secondary: #1f2937; /* 辅色 */
--brand-tertiary: #f1f6fc; /* 色 */
--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: #f4f6fa;
--bg-container: #ffffff;
--bg-elevated: #ffffff;
--bg-muted: #f7f9fc;
--bg-overlay: rgba(31, 41, 55, 0.45);
/* === 边框色 Border Colors === */
--border-primary: #e8e8e8; /* 主边框 */
--border-secondary: #f0f0f0; /* 次边框 */
--border-light: #fafafa; /* 浅边框 */
--border-primary: #e1eaf5;
--border-secondary: #edf2f8;
--border-light: #f7f9fc;
/* === 阴影 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(102, 166, 255, 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: #8fc0ff;
--brand-primary-dark: #408de8;
--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(102, 166, 255, 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(102, 166, 255, 0.22); }
.shadow-brand-sm { box-shadow: 0 4px 14px rgba(102, 166, 255, 0.14); }
.shadow-brand-xs { box-shadow: 0 2px 8px rgba(102, 166, 255, 0.16); }
.border-brand { border-color: var(--brand-primary); }
@@ -186,14 +207,12 @@ page {
.card {
background-color: var(--bg-container);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: var(--space-lg);
transition: var(--transition-base);
}
.card:active {
transform: scale(0.98);
box-shadow: var(--shadow-xs);
}
/* 按钮 */
@@ -241,7 +260,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(102, 166, 255, 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,9 @@ 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']

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: {