This commit is contained in:
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
207
docs/代理首页可提现佣金-spec.md
Normal file
207
docs/代理首页可提现佣金-spec.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# 代理首页佣金展示 Spec
|
||||
|
||||
## 1. 背景
|
||||
|
||||
代理首页当前已经会请求 `getFundSummary()`,但佣金区域使用了 `v-if="isAgent && commissionSummary"`。
|
||||
|
||||
这会带来两个问题:
|
||||
|
||||
- 只要 `commissionSummary` 为空,首页代理佣金模块就整块不显示
|
||||
- 首页顶部还保留了“佣金概览”标题文案,这个文案本次不再需要
|
||||
|
||||
本次需求明确为:
|
||||
|
||||
- 首页里去掉“佣金概览”这个标题文案
|
||||
- 只要当前账号是代理账号,就显示首页代理佣金区域
|
||||
- 不管接口有没有值,模块都要显示
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 首页代理佣金区域的外层显示条件改为仅判断 `isAgent`
|
||||
- 首页不再展示“佣金概览”标题
|
||||
- 当佣金概览数据未返回、为空或请求失败时,佣金卡片仍然保留,金额位置显示占位内容
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围
|
||||
|
||||
### 页面
|
||||
|
||||
- `src/pages/agent-system/home/index.vue`
|
||||
|
||||
### 用户
|
||||
|
||||
- 代理账号显示:`user_type === 3`
|
||||
- 企业账号不显示:`user_type === 4`
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据来源与约束
|
||||
|
||||
### 数据来源
|
||||
|
||||
- 接口:`GET /api/admin/shops/fund-summary`
|
||||
- 方法:`getFundSummary()`
|
||||
- 现有状态:`commissionSummary`
|
||||
|
||||
### 关键字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `available_commission` | number | 可提现佣金,单位分 |
|
||||
| `total_commission` | number | 累计佣金,单位分 |
|
||||
| `withdrawn_commission` | number | 已提现佣金,单位分 |
|
||||
| `frozen_commission` | number | 冻结佣金,单位分 |
|
||||
|
||||
### 约束
|
||||
|
||||
1. 首页不得新增新的佣金概览接口请求
|
||||
2. 首页直接复用现有 `commissionSummary`
|
||||
3. 本次不改后端接口、不改接口字段
|
||||
|
||||
---
|
||||
|
||||
## 5. 展示规则
|
||||
|
||||
### 5.1 外层显示条件
|
||||
|
||||
首页代理佣金区域显示条件改为:
|
||||
|
||||
- `isAgent === true`
|
||||
|
||||
不再要求:
|
||||
|
||||
- `commissionSummary` 必须有值
|
||||
|
||||
### 5.2 标题文案
|
||||
|
||||
首页移除“佣金概览”标题文案,包括:
|
||||
|
||||
- 不再在卡片上方展示“佣金概览”
|
||||
- 保留卡片内部的“可提现佣金”主标题
|
||||
|
||||
### 5.3 金额展示
|
||||
|
||||
佣金卡片继续展示以下内容:
|
||||
|
||||
- `可提现佣金`
|
||||
- `累计佣金`
|
||||
- `已提现`
|
||||
- `冻结`
|
||||
|
||||
展示规则:
|
||||
|
||||
- 有值时,按现有金额格式化规则显示
|
||||
- 无值时,显示 `--`
|
||||
- 不允许因为空值显示错误金额
|
||||
|
||||
### 5.4 今日佣金区域
|
||||
|
||||
“今日佣金”区域继续跟随代理佣金模块一起显示。
|
||||
|
||||
展示规则保持现状:
|
||||
|
||||
- 日期无值显示 `--`
|
||||
- 佣金金额无值显示 `¥0.00`
|
||||
- 订单笔数无值显示 `0`
|
||||
- 无当日数据时显示“今日暂无佣金数据”
|
||||
|
||||
---
|
||||
|
||||
## 6. 交互与状态
|
||||
|
||||
### 6.1 跳转
|
||||
|
||||
点击佣金卡片后跳转到:
|
||||
|
||||
- `/pages/agent-system/commission-center/index`
|
||||
|
||||
### 6.2 加载态
|
||||
|
||||
当 `commissionSummary` 还未返回时:
|
||||
|
||||
- 佣金卡片仍然显示
|
||||
- 金额区域显示 `--`
|
||||
|
||||
### 6.3 异常态
|
||||
|
||||
当 `getFundSummary()` 请求失败时:
|
||||
|
||||
- 佣金卡片仍然显示
|
||||
- 金额区域显示 `--`
|
||||
- 不影响首页其他模块渲染
|
||||
|
||||
---
|
||||
|
||||
## 7. 非目标
|
||||
|
||||
- 不修改佣金中心页面结构
|
||||
- 不修改提现管理页面结构
|
||||
- 不新增首页专用佣金接口
|
||||
- 不修改企业账号首页展示逻辑
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
### 用例 1:代理账号且接口有值
|
||||
|
||||
前置条件:
|
||||
|
||||
- `user_type === 3`
|
||||
- `commissionSummary.available_commission = 85000`
|
||||
|
||||
期望:
|
||||
|
||||
- 首页显示代理佣金卡片
|
||||
- 首页不显示“佣金概览”标题
|
||||
- 主金额显示 `¥850.00`
|
||||
|
||||
### 用例 2:代理账号但接口无值
|
||||
|
||||
前置条件:
|
||||
|
||||
- `user_type === 3`
|
||||
- `commissionSummary = null`
|
||||
|
||||
期望:
|
||||
|
||||
- 首页仍显示代理佣金卡片
|
||||
- `可提现佣金 / 累计佣金 / 已提现 / 冻结` 均显示 `--`
|
||||
- 今日佣金区域仍显示
|
||||
|
||||
### 用例 3:代理账号且接口失败
|
||||
|
||||
前置条件:
|
||||
|
||||
- `user_type === 3`
|
||||
- `getFundSummary()` 请求失败
|
||||
|
||||
期望:
|
||||
|
||||
- 首页仍显示代理佣金卡片
|
||||
- 金额不显示错误数据
|
||||
- 页面其他内容正常渲染
|
||||
|
||||
### 用例 4:企业账号
|
||||
|
||||
前置条件:
|
||||
|
||||
- `user_type === 4`
|
||||
|
||||
期望:
|
||||
|
||||
- 首页不显示代理佣金区域
|
||||
|
||||
### 用例 5:不重复请求
|
||||
|
||||
前置条件:
|
||||
|
||||
- 首页已执行 `getFundSummary()`
|
||||
|
||||
期望:
|
||||
|
||||
- 不因本次需求新增第二次佣金接口请求
|
||||
- 页面继续复用 `commissionSummary`
|
||||
60
docs/资产详情修改.md
Normal file
60
docs/资产详情修改.md
Normal file
@@ -0,0 +1,60 @@
|
||||
1. 资产列表页面的IoT卡, 响应新增了返回了一个device_virtual_no这个字段
|
||||
如果这个字段为空显示的就是未绑定设备, 如果是有值就显示已绑定设备
|
||||
|
||||
2.资产列表里面加一个店铺联级查询条件
|
||||
/api/admin/iot-cards/standalone?shop_id=xxxx
|
||||
|
||||
# 店铺联级查询
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 接口地址 | `GET /api/admin/shops/cascade` |
|
||||
| 认证方式 | Bearer Token (JWT) |
|
||||
| 所属模块 | 店铺管理 |
|
||||
|
||||
## 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `shop_name` | string | 否 | 店铺名称(模糊查询),最大长度 100 |
|
||||
| `parent_id` | integer | 否 | 上级店铺 ID,不传则查询顶级店铺 |
|
||||
|
||||
## 响应参数
|
||||
|
||||
### 成功响应(200)
|
||||
|
||||
{
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"shop_name": "总店",
|
||||
"has_children": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"shop_name": "分店 A",
|
||||
"has_children": false
|
||||
}
|
||||
],
|
||||
"msg": "success",
|
||||
"timestamp": "2026-05-09T14:28:00Z"
|
||||
}
|
||||
|
||||
#### DtoShopCascadeItem
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | integer | 店铺 ID |
|
||||
| `shop_name` | string | 店铺名称 |
|
||||
| `has_children` | boolean | 是否有下级店铺 |
|
||||
|
||||
3. 资产列表是设备的情况下让默认选择的搜索类型是虚拟号, 并把这个虚拟号改成设备号, 然后那个选择搜索类型的不要了,如果是IoT卡的时候就是ICCID, 也不要那个选择搜索类型去掉
|
||||
|
||||
4. 在资产详情里面制造商,批次号字段去掉, 然后绑定的IoT卡里面电话改成MSISDN, 实名改显眼一点, 加一个运营商(carrier_name)返回里面有返回这个字段
|
||||
|
||||
5. 设备实时状态里面的下载流量,上传流量去掉.
|
||||
6. 套餐列表里面的生肖中的那xxx至xxx 时间要明显一点
|
||||
7. 创建时间和网关同步删掉, 把最后在线改成最后上线时间放到设备实时状态里面
|
||||
456
openspec/AGENTS.md
Normal file
456
openspec/AGENTS.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: `proposal`, `change`, `spec`
|
||||
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||
- Change: `openspec show <change-id> --json --deltas-only`
|
||||
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create `design.md` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal `design.md` skeleton:
|
||||
```markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||
|
||||
Example for RENAMED:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Login`
|
||||
- TO: `### Requirement: User Authentication`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
```bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
Users MUST provide a second factor during login.
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
```
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
```
|
||||
|
||||
auth/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
```
|
||||
|
||||
notifications/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Change: Update agent asset list and detail display
|
||||
|
||||
## Why
|
||||
|
||||
The current agent-side asset experience exposes too much low-value information, hides some high-value state, and requires unnecessary search-type switching. The latest requests also require shop-based filtering, clearer detail presentation, explicit pagination, and a fixed top control area so search and filters remain available while the user browses long lists.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add IoT card device-binding status to the agent asset list
|
||||
- Add cascaded shop filtering for the agent IoT card and device lists
|
||||
- Remove the manual search-type selector and fix search semantics by active tab
|
||||
- Keep the search input and filter controls fixed at the top while the asset list scrolls underneath
|
||||
- Replace bottom-reached auto pagination with an explicit load-more button for both asset tabs
|
||||
- Simplify asset detail basic fields and strengthen bound-card status presentation
|
||||
- Remove `保护状态`
|
||||
- Remove `切卡模式`
|
||||
- Move the bound-card real-name status badge into the slot-title row
|
||||
- Remove low-value device realtime traffic items and move "last online" into the realtime block as `最后上线时间`
|
||||
- Make package effective-period text more prominent in asset detail package lists
|
||||
- Only active packages should show the effective-period line
|
||||
- Adjust the current active package header content
|
||||
- Remove the order number line below the package name
|
||||
- Show the active period there as `生效期:开始时间 - 到期时间`
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `agent-asset-list`
|
||||
- `agent-asset-detail`
|
||||
- Affected code:
|
||||
- `src/pages/agent-system/assets/index.vue`
|
||||
- `src/pages/agent-system/asset-search/index.vue`
|
||||
- `src/pages/agent-system/asset-detail/index.vue`
|
||||
- `src/api/assets.ts`
|
||||
@@ -0,0 +1,108 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent Asset Detail SHALL Remove Redundant Basic Fields
|
||||
|
||||
The agent asset detail experience SHALL remove fields that are no longer required from the basic information area.
|
||||
|
||||
#### Scenario: Viewing an IoT card detail
|
||||
|
||||
- **WHEN** the current asset is an IoT card
|
||||
- **THEN** the basic information section SHALL NOT show `批次号`
|
||||
|
||||
#### Scenario: Viewing a device detail
|
||||
|
||||
- **WHEN** the current asset is a device
|
||||
- **THEN** the basic information section SHALL NOT show `制造商`
|
||||
- **AND** the basic information section SHALL NOT show `批次号`
|
||||
- **AND** the basic information section SHALL NOT show `保护状态`
|
||||
- **AND** the basic information section SHALL NOT show `切卡模式`
|
||||
|
||||
### Requirement: Agent Asset Detail SHALL Emphasize Bound IoT Card Identity And Status
|
||||
|
||||
The agent device detail view SHALL make bound IoT card identity and real-name status easier to scan.
|
||||
|
||||
#### Scenario: Rendering a bound IoT card item
|
||||
|
||||
- **WHEN** a device detail contains a bound IoT card entry
|
||||
- **THEN** the label `电话` SHALL be renamed to `MSISDN`
|
||||
- **AND** the item SHALL display `carrier_name` as `运营商`
|
||||
- **AND** missing `msisdn` or `carrier_name` values SHALL render as `-`
|
||||
|
||||
#### Scenario: Rendering real-name status
|
||||
|
||||
- **WHEN** a bound IoT card has a `real_name_status`
|
||||
- **THEN** the UI SHALL render that status with stronger visual emphasis than ordinary low-contrast metadata text
|
||||
- **AND** the meaning of existing status values SHALL remain unchanged
|
||||
|
||||
#### Scenario: Rendering the bound-card header row
|
||||
|
||||
- **WHEN** a bound IoT card item is rendered in the device detail page
|
||||
- **THEN** the real-name status badge SHALL be placed in the same row as the slot title
|
||||
- **AND** the badge SHALL appear after the slot title text
|
||||
- **AND** the lower metadata area SHALL NOT render a second standalone real-name field for the same card
|
||||
|
||||
### Requirement: Agent Device Realtime Status SHALL Prioritize High-Value Realtime Information
|
||||
|
||||
The agent device detail realtime block SHALL remove low-value traffic rows and absorb the "last online" field as a realtime-oriented field.
|
||||
|
||||
#### Scenario: Rendering device realtime traffic statistics
|
||||
|
||||
- **WHEN** the device realtime block is shown
|
||||
- **THEN** it SHALL continue to show `今日用量`
|
||||
- **AND** it SHALL continue to show `限速`
|
||||
- **AND** it SHALL NOT show `下载流量`
|
||||
- **AND** it SHALL NOT show `上传流量`
|
||||
|
||||
#### Scenario: Rendering last online time
|
||||
|
||||
- **WHEN** a device detail page has a realtime section
|
||||
- **THEN** the page SHALL show `最后上线时间` inside the realtime section
|
||||
- **AND** it SHALL prefer `realtimeStatus.device_realtime.last_online_time`
|
||||
- **AND** it SHALL fall back to `deviceInfo.last_online_time` when the realtime value is absent
|
||||
|
||||
#### Scenario: Rendering the legacy time-information block
|
||||
|
||||
- **WHEN** the device detail page previously showed `创建时间`, `最后在线`, or `网关同步` in a separate time-information block
|
||||
- **THEN** that block SHALL no longer show `创建时间`
|
||||
- **AND** that block SHALL no longer show `最后在线`
|
||||
- **AND** that block SHALL no longer show `网关同步`
|
||||
|
||||
### Requirement: Agent Asset Detail Package List SHALL Emphasize Effective Period
|
||||
|
||||
The package list in agent asset detail SHALL make the effective period line visually prominent.
|
||||
|
||||
#### Scenario: Package has both start and end time
|
||||
|
||||
- **WHEN** a package list item has both `activated_at` and `expires_at`
|
||||
- **AND** the package is in active status
|
||||
- **THEN** the page SHALL render a dedicated effective-period line with the meaning `生效期:开始时间 至 结束时间`
|
||||
- **AND** that line SHALL have stronger visual prominence than the current weak auxiliary timestamp style
|
||||
|
||||
#### Scenario: Package is missing part of the effective period
|
||||
|
||||
- **WHEN** either `activated_at` or `expires_at` is missing
|
||||
- **AND** the package is in active status
|
||||
- **THEN** the effective-period line SHALL still be rendered
|
||||
- **AND** the missing value SHALL display as `-`
|
||||
|
||||
#### Scenario: Package is not in active status
|
||||
|
||||
- **WHEN** a package list item is not in active status
|
||||
- **THEN** the page SHALL NOT render the dedicated `生效期` line for that package
|
||||
|
||||
### Requirement: Agent Asset Detail Current Package SHALL Prioritize Effective Period Over Order Number
|
||||
|
||||
The current active package card in agent asset detail SHALL remove the order number from the package header area and use that space to surface the package active period.
|
||||
|
||||
#### Scenario: Rendering the current active package card
|
||||
|
||||
- **WHEN** the current active package card is rendered
|
||||
- **THEN** the package name SHALL remain visible
|
||||
- **AND** the order number line below the package name SHALL NOT be shown
|
||||
- **AND** the card SHALL show a labeled `生效期:开始时间 - 到期时间` line in that position instead
|
||||
|
||||
#### Scenario: Current package is missing part of the active period
|
||||
|
||||
- **WHEN** either the current package `activated_at` or `expires_at` is missing
|
||||
- **THEN** the labeled `生效期:开始时间 - 到期时间` line SHALL still be rendered
|
||||
- **AND** the missing value SHALL display as `-`
|
||||
@@ -0,0 +1,123 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Agent IoT Card List SHALL Surface Device Binding Status
|
||||
|
||||
The agent-side IoT card list SHALL derive a device-binding status from the `device_virtual_no` field returned by `GET /api/admin/iot-cards/standalone` and show that status in each IoT card list item.
|
||||
|
||||
#### Scenario: Card is not bound to a device
|
||||
|
||||
- **WHEN** an IoT card item has `device_virtual_no` equal to `null`, `undefined`, an empty string, or a whitespace-only string
|
||||
- **THEN** the list item SHALL show a status tag with `未绑定设备`
|
||||
- **AND** the UI SHALL NOT display a fabricated device identifier
|
||||
- **AND** the UI SHALL NOT prepend an extra `设备绑定` label before the tag
|
||||
|
||||
#### Scenario: Card is bound to a device
|
||||
|
||||
- **WHEN** an IoT card item has a non-empty `device_virtual_no`
|
||||
- **THEN** the list item SHALL show a status tag with `已绑定设备`
|
||||
- **AND** the UI MAY keep the raw `device_virtual_no` hidden
|
||||
|
||||
### Requirement: Agent Asset Lists SHALL Support Cascaded Shop Filtering
|
||||
|
||||
The agent-side asset lists SHALL provide a cascaded shop filter that is backed by `GET /api/admin/shops/cascade` and applies the selected shop ID to the active list request.
|
||||
|
||||
#### Scenario: User chooses a shop node under the IoT card tab
|
||||
|
||||
- **WHEN** the user opens the shop filter under the `IoT卡` tab and confirms a shop node
|
||||
- **THEN** the selected node `id` SHALL be sent as `shop_id` in the subsequent IoT card list request
|
||||
- **AND** the list SHALL refresh using that shop filter
|
||||
|
||||
#### Scenario: User chooses a shop node under the device tab
|
||||
|
||||
- **WHEN** the user opens the shop filter under the `设备` tab and confirms a shop node
|
||||
- **THEN** the selected node `id` SHALL be sent as `shop_id` in the subsequent device list request
|
||||
- **AND** the device list SHALL refresh using that shop filter
|
||||
- **AND** the device list request SHALL keep its existing search semantics and append `shop_id` in addition to them
|
||||
|
||||
#### Scenario: Selected shop has children
|
||||
|
||||
- **WHEN** a shop node returned from `GET /api/admin/shops/cascade` has `has_children = true`
|
||||
- **THEN** the UI SHALL allow the user to continue drilling down to child shops
|
||||
- **AND** the UI SHALL use `parent_id` to load the next level
|
||||
|
||||
#### Scenario: User clears the shop filter
|
||||
|
||||
- **WHEN** the user clears the shop filter
|
||||
- **THEN** subsequent IoT card list requests SHALL omit `shop_id`
|
||||
- **AND** subsequent device list requests SHALL omit `shop_id`
|
||||
|
||||
#### Scenario: Device tab is active
|
||||
|
||||
- **WHEN** the active asset tab is `设备`
|
||||
- **THEN** the shop filter SHALL still be shown
|
||||
- **AND** device list requests SHALL support `shop_id`
|
||||
|
||||
### Requirement: Agent Asset Search Input SHALL Be Fixed By Active Tab
|
||||
|
||||
The agent asset list SHALL remove the manual search-type selector and bind search semantics directly to the active tab.
|
||||
|
||||
#### Scenario: IoT card tab search
|
||||
|
||||
- **WHEN** the active tab is `IoT卡`
|
||||
- **THEN** the search field SHALL target `iccid`
|
||||
- **AND** the search placeholder SHALL be `搜索ICCID`
|
||||
- **AND** the UI SHALL NOT show a search-type switcher
|
||||
|
||||
#### Scenario: Device tab search
|
||||
|
||||
- **WHEN** the active tab is `设备`
|
||||
- **THEN** the search field SHALL target `virtual_no`
|
||||
- **AND** the user-facing search label SHALL be `设备号`
|
||||
- **AND** the search placeholder SHALL be `搜索设备号`
|
||||
- **AND** the UI SHALL NOT show a search-type switcher
|
||||
|
||||
### Requirement: Agent Asset List Controls SHALL Stay Fixed At The Top
|
||||
|
||||
The agent-side asset list page SHALL keep the search input and the filter/tab control area fixed at the top of the page while the list content scrolls independently underneath.
|
||||
|
||||
#### Scenario: User scrolls a long IoT card list
|
||||
|
||||
- **WHEN** the active tab is `IoT卡` and the user scrolls the list
|
||||
- **THEN** the search input SHALL remain visible at the top
|
||||
- **AND** the filter and tab controls directly below it SHALL remain visible at the top
|
||||
- **AND** only the list content area SHALL scroll
|
||||
|
||||
#### Scenario: User scrolls a long device list
|
||||
|
||||
- **WHEN** the active tab is `设备` and the user scrolls the list
|
||||
- **THEN** the search input SHALL remain visible at the top
|
||||
- **AND** the filter and tab controls directly below it SHALL remain visible at the top
|
||||
- **AND** only the list content area SHALL scroll
|
||||
|
||||
#### Scenario: Fixed controls do not cover list content
|
||||
|
||||
- **WHEN** the fixed top control area is rendered
|
||||
- **THEN** the list content SHALL start below that area
|
||||
- **AND** the first list item SHALL remain fully visible and operable
|
||||
|
||||
### Requirement: Agent Asset Lists SHALL Use Explicit Load-More Pagination
|
||||
|
||||
The agent-side IoT card list and device list SHALL stop automatic pagination triggered by scrolling to the bottom and SHALL use an explicit footer button to load the next page.
|
||||
|
||||
#### Scenario: IoT card tab still has more pages
|
||||
|
||||
- **WHEN** the active tab is `IoT卡` and the current IoT card result set has more pages available
|
||||
- **THEN** the footer SHALL show a button labeled `加载更多`
|
||||
- **AND** the next page SHALL NOT be requested until the user clicks that button
|
||||
|
||||
#### Scenario: Device tab still has more pages
|
||||
|
||||
- **WHEN** the active tab is `设备` and the current device result set has more pages available
|
||||
- **THEN** the footer SHALL show a button labeled `加载更多`
|
||||
- **AND** the next page SHALL NOT be requested until the user clicks that button
|
||||
|
||||
#### Scenario: User clicks the load-more button
|
||||
|
||||
- **WHEN** the user clicks the `加载更多` button for the active tab
|
||||
- **THEN** the UI SHALL request the next page for that tab using the existing active filters and search parameters
|
||||
- **AND** the returned items SHALL append to the existing list instead of replacing it
|
||||
|
||||
#### Scenario: No more pages remain
|
||||
|
||||
- **WHEN** the active list has no more pages available
|
||||
- **THEN** the `加载更多` button SHALL NOT be shown
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Spec
|
||||
|
||||
- [x] 1.1 Initialize OpenSpec in the repository
|
||||
- [x] 1.2 Capture project context in `openspec/project.md`
|
||||
- [x] 1.3 Create change proposal and delta specs for asset list and asset detail
|
||||
- [x] 1.4 Review and approve the proposal before implementation
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Extend asset list typing and request parameters for `device_virtual_no` and `shop_id`
|
||||
- [x] 2.2 Update agent asset list UI for binding status, shop filter, and fixed search semantics
|
||||
- [x] 2.3 Update asset detail UI for field removal, bound-card emphasis, and realtime-status changes
|
||||
- [x] 2.4 Update package-list date presentation in detail pages
|
||||
- [ ] 2.5 Verify card and device flows manually after implementation
|
||||
- [x] 2.6 Remove `保护状态` and `切卡模式`, and move bound-card real-name status next to the slot title
|
||||
- [x] 2.7 Restrict package effective-period display to packages in active status only
|
||||
- [x] 2.8 Remove the current-package order number and replace it with a `生效期:开始时间 - 到期时间` line
|
||||
- [x] 2.9 Apply the cascaded shop filter to the device list request and device tab UI
|
||||
- [x] 2.10 Replace bottom-reached auto pagination with an explicit `加载更多` button for the IoT card and device lists
|
||||
- [x] 2.11 Keep the asset-list search input and filter controls fixed at the top while the list scrolls underneath
|
||||
68
openspec/project.md
Normal file
68
openspec/project.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Project Context
|
||||
|
||||
## Purpose
|
||||
|
||||
This project is a Uni-app based agent and enterprise management frontend for IoT assets. It provides asset list, asset search/detail, package display, commission, withdrawal, and related operational pages for two user roles: agent accounts and enterprise accounts.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- TypeScript
|
||||
- Vue 3 with `<script setup>`
|
||||
- Uni-app
|
||||
- Vite
|
||||
- UnoCSS
|
||||
- Pinia
|
||||
- uview-plus
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use Composition API and `<script setup lang="ts">`.
|
||||
- Keep API types close to the request layer in `src/api/*.ts`.
|
||||
- Prefer small, direct page-level logic over premature abstraction.
|
||||
- Existing pages use UnoCSS utility classes heavily; UI changes should preserve the current visual language.
|
||||
- User-facing copy is primarily Chinese.
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
- Business pages live under `src/pages/agent-system/`.
|
||||
- Backend calls are wrapped in `src/api/*.ts`.
|
||||
- Shared formatting helpers live in `src/utils/format` and nearby page helpers.
|
||||
- Asset pages distinguish between two primary resource types: `card` (IoT card) and `device`.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- Run targeted validation with `pnpm type-check`, lint, and manual page checks when implementation begins.
|
||||
- For spec work, validate proposals with `openspec validate --strict`.
|
||||
- User-visible asset changes should be verified on both card and device flows.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- The repository may contain unrelated user changes; do not revert them.
|
||||
- Use OpenSpec change proposals for user-visible feature changes before implementation.
|
||||
- Keep proposals focused and capability-scoped.
|
||||
|
||||
## Domain Context
|
||||
|
||||
- Assets are mainly IoT cards and devices.
|
||||
- Asset identifiers may be ICCID or VirtualNo depending on the resource and entry point.
|
||||
- Agent-side pages currently include:
|
||||
- asset list: `src/pages/agent-system/assets/index.vue`
|
||||
- asset search/detail: `src/pages/agent-system/asset-search/index.vue`
|
||||
- secondary asset detail page: `src/pages/agent-system/asset-detail/index.vue`
|
||||
- Package display and realtime status are important parts of the asset detail experience.
|
||||
|
||||
## Important Constraints
|
||||
|
||||
- Agent and enterprise views are not identical; requirements in one role should not be assumed to apply globally.
|
||||
- Existing backend contracts are documented partly in `docs/*.md` and partly in the TypeScript API layer.
|
||||
- UI changes should preserve current behavior unless the proposal explicitly changes it.
|
||||
- Search semantics, display labels, and field visibility are product requirements and must be documented precisely.
|
||||
|
||||
## External Dependencies
|
||||
|
||||
- Backend admin APIs under `/api/admin/...`
|
||||
- Shop cascade API for hierarchical shop filtering
|
||||
- Asset resolve, realtime status, current package, and package list APIs
|
||||
- Uni-app runtime and uview-plus components
|
||||
@@ -47,7 +47,8 @@ export interface AssetResolveResponse {
|
||||
cards?: Array<{
|
||||
card_id: number
|
||||
iccid: string
|
||||
msisdn: string
|
||||
msisdn?: string | null
|
||||
carrier_name?: string | null
|
||||
network_status: number
|
||||
real_name_status: number
|
||||
slot_position: number
|
||||
@@ -74,6 +75,7 @@ export interface CardInfo {
|
||||
msisdn?: string // 电话号码
|
||||
virtual_no?: string // 虚拟号
|
||||
virtual_number?: string // 虚拟号 (别名兼容)
|
||||
device_virtual_no?: string | null
|
||||
imei?: string
|
||||
carrier_id?: number // 运营商ID
|
||||
carrier_name?: string // 运营商名称
|
||||
@@ -246,7 +248,8 @@ export interface AssetRealtimeStatus {
|
||||
card_id: number
|
||||
iccid: string
|
||||
is_current: boolean
|
||||
msisdn: string
|
||||
msisdn?: string | null
|
||||
carrier_name?: string | null
|
||||
network_status: number
|
||||
real_name_status: number
|
||||
slot_position: number
|
||||
|
||||
16
src/api/shop.ts
Normal file
16
src/api/shop.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
export interface ShopCascadeParams {
|
||||
parent_id?: number
|
||||
shop_name?: string
|
||||
}
|
||||
|
||||
export interface ShopCascadeItem {
|
||||
id: number
|
||||
shop_name: string
|
||||
has_children: boolean
|
||||
}
|
||||
|
||||
export function getShopCascade(params?: ShopCascadeParams) {
|
||||
return get<ShopCascadeItem[]>('/api/admin/shops/cascade', { params })
|
||||
}
|
||||
345
src/components/ShopCascadePicker.vue
Normal file
345
src/components/ShopCascadePicker.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { getShopCascade } from '@/api/shop'
|
||||
import type { ShopCascadeItem } from '@/api/shop'
|
||||
|
||||
interface SelectedShop {
|
||||
id: number
|
||||
shop_name: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
selectedShop?: SelectedShop | null
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'confirm', shop: SelectedShop | null): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '选择店铺',
|
||||
selectedShop: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const loading = ref(false)
|
||||
const shopOptions = ref<ShopCascadeItem[]>([])
|
||||
const breadcrumbs = ref<ShopCascadeItem[]>([])
|
||||
const selectedShop = ref<SelectedShop | null>(null)
|
||||
|
||||
async function loadShopOptions(parentId?: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getShopCascade(parentId !== undefined ? { parent_id: parentId } : undefined)
|
||||
shopOptions.value = response || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载店铺级联数据失败:', error)
|
||||
shopOptions.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
selectedShop.value = props.selectedShop ? { ...props.selectedShop } : null
|
||||
breadcrumbs.value = []
|
||||
await loadShopOptions()
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
close()
|
||||
}
|
||||
|
||||
function selectShop(option: ShopCascadeItem) {
|
||||
selectedShop.value = {
|
||||
id: option.id,
|
||||
shop_name: option.shop_name,
|
||||
}
|
||||
}
|
||||
|
||||
async function enterChildren(option: ShopCascadeItem) {
|
||||
const nextPath = [...breadcrumbs.value, option]
|
||||
breadcrumbs.value = nextPath
|
||||
await loadShopOptions(option.id)
|
||||
}
|
||||
|
||||
async function goToBreadcrumb(index: number) {
|
||||
if (index < 0) {
|
||||
breadcrumbs.value = []
|
||||
await loadShopOptions()
|
||||
return
|
||||
}
|
||||
|
||||
const nextPath = breadcrumbs.value.slice(0, index + 1)
|
||||
breadcrumbs.value = nextPath
|
||||
await loadShopOptions(nextPath[index]?.id)
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', selectedShop.value)
|
||||
close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (show) {
|
||||
await openPicker()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-if="show"
|
||||
class="shop-picker-overlay"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<view
|
||||
class="shop-picker-content"
|
||||
@click.stop
|
||||
>
|
||||
<view class="shop-picker-header">
|
||||
<view class="shop-picker-action" @click="handleCancel">
|
||||
<text class="text-sm text-[#666]">取消</text>
|
||||
</view>
|
||||
<view class="shop-picker-title">
|
||||
<text class="text-base font-600 text-[#1e293b]">{{ title }}</text>
|
||||
</view>
|
||||
<view class="shop-picker-action text-right" @click="handleConfirm">
|
||||
<text class="text-sm text-brand font-600">确定</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="shop-picker-breadcrumb"
|
||||
scroll-x
|
||||
>
|
||||
<view class="shop-picker-breadcrumb-list">
|
||||
<text
|
||||
class="shop-picker-breadcrumb-item"
|
||||
:class="{ 'shop-picker-breadcrumb-item-active': breadcrumbs.length === 0 }"
|
||||
@click="goToBreadcrumb(-1)"
|
||||
>
|
||||
顶级店铺
|
||||
</text>
|
||||
<template
|
||||
v-for="(item, index) in breadcrumbs"
|
||||
:key="item.id"
|
||||
>
|
||||
<text class="shop-picker-breadcrumb-separator">/</text>
|
||||
<text
|
||||
class="shop-picker-breadcrumb-item"
|
||||
:class="{ 'shop-picker-breadcrumb-item-active': index === breadcrumbs.length - 1 }"
|
||||
@click="goToBreadcrumb(index)"
|
||||
>
|
||||
{{ item.shop_name }}
|
||||
</text>
|
||||
</template>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="selectedShop" class="shop-picker-selection">
|
||||
<text class="text-12px text-[#666]">
|
||||
已选店铺
|
||||
</text>
|
||||
<text class="text-13px font-600 text-[#212121]">
|
||||
{{ selectedShop.shop_name }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="shop-picker-list"
|
||||
scroll-y
|
||||
>
|
||||
<view v-if="loading" class="shop-picker-empty">
|
||||
<i class="i-mdi-loading animate-spin text-18px text-brand" />
|
||||
<text class="text-13px text-[#64748b]">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="shopOptions.length === 0" class="shop-picker-empty">
|
||||
<text class="text-13px text-[#94a3b8]">暂无下级店铺</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view
|
||||
v-for="option in shopOptions"
|
||||
:key="option.id"
|
||||
class="shop-picker-item"
|
||||
:class="{ 'shop-picker-item-active': selectedShop?.id === option.id }"
|
||||
>
|
||||
<view
|
||||
class="shop-picker-item-main"
|
||||
@click="selectShop(option)"
|
||||
>
|
||||
<text class="shop-picker-item-text">{{ option.shop_name }}</text>
|
||||
<i
|
||||
v-if="selectedShop?.id === option.id"
|
||||
class="i-mdi-check text-16px text-brand"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="option.has_children"
|
||||
class="shop-picker-next"
|
||||
@click="enterChildren(option)"
|
||||
>
|
||||
<text class="text-12px text-[#999]">下级</text>
|
||||
<i class="i-mdi-chevron-right text-14px text-[#999]" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shop-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.shop-picker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 72vh;
|
||||
background: #fff;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.shop-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.shop-picker-action {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.shop-picker-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-picker-breadcrumb {
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
|
||||
.shop-picker-breadcrumb-list {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 100%;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.shop-picker-breadcrumb-item {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.shop-picker-breadcrumb-item-active {
|
||||
color: var(--brand-primary, #1677ff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-picker-breadcrumb-separator {
|
||||
margin: 0 8px;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.shop-picker-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.shop-picker-list {
|
||||
flex: 1;
|
||||
max-height: calc(72vh - 114px);
|
||||
}
|
||||
|
||||
.shop-picker-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.shop-picker-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
|
||||
.shop-picker-item-active {
|
||||
background: #fef3f2;
|
||||
}
|
||||
|
||||
.shop-picker-item-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.shop-picker-item-text {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.shop-picker-item-active .shop-picker-item-text {
|
||||
color: var(--brand-primary, #1677ff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-picker-next {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 16px 0 8px;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -76,6 +76,14 @@ function getPackageTypeText(type?: AssetPackage['package_type']) {
|
||||
return type ? map[type] || type : '-'
|
||||
}
|
||||
|
||||
function formatPackageEffectivePeriod(activatedAt?: string | null, expiresAt?: string | null) {
|
||||
return `生效期:${formatTime(activatedAt || undefined, 'date')} 至 ${formatTime(expiresAt || undefined, 'date')}`
|
||||
}
|
||||
|
||||
function formatCurrentPackagePeriodRange(activatedAt?: string | null, expiresAt?: string | null) {
|
||||
return `生效期:${formatTime(activatedAt || undefined, 'date')} - ${formatTime(expiresAt || undefined, 'date')}`
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onMounted(() => {
|
||||
// 从路由参数获取 assetType 和 assetIdentifier
|
||||
@@ -506,8 +514,8 @@ function viewWallet() {
|
||||
<text class="text-14px font-600 text-[#212121] block break-all">
|
||||
{{ currentPackage.package_name || '-' }}
|
||||
</text>
|
||||
<text class="text-11px text-[#999] block mt-1 break-all">
|
||||
订单号:{{ currentPackage.order_no || '-' }}
|
||||
<text class="text-11px text-[#64748b] block mt-1 break-all">
|
||||
{{ formatCurrentPackagePeriodRange(currentPackage.activated_at, currentPackage.expires_at) }}
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
@@ -564,14 +572,6 @@ function viewWallet() {
|
||||
<text class="text-[#666]">套餐类型</text>
|
||||
<text class="text-[#212121]">{{ getPackageTypeText(currentPackage.package_type) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-12px">
|
||||
<text class="text-[#666]">开始时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.activated_at) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-12px">
|
||||
<text class="text-[#666]">到期时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.expires_at) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-12px">
|
||||
<text class="text-[#666]">下单时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.created_at) }}</text>
|
||||
@@ -642,9 +642,11 @@ function viewWallet() {
|
||||
<text class="text-[#666]">金额</text>
|
||||
<text class="text-[#212121]">{{ formatPackagePrice(pkg.paid_amount) }}</text>
|
||||
</view>
|
||||
<text class="text-11px text-[#999]">
|
||||
{{ pkg.activated_at || '-' }} ~ {{ pkg.expires_at || '-' }}
|
||||
</text>
|
||||
<view v-if="pkg.status === 1" class="mt-2 bg-[#f8fafc] rounded-8px px-2.5 py-2">
|
||||
<text class="text-11px font-600 text-[#475569]">
|
||||
{{ formatPackageEffectivePeriod(pkg.activated_at, pkg.expires_at) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { resolveAsset, getAssetRealtimeStatus, getCurrentPackage, getAssetPackages } from '@/api/assets'
|
||||
import type { CardInfo, DeviceInfo, AssetRealtimeStatus, AssetPackage } from '@/api/assets'
|
||||
import type { AssetRealtimeStatus, AssetPackage } from '@/api/assets'
|
||||
import { formatTime, formatDataSize } from '@/utils/format'
|
||||
import { formatPackageDataSize, formatPackagePrice, getPackageRemainMb, getPackageTotalMb, getPackageUsagePercent, getPackageUsedMb } from '../package-display'
|
||||
|
||||
@@ -122,26 +122,23 @@
|
||||
return status !== undefined ? map[status] || '未知' : '未知'
|
||||
}
|
||||
|
||||
function getRealNameStatusTagClass(status ?: number | null) {
|
||||
return status === 1
|
||||
? 'bg-[#e8f5e9] text-[#2e7d32]'
|
||||
: 'bg-[#fff1f0] text-[#cf1322]'
|
||||
}
|
||||
|
||||
// 获取网络状态文本 (卡片)
|
||||
function getNetworkStatusText(status ?: number) {
|
||||
const map : Record<number, string> = {
|
||||
0: '停机',
|
||||
1: '开机',
|
||||
1: '正常',
|
||||
}
|
||||
return status !== undefined ? map[status] || '未知' : '未知'
|
||||
}
|
||||
|
||||
// 获取切卡模式文本 (设备)
|
||||
function getSwitchModeText(mode ?: string) {
|
||||
const map : Record<string, string> = {
|
||||
'0': '自动',
|
||||
'1': '手动',
|
||||
}
|
||||
return mode ? map[mode] || mode : '自动'
|
||||
}
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes ?: string | number) {
|
||||
function formatBytes(bytes ?: string | number | null) {
|
||||
if (!bytes) return '0B'
|
||||
const num = typeof bytes === 'string' ? parseInt(bytes) : bytes
|
||||
if (num < 1024) return `${num}B`
|
||||
@@ -174,16 +171,6 @@
|
||||
return `${secs}秒`
|
||||
}
|
||||
|
||||
// 获取设备保护状态文本
|
||||
function getDeviceProtectText(status ?: string | null) {
|
||||
const map : Record<string, string> = {
|
||||
start: '已启动',
|
||||
stop: '已停止',
|
||||
none: '未启用',
|
||||
}
|
||||
return status ? map[status] || status : '未知'
|
||||
}
|
||||
|
||||
// 获取在线状态文本
|
||||
function getOnlineStatusText(status ?: number) {
|
||||
return status === 1 ? '在线' : '离线'
|
||||
@@ -209,6 +196,19 @@
|
||||
return type ? map[type] || type : '-'
|
||||
}
|
||||
|
||||
function formatPackageEffectivePeriod(activatedAt ?: string | null, expiresAt ?: string | null) {
|
||||
return `生效期:${formatTime(activatedAt || undefined, 'date')} 至 ${formatTime(expiresAt || undefined, 'date')}`
|
||||
}
|
||||
|
||||
function formatCurrentPackagePeriodRange(activatedAt ?: string | null, expiresAt ?: string | null) {
|
||||
return `生效期:${formatTime(activatedAt || undefined, 'date')} - ${formatTime(expiresAt || undefined, 'date')}`
|
||||
}
|
||||
|
||||
function getDeviceLastOnlineTime() {
|
||||
const lastOnlineTime = realtimeStatus.value?.device_realtime?.last_online_time || deviceInfo.value?.last_online_time
|
||||
return lastOnlineTime ? formatTime(lastOnlineTime) : '-'
|
||||
}
|
||||
|
||||
// 获取信号质量等级 (基于 RSRP)
|
||||
function getSignalQuality(rsrp ?: number) {
|
||||
if (rsrp === undefined) return { level: '未知', text: '无信号', color: '#999' }
|
||||
@@ -270,19 +270,9 @@
|
||||
return status !== undefined ? map[status] : { bg: '#f5f5f5', text: '#999' }
|
||||
}
|
||||
|
||||
// 获取设备保护状态文本
|
||||
function getDeviceProtectStatusText(status ?: string) {
|
||||
const map : Record<string, string> = {
|
||||
none: '无保护',
|
||||
basic: '基础保护',
|
||||
advanced: '高级保护',
|
||||
}
|
||||
return status ? map[status] || status : '无保护'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const currentPage = pages[pages.length - 1] as any
|
||||
const options = currentPage.options || {}
|
||||
|
||||
// 如果有 iccid 或 virtual_no 参数,自动搜索
|
||||
@@ -366,14 +356,6 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 批次号 -->
|
||||
<view v-if="cardInfo.batch_no" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-70px">批次号</text>
|
||||
<text class="flex-1 text-13px text-[#212121] text-right">
|
||||
{{ cardInfo.batch_no }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 网络状态 -->
|
||||
<view v-if="cardInfo.network_status !== undefined" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-70px">网络状态</text>
|
||||
@@ -512,8 +494,8 @@
|
||||
<text class="text-14px font-600 text-[#212121] block break-all">
|
||||
{{ currentPackage.package_name || '-' }}
|
||||
</text>
|
||||
<text class="text-11px text-[#999] block mt-1 break-all">
|
||||
订单号:{{ currentPackage.order_no || '-' }}
|
||||
<text class="text-11px text-[#64748b] block mt-1 break-all">
|
||||
{{ formatCurrentPackagePeriodRange(currentPackage.activated_at, currentPackage.expires_at) }}
|
||||
</text>
|
||||
</view>
|
||||
<view :style="{
|
||||
@@ -564,14 +546,6 @@
|
||||
<text class="text-[#666]">套餐类型</text>
|
||||
<text class="text-[#212121]">{{ getPackageTypeText(currentPackage.package_type) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">开始时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.activated_at) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">到期时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.expires_at) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">下单时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.created_at) }}</text>
|
||||
@@ -912,14 +886,6 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 制造商 -->
|
||||
<view v-if="deviceInfo.manufacturer" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">制造商</text>
|
||||
<text class="flex-1 text-13px text-[#212121] text-right">
|
||||
{{ deviceInfo.manufacturer }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 最大卡槽数 -->
|
||||
<view v-if="deviceInfo.max_sim_slots" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">最大卡槽</text>
|
||||
@@ -936,14 +902,6 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 批次号 -->
|
||||
<view v-if="deviceInfo.batch_no" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">批次号</text>
|
||||
<text class="flex-1 text-13px text-[#212121] text-right">
|
||||
{{ deviceInfo.batch_no }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 店铺信息 -->
|
||||
<view v-if="deviceInfo.shop_name" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">所属店铺</text>
|
||||
@@ -960,22 +918,6 @@
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 保护状态 -->
|
||||
<view v-if="deviceInfo.device_protect_status" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">保护状态</text>
|
||||
<text class="flex-1 text-13px text-[#212121] text-right">
|
||||
{{ getDeviceProtectStatusText(deviceInfo.device_protect_status) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 切卡模式 -->
|
||||
<view v-if="deviceInfo.switch_mode !== undefined" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">切卡模式</text>
|
||||
<text class="flex-1 text-13px text-[#212121] text-right">
|
||||
{{ getSwitchModeText(deviceInfo.switch_mode) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 实名状态 -->
|
||||
<view v-if="deviceInfo.real_name_status !== undefined" class="flex items-start justify-between">
|
||||
<text class="text-12px text-[#999] w-80px">实名状态</text>
|
||||
@@ -1033,21 +975,27 @@
|
||||
<view class="space-y-2">
|
||||
<view v-for="card in deviceInfo.cards" :key="card.card_id" class="bg-[#f5f5f5] rounded-12px p-3">
|
||||
<view class="flex items-center justify-between mb-1">
|
||||
<text class="text-12px text-[#212121]">卡槽 {{ card.slot_position }}</text>
|
||||
<view class="flex items-center gap-2 min-w-0">
|
||||
<text class="text-12px text-[#212121]">卡槽 {{ card.slot_position }}</text>
|
||||
<text :class="[
|
||||
'px-2 py-0.5 rounded-full text-11px font-600',
|
||||
getRealNameStatusTagClass(card.real_name_status),
|
||||
]">
|
||||
{{ getRealNameStatusText(card.real_name_status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="card.is_current" class="text-11px text-white bg-[#3ed268] px-2 py-0.5 rounded">
|
||||
当前
|
||||
</text>
|
||||
</view>
|
||||
<view class="space-y-1">
|
||||
<text class="text-11px text-[#666] block">ICCID: {{ card.iccid }}</text>
|
||||
<text class="text-11px text-[#666] block">电话: {{ card.msisdn }}</text>
|
||||
<text class="text-11px text-[#666] block">MSISDN: {{ card.msisdn || '-' }}</text>
|
||||
<text class="text-11px text-[#666] block">运营商: {{ card.carrier_name || '-' }}</text>
|
||||
<view class="flex items-center justify-between">
|
||||
<text class="text-11px text-[#666]">
|
||||
网络: {{ getNetworkStatusText(card.network_status) }}
|
||||
</text>
|
||||
<text class="text-11px text-[#666]">
|
||||
实名: {{ getRealNameStatusText(card.real_name_status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1101,6 +1049,11 @@
|
||||
<text class="text-[#212121]">{{ formatDuration(realtimeStatus.device_realtime.run_time) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">最后上线时间</text>
|
||||
<text class="text-[#212121]">{{ getDeviceLastOnlineTime() }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 信号 -->
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">信号</text>
|
||||
@@ -1125,18 +1078,6 @@
|
||||
<text class="text-[var(--brand-primary)] font-600">{{ formatBytesDisplay(realtimeStatus.device_realtime.daily_usage) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 下载流量 -->
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">下载流量</text>
|
||||
<text class="text-[#212121]">{{ formatBytesDisplay(realtimeStatus.device_realtime.dl_stats) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 上传流量 -->
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">上传流量</text>
|
||||
<text class="text-[#212121]">{{ formatBytesDisplay(realtimeStatus.device_realtime.ul_stats) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 限速 -->
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">限速</text>
|
||||
@@ -1265,8 +1206,8 @@
|
||||
<text class="text-14px font-600 text-[#212121] block break-all">
|
||||
{{ currentPackage.package_name || '-' }}
|
||||
</text>
|
||||
<text class="text-11px text-[#999] block mt-1 break-all">
|
||||
订单号:{{ currentPackage.order_no || '-' }}
|
||||
<text class="text-11px text-[#64748b] block mt-1 break-all">
|
||||
{{ formatCurrentPackagePeriodRange(currentPackage.activated_at, currentPackage.expires_at) }}
|
||||
</text>
|
||||
</view>
|
||||
<view :style="{
|
||||
@@ -1317,14 +1258,6 @@
|
||||
<text class="text-[#666]">套餐类型</text>
|
||||
<text class="text-[#212121]">{{ getPackageTypeText(currentPackage.package_type) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">开始时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.activated_at) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">到期时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.expires_at) }}</text>
|
||||
</view>
|
||||
<view class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#666]">下单时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(currentPackage.created_at) }}</text>
|
||||
@@ -1434,35 +1367,14 @@
|
||||
<text class="text-[#666]">金额</text>
|
||||
<text class="text-[#212121]">{{ formatPackagePrice(pkg.paid_amount) }}</text>
|
||||
</view>
|
||||
<view v-if="pkg.activated_at" class="flex items-center justify-between text-10px">
|
||||
<text class="text-[#999]">{{ formatTime(pkg.activated_at, 'date') }}</text>
|
||||
<text v-if="pkg.expires_at" class="text-[#999]">至 {{ formatTime(pkg.expires_at, 'date') }}</text>
|
||||
<view v-if="pkg.status === 1" class="mt-2 bg-white rounded-8px px-2.5 py-2">
|
||||
<text class="text-11px font-600 text-[#475569]">{{ formatPackageEffectivePeriod(pkg.activated_at, pkg.expires_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间信息 -->
|
||||
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] overflow-hidden mt-2 p-2 px-4 pb-4">
|
||||
<view class="bg-[#f5f5f5] rounded-12px p-3">
|
||||
<view class="space-y-1">
|
||||
<view v-if="deviceInfo.created_at" class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#999]">创建时间</text>
|
||||
<text class="text-[#212121]">{{ formatTime(deviceInfo.created_at) }}</text>
|
||||
</view>
|
||||
<view v-if="deviceInfo.last_online_time" class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#999]">最后在线</text>
|
||||
<text class="text-[#212121]">{{ formatTime(deviceInfo.last_online_time) }}</text>
|
||||
</view>
|
||||
<view v-if="deviceInfo.last_gateway_sync_at" class="flex items-center justify-between text-11px">
|
||||
<text class="text-[#999]">网关同步</text>
|
||||
<text class="text-[#212121]">{{ formatTime(deviceInfo.last_gateway_sync_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { stopAsset, startAsset } from '@/api/assets'
|
||||
import Skeleton from '@/components/Skeleton.vue'
|
||||
import SimplePicker from '@/components/SimplePicker.vue'
|
||||
import { formatTime, formatDataSize } from '@/utils/format'
|
||||
import ShopCascadePicker from '@/components/ShopCascadePicker.vue'
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
// 卡片信息接口
|
||||
@@ -18,6 +18,7 @@
|
||||
imsi ?: string
|
||||
msisdn ?: string
|
||||
virtual_no ?: string
|
||||
device_virtual_no ?: string | null
|
||||
virtual_number ?: string // 企业端字段
|
||||
status ?: number
|
||||
status_name ?: string
|
||||
@@ -95,9 +96,15 @@
|
||||
status : string
|
||||
statusColor : string
|
||||
detail : string
|
||||
bindingStatus ?: string
|
||||
raw : CardInfo | DeviceInfo
|
||||
}
|
||||
|
||||
interface SelectedShopFilter {
|
||||
id : number
|
||||
shop_name : string
|
||||
}
|
||||
|
||||
// 当前标签
|
||||
const activeTab = ref<'card' | 'device'>('card')
|
||||
// 资产列表
|
||||
@@ -112,15 +119,21 @@
|
||||
const deviceHasMore = ref(true)
|
||||
const pageSize = 20
|
||||
// 搜索和筛选
|
||||
const searchType = ref<'iccid' | 'virtual'>('iccid')
|
||||
const searchKeyword = ref('')
|
||||
const carrierFilter = ref<number | undefined>(undefined)
|
||||
const statusFilter = ref<number | undefined>(undefined)
|
||||
const selectedShopFilter = ref<SelectedShopFilter | null>(null)
|
||||
const showCarrierPicker = ref(false)
|
||||
const showStatusPicker = ref(false)
|
||||
const showSearchTypePicker = ref(false)
|
||||
const showShopPicker = ref(false)
|
||||
// 运营商列表
|
||||
const carrierList = ref<CarrierInfo[]>([])
|
||||
const isAgent = computed(() => userInfo.value?.user_type === 3)
|
||||
const searchPlaceholder = computed(() => activeTab.value === 'card' ? '搜索ICCID' : '搜索设备号')
|
||||
|
||||
function hasStatusCode(error : unknown) {
|
||||
return typeof error === 'object' && error !== null && 'statusCode' in error
|
||||
}
|
||||
// 运营商选择器选项
|
||||
const carrierOptions = computed(() => [
|
||||
{ label: '全部运营商', value: undefined },
|
||||
@@ -134,11 +147,16 @@
|
||||
{ label: '已激活', value: 3 },
|
||||
{ label: '已停用', value: 4 }
|
||||
]
|
||||
// 搜索类型选项
|
||||
const searchTypeOptions = [
|
||||
{ label: 'ICCID', value: 'iccid' },
|
||||
{ label: '虚拟号', value: 'virtual' }
|
||||
]
|
||||
|
||||
function getCardBindingStatus(card : CardInfo) {
|
||||
return card.device_virtual_no?.trim() ? '已绑定设备' : '未绑定设备'
|
||||
}
|
||||
|
||||
function getCardBindingTagClass(bindingStatus ?: string) {
|
||||
return bindingStatus === '已绑定设备'
|
||||
? 'bg-[#e8f5e9] text-[#2e7d32]'
|
||||
: 'bg-[#fff7e6] text-[#d46b08]'
|
||||
}
|
||||
|
||||
// 合并后的资产列表
|
||||
const assetList = computed<AssetItem[]>(() => {
|
||||
@@ -150,7 +168,11 @@
|
||||
status: card.network_status_name || (card.network_status === 1 ? '正常' : card.network_status === 0 ? '停机' : card.status_name || '未知'),
|
||||
statusColor: (card.network_status === 1) ? '#52c41a' : (card.network_status === 0) ? '#999' : '#ff4d4f',
|
||||
// 兼容企业端和代理端的字段
|
||||
detail: `虚拟号: ${card.virtual_no || card.virtual_number || '-'} | 运营商: ${card.carrier_name || card.operator || '-'}`,
|
||||
detail: `${[
|
||||
`虚拟号: ${card.virtual_no || card.virtual_number || '-'}`,
|
||||
`运营商: ${card.carrier_name || card.operator || '-'}`,
|
||||
].filter(Boolean).join(' | ')}`,
|
||||
bindingStatus: isAgent.value ? getCardBindingStatus(card) : undefined,
|
||||
raw: card,
|
||||
}))
|
||||
|
||||
@@ -215,12 +237,11 @@
|
||||
if (statusFilter.value !== undefined) {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
if (searchKeyword.value) {
|
||||
if (searchType.value === 'iccid') {
|
||||
params.iccid = searchKeyword.value
|
||||
} else {
|
||||
params.virtual_no = searchKeyword.value
|
||||
}
|
||||
if (searchKeyword.value.trim()) {
|
||||
params.iccid = searchKeyword.value.trim()
|
||||
}
|
||||
if (userInfo.value?.user_type === 3 && selectedShopFilter.value) {
|
||||
params.shop_id = selectedShopFilter.value.id
|
||||
}
|
||||
|
||||
let cardsData : any
|
||||
@@ -249,7 +270,7 @@
|
||||
}
|
||||
catch (error : any) {
|
||||
console.error('加载IoT卡列表失败:', error)
|
||||
if (error instanceof Error && error.message && !error.statusCode) {
|
||||
if (error instanceof Error && error.message && !hasStatusCode(error)) {
|
||||
uni.$u.toast(error.message)
|
||||
}
|
||||
}
|
||||
@@ -276,12 +297,15 @@
|
||||
page_size: pageSize,
|
||||
}
|
||||
|
||||
if (searchKeyword.value) {
|
||||
params.virtual_no = searchKeyword.value
|
||||
if (searchKeyword.value.trim()) {
|
||||
params.virtual_no = searchKeyword.value.trim()
|
||||
}
|
||||
if (statusFilter.value !== undefined) {
|
||||
params.status = statusFilter.value
|
||||
}
|
||||
if (userInfo.value?.user_type === 3 && selectedShopFilter.value) {
|
||||
params.shop_id = selectedShopFilter.value.id
|
||||
}
|
||||
|
||||
let devicesData : any
|
||||
|
||||
@@ -309,7 +333,7 @@
|
||||
}
|
||||
catch (error : any) {
|
||||
console.error('加载设备列表失败:', error)
|
||||
if (error instanceof Error && error.message && !error.statusCode) {
|
||||
if (error instanceof Error && error.message && !hasStatusCode(error)) {
|
||||
uni.$u.toast(error.message)
|
||||
}
|
||||
}
|
||||
@@ -339,7 +363,7 @@
|
||||
// 加载运营商列表
|
||||
async function loadCarriers() {
|
||||
try {
|
||||
const response = await getCarriers({ page: 1, page_size: 100 })
|
||||
const response = await getCarriers({ page: 1, size: 100 })
|
||||
carrierList.value = response?.items || []
|
||||
} catch (error) {
|
||||
console.error('加载运营商列表失败:', error)
|
||||
@@ -358,11 +382,6 @@
|
||||
refreshList()
|
||||
}
|
||||
|
||||
// 选择搜索类型
|
||||
function onSearchTypeConfirm(option : { label : string, value : any }) {
|
||||
searchType.value = option.value
|
||||
}
|
||||
|
||||
// 获取当前选中的运营商名称
|
||||
const selectedCarrierName = computed(() => {
|
||||
if (carrierFilter.value === undefined) return '运营商'
|
||||
@@ -377,6 +396,15 @@
|
||||
return option?.label || '状态'
|
||||
})
|
||||
|
||||
const selectedShopName = computed(() => selectedShopFilter.value?.shop_name || '店铺')
|
||||
const activeTabHasMore = computed(() => activeTab.value === 'card' ? cardHasMore.value : deviceHasMore.value)
|
||||
|
||||
function onShopConfirm(shop : SelectedShopFilter | null) {
|
||||
if (!shop) return
|
||||
selectedShopFilter.value = shop
|
||||
refreshList()
|
||||
}
|
||||
|
||||
// 查看资产详情
|
||||
function viewAssetDetail(asset : AssetItem) {
|
||||
if (asset.type === 'card') {
|
||||
@@ -397,13 +425,7 @@
|
||||
if (activeTab.value === tab) return
|
||||
|
||||
activeTab.value = tab
|
||||
|
||||
// 切换后加载对应数据
|
||||
if (tab === 'card' && cardList.value.length === 0) {
|
||||
loadCards(true)
|
||||
} else if (tab === 'device' && deviceList.value.length === 0) {
|
||||
loadDevices(true)
|
||||
}
|
||||
refreshList()
|
||||
}
|
||||
|
||||
// 设备停机
|
||||
@@ -411,8 +433,9 @@
|
||||
event.stopPropagation()
|
||||
|
||||
const device = asset.raw as DeviceInfo
|
||||
const identifier = device.virtual_no
|
||||
|
||||
if (!device.virtual_no) {
|
||||
if (!identifier) {
|
||||
uni.$u.toast('设备虚拟号不存在')
|
||||
return
|
||||
}
|
||||
@@ -424,7 +447,7 @@
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '停机中...' })
|
||||
try {
|
||||
const result = await stopAsset(device.virtual_no)
|
||||
await stopAsset(identifier)
|
||||
uni.hideLoading()
|
||||
|
||||
// 显示停机结果 (统一接口不返回详情,仅提示成功)
|
||||
@@ -452,8 +475,9 @@
|
||||
event.stopPropagation()
|
||||
|
||||
const device = asset.raw as DeviceInfo
|
||||
const identifier = device.virtual_no
|
||||
|
||||
if (!device.virtual_no) {
|
||||
if (!identifier) {
|
||||
uni.$u.toast('设备虚拟号不存在')
|
||||
return
|
||||
}
|
||||
@@ -465,7 +489,7 @@
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '复机中...' })
|
||||
try {
|
||||
await startAsset(device.virtual_no)
|
||||
await startAsset(identifier)
|
||||
uni.hideLoading()
|
||||
uni.$u.toast('复机成功')
|
||||
// 刷新列表
|
||||
@@ -546,15 +570,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
// 滚动事件处理
|
||||
function onScroll(e : any) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target
|
||||
// 当滚动到底部附近时加载更多
|
||||
if (scrollHeight - scrollTop - clientHeight < 100) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -578,14 +593,14 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bg-page min-h-screen flex flex-col">
|
||||
<view class="bg-page h-screen flex flex-col overflow-hidden">
|
||||
<!-- 搜索区域 -->
|
||||
<view class="bg-white px-4 pt-4 pb-3 mb-3">
|
||||
<view class="bg-white px-4 pt-4 pb-3 shrink-0 shadow-[0_4px_18px_rgba(15,23,42,0.06)] z-10">
|
||||
<!-- 搜索框 -->
|
||||
<view class="relative flex items-center">
|
||||
<input v-model="searchKeyword"
|
||||
class="flex-1 h-10 bg-[#f1f5f9] rounded-full pl-4 pr-12 text-sm text-[#1e293b] placeholder-[#94a3b8]"
|
||||
:placeholder="searchType === 'iccid' ? '搜索ICCID' : '搜索虚拟号'" @confirm="refreshList" />
|
||||
:placeholder="searchPlaceholder" @confirm="refreshList" />
|
||||
<view class="absolute right-1 flex items-center">
|
||||
<view v-if="searchKeyword" class="w-8 h-8 flex items-center justify-center text-[#94a3b8]"
|
||||
@click="searchKeyword = ''">
|
||||
@@ -611,14 +626,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex items-center gap-1 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0"
|
||||
@click="showSearchTypePicker = true">
|
||||
<text class="text-12px" :class="searchType !== 'iccid' ? 'text-brand font-medium' : 'text-[#64748b]'">
|
||||
{{ searchType === 'iccid' ? 'ICCID' : '虚拟号' }}
|
||||
</text>
|
||||
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
|
||||
</view>
|
||||
|
||||
<view class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0 max-w-36"
|
||||
@click="showCarrierPicker = true">
|
||||
<text class="text-12px truncate"
|
||||
@@ -636,9 +643,19 @@
|
||||
<i class="i-mdi-chevron-down text-12px text-[#94a3b8]" />
|
||||
</view>
|
||||
|
||||
<view v-if="carrierFilter !== undefined || statusFilter !== undefined"
|
||||
<view v-if="isAgent"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-[#f8fafc] rounded-full flex-shrink-0 max-w-36"
|
||||
@click="showShopPicker = true">
|
||||
<text class="text-12px truncate"
|
||||
:class="selectedShopFilter ? 'text-brand font-medium' : 'text-[#64748b]'">
|
||||
{{ selectedShopName }}
|
||||
</text>
|
||||
<i class="i-mdi-chevron-down text-12px text-[#94a3b8] flex-shrink-0" />
|
||||
</view>
|
||||
|
||||
<view v-if="carrierFilter !== undefined || statusFilter !== undefined || selectedShopFilter"
|
||||
class="flex items-center gap-1 px-2 py-1.5 rounded-full bg-[#ff4d4f]/10 text-[#ff4d4f] flex-shrink-0"
|
||||
@click="carrierFilter = undefined; statusFilter = undefined; refreshList()">
|
||||
@click="carrierFilter = undefined; statusFilter = undefined; selectedShopFilter = null; refreshList()">
|
||||
<i class="i-mdi-close text-12px" />
|
||||
<text class="text-12px font-medium">清除</text>
|
||||
</view>
|
||||
@@ -646,13 +663,13 @@
|
||||
</view>
|
||||
|
||||
<!-- 列表内容区域 -->
|
||||
<view class="safe-area-bottom min-h-screen overflow-y-auto">
|
||||
<view class="safe-area-bottom flex-1 min-h-0 overflow-y-auto">
|
||||
<!-- 加载状态 -->
|
||||
<Skeleton v-if="loading && assetList.length === 0" type="list" />
|
||||
|
||||
<!-- 资产列表 -->
|
||||
<view v-else class="flex-1 overflow-y-auto min-h-0" @scroll="onScroll">
|
||||
<view v-if="assetList.length > 0" class="px-4 pb-4">
|
||||
<view v-else class="min-h-full">
|
||||
<view v-if="assetList.length > 0" class="px-4 pt-3 pb-4">
|
||||
<view v-for="asset in assetList" :key="`${asset.type}-${asset.id}`"
|
||||
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3 cursor-pointer"
|
||||
@click="viewAssetDetail(asset)">
|
||||
@@ -677,6 +694,14 @@
|
||||
<text class="text-12px text-[#999]">
|
||||
{{ asset.detail }}
|
||||
</text>
|
||||
<view v-if="asset.type === 'card' && asset.bindingStatus" class="mt-2">
|
||||
<text :class="[
|
||||
'inline-flex px-2 py-1 rounded-full text-11px font-600',
|
||||
getCardBindingTagClass(asset.bindingStatus),
|
||||
]">
|
||||
{{ asset.bindingStatus }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部区域:状态标签 + 操作按钮 -->
|
||||
@@ -738,19 +763,30 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="loading" class="py-4 text-center">
|
||||
<i class="i-mdi-loading animate-spin text-xl text-brand mr-2" />
|
||||
<text class="text-sm text-[#64748b]">加载中...</text>
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="activeTabHasMore" class="py-4 flex justify-center">
|
||||
<view
|
||||
class="min-w-32 px-5 py-2.5 rounded-full text-sm font-medium transition-all"
|
||||
:class="loading
|
||||
? 'bg-[#f1f5f9] text-[#94a3b8]'
|
||||
: 'bg-brand text-white shadow-sm shadow-brand/30 active:scale-95'"
|
||||
@click="!loading && loadMore()">
|
||||
<template v-if="loading">
|
||||
<i class="i-mdi-loading animate-spin text-base mr-1.5" />
|
||||
<text>加载中...</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text>加载更多</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="!((activeTab === 'card' && cardHasMore) || (activeTab === 'device' && deviceHasMore))"
|
||||
class="py-4 text-center">
|
||||
<view v-else class="py-4 text-center">
|
||||
<text class="text-sm text-[#94a3b8]">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else class="px-4">
|
||||
<view v-else class="px-4 pt-3 pb-4">
|
||||
<view class="bg-white rounded-2xl py-12 flex flex-col items-center">
|
||||
<i class="i-mdi-package-variant-closed text-6xl text-[#cbd5e1] mb-3" />
|
||||
<text class="text-base text-[#64748b] mb-1">暂无数据</text>
|
||||
@@ -758,17 +794,16 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 运营商选择器 -->
|
||||
<SimplePicker v-model:show="showCarrierPicker" :options="carrierOptions" title="选择运营商"
|
||||
@confirm="onCarrierConfirm" />
|
||||
|
||||
<!-- 状态选择器 -->
|
||||
<SimplePicker v-model:show="showStatusPicker" :options="statusOptions" title="选择状态" @confirm="onStatusConfirm" />
|
||||
|
||||
<!-- 搜索类型选择器 -->
|
||||
<SimplePicker v-model:show="showSearchTypePicker" :options="searchTypeOptions" title="选择搜索类型"
|
||||
@confirm="onSearchTypeConfirm" />
|
||||
</view>
|
||||
|
||||
<!-- 运营商选择器 -->
|
||||
<SimplePicker v-model:show="showCarrierPicker" :options="carrierOptions" title="选择运营商"
|
||||
@confirm="onCarrierConfirm" />
|
||||
|
||||
<!-- 状态选择器 -->
|
||||
<SimplePicker v-model:show="showStatusPicker" :options="statusOptions" title="选择状态" @confirm="onStatusConfirm" />
|
||||
|
||||
<ShopCascadePicker v-model:show="showShopPicker" :selected-shop="selectedShopFilter" title="选择店铺"
|
||||
@confirm="onShopConfirm" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
return `¥${parts.join('.')}`
|
||||
}
|
||||
|
||||
function formatAmountDisplay(amount?: number | null) {
|
||||
return typeof amount === 'number' ? formatAmount(amount) : '--'
|
||||
}
|
||||
|
||||
// 格式化日期 (MM-DD)
|
||||
// 根据用户类型动态生成菜单
|
||||
function formatDate(dateStr : string) {
|
||||
@@ -104,7 +108,7 @@
|
||||
|
||||
// 加载佣金数据
|
||||
async function loadCommissionData(shopName?: string) {
|
||||
if (!isAgent.value || !shopId.value) return
|
||||
if (!isAgent.value) return
|
||||
|
||||
loadingCommission.value = true
|
||||
|
||||
@@ -112,6 +116,12 @@
|
||||
// 1. 加载佣金概览 (新接口 /shops/fund-summary)
|
||||
const summary = await getFundSummary(shopName ? { shop_name: shopName } : undefined)
|
||||
commissionSummary.value = summary
|
||||
|
||||
if (!shopId.value) {
|
||||
dailyStats.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const today = getTodayDate()
|
||||
const todayStats = await getCommissionDailyStats(shopId.value, {
|
||||
days: 1,
|
||||
@@ -143,11 +153,8 @@
|
||||
userInfo.value = user
|
||||
|
||||
// 设置店铺ID
|
||||
if (user.shop_id) {
|
||||
shopId.value = user.shop_id
|
||||
}
|
||||
|
||||
const loginUserInfo = getLoginUserInfo()
|
||||
shopId.value = user.shop_id || loginUserInfo?.shop_id || 0
|
||||
const fundSummaryShopName = loginUserInfo?.username || user.username
|
||||
|
||||
console.log('首页数据加载成功:', { user })
|
||||
@@ -244,33 +251,47 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 代理端: 佣金概览 -->
|
||||
<view v-if="isAgent && commissionSummary" class="px-4 pt-4">
|
||||
<text class="text-16px font-700 text-[#212121] block mb-3">可提现佣金</text>
|
||||
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4 mb-3">
|
||||
<view class="grid grid-cols-4 gap-3">
|
||||
<view class="text-center">
|
||||
<text class="text-11px text-[#999] block mb-1">累计</text>
|
||||
<text class="text-14px font-600 text-[#212121]">
|
||||
{{ formatAmount(commissionSummary.total_commission) }}
|
||||
<!-- 代理端: 佣金展示 -->
|
||||
<view v-if="isAgent" class="px-4 pt-4">
|
||||
<view
|
||||
class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-5 mb-3"
|
||||
@click="navigateTo('/pages/agent-system/commission-center/index')"
|
||||
>
|
||||
<view class="flex items-center justify-between gap-3">
|
||||
<view class="min-w-0 flex-1">
|
||||
<text class="text-12px text-[#999]">可提现佣金</text>
|
||||
<text class="text-32px font-700 text-[#212121] block mt-1 break-all">
|
||||
{{ formatAmountDisplay(commissionSummary?.available_commission) }}
|
||||
</text>
|
||||
<text class="text-11px text-[#999] block mt-2">
|
||||
当前可直接发起提现的佣金金额
|
||||
</text>
|
||||
</view>
|
||||
<view class="text-center">
|
||||
<text class="text-11px text-[#999] block mb-1">冻结</text>
|
||||
<text class="text-14px font-600 text-[#212121]">
|
||||
{{ formatAmount(commissionSummary.frozen_commission) }}
|
||||
<view class="flex flex-col items-end gap-2">
|
||||
<image src="@/static/icons/佣金中心.png" class="w-12 h-12 rounded-12px" mode="aspectFit" />
|
||||
<view class="flex items-center gap-1">
|
||||
<text class="text-12px text-[#999]">佣金中心</text>
|
||||
<i class="i-mdi-chevron-right text-16px text-[#cbd5e1]" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex gap-3 mt-4 pt-4 border-t border-[#f5f5f5]">
|
||||
<view class="flex-1 text-center">
|
||||
<text class="text-11px text-[#999]">累计佣金</text>
|
||||
<text class="text-14px font-600 text-[#212121] block mt-1">
|
||||
{{ formatAmountDisplay(commissionSummary?.total_commission) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="text-center">
|
||||
<text class="text-11px text-[#999] block mb-1">已提现</text>
|
||||
<text class="text-14px font-600 text-[#212121]">
|
||||
{{ formatAmount(commissionSummary.withdrawn_commission) }}
|
||||
<view class="flex-1 text-center border-l border-[#f5f5f5]">
|
||||
<text class="text-11px text-[#999]">已提现</text>
|
||||
<text class="text-14px font-600 text-[#52c41a] block mt-1">
|
||||
{{ formatAmountDisplay(commissionSummary?.withdrawn_commission) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="text-center">
|
||||
<text class="text-11px text-[#999] block mb-1">可提现</text>
|
||||
<text class="text-14px font-600 text-[#212121]">
|
||||
{{ formatAmount(commissionSummary.available_commission) }}
|
||||
<view class="flex-1 text-center border-l border-[#f5f5f5]">
|
||||
<text class="text-11px text-[#999]">冻结</text>
|
||||
<text class="text-14px font-600 text-[#ff4d4f] block mt-1">
|
||||
{{ formatAmountDisplay(commissionSummary?.frozen_commission) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -280,7 +301,7 @@
|
||||
<view>
|
||||
<view class="flex items-center justify-between mb-3">
|
||||
<text class="text-16px font-700 text-[#212121]">今日佣金</text>
|
||||
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">更多</text>
|
||||
<text class="text-13px text-[#999]" @click="navigateTo('/pages/agent-system/commission-center/index')">明细</text>
|
||||
</view>
|
||||
<view class="bg-white rounded-16px shadow-[0_2px_12px_rgba(0,0,0,0.06)] p-4">
|
||||
<view class="grid grid-cols-3 gap-3">
|
||||
|
||||
1
types/components.d.ts
vendored
1
types/components.d.ts
vendored
@@ -12,6 +12,7 @@ declare module 'vue' {
|
||||
LangSelect: typeof import('./../src/components/lang-select/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ShopCascadePicker: typeof import('./../src/components/ShopCascadePicker.vue')['default']
|
||||
SimplePicker: typeof import('./../src/components/SimplePicker.vue')['default']
|
||||
Skeleton: typeof import('./../src/components/Skeleton.vue')['default']
|
||||
ThemePicker: typeof import('./../src/components/theme-picker/index.vue')['default']
|
||||
|
||||
Reference in New Issue
Block a user