merge: develop into main
@@ -1,9 +1,9 @@
|
||||
name: 构建并部署前端到测试环境
|
||||
name: 构建并部署前端到生产环境
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- dev
|
||||
- test
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/develop" ]; then
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
|
||||
echo "tag=dev" >> $GITHUB_OUTPUT
|
||||
@@ -51,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 }}/
|
||||
|
||||
|
||||
13
Dockerfile
@@ -12,25 +12,28 @@ WORKDIR /build
|
||||
# 安装 pnpm
|
||||
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-test
|
||||
# 使用 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,16 @@ 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/ /h5/index.html;
|
||||
}
|
||||
|
||||
305
docs/通知.md
Normal 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,范围 1~10000 |
|
||||
| page_size | integer | 否 | 每页数量,默认 20,范围 1~50 |
|
||||
|
||||
#### 成功响应
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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.
|
||||
72
src/api/notifications.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FundSummary } from '@/api/commission'
|
||||
<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" />
|
||||
|
||||
type SummaryMetric = {
|
||||
label: string
|
||||
amount?: number | null
|
||||
cardClass: string
|
||||
labelClass: string
|
||||
<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
|
||||
}>()
|
||||
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-[#f4fbf5] border border-[#e3f2e6]',
|
||||
labelClass: 'text-[#2f7d32]',
|
||||
cardClass: 'bg-[#f1f6fc] border border-[#e1ecfa]',
|
||||
labelClass: 'text-[var(--brand-primary-dark)]',
|
||||
},
|
||||
{
|
||||
label: '累计佣金',
|
||||
@@ -38,51 +156,29 @@ const metrics = computed<SummaryMetric[]>(() => [
|
||||
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('.')}`
|
||||
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) : '--'
|
||||
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>
|
||||
|
||||
<template>
|
||||
<view class="bg-white rounded-20px shadow-[0_8px_24px_rgba(15,23,42,0.06)] p-5">
|
||||
<view class="rounded-18px bg-gradient-to-br from-[#f5fbf5] via-white to-[#f8fafc] p-4">
|
||||
<view class="flex items-start justify-between gap-3">
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="inline-flex items-center gap-1 rounded-full bg-[#e8f5e9] px-2.5 py-1">
|
||||
<i class="i-mdi-wallet-outline text-13px text-[#2f7d32]" />
|
||||
<text class="text-11px font-600 text-[#2f7d32]">可提现佣金</text>
|
||||
</view>
|
||||
<text class="text-34px font-700 leading-none text-[#1f2937] block mt-3 break-all">
|
||||
{{ formatAmountDisplay(summary?.available_commission) }}
|
||||
</text>
|
||||
</view>
|
||||
<image src="@/static/icons/佣金中心.png" class="w-16 h-16 shrink-0" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="grid grid-cols-2 gap-3 mt-4">
|
||||
<view
|
||||
v-for="metric in metrics"
|
||||
:key="metric.label"
|
||||
:class="metric.cardClass"
|
||||
class="rounded-16px px-3 py-3"
|
||||
>
|
||||
<text :class="metric.labelClass" class="text-11px block">
|
||||
{{ metric.label }}
|
||||
</text>
|
||||
<text class="text-15px font-700 text-[#1f2937] block mt-2 break-all">
|
||||
{{ formatAmountDisplay(metric.amount) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +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
|
||||
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>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="show"
|
||||
@@ -100,17 +11,23 @@ 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
|
||||
v-if="!autoConfirm"
|
||||
class="simple-picker-confirm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<text class="text-sm text-brand font-600">确定</text>
|
||||
<text class="text-brand text-sm font-600">
|
||||
确定
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
@@ -130,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>
|
||||
@@ -141,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;
|
||||
@@ -220,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent-system/notifications/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "通知",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agent-system/assets/index",
|
||||
"style": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,197 +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 AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
|
||||
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 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>
|
||||
|
||||
<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>
|
||||
@@ -202,62 +55,88 @@ function loadMore() {
|
||||
<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>
|
||||
@@ -265,17 +144,25 @@ function loadMore() {
|
||||
</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>
|
||||
@@ -296,6 +183,167 @@ function loadMore() {
|
||||
</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;
|
||||
|
||||
@@ -1,188 +1,41 @@
|
||||
<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'
|
||||
import AgentFundSummaryCard from '@/components/AgentFundSummaryCard.vue'
|
||||
|
||||
|
||||
// 用户信息
|
||||
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('.')}`
|
||||
}
|
||||
|
||||
// 格式化日期 (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}`
|
||||
}
|
||||
|
||||
// 加载佣金数据
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转页面
|
||||
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>
|
||||
@@ -192,107 +45,118 @@
|
||||
<AgentFundSummaryCard
|
||||
class="mb-3"
|
||||
:summary="commissionSummary"
|
||||
variant="wallet"
|
||||
@click="navigateTo('/pages/agent-system/commission-center/index')"
|
||||
@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>
|
||||
@@ -300,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;
|
||||
|
||||
153
src/pages/agent-system/notifications/index.vue
Normal 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>
|
||||
@@ -1,128 +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()
|
||||
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>
|
||||
|
||||
@@ -132,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>
|
||||
@@ -141,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>
|
||||
@@ -158,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>
|
||||
@@ -200,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>
|
||||
@@ -237,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;
|
||||
|
||||
@@ -1,223 +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 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 = 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>
|
||||
|
||||
<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)"
|
||||
>
|
||||
@@ -231,42 +20,48 @@ function loadMore() {
|
||||
<!-- 申请提现 -->
|
||||
<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"
|
||||
>
|
||||
@@ -276,25 +71,29 @@ function loadMore() {
|
||||
</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="mt-6 mb-3 flex items-center gap-1.5 px-3 py-2 rounded-12px bg-[var(--color-warning-bg)]">
|
||||
<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元才可提现
|
||||
@@ -303,7 +102,7 @@ function loadMore() {
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<view
|
||||
class="bg-brand rounded-full py-4 text-center text-16px font-600 text-white"
|
||||
class="bg-brand rounded-full py-4 text-center text-16px text-white font-600"
|
||||
:class="{ 'opacity-50': submitting || !canWithdrawByMinAmount }"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
@@ -313,61 +112,83 @@ function loadMore() {
|
||||
|
||||
<!-- 提现记录 -->
|
||||
<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>
|
||||
@@ -381,6 +202,223 @@ function loadMore() {
|
||||
</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;
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -139,6 +144,7 @@ async function submit() {
|
||||
|
||||
// 保存用户信息到本地存储
|
||||
uni.setStorageSync('user_info', sessionUser);
|
||||
uni.setStorageSync(NOTIFICATION_REMINDER_PENDING_KEY, true);
|
||||
|
||||
uni.showToast({
|
||||
title: '登录成功',
|
||||
@@ -153,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;
|
||||
}
|
||||
}
|
||||
@@ -203,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;
|
||||
}
|
||||
|
||||
@@ -216,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;
|
||||
}
|
||||
@@ -226,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;
|
||||
}
|
||||
@@ -236,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;
|
||||
}
|
||||
@@ -292,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);
|
||||
}
|
||||
@@ -386,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);
|
||||
}
|
||||
|
||||
@@ -441,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;
|
||||
@@ -469,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 {
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 6.0 KiB |
BIN
src/static/icons/右边.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/static/icons/复制.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 4.5 KiB |
BIN
src/static/icons/通知.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
@@ -6,11 +6,11 @@
|
||||
:root,
|
||||
page {
|
||||
/* === 品牌色 Brand Colors === */
|
||||
--brand-primary: #2f7d32; /* 主色 */
|
||||
--brand-primary-light: #5a9f5d; /* 主色浅 */
|
||||
--brand-primary-dark: #245f27; /* 主色深 */
|
||||
--brand-primary: #66a6ff; /* 主色 */
|
||||
--brand-primary-light: #8fc0ff; /* 主色浅 */
|
||||
--brand-primary-dark: #408de8; /* 主色深 */
|
||||
--brand-secondary: #1f2937; /* 辅色 */
|
||||
--brand-tertiary: #f4fbf5; /* 次色 */
|
||||
--brand-tertiary: #f1f6fc; /* 次色 */
|
||||
--brand-accent: #ffffff; /* 强调白 */
|
||||
|
||||
/* === 语义色 Semantic Colors === */
|
||||
@@ -31,16 +31,16 @@ page {
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* === 背景色 Background Colors === */
|
||||
--bg-page: var(--brand-tertiary);
|
||||
--bg-page: #f4f6fa;
|
||||
--bg-container: #ffffff;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-muted: #f8faf8;
|
||||
--bg-muted: #f7f9fc;
|
||||
--bg-overlay: rgba(31, 41, 55, 0.45);
|
||||
|
||||
/* === 边框色 Border Colors === */
|
||||
--border-primary: #e3ece4;
|
||||
--border-secondary: #edf2ee;
|
||||
--border-light: #f7faf7;
|
||||
--border-primary: #e1eaf5;
|
||||
--border-secondary: #edf2f8;
|
||||
--border-light: #f7f9fc;
|
||||
|
||||
/* === 阴影 Shadows === */
|
||||
--shadow-xs: 0 1px 2px 0 rgba(31, 41, 55, 0.04);
|
||||
@@ -48,7 +48,7 @@ page {
|
||||
--shadow-md: 0 4px 12px 0 rgba(31, 41, 55, 0.08);
|
||||
--shadow-lg: 0 8px 20px 0 rgba(31, 41, 55, 0.12);
|
||||
--shadow-xl: 0 12px 28px 0 rgba(31, 41, 55, 0.16);
|
||||
--shadow-brand: 0 6px 18px 0 rgba(47, 125, 50, 0.18);
|
||||
--shadow-brand: 0 6px 18px 0 rgba(102, 166, 255, 0.18);
|
||||
|
||||
/* === 圆角 Radius === */
|
||||
--radius-xs: 4px;
|
||||
@@ -118,8 +118,8 @@ page {
|
||||
|
||||
/* === 暗色主题 Dark Theme === */
|
||||
.theme-dark {
|
||||
--brand-primary-light: #74b377;
|
||||
--brand-primary-dark: #245f27;
|
||||
--brand-primary-light: #8fc0ff;
|
||||
--brand-primary-dark: #408de8;
|
||||
--color-warning: #f0b451;
|
||||
--color-error: #ef7f7f;
|
||||
|
||||
@@ -144,7 +144,7 @@ page {
|
||||
--shadow-md: 0 4px 12px 0 rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 8px 20px 0 rgba(0, 0, 0, 0.45);
|
||||
--shadow-xl: 0 12px 28px 0 rgba(0, 0, 0, 0.55);
|
||||
--shadow-brand: 0 6px 18px 0 rgba(47, 125, 50, 0.22);
|
||||
--shadow-brand: 0 6px 18px 0 rgba(102, 166, 255, 0.22);
|
||||
}
|
||||
|
||||
/* === 全局重置 Global Resets === */
|
||||
@@ -178,9 +178,9 @@ page {
|
||||
.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 8px 22px rgba(47, 125, 50, 0.22); }
|
||||
.shadow-brand-sm { box-shadow: 0 4px 14px rgba(47, 125, 50, 0.14); }
|
||||
.shadow-brand-xs { box-shadow: 0 2px 8px rgba(47, 125, 50, 0.16); }
|
||||
.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); }
|
||||
|
||||
@@ -207,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);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
@@ -262,7 +260,7 @@ page {
|
||||
.input:focus {
|
||||
border-color: var(--brand-primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(47, 125, 50, 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(102, 166, 255, 0.12);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
|
||||
2
types/components.d.ts
vendored
@@ -11,8 +11,6 @@ declare module 'vue' {
|
||||
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']
|
||||
|
||||