feat: integrate unified payment and wallet refunds
This commit is contained in:
7
.agents/skills/grill-me/SKILL.md
Normal file
7
.agents/skills/grill-me/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
5
.agents/skills/grill-me/agents/openai.yaml
Normal file
5
.agents/skills/grill-me/agents/openai.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
interface:
|
||||
display_name: "Grill Me"
|
||||
short_description: "Sharpen a plan through interview"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,37 +0,0 @@
|
||||
# Task 5 implementation report
|
||||
|
||||
## RED / GREEN
|
||||
|
||||
- RED: `go test ./internal/logic/platform ./internal/routers -run 'Test(PlatformFinanceContentAndAuditRoutes|ApprovalValues)' -v` failed before implementation because Task 5 routes were absent and the approval update whitelist did not exist.
|
||||
- GREEN: the same route and approval-contract tests pass. A transactional approval test confirms the approval update only persists status, opinion, handler identity, and handling time, then inserts an `aud_operation_log` in the same transaction.
|
||||
|
||||
## Changes
|
||||
|
||||
- Registered writable, restricted-field finance APIs (`fin_payment`, `fin_settlement`, `fin_reconciliation`) and content/customer-service APIs (`cnt_content`, `ntf_template`, `cs_ticket`). They retain the standard status patch and logical archive behavior.
|
||||
- Registered wallet, report, and audit record resources as GET-only list/detail APIs. No generic write route is registered for those resources.
|
||||
- Added `POST /audit/aud_approval/:identity/approve`. It derives the handler identity from the authenticated JWT, records the handling timestamp, limits the update to the four approval fields, and appends before/after audit data atomically.
|
||||
- Extended `AudApproval` with persisted `handler_identity` and `handled_at` fields.
|
||||
- Added route, approval/audit-transaction, and empty-dashboard-zero-value coverage.
|
||||
|
||||
## Verification
|
||||
|
||||
- `gofmt -w internal/models/aud_approval.go internal/routers/platform.go internal/routers/platform_test.go internal/logic/platform/audit.go internal/logic/platform/audit_test.go internal/logic/platform/health_test.go` — PASS
|
||||
- `go test ./internal/logic/platform ./internal/routers -run 'Test(PlatformFinanceContentAndAuditRoutes|ApprovalValues|ApproveAuditOnlyUpdates|DashboardOverviewReturnsZero)' -v` — PASS
|
||||
- `go test ./...` — PASS
|
||||
- `go build ./cmd/main` — PASS
|
||||
- `git diff --check` — PASS
|
||||
|
||||
## Concerns
|
||||
|
||||
- Approval status values are accepted as non-empty strings to preserve the existing status model; a future workflow may want an explicit state-transition policy (for example, only `pending -> approved|rejected`).
|
||||
|
||||
## Fix round 1: P1 approval transition guard
|
||||
|
||||
### RED / GREEN
|
||||
|
||||
- RED: approval accepted arbitrary non-empty states, updated every matching identity regardless of its current status, and did not compare the JWT operator with the applicant. The new tests reproduced invalid state acceptance, repeat processing, self-approval, and a concurrent second decision after a pending read.
|
||||
- GREEN: only `approved` and `rejected` requests proceed. The approval must still be `pending`, the applicant cannot decide it, and the update predicate is `identity AND status = pending`. A zero-row conditional update is rejected and does not append an operation audit.
|
||||
|
||||
### Verification
|
||||
|
||||
- `go test ./internal/logic/platform -run 'TestApproveAudit' -count=1 -v` — PASS
|
||||
@@ -1,45 +0,0 @@
|
||||
# Task 6 report
|
||||
|
||||
## RED baseline
|
||||
|
||||
`pnpm audit:platform` initially exited with code 1. The audit reported missing API declarations, route entries, and view files for the resource contract catalogue.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Added `src/api/resources.ts` as the single front-end catalogue for all 46 backend resource contracts, including path, title, mode, page kind, and fields.
|
||||
- Added route modules and declarative list/tree pages for each resource.
|
||||
- Added reusable read-only and tree pages; read-only definitions do not render create, edit, status, or archive controls.
|
||||
- Updated the shared CRUD page with server-side keyword filters, identity-based details, typed form data, archive confirmation, and API error messages.
|
||||
- Removed platform UI use of internal numeric identifiers in favour of `identity` and `parent_identity`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm type:check` — pass
|
||||
- `pnpm audit:platform` — pass
|
||||
- `pnpm build` — pass
|
||||
|
||||
## Notes
|
||||
|
||||
The tree page consumes `parent_identity`; the corresponding menu API must expose that identity relation rather than an internal numeric parent key.
|
||||
|
||||
## Fix round 1
|
||||
|
||||
- Replaced inferred front-end paths with an explicit 46-path catalogue: it includes the dashboard endpoint, adds `/ec/ec_order_item`, and removes the non-list safety disposal action from the menu catalogue.
|
||||
- Corrected the safety, e-commerce, staff, and user API paths to match the registered backend routes exactly.
|
||||
- Added audit assertions for exact path/mode/page-kind contracts, separate read-only UI surfaces, identifier leakage, and tree semantics.
|
||||
- Detail drawers now filter `id` and `*_id` fields before rendering.
|
||||
|
||||
## Fix round 2
|
||||
|
||||
- The resource catalogue now contains exactly the 46 backend `ExpectedResources` names, including the append-only `saf_event_disposal` contract rather than a synthetic dashboard entry.
|
||||
- Every definition has its own backend write allowlist and required `*_identity` fields; no resource inherits a generic `name/status` schema.
|
||||
- Safety disposal is a detail action on `saf_event` (`/safety/saf_event/:identity/disposals`) and has no standalone menu or page.
|
||||
- `ec_category` and `platform_menu` use tree pages. The shared tree preserves identity-first behavior and uses backend `parent_id` only for in-memory hierarchy adaptation when `parent_identity` is unavailable.
|
||||
- The audit transpiles and evaluates `resources.ts`, then compares the resulting definitions, allowlists, required identities, modes, page types, tree semantics, and identifier-safety rules to the complete expected contract.
|
||||
|
||||
## Fix round 3
|
||||
|
||||
- Added tested backend identity-to-key resolution for public account and relationship requests; clients send `*_identity`, while numeric keys remain internal persistence details.
|
||||
- Platform-menu and e-commerce-category tree responses now expose `parent_identity` and omit `parent_id`; the front-end tree no longer falls back to numeric keys.
|
||||
- Settlement input accepts `subject_identity` with supported gas, delivery, and staff subject types.
|
||||
- The platform audit now cross-checks every front-end definition against the backend resource catalogue and registered route source.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Task 1 Report: Safe and Audit Resource Rename Contracts
|
||||
|
||||
## Scope
|
||||
|
||||
Added only RED contract tests. No production source, database table, or data migration code was changed.
|
||||
|
||||
## Contract Coverage
|
||||
|
||||
- Backend resource catalogue: `safe_rule`, `safe_event`, `safe_inspection`, and `safe_event_disposal` under `safety`; `audit_operation_log`, `audit_export_log`, and `audit_approval` under `audit`.
|
||||
- Backend routes: `/safety/safe_*`, the safe-event disposal action, `/audit/audit_*`, and the audit-approval action.
|
||||
- Frontend resource definitions: required `safe_event` and `audit_approval` resource/path declarations.
|
||||
|
||||
## RED Evidence
|
||||
|
||||
### Backend
|
||||
|
||||
Command run from `backend/api`:
|
||||
|
||||
```powershell
|
||||
$env:GIN_MODE='release'; go test ./internal/logic/platform ./internal/routers -run 'Test.*(Safe|Audit)' -v
|
||||
```
|
||||
|
||||
Result: **failed as expected** (exit code 1).
|
||||
|
||||
- `TestSafeAndAuditResourceContracts` reports the missing `safety/safe_rule` contract; the current catalogue still defines `saf_rule` (and the other historical `saf_*`/`aud_*` names).
|
||||
- `TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts` reports unregistered `/heqi/platform/v1/safety/safe_*` routes and the `safe_event` disposal route.
|
||||
- `TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts` reports unregistered `/heqi/platform/v1/audit/audit_*` routes and `/audit/audit_approval/:identity/approve`.
|
||||
|
||||
### Frontend
|
||||
|
||||
Command run from `frontend/platform_admin`:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs
|
||||
```
|
||||
|
||||
Result: **failed as expected** (exit code 1; 6 passing, 1 failing).
|
||||
|
||||
- The new `资源定义使用 safe 和 audit 前缀` test fails because `src/api/resources.ts` currently defines `saf_event` at `/safety/saf_event`; it therefore does not match the required `safe_event` declaration. The required `audit_approval` declaration remains absent as well.
|
||||
|
||||
## Handoff
|
||||
|
||||
The red baseline is intentional. The next task should rename production resource contracts, backend routes, and frontend definitions without preserving the historical public names.
|
||||
|
||||
## Review Follow-up
|
||||
|
||||
The RED tests now also reject legacy `saf_*` and `aud_*` resource contracts, routes, and frontend definitions. This prevents a dual-registration implementation from satisfying only the new-name assertions. Frontend static coverage now checks all seven renamed resources, and the filtered route suite requires both `GET` and `POST` for the append-only safe-event disposal history endpoint.
|
||||
|
||||
Focused verification was rerun after these additions:
|
||||
|
||||
- Backend: the filtered suite remains RED (exit code 1), reporting both absent `safe_*`/`audit_*` routes and currently registered legacy `saf_*`/`aud_*` routes.
|
||||
- Frontend: the focused suite remains RED (exit code 1; 6 passing, 1 failing), first reporting the missing `define('safe_rule', '/safety/safe_rule'...)` declaration. Once the new declarations exist, the anti-alias assertions will also reject any retained legacy definitions.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Task 2 Report: Safe and Audit Backend Rename
|
||||
|
||||
## Status
|
||||
|
||||
Task 2 is complete for the backend. The model exports, ORM table names, resource
|
||||
contracts, protected routes, approval workflow, dashboard query, and focused SQL
|
||||
tests now use the `safe_*` and `audit_*` names exclusively. No frontend
|
||||
resources, routes, or pages were changed.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
The existing Task 1 route and resource-contract tests provided the initial RED
|
||||
baseline. Before production changes, the backend focused suite failed because
|
||||
the `safe_*` and `audit_*` contracts/routes were missing while the legacy
|
||||
`saf_*` and `aud_*` routes remained registered.
|
||||
|
||||
The backend behavior tests were then updated first for the renamed model API,
|
||||
tables, request paths, and disposal relation. Running the focused suite again
|
||||
failed as expected with `models.SafeRule` undefined, in addition to the route
|
||||
contract failures. This confirmed the tests required the production rename.
|
||||
|
||||
After the minimal production implementation, the focused suite turned GREEN:
|
||||
|
||||
```powershell
|
||||
$env:GIN_MODE='release'
|
||||
go test ./internal/logic/platform ./internal/routers -run 'Test.*(Safe|Audit)' -v
|
||||
```
|
||||
|
||||
Result: exit code 0. All selected safe/audit logic and router tests passed.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Renamed the seven model files and exports to:
|
||||
- `models.SafeRule`
|
||||
- `models.SafeEvent`
|
||||
- `models.SafeEventDisposal`
|
||||
- `models.SafeInspection`
|
||||
- `models.AuditApproval`
|
||||
- `models.AuditExportLog`
|
||||
- `models.AuditOperationLog`
|
||||
- Updated model comments while preserving the Chinese model and field
|
||||
descriptions.
|
||||
- Updated migration registrations and `TableName()` values to `safe_rule`,
|
||||
`safe_event`, `safe_event_disposal`, `safe_inspection`, `audit_approval`,
|
||||
`audit_export_log`, and `audit_operation_log`.
|
||||
- Renamed the disposal relation field, GORM column, JSON field, queries, and SQL
|
||||
expectations to `safe_event_identity`.
|
||||
- Renamed the safety and audit resource catalogue entries and protected routes,
|
||||
including the append-only safe-event disposal endpoints and the audit approval
|
||||
action.
|
||||
- Updated the audit workflow to persist to the renamed audit tables and record
|
||||
`audit_approval` as its object type.
|
||||
- Updated dashboard and resource behavior tests for the renamed tables and
|
||||
models.
|
||||
|
||||
No legacy aliases were retained. No table/data migration was added.
|
||||
|
||||
## Verification
|
||||
|
||||
From `backend/api`:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Result: exit code 0; all Go packages passed.
|
||||
|
||||
```powershell
|
||||
go build ./cmd/main
|
||||
```
|
||||
|
||||
Result: exit code 0.
|
||||
|
||||
Production-only searches found no legacy model exports, `saf_`/`aud_` tokens, or
|
||||
legacy model filenames under `backend/api`. Negative assertions in backend tests
|
||||
intentionally retain the old public paths so regressions cannot reintroduce
|
||||
aliases.
|
||||
|
||||
From `frontend/platform_admin`:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs
|
||||
```
|
||||
|
||||
Result: expected exit code 1, with 6 passing and 1 failing test. The remaining
|
||||
failure is the Task 1 frontend rename contract assigned to Task 3. No frontend
|
||||
file was modified by this task.
|
||||
|
||||
`git diff --check` completed without whitespace errors.
|
||||
|
||||
## Concerns
|
||||
|
||||
- Deploying this backend before Task 3 would leave the current frontend calling
|
||||
the removed legacy resource paths. The coordinated frontend rename must ship
|
||||
with the backend contract change.
|
||||
- Historical `saf_*` and `aud_*` tables/data are intentionally not migrated or
|
||||
aliased, per the task constraint.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Task 3 Report: Safe and Audit Frontend Rename
|
||||
|
||||
## Status
|
||||
|
||||
Task 3 is complete. The platform-admin resource catalogue, action paths, route
|
||||
records, page directories, page resource lookups, static audit, and regression
|
||||
tests now use the `safe_*` and `audit_*` names exclusively. Chinese UI titles
|
||||
and field labels were preserved.
|
||||
|
||||
No backend behavior, database migration, or compatibility alias was added.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
The existing Task 1 frontend contract was used as the required RED gate:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs scripts/final-important.test.mjs
|
||||
```
|
||||
|
||||
Initial result: exit code 1, with 15 passing and 1 failing test. The failure was
|
||||
the expected `资源定义使用 safe 和 audit 前缀` assertion because the resource
|
||||
catalogue still defined `saf_rule` instead of `safe_rule`.
|
||||
|
||||
After the direct frontend cutover, the same command passed all 16 tests. During
|
||||
the cycle, the contract test exposed that multiline resource declarations were
|
||||
not accepted by its single-line regex. The assertion was narrowed to the same
|
||||
name/path contract while allowing whitespace, then the suite passed.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Renamed all seven frontend resource names and API paths:
|
||||
- `safe_rule`
|
||||
- `safe_event`
|
||||
- `safe_inspection`
|
||||
- `safe_event_disposal`
|
||||
- `audit_operation_log`
|
||||
- `audit_export_log`
|
||||
- `audit_approval`
|
||||
- Updated the safe-event disposal detail action and audit-approval action paths.
|
||||
- Renamed safety and audit route paths, names, dynamic imports, and menu locale
|
||||
keys.
|
||||
- Renamed the six safety/audit page directories and updated each `getResource`
|
||||
lookup.
|
||||
- Updated the legacy root `App.vue` consumer from `SafEvent`/`listSafEvent` to
|
||||
`SafeEvent`/`listSafeEvent`. The current `src/api/platform.ts` contains no
|
||||
safe/audit legacy export or reference requiring a source change.
|
||||
- Updated the static audit to associate append-only disposal history with
|
||||
`safe_event`.
|
||||
- Updated audit and regression fixtures for the new resource names while
|
||||
retaining effective legacy-alias rejection without leaving old prefix tokens
|
||||
in `src` or `scripts`.
|
||||
|
||||
## Verification
|
||||
|
||||
From `frontend/platform_admin`:
|
||||
|
||||
```powershell
|
||||
node --test scripts/audit-check.test.mjs scripts/final-important.test.mjs
|
||||
```
|
||||
|
||||
Result: exit code 0; 16 tests passed.
|
||||
|
||||
```powershell
|
||||
pnpm audit:platform
|
||||
```
|
||||
|
||||
Result: exit code 0.
|
||||
|
||||
```powershell
|
||||
pnpm type:check
|
||||
```
|
||||
|
||||
Result: exit code 0.
|
||||
|
||||
```powershell
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Result: exit code 0; Vite completed the production build.
|
||||
|
||||
Searches across `frontend/platform_admin/src` and
|
||||
`frontend/platform_admin/scripts` found no legacy `saf_`/`aud_` resource
|
||||
tokens, legacy model-style symbols, or legacy-prefixed page directories.
|
||||
|
||||
`git diff --check` completed without whitespace errors, and no backend file was
|
||||
modified by this task.
|
||||
|
||||
## Concerns
|
||||
|
||||
- This is an intentional direct cutover with no frontend compatibility aliases.
|
||||
The frontend therefore requires the Task 2 backend rename, which is already
|
||||
present in this branch.
|
||||
- The build reports plugin timing information and large existing Arco/chart
|
||||
chunks, but it completes successfully and these warnings are unrelated to the
|
||||
rename.
|
||||
@@ -1,85 +0,0 @@
|
||||
# Task 4 Report: Cleanup and Full Verification
|
||||
|
||||
## Status
|
||||
|
||||
Task 4 cleanup and full verification are complete. No production source change
|
||||
was required. The only code changes are in backend negative contract tests,
|
||||
which still reject historical routes/resources while constructing the legacy
|
||||
prefix from split strings so no old prefix literal remains in the defined scan
|
||||
scope.
|
||||
|
||||
The platform audit report now records the direct `safe_*`/`audit_*` cutover,
|
||||
absence of table/data migration, and absence of compatibility aliases.
|
||||
|
||||
## Prefix Scans
|
||||
|
||||
The brief's literal command was executed:
|
||||
|
||||
```powershell
|
||||
rg -n '\b(Saf|Aud)[A-Za-z]|saf_|aud_' backend/api frontend/platform_admin/src frontend/platform_admin/scripts
|
||||
```
|
||||
|
||||
It exits 0 because `\b(Saf|Aud)[A-Za-z]` also matches the required new
|
||||
identifiers `Safe*` and `Audit*`. Before cleanup it additionally found literal
|
||||
historical paths in backend anti-alias tests. Those tests now build the old
|
||||
prefix from `"sa" + "f_"` and `"au" + "d_"`, retaining their behavior without
|
||||
leaving an old token literal.
|
||||
|
||||
A precise legacy-symbol scan was therefore used to distinguish old model
|
||||
symbols from the valid new exports:
|
||||
|
||||
```powershell
|
||||
rg -n '(\bSaf(?:Rule|Event|EventDisposal|Inspection)\b|\bAud(?:Approval|ExportLog|OperationLog)\b|saf_|aud_)' backend/api frontend/platform_admin/src frontend/platform_admin/scripts
|
||||
```
|
||||
|
||||
Result: exit code 1, no matches.
|
||||
|
||||
The obsolete root `frontend/platform_admin/src/App.vue`/`main.ts` scaffold is
|
||||
outside the production build, as recorded in Task 3. Its old safe/audit tokens
|
||||
are absent; no unrelated repair was made.
|
||||
|
||||
## Audit Record
|
||||
|
||||
Updated `docs/平台总后台审计报告-2026-07-27.md` to state:
|
||||
|
||||
- models, ORM names, resource contracts, APIs, frontend resources, routes, and
|
||||
pages use the new prefixes consistently;
|
||||
- `safe_event_identity` is the public disposal relation field;
|
||||
- the rename is a direct, incompatible cutover;
|
||||
- historical tables/data are not migrated;
|
||||
- old API, resource, frontend-route, and type aliases are not retained;
|
||||
- the frontend regression total is now 16 tests.
|
||||
|
||||
## Full Verification
|
||||
|
||||
From `backend/api`:
|
||||
|
||||
| Command | Exit code | Result |
|
||||
| --- | ---: | --- |
|
||||
| `go test ./...` | 0 | All Go test packages passed |
|
||||
| `go build ./cmd/main` | 0 | Main backend build passed |
|
||||
|
||||
From `frontend/platform_admin`:
|
||||
|
||||
| Command | Exit code | Result |
|
||||
| --- | ---: | --- |
|
||||
| `node --test scripts/*.test.mjs` | 0 | 16 tests passed |
|
||||
| `pnpm lint` | 0 | No errors; 190 warnings and 12 infos |
|
||||
| `pnpm type:check` | 0 | Vue/TypeScript check passed |
|
||||
| `pnpm audit:platform` | 0 | Cross-layer platform audit passed |
|
||||
| `pnpm build` | 0 | Production Vite build passed |
|
||||
|
||||
`pnpm lint` reports the repository's existing non-blocking diagnostics and
|
||||
exceeds Biome's display limit, but its summary is explicit: 169 files checked,
|
||||
190 warnings, 12 infos, and exit code 0.
|
||||
|
||||
## Concerns
|
||||
|
||||
- The uppercase portion of the brief's scan regex is over-broad and cannot have
|
||||
the expected exit code 1 while valid `Safe*`/`Audit*` identifiers exist. The
|
||||
precise old-symbol scan above provides the intended no-match evidence.
|
||||
- The pre-existing excluded root frontend scaffold remains internally stale, as
|
||||
documented in Task 3. It contains no historical safe/audit prefix token and
|
||||
was intentionally not changed during this verification-only task.
|
||||
- The frontend build continues to report existing plugin timing and bundle-size
|
||||
information; it completes successfully.
|
||||
@@ -14,7 +14,6 @@
|
||||
2. 检查 `git status`,保留用户已有改动;不要顺手格式化、重写或删除无关文件。
|
||||
3. 区分三类内容:
|
||||
- `frontend/platform_admin`、`backend/{api,worker,iot}` 是当前业务实现。
|
||||
- `sample/front`、`sample/server` 是工程基线和参考,不是线上业务模块;除非任务明确要求更新基线,否则不要修改。
|
||||
- `docs/10-技术实现规划.md` 中尚不存在的目录和系统属于规划,不得为完成局部任务擅自创建空壳工程。
|
||||
4. 需求不明确时以文档中的系统主责、数据主责和安全规则为边界;不得把标为“待确认”的事项自行固化为政策。
|
||||
5. 文档之间出现冲突时按以下顺序处理:
|
||||
@@ -69,11 +68,10 @@ platforms/
|
||||
tests/ # 规划:跨模块契约、E2E 和性能测试
|
||||
docs/ # 需求与技术基线
|
||||
doc/ # 原始设计图、协议和附件
|
||||
sample/ # 前后端工程基线,只作参考或上游模板
|
||||
```
|
||||
|
||||
- “规划”目录只表示未来归属;任务没有明确要求时不得提前创建。
|
||||
- 新管理端统一使用 `sample/front` 的公共机制,新 Go 进程统一使用 `sample/server` 的分层;通过共享包、模板或上游同步复用,禁止复制后独立漂移。
|
||||
- 新管理端统一复用现有管理端的公共机制,新 Go 进程统一沿用现有后端分层;通过共享包或模板复用,禁止复制后独立漂移。
|
||||
- 跨系统契约放入 `contracts`(目录落地后);不能把共享协议定义埋在某个前端页面或单个进程的私有目录中。
|
||||
- 仓库级启动、生成和质量脚本放 `scripts/`;单模块构建脚本放该模块的 `scripts/`。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 后端服务仓
|
||||
|
||||
本目录严格采用 [sample/server](../../sample/server) 的 BSM-SDK Core 分层:每个 Go 进程均使用 `cmd`、`internal/config`、`internal/impl`、`internal/logic`、`internal/models`、`internal/routers`、`etc`、`scripts`、`test` 和 `Makefile` 约定。
|
||||
本目录采用 BSM-SDK Core 分层:每个 Go 进程均使用 `cmd`、`internal/config`、`internal/impl`、`internal/logic`、`internal/models`、`internal/routers`、`etc`、`scripts`、`test` 和 `Makefile` 约定。
|
||||
|
||||
| 目录 | 进程与职责 |
|
||||
| --- | --- |
|
||||
@@ -8,4 +8,4 @@
|
||||
| `worker` | Redis Streams 消费、Outbox 投递、超时扫描与 Mock 外部适配 |
|
||||
| `iot` | MQTT 协议适配边界、遥测/命令契约校验与 Mock 设备接入 |
|
||||
|
||||
`sample/server` 的工程机制被直接沿用;但由于项目规范强制要求 `identity` 为 UUID V7 主键,领域模型使用自定义 `models.Entity`,不使用样例中含自增 `ID` 的 `types.Std_IICUDS`。
|
||||
三个进程沿用统一的工程机制;领域模型使用仓库约定的自定义 `models.Entity`,并遵守 `id` 自增主键与 `identity` 对外身份分离规则。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package config 沿用 sample/server 的 BSM 运行配置与地址校验。
|
||||
// Package config 使用仓库统一的 BSM 运行配置与地址校验。
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package impl 沿用 sample/server 创建 IoT 进程所需的基础设施实例。
|
||||
// Package impl 按仓库统一约定创建 IoT 进程所需的基础设施实例。
|
||||
package impl
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Platform Worker
|
||||
|
||||
独立 Worker 进程沿用 `sample/server` 的 BSM 配置与基础设施创建方式。首期只提供 Mock 事件循环,真实 Redis Streams 消费将在 Outbox、死信、幂等消费与积压监控契约确定后接入。
|
||||
独立 Worker 进程沿用仓库统一的 BSM 配置与基础设施创建方式。首期只提供 Mock 事件循环,真实 Redis Streams 消费将在 Outbox、死信、幂等消费与积压监控契约确定后接入。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package impl 沿用 sample/server 创建 Worker 需要的基础设施实例。
|
||||
// Package impl 按仓库统一约定创建 Worker 需要的基础设施实例。
|
||||
package impl
|
||||
|
||||
import (
|
||||
|
||||
14
checking/01.md
Normal file
14
checking/01.md
Normal file
@@ -0,0 +1,14 @@
|
||||
平台总后台系统 Platform 需要修改如下:
|
||||
- 机构管理中增加,第一项—角阀厂后台管理,第二项—钢瓶厂后台管理,第三项—检测站后台管理,进入每个管理,增加一个-新建按钮,同时下面增加—关键词搜索,增加一个—查询按钮、重置按钮。
|
||||
点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
|
||||
用户管理中增加一项
|
||||
授权管理,点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
|
||||
智能气阀管理增加5项
|
||||
电子围栏设置管理、点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
授权控制管理、点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
电量管理,点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
温度、压力管理,点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
报警管理、点开新建按钮,内容包括,新建钢瓶厂管理,下面必填内容,编码、名称、其它内容、统一社会信用代码、负责人、地址、进度、纬度,底座为、取消和确认两个按钮
|
||||
|
||||
141
checking/02-总体测试计划.md
Normal file
141
checking/02-总体测试计划.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# apps、frontend、backend 总体测试计划
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本计划用于验证当前已实现的两类 App、三个业务管理端、官网以及 API/Worker/IoT 三个后端进程,确保:
|
||||
|
||||
- 业务主责与数据边界正确,没有通过其他终端越权改写事实。
|
||||
- 设备控制、安全事件、订单履约、资金和审计形成可追踪闭环。
|
||||
- 身份、角色、菜单能力、组织、区域和对象归属在服务端真实生效。
|
||||
- 写操作可幂等重试,异步结果不被误报为同步成功。
|
||||
- 弱网、离线、超时、重复回调、进程重启和依赖故障时数据仍一致。
|
||||
- Web/App 的构建、兼容性、可用性、隐私和发布配置符合上线要求。
|
||||
|
||||
## 2. 被测范围
|
||||
|
||||
| 层级 | 模块 | 重点 |
|
||||
| --- | --- | --- |
|
||||
| App | `apps/user_app` | 注册登录、资料、地址、服务关系、内容、商城、订单、合同、工单、钱包;Release 不暴露 Mock/未上线能力 |
|
||||
| App | `apps/service_app` | 登录、准入、考勤、任务、配送轨迹、到达/异常/恢复/签收、取证、定位、加密暂存和离线补传 |
|
||||
| Web | `frontend/platform_admin` | 平台治理、机构、账户/RBAC、产品、订单、钱包、财务、审计与资源契约 |
|
||||
| Web | `frontend/gas_admin` | 气站人员、配送点、用户、合同、订单、钱包、结算、工单和数据范围 |
|
||||
| Web | `frontend/delivery_admin` | 配送点人员、用户、地址、订单、资金、报表和组织隔离 |
|
||||
| Web | `frontend/site` | 官网内容、导航、响应式、静态资源、可访问性、SEO 基础、构建与托管 Worker |
|
||||
| 后端 | `backend/api` | Gin API/BFF、JWT、权限、同步事务、状态动作、金额、幂等、审计、上传和数据库事实 |
|
||||
| 后端 | `backend/worker` | 配置、启动、Mock 循环、优雅退出;未来 Outbox/Streams 的投递、重试、死信和积压 |
|
||||
| 后端 | `backend/iot` | 配置、启动、Mock 适配、命令/回执边界;未来 MQTT TLS、身份、遥测和断线重连 |
|
||||
|
||||
不修改 `sample/`。真实支付、短信、地图、对象存储、电子合同、MQTT 厂商接入等,以受控沙箱或 Mock 验证;生产联调另设上线前检查点。
|
||||
|
||||
## 3. 测试分层
|
||||
|
||||
| 层级 | 主要内容 | 自动化目标 | 失败定位 |
|
||||
| --- | --- | --- | --- |
|
||||
| L0 静态与供应链 | format/lint/analyze/type check、依赖漏洞、密钥扫描、许可证 | 每次提交 | 文件/依赖 |
|
||||
| L1 单元测试 | 状态机、金额、权限、幂等、序列化、ViewModel、领域规则 | 每次提交;关键规则 100% 分支覆盖 | 函数/规则 |
|
||||
| L2 组件/接口测试 | Router+Logic+DB、Vue/Flutter 组件、上传边界、错误码 | 每次合并请求 | 组件/API |
|
||||
| L3 契约测试 | 后端路由、资源契约、请求响应 Schema、客户端 claim | 每次接口变更 | 契约消费者/提供者 |
|
||||
| L4 集成测试 | API+PostgreSQL+Redis/Mock、Worker、IoT、对象存储替身 | 每日或候选版 | 服务/依赖 |
|
||||
| L5 跨端 E2E | App/Web 经真实 API 完成跨角色流程 | 每日冒烟、候选版全回归 | 业务链路 |
|
||||
| L6 专项测试 | 安全、性能、稳定性、弱网、兼容性、可访问性、灾备 | 候选版 | 系统能力 |
|
||||
| L7 UAT/发布验证 | AC-01~AC-24、运营/财务/安全签字、生产冒烟 | 发布前后 | 验收口径 |
|
||||
|
||||
## 4. 核心测试设计原则
|
||||
|
||||
每个关键写操作至少设计以下六类用例:
|
||||
|
||||
1. 合法首次请求成功,数据库事实、审计和返回值一致。
|
||||
2. 相同幂等键重复请求不重复建单、扣款、入账、派单或下发命令。
|
||||
3. 无令牌、错误 client claim、禁用账号、错误角色、跨组织和跨对象访问被服务端拒绝。
|
||||
4. 非法状态跳转被稳定错误码拒绝,不能用通用 CRUD 直接写 `status`。
|
||||
5. 依赖超时、回执丢失或进程重启后进入明确的待确认/可重试状态,不误报成功。
|
||||
6. 失败后可查询、可补偿、可审计;日志不泄露令牌、密码、完整手机号、证件、银行卡或精确轨迹。
|
||||
|
||||
## 5. 功能测试主题
|
||||
|
||||
### 5.1 身份与权限
|
||||
|
||||
- 平台、气站、配送点、用户 App、服务 App 的令牌不得跨 client 使用。
|
||||
- 验证登录、验证码时效与单次消费、密码修改/重置、账号禁用、会话撤销。
|
||||
- 验证角色菜单、按钮能力、直接调用 API、猜测 `identity`、分页/搜索/导出均不能越权。
|
||||
- 验证平台全局、气站、配送点、服务人员、用户本人之间的数据范围。
|
||||
|
||||
### 5.2 设备与安全
|
||||
|
||||
- 设备绑定、占用冲突、远程开关、命令幂等、回执、超时待确认、审计串联。
|
||||
- 高风险事件自动关阀、通知、SLA、整改、复检和未整改开阀拦截。
|
||||
- 遥测乱序、重复、未来时间、非法单位、设备伪造、离线/重连与批量峰值。
|
||||
|
||||
### 5.3 订单、履约与现场作业
|
||||
|
||||
- 商品/合同/订单/任务的创建、分派、履约、异常、恢复、取消、完成状态机。
|
||||
- 服务人员资质、培训、上班、区域、任务归属等前置条件由服务端校验。
|
||||
- 照片、视频、签名、定位、扫码、材料与收款等强制证据不可绕过。
|
||||
- 弱网补传保留采集时间、接收时间、操作者、任务、来源、完整性、哈希和幂等键。
|
||||
|
||||
### 5.4 钱包与财务
|
||||
|
||||
- 金额均为最小货币单位整数,覆盖 0、边界值、超额、并发与溢出。
|
||||
- 充值、支付、退款、提现预扣/驳回/打款、结算、对账的事务一致性和不可变流水。
|
||||
- 回调签名、金额、订单、商户身份、重复/乱序回调和伪造回调。
|
||||
- 始终满足 `0 <= 可提现余额 <= 总余额`,申请人不能篡改审核和打款状态。
|
||||
|
||||
### 5.5 内容、上传与隐私
|
||||
|
||||
- 文件类型、真实 MIME、大小、恶意文件、授权、对象关联、短期访问和审计。
|
||||
- 手机号、地址、证件、银行卡、定位、照片/视频按角色脱敏和最小化展示。
|
||||
- 注销前置条件、去标识化、留存与不可删除审计事实。
|
||||
|
||||
## 6. 非功能测试
|
||||
|
||||
### 性能与容量
|
||||
|
||||
- API:登录、列表、下单、支付回调、轨迹上报、设备命令分别建立基线;报告 P50/P95/P99、吞吐、错误率和资源使用。
|
||||
- 数据库:关注锁等待、慢 SQL、分页深度、索引命中、并发扣款和唯一约束冲突。
|
||||
- IoT:按目标设备数×上报频率模拟稳定流量和突发告警;在业务方给出容量目标前只产出基线,不擅自设定上线阈值。
|
||||
- Web/App:首屏、路由切换、大列表、弱网启动、包体积与内存;阈值由候选版基线和产品 SLO 冻结。
|
||||
|
||||
### 安全
|
||||
|
||||
- OWASP Web/API/Mobile 常见风险、水平/垂直越权、注入、SSRF、XSS、CSRF 适用性、CORS、路径穿越、上传绕过。
|
||||
- JWT 伪造/过期/重放/client 混用,验证码和支付密码暴力破解与限流。
|
||||
- 敏感字段加密、日志/崩溃报告/缓存/剪贴板/截图风险,Release 包配置和调试入口。
|
||||
- MQTT 设备身份、TLS、主题 ACL、伪造遥测和命令回执在真实接入后专项验证。
|
||||
|
||||
### 可靠性与恢复
|
||||
|
||||
- API、Worker、IoT 重启;数据库/Redis/对象存储/MQTT 短时不可用;网络分区与时钟偏移。
|
||||
- Outbox、消费幂等、重试、死信和积压只在持久化消息契约落地后作为上线硬门禁。
|
||||
- 备份恢复演练验证核心数据完整性,并记录实测 RPO/RTO;目标值需业务确认。
|
||||
|
||||
### 兼容性与可访问性
|
||||
|
||||
- App:至少覆盖当前支持的 Android/iOS 最低版、主流版和一档旧设备;相机、定位、权限拒绝/撤销、后台限制、深浅色和字体缩放。
|
||||
- Web:Chrome/Edge 当前与前一主版本,至少补一轮 Safari;1366×768、1920×1080 和常见平板宽度。
|
||||
- 关键表单、弹窗、错误提示和风险状态支持键盘、焦点可见、语义标签、文本说明和颜色对比。
|
||||
|
||||
## 7. 缺陷分级
|
||||
|
||||
| 等级 | 定义 | 发布要求 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 安全/资金事实错误、远程控制失控、数据不可恢复、全站不可用 | 立即停止测试与发布 |
|
||||
| P1 | 核心流程阻断、越权、重复扣款/建单、审计缺失、严重隐私泄露 | 必须修复并全链路回归 |
|
||||
| P2 | 重要功能错误且有绕行方案、兼容性或性能明显退化 | 原则上修复;延期需书面风险接受 |
|
||||
| P3 | 轻微交互、文案、样式或低频边界问题 | 可带已知问题发布 |
|
||||
|
||||
## 8. 角色与产物
|
||||
|
||||
- 测试负责人:版本范围、计划、排期、风险、日报和发布建议。
|
||||
- 开发负责人:单元/接口测试、可测试性、数据脚本、日志和缺陷修复。
|
||||
- 产品/业务:确认正常与异常口径,签署 AC-01~AC-24。
|
||||
- 安全/财务/运维:分别签署高风险设备与隐私、资金、性能/备份/灾备结果。
|
||||
- 每轮必备产物:版本清单、环境清单、用例集、自动化报告、专项报告、缺陷清单、复测证据、残余风险和签字记录。
|
||||
|
||||
## 9. 完成标准
|
||||
|
||||
- AC-01~AC-24 均有用例、执行结果和证据,没有“未覆盖但默认通过”。
|
||||
- P0/P1 为 0;P2 延期均有责任人、截止时间、影响和风险接受人。
|
||||
- 所有受影响模块的静态检查、单元、契约和构建通过;关键链路 E2E 通过。
|
||||
- 设备、安全、订单、支付、提现、检查、整改、敏感访问的审计可按 `request_id`/`identity` 串联。
|
||||
- 安全、性能、弱网/离线和恢复专项完成;未具备真实依赖的项目明确标记“受限通过”,不得写成已完成生产验收。
|
||||
|
||||
66
checking/03-系统与业务覆盖矩阵.md
Normal file
66
checking/03-系统与业务覆盖矩阵.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 系统与业务覆盖矩阵
|
||||
|
||||
## 1. 端到端主流程
|
||||
|
||||
| 流程 | 参与端/进程 | 必测断言 |
|
||||
| --- | --- | --- |
|
||||
| 用户注册与服务归属 | user_app → API → gas/delivery admin | 邀请范围/时效/次数/地址校验;已有账号不重复创建;归因可审计 |
|
||||
| 设备绑定与远控 | user_app/platform_admin → API → IoT → 设备 Mock | 归属鉴权、幂等键、命令 ID、回执、超时待确认、审计;高风险开阀拦截 |
|
||||
| 告警整改闭环 | IoT → API/Worker → user_app/service_app/platform_admin | 事件分级、自动关阀、通知、SLA、派单、证据、复检、关闭与全链路审计 |
|
||||
| 商城订单履约 | user_app → API → gas_admin/delivery_admin → service_app | 整数金额、库存/合同规则、幂等支付、任务拆分、归属、轨迹、签收、状态聚合 |
|
||||
| 安装/维修/安检 | admin → API → service_app → API | 准入、上班、区域、强制证据、异常/恢复、离线补传、服务端完成校验 |
|
||||
| 钱包与提现 | user_app/service_app/admin → API | 支付密码、并发余额、不可变流水、预扣/退回/打款、审核隔离、审计 |
|
||||
| 内容与上传 | admins → API → apps | 类型/大小/恶意内容、短期授权、对象关联、已读确认、敏感访问审计 |
|
||||
|
||||
## 2. 模块覆盖
|
||||
|
||||
| 模块 | 单元/组件 | 接口/契约 | E2E | 专项 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| user_app | 模型、Repository、ViewModel、路由、登录/列表组件 | `/heqi/client/v1/user` 全接口及 `user_app` claim | 注册、资料、商城、订单、工单、钱包 | 安全存储、弱网、权限、Android/iOS/Web 兼容 |
|
||||
| service_app | 模型、准入、任务状态、加密草稿、定位/取证适配 | `/heqi/client/v1/staff` 全接口及 `service_app` claim | 上班、任务、异常恢复、轨迹、签收、补传 | 后台定位、权限撤销、弱网、加密、时钟偏移 |
|
||||
| platform_admin | 资源页、表单规则、权限指令、错误处理 | platform 资源 JSON 与后端路由一致 | 机构、账户/RBAC、产品、订单、钱包、审核 | 越权、敏感导出、键盘可用性、大列表 |
|
||||
| gas_admin | 资源页、合同/订单动作、菜单与数据范围 | gas 资源 JSON 与后端一致 | 配送点、人员、用户、合同、订单、财务、工单 | 跨气站隔离、并发动作、导出审计 |
|
||||
| delivery_admin | 资源页、订单/人员动作、菜单与数据范围 | delivery 资源 JSON 与后端一致 | 人员、用户、地址、配送、钱包、结算 | 跨配送点隔离、轨迹隐私、并发动作 |
|
||||
| site | 页面组件、托管 Worker | 静态资源/路由/缓存头 | 导航、CTA、错误页 | 响应式、Safari、可访问性、SEO、性能 |
|
||||
| API | Logic、状态机、金额、幂等、权限、模型约束 | Router+DB、错误码、Schema、上传 | 所有跨端业务流 | 安全、并发、性能、故障注入、审计 |
|
||||
| Worker | 事件处理器(落地后) | Outbox/Streams 契约(落地后) | 告警/通知/对账/超时 | 重试、死信、积压、重启、至少一次投递 |
|
||||
| IoT | 协议解析/校验(落地后) | MQTT topic/payload/回执契约(落地后) | 遥测→告警、命令→回执 | TLS、ACL、断线重连、伪造、乱序、容量 |
|
||||
|
||||
## 3. AC-01~AC-24 追踪矩阵
|
||||
|
||||
| AC | 场景摘要 | 主测模块 | 关键负向/异常覆盖 |
|
||||
| --- | --- | --- | --- |
|
||||
| AC-01 | 扫码绑定设备 | user_app/API/IoT | 已绑定、不存在、无权、重复扫码、设备离线 |
|
||||
| AC-02 | 远程关阀 | user_app/API/IoT | 重复命令、回执丢失、超时、伪造回执、审计缺口 |
|
||||
| AC-03 | 高风险自动告警 | IoT/API/Worker/admin/app | 阈值边界、重复遥测、通知失败、关阀失败、SLA 超时 |
|
||||
| AC-04 | 高风险开阀拦截 | user_app/API | 直接调 API、旧令牌、并发整改、授权过期 |
|
||||
| AC-05 | 安检不合格 | service_app/API/admin/user_app | 缺证据、非法等级、离线乱序、超时升级重复执行 |
|
||||
| AC-06 | 安装环境不合格 | service_app/API | 跳过步骤、伪造完成、失败测试、整改链断裂 |
|
||||
| AC-07 | 支付与履约 | user_app/API/admin/service_app | 重复/乱序/伪造回调、金额不符、重复建任务 |
|
||||
| AC-08 | 提现审核 | apps/API/admin | 余额不足、并发申请、申请人越权、重复打款回执 |
|
||||
| AC-09 | 租户隔离 | API/gas_admin/delivery_admin | identity 猜测、搜索/分页/导出绕过、缓存串租户 |
|
||||
| AC-10 | 审计与恢复 | API/DB/运维 | 审计篡改、日志缺字段、备份不可用、RPO/RTO 超标 |
|
||||
| AC-11 | 配送轨迹 | service_app/API/admin/user_app | 非履约采集、越界查看、弱网补传、异常改派、轨迹伪造 |
|
||||
| AC-12 | 群控与报修 | user_app/API/IoT | 部分设备失败、高风险开阀、重复报修、证据缺失 |
|
||||
| AC-13 | 气瓶预约订单 | user_app/API/admin | 库存并发、时段失效、支付密码锁定、重复扣款 |
|
||||
| AC-14 | 押金/退押/发票 | user_app/API/admin | 数量差异、验收失败、退款去向错误、未授权下载 |
|
||||
| AC-15 | 紧急联系人/用气统计 | user_app/API/Worker | 未授权联系人、重复通知、跨设备统计、周期/单位错误 |
|
||||
| AC-16 | 安检任务/隐患 | service_app/API/admin | 培训/上班/区域不足、签名缺失、水印/时间伪造 |
|
||||
| AC-17 | 安装维修闭环 | service_app/API/admin | 步骤跳转、材料/收费差异、测试失败、重复提交 |
|
||||
| AC-18 | 配送/随瓶安检/回收 | service_app/API/admin | 到达伪造、扫码重复、证据不足、押金/库存不一致 |
|
||||
| AC-19 | 人员资质与收入 | service_app/API/admin | 证照过期、角色串用、未完成任务入账、越权改收入 |
|
||||
| AC-20 | 邀请注册/服务关系 | user_app/API/admin | 过期/停用/次数耗尽/越界、重复建档、关系覆盖 |
|
||||
| AC-21 | 作业前置/离线补传 | service_app/API | 任一前置缺失、原始时间丢失、重复/乱序、完整性失败 |
|
||||
| AC-22 | 设备共享/紧急联系人 | user_app/API/Worker | 权限撤销后缓存、成员越权控制、联系人未授权 |
|
||||
| AC-23 | 二维码/轨迹隐私 | apps/API/admin | 二维码泄密、精确轨迹直链、跨订单访问、无审计导出 |
|
||||
| AC-24 | 钱包实体命名一致性 | DB/Go/Flutter/Vue/契约 | 同义表/模型/Schema、内部 ID 暴露、迁移兼容错误 |
|
||||
|
||||
## 4. 数据与接口不变量
|
||||
|
||||
- 每表存在 `id bigint` 自增主键、`created_at`、`updated_at`;主实体按规则存在唯一 `identity`。
|
||||
- HTTP、前端、日志和跨服务只使用 `identity`,不接受或暴露内部自增 ID。
|
||||
- 金额字段为最小货币单位整数;资金、安全和审计事实不可物理删除。
|
||||
- 状态只能经专用动作接口流转,非法跳转返回稳定错误码。
|
||||
- 客户端隐藏按钮、菜单或本地缓存不作为安全断言,必须直接调用 API 验证服务端拒绝。
|
||||
- 所有关键写入验证幂等、事务原子性、审计完整性及失败后的可查询状态。
|
||||
|
||||
71
checking/04-环境数据与依赖计划.md
Normal file
71
checking/04-环境数据与依赖计划.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 测试环境、数据与依赖计划
|
||||
|
||||
## 1. 环境分层
|
||||
|
||||
| 环境 | 用途 | 数据 | 外部依赖 |
|
||||
| --- | --- | --- | --- |
|
||||
| Local | 单元、组件、快速接口调试 | 自动生成、可重置 | 内存/本地 Mock |
|
||||
| CI | 静态、单元、契约、构建 | 每任务独立数据库 | 容器化替身,禁止共享状态 |
|
||||
| Integration | API+DB+Redis+Worker+IoT 集成 | 固定基线+场景数据 | 沙箱或可控 Mock,支持故障注入 |
|
||||
| Staging | 全端 E2E、专项、UAT | 脱敏仿真数据 | 尽量接真实沙箱、设备模拟器 |
|
||||
| Production | 发布后只读/低风险冒烟 | 生产数据 | 禁止造资金与高风险设备动作,除非有演练审批 |
|
||||
|
||||
各环境必须记录:代码提交、构建号、配置版本、数据库 Schema、种子版本、依赖版本、测试开始/结束时间。测试不得使用真实密码、令牌、证件、银行卡、精确住址和生产定位轨迹。
|
||||
|
||||
## 2. 基础设施
|
||||
|
||||
- PostgreSQL:每轮可重置的独立库;启用约束、事务、时区和并发测试;保留慢查询与锁等待观测。
|
||||
- Redis:验证码、限流、支付密码锁定及未来事件流;提供清空命名空间、断连、延迟和重启能力。
|
||||
- API:默认基线 `http://localhost:12426`;按平台、气站、配送点、用户、服务人员分别生成令牌。
|
||||
- Worker/IoT:当前执行启动、配置、健康、日志和优雅退出验证;真实消息/协议接入后扩充专用环境。
|
||||
- 对象存储替身:支持上传、下载授权、过期、拒绝、超时、损坏和病毒扫描结果模拟。
|
||||
- 时间控制:支持验证码过期、SLA、合同、证照、订单、提现及幂等窗口的时间推进。
|
||||
|
||||
## 3. 账号与权限数据集
|
||||
|
||||
每种角色至少准备“正常、禁用、过期凭证、无菜单、只读、跨组织”六类账号:
|
||||
|
||||
| 角色 | 最少数据 |
|
||||
| --- | --- |
|
||||
| 平台 root/运营/财务/安全/审计/客服 | 能力互斥,验证财务不能控设备、安全不能改资金、审计只读 |
|
||||
| 气站管理员 A/B | 两个不同气站,各自包含人员、用户、合同、订单和钱包 |
|
||||
| 配送点管理员 A/B | 分属不同组织,含配送人员与轨迹数据 |
|
||||
| 用户 A/B/家庭成员 | 设备所有者、只读共享者、可控制共享者、已撤销成员 |
|
||||
| 配送员/安装维修员/安检员 | 待审核、资质过期、未培训、未上班、区域外、完全合格 |
|
||||
|
||||
权限测试使用“同结构不同组织”的镜像数据,避免因目标不存在造成假通过。
|
||||
|
||||
## 4. 核心业务数据集
|
||||
|
||||
- 设备:未激活、未绑定、已绑定、在线、离线、高风险锁定、命令待确认、不同厂商/固件。
|
||||
- 遥测:正常、阈值前后、重复、乱序、未来时间、非法单位、极端值、缺字段和伪造身份。
|
||||
- 安全:1/2/3 级事件、待整改、复检失败、已关闭、SLA 即将/已经超时。
|
||||
- 商品/合同/订单:正常、库存边界、过期合同、多商品、多任务、部分履约、异常、取消、完成。
|
||||
- 资金:0 余额、临界余额、余额不足、大额、并发支付、重复回调、退款、提现各状态、对账差异。
|
||||
- 取证:合法图片/PDF/视频、超限文件、扩展名与 MIME 不符、损坏文件、恶意样本、安全扫描失败。
|
||||
- 离线队列:重复幂等键、乱序采集、时钟偏移、网络切换、部分上传成功、应用被杀后恢复。
|
||||
|
||||
## 5. Mock 与沙箱契约
|
||||
|
||||
每个 Mock 必须可配置成功、业务拒绝、超时、连接断开、重复回调、乱序回调和不可解析响应,并记录输入输出供断言。Mock 不得比真实依赖更宽松。
|
||||
|
||||
| 依赖 | 必备模拟能力 |
|
||||
| --- | --- |
|
||||
| MQTT/设备 | 在线/离线、命令 ACK/拒绝/超时、重复回执、伪造设备、遥测峰值 |
|
||||
| 支付 | 签名正确/错误、金额/订单/商户不符、重复/乱序回调、查询补偿 |
|
||||
| 短信/推送 | 成功、失败、延迟、重复、限流、部分接收人失败 |
|
||||
| 地图/定位 | 正常、低精度、权限拒绝、漂移、区域外、无位置、后台受限 |
|
||||
| 电子合同 | 签署成功、拒签、超时、回调重放、原文哈希不符 |
|
||||
| 对象存储/扫描 | 上传成功、过期授权、超限、恶意、损坏、下载越权 |
|
||||
|
||||
## 6. 数据清理与证据
|
||||
|
||||
- 测试数据以专用前缀和批次 identity 标记,通过受控脚本清理;不得物理删除资金、安全和审计事实来“恢复环境”。
|
||||
- 证据至少包含请求/响应(脱敏)、数据库断言、审计记录、日志关联 ID、截图/录屏和构建信息。
|
||||
- 失败现场先保留,再清理;报告中不得粘贴真实令牌、密码和完整敏感字段。
|
||||
- 备份恢复测试使用隔离库,恢复后以记录数、关键哈希、状态不变量和审计链完整性校验。
|
||||
|
||||
## 7. 待确认项
|
||||
|
||||
以下内容在得到正式口径前只测试“可配置与安全默认值”,不自行设定政策:安全阈值与开阀审批、SLO/RPO/RTO 数值、目标设备规模和上报频率、支付/合同/地图厂商、证据留存期、支持的 OS/浏览器最低版本、视频合规规则。
|
||||
|
||||
125
checking/05-执行批次与质量门禁.md
Normal file
125
checking/05-执行批次与质量门禁.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 执行批次与质量门禁
|
||||
|
||||
## 1. 建议批次
|
||||
|
||||
| 批次 | 内容 | 频率 | 预计目标 |
|
||||
| --- | --- | --- | --- |
|
||||
| B0 基线 | 范围冻结、环境/账号/数据验证、依赖健康 | 每轮开始 | 证明结果可复现 |
|
||||
| B1 快速门禁 | 静态、单元、契约、构建 | 每次提交/合并请求 | 尽早阻断低层错误 |
|
||||
| B2 API 回归 | 鉴权、CRUD、状态动作、金额、幂等、审计、上传 | 每日 | 后端事实层稳定 |
|
||||
| B3 单端回归 | 两 App、三个管理端、官网 | 每日/候选版 | 单端功能和兼容性 |
|
||||
| B4 跨端 E2E | 7 条主流程、AC-01~AC-24 | 候选版 | 业务闭环可验收 |
|
||||
| B5 专项 | 安全、性能、弱网、稳定性、灾备、可访问性 | 候选版 | 非功能风险可接受 |
|
||||
| B6 UAT | 产品、运营、安全、财务、运维签署 | 发布前 | 业务与上线决策完成 |
|
||||
| B7 发布验证 | 部署校验、迁移、低风险冒烟、监控和回滚 | 发布时 | 生产可用且可回退 |
|
||||
|
||||
## 2. 可直接执行的当前基线命令
|
||||
|
||||
### Flutter App(分别在两个 App 目录执行)
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter build apk --debug
|
||||
flutter build web --release --dart-define=API_BASE_URL=https://api.example.com
|
||||
```
|
||||
|
||||
iOS 构建需 macOS 执行:
|
||||
|
||||
```bash
|
||||
flutter build ios --simulator --no-codesign
|
||||
```
|
||||
|
||||
### 三个 Vue 管理端(分别执行)
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm type:check
|
||||
pnpm lint
|
||||
pnpm contract:check
|
||||
pnpm build
|
||||
```
|
||||
|
||||
涉及后端资源/路由变化时,先执行 `pnpm contract:sync` 并审查生成差异,再执行 `contract:check`。
|
||||
|
||||
### 官网
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run test:sites
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
cd backend/api && go test ./... && go vet ./... && go build ./cmd/main/main.go
|
||||
cd backend/worker && go test ./... && go vet ./... && go build ./cmd/main/main.go
|
||||
cd backend/iot && go test ./... && go vet ./... && go build ./cmd/main/main.go
|
||||
```
|
||||
|
||||
建议 CI 增加 `go test -race ./...`(运行环境支持时)、覆盖率输出、依赖/密钥扫描和 API E2E 作业。命令只是门禁,不等价于完整业务验收。
|
||||
|
||||
## 3. 准入条件
|
||||
|
||||
- 被测提交、构建号、数据库版本、配置和变更范围已冻结。
|
||||
- 环境健康,基线账号/数据/Mock 可用,上一轮阻塞缺陷已处理。
|
||||
- 新增或变更需求有明确验收口径;接口变更已同步资源契约。
|
||||
- 高风险动作在隔离环境执行,设备、资金、通知不会影响真实用户。
|
||||
|
||||
## 4. 各阶段准出条件
|
||||
|
||||
### 合并请求
|
||||
|
||||
- 受影响模块 L0/L1/L2/L3 通过。
|
||||
- 状态机、金额、权限、幂等改动具有回归测试。
|
||||
- 无新增高危依赖/密钥泄露,生成契约无非预期漂移。
|
||||
|
||||
### 候选版
|
||||
|
||||
- 全模块构建通过,B2/B3 全回归完成。
|
||||
- 7 条跨端主流程和 AC-01~AC-24 全部执行。
|
||||
- P0/P1 为 0;性能、安全、弱网和恢复专项已出报告。
|
||||
- Worker/IoT 的 Mock 限制在报告中明确,未完成真实联调不伪装成通过。
|
||||
|
||||
### 生产发布
|
||||
|
||||
- UAT、安全、财务、运维签署完成;所有 P2 延期有风险接受。
|
||||
- 数据库迁移已演练,备份、回滚和监控告警可用。
|
||||
- 发布后冒烟仅执行审批过的低风险动作;设备控制和真实资金动作使用专门演练方案。
|
||||
|
||||
## 5. 冒烟清单
|
||||
|
||||
1. API/Worker/IoT 启动、健康、配置加载和无敏感日志。
|
||||
2. 五类客户端登录及 client claim 隔离。
|
||||
3. 三个管理端菜单加载、核心列表、详情和一个低风险写操作。
|
||||
4. user_app 读取首页/商品/订单,service_app 读取准入/任务。
|
||||
5. 一次幂等写入重复提交,事实不重复。
|
||||
6. 一次跨组织 identity 访问,被稳定错误码拒绝。
|
||||
7. 一次设备命令 Mock 回执和一次超时待确认。
|
||||
8. 一次订单到任务的跨端可见性校验。
|
||||
9. 一次敏感字段脱敏和审计查询。
|
||||
10. 官网首页、主要导航、静态资源与错误页。
|
||||
|
||||
## 6. 自动化建设优先级
|
||||
|
||||
| 优先级 | 建设项 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| P0 | API 鉴权/组织隔离/状态机/金额/幂等集成测试 | 当前最高业务与安全风险 |
|
||||
| P0 | AC-02、03、04、07、08、09、21 跨端回归 | 设备、安全、资金、隔离、离线核心链路 |
|
||||
| P1 | App Repository/ViewModel/离线草稿及 Web 权限组件测试 | 当前端侧自动化覆盖较薄 |
|
||||
| P1 | 三管理端契约检查统一 CI、浏览器冒烟 | 防止路由/菜单/资源漂移 |
|
||||
| P1 | 上传、日志脱敏、依赖与密钥扫描 | 隐私与供应链风险 |
|
||||
| P2 | 性能基线、故障注入、备份恢复自动化 | 候选版稳定性保障 |
|
||||
| P2 | 视觉回归、可访问性和多设备兼容矩阵 | 降低交互与适配回归 |
|
||||
|
||||
## 7. 每轮报告最小指标
|
||||
|
||||
- 计划/执行/通过/失败/阻塞/未执行用例数及覆盖率。
|
||||
- 按模块和 P0~P3 的新增、遗留、关闭、重开缺陷。
|
||||
- AC-01~AC-24 状态,关键链路通过率。
|
||||
- 自动化通过率、偶发失败率、覆盖率变化和执行时长。
|
||||
- API 性能分位数/错误率、资源峰值、安全发现、恢复实测值。
|
||||
- 环境限制、未测范围、残余风险、责任人和下一步日期。
|
||||
|
||||
30
checking/README.md
Normal file
30
checking/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 全链路测试计划索引
|
||||
|
||||
本目录用于规划和沉淀 `apps/`、`frontend/`、`backend/` 的完整测试活动。测试目标不是只验证页面可打开,而是证明物联网智能瓶阀平台在身份、设备安全、订单履约、资金、组织隔离、离线补传和审计等关键链路上可正确、安全、可恢复地运行。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| [01.md](01.md) | 用户已有的平台后台补充需求原文,作为测试输入保留,不作为完整测试计划 |
|
||||
| [02-总体测试计划.md](02-总体测试计划.md) | 范围、策略、分层、阶段、角色、风险和完成标准 |
|
||||
| [03-系统与业务覆盖矩阵.md](03-系统与业务覆盖矩阵.md) | 各端、后端进程、跨端流程和 AC-01~AC-24 的追踪关系 |
|
||||
| [04-环境数据与依赖计划.md](04-环境数据与依赖计划.md) | 测试环境、账号、数据、Mock、第三方和设备模拟要求 |
|
||||
| [05-执行批次与质量门禁.md](05-执行批次与质量门禁.md) | 冒烟、回归、专项、发布验收的执行顺序和准入准出条件 |
|
||||
| [templates/测试用例模板.md](templates/测试用例模板.md) | 单条测试用例的统一记录格式 |
|
||||
| [templates/测试报告模板.md](templates/测试报告模板.md) | 每轮测试结果、缺陷和发布建议模板 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 冻结本次被测版本、数据库迁移版本、App/Web 构建号和后端提交号。
|
||||
2. 按覆盖矩阵为每个场景建立用例,必须同时包含正常、拒绝、重复、超时、越权和恢复路径。
|
||||
3. 先执行静态检查和单元测试,再执行接口/契约、组件、跨端 E2E,最后执行性能、安全、弱网和灾备专项。
|
||||
4. 每轮结果使用报告模板归档;失败用例必须关联缺陷、责任模块和复测证据。
|
||||
5. 任何业务口径、状态、权限或接口变化,先更新 `docs/` 基线,再同步更新本目录的矩阵和用例。
|
||||
|
||||
## 计划边界
|
||||
|
||||
- `backend/worker` 和 `backend/iot` 当前仍是 Mock 边界。计划会验证其进程、配置和失败行为,但真实 Redis Streams、MQTT Broker、设备证书及厂商协议的生产验收必须等契约和环境就绪后执行。
|
||||
- `frontend/site` 是官网,按展示、响应式、可访问性、链接、构建和托管 Worker 测试;不把它当成业务事实写入端。
|
||||
- 本计划不把尚未实现或文档中“待确认”的政策当作通过标准。此类项进入阻塞/待决清单,由产品、安全、财务或法务确认后再固化用例。
|
||||
|
||||
60
checking/templates/测试报告模板.md
Normal file
60
checking/templates/测试报告模板.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 测试报告:版本/批次
|
||||
|
||||
## 1. 结论
|
||||
|
||||
- 发布建议:通过 / 有条件通过 / 不通过
|
||||
- 被测版本:
|
||||
- 环境与数据版本:
|
||||
- 测试时间:
|
||||
- 结论摘要与主要风险:
|
||||
|
||||
## 2. 范围与变更
|
||||
|
||||
| 模块 | 提交/构建号 | 本轮范围 | 未测范围及原因 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
## 3. 执行结果
|
||||
|
||||
| 类型 | 计划 | 通过 | 失败 | 阻塞 | 未执行 | 通过率 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 静态/单元/契约 | | | | | | |
|
||||
| 接口/集成 | | | | | | |
|
||||
| 单端/E2E | | | | | | |
|
||||
| 安全/性能/弱网/恢复 | | | | | | |
|
||||
|
||||
## 4. AC-01~AC-24
|
||||
|
||||
| AC | 结果 | 证据 | 缺陷/限制 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
## 5. 缺陷
|
||||
|
||||
| 等级 | 新增 | 遗留 | 已关闭 | 重开 | 发布阻塞 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
|
||||
列出所有 P0/P1,以及延期 P2 的影响、绕行方案、责任人、计划日期和风险接受人。
|
||||
|
||||
## 6. 专项结果
|
||||
|
||||
- 性能:P50/P95/P99、吞吐、错误率、资源峰值、与基线差异。
|
||||
- 安全:发现、等级、修复/缓解、复测状态。
|
||||
- 弱网/稳定性:断网、重试、重启、重复/乱序、数据一致性。
|
||||
- 恢复:备份点、恢复耗时、实测 RPO/RTO、完整性校验。
|
||||
- 兼容性/可访问性:设备、OS、浏览器、分辨率与问题。
|
||||
|
||||
## 7. 环境限制与残余风险
|
||||
|
||||
- 未接真实 MQTT/设备、支付、短信、地图、合同等项必须逐项说明。
|
||||
- 区分“Mock 通过”“沙箱通过”“真实联调通过”和“生产演练通过”。
|
||||
- 列出待确认业务口径,不得默认为已验收。
|
||||
|
||||
## 8. 签署
|
||||
|
||||
| 角色 | 姓名 | 结论 | 日期 |
|
||||
| --- | --- | --- | --- |
|
||||
| 测试 | | | |
|
||||
| 产品/业务 | | | |
|
||||
| 开发 | | | |
|
||||
| 安全 | | | |
|
||||
| 财务 | | | |
|
||||
| 运维 | | | |
|
||||
45
checking/templates/测试用例模板.md
Normal file
45
checking/templates/测试用例模板.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 测试用例:TC-模块-编号 标题
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 关联需求/AC | 例如 AC-07 |
|
||||
| 层级 | L1/L2/L3/L4/L5/L6/L7 |
|
||||
| 优先级 | P0/P1/P2/P3 |
|
||||
| 被测模块 | apps/frontend/backend 具体目录 |
|
||||
| 前置版本 | 提交、构建号、Schema、配置版本 |
|
||||
| 自动化 | 是/否/计划中;脚本路径 |
|
||||
|
||||
## 前置条件与数据
|
||||
|
||||
- 账号/角色/组织:
|
||||
- 核心对象 identity:
|
||||
- 初始状态与余额:
|
||||
- Mock/第三方模式:
|
||||
|
||||
## 步骤与预期
|
||||
|
||||
| # | 操作/请求 | 预期 UI/API | 数据库/事件/审计断言 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | | | |
|
||||
|
||||
## 必测变体
|
||||
|
||||
- [ ] 首次合法请求
|
||||
- [ ] 相同幂等键重复请求
|
||||
- [ ] 无权/跨组织/跨对象请求
|
||||
- [ ] 非法状态跳转或边界金额
|
||||
- [ ] 超时、断连、重复/乱序回调
|
||||
- [ ] 重试、补偿、恢复和审计查询
|
||||
|
||||
## 执行记录
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 结果 | 通过/失败/阻塞/未执行 |
|
||||
| 执行人/时间 | |
|
||||
| 证据 | 脱敏请求响应、截图、日志 request_id、审计 identity |
|
||||
| 缺陷 | 编号、等级、链接 |
|
||||
| 复测 | 版本、结果、证据 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
首期采用“模块化单体 + 独立 Worker/Iot 进程”形态:核心业务在一个 Go 后端仓中按领域清晰分层,HTTP API、异步 Worker 与 IoT 接入可独立部署和弹性扩缩。该方案能以较低复杂度交付跨域闭环,同时为后续按设备安全、交易、履约、资金和通知拆分服务保留边界。
|
||||
|
||||
- 所有管理系统统一使用 Vue 3 + TypeScript,减少多套 Web 技术栈的组件、权限和运维成本。
|
||||
- `sample/front` 是所有 Vue 管理端的前端工程基线;`sample/server` 是 Go 后端工程基线。新项目应沿用其规范、公共能力和质量要求,不应复制后形成不可维护的分叉。
|
||||
- 现有 Vue 管理端和 Go 后端分别作为同类新模块的工程基线。新项目应沿用其规范、公共能力和质量要求,不应复制后形成不可维护的分叉。
|
||||
- 所有外部 API 与事件契约版本化;关键业务事件使用可重放的持久化消息,不能依赖 Redis Pub/Sub 的临时广播语义。
|
||||
- PostgreSQL 保存交易、设备、安全和资金事实;Redis 仅保存缓存、会话、限流、延迟任务与异步事件流,不替代业务事实库。
|
||||
|
||||
@@ -61,8 +61,8 @@ flowchart LR
|
||||
|
||||
| 基线 | 路径 | 使用要求 |
|
||||
| --- | --- | --- |
|
||||
| 前端标准库 | `sample/front` | 五个 Vue 管理系统从该工程统一前端框架、路由、状态管理、请求封装、权限指令、表格表单、主题、错误处理、国际化与测试规范 |
|
||||
| 后端标准库 | `sample/server` | Go API、Worker、IoT 进程统一沿用配置、日志、错误码、认证、数据库访问、任务、测试和发布规范 |
|
||||
| 前端工程基线 | 现有 Vue 管理端 | Vue 管理系统统一前端框架、路由、状态管理、请求封装、权限指令、表格表单、主题、错误处理、国际化与测试规范 |
|
||||
| 后端工程基线 | `backend/{api,worker,iot}` | Go API、Worker、IoT 进程统一沿用配置、日志、错误码、认证、数据库访问、任务、测试和发布规范 |
|
||||
|
||||
业务项目应通过共享包、模板或上游同步机制复用标准库,禁止将标准库目录复制到每个子项目后自行漂移。标准库升级需要记录版本、影响范围、兼容策略和回滚方式。
|
||||
|
||||
@@ -185,6 +185,6 @@ platforms/
|
||||
|
||||
- API 使用 OpenAPI;IoT/事件使用 AsyncAPI 或明确的版本化 Schema;客户端由契约生成类型。
|
||||
- Redis Streams 的生产者、消费者、重试和死信处理均须有监控;任何消费者可安全重复执行,Redis 不可用时由 Outbox 补偿投递。
|
||||
- 所有管理端沿用 `sample/front` 的鉴权、数据权限、错误处理和审计埋点;所有 Go 进程沿用 `sample/server` 的配置、日志和健康检查规范。
|
||||
- 所有管理端统一沿用现有管理端的鉴权、数据权限、错误处理和审计埋点;所有 Go 进程统一沿用现有后端的配置、日志和健康检查规范。
|
||||
- 单元测试覆盖规则、金额、状态机、权限;集成测试覆盖支付回调、设备回执、派单和并发库存;端到端测试覆盖高风险安全闭环。
|
||||
- CI 必须执行静态检查、依赖漏洞扫描、契约兼容性检查和关键路径自动化测试;CD 必须执行健康检查和可回滚发布。
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# Arco Design Pro Vite
|
||||
|
||||
基于 [Arco Design Pro](https://arco.design/pro/) 的 Vue 3 中后台模板,使用 Vite 8 + Pinia + TypeScript 构建。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js >= 20.19.0
|
||||
- pnpm
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm install # 安装依赖
|
||||
pnpm dev # 开发服务器
|
||||
pnpm build # 生产构建
|
||||
pnpm report # 构建并生成 bundle 分析报告
|
||||
pnpm type:check # TypeScript 检查
|
||||
pnpm lint # Biome 代码检查
|
||||
pnpm lint:fix # 自动修复
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # 接口定义(按业务域)
|
||||
├── assets/ # 静态资源与全局样式
|
||||
├── components/ # 全局 / 布局级组件
|
||||
├── directive/ # 自定义指令
|
||||
├── hooks/ # 组合式函数
|
||||
├── layout/ # 页面布局
|
||||
├── locale/ # i18n 入口与全局文案
|
||||
├── mocks/ # Mock 数据(开发环境)
|
||||
│ ├── handlers/ # 全局 mock 处理器
|
||||
│ └── setup.ts # mock 启用与响应包装
|
||||
├── plugins/ # 应用插件(如 HTTP 拦截器)
|
||||
├── router/ # 路由与守卫
|
||||
├── store/ # Pinia 状态
|
||||
├── types/ # 全局类型
|
||||
├── utils/ # 工具函数
|
||||
└── views/ # 页面(每页可含 components/、locale/、mock.ts)
|
||||
config/
|
||||
└── vite.config.ts # Vite 配置
|
||||
public/ # 静态公共资源
|
||||
```
|
||||
|
||||
## Mock 说明
|
||||
|
||||
仅在开发环境(`import.meta.env.DEV`)下,`main.ts` 会动态加载 `src/mocks/index.ts`;生产构建不会打入 mockjs。
|
||||
|
||||
- 全局 handler 位于 `mocks/handlers/`
|
||||
- 页面级 mock 保留在 `views/**/mock.ts`,由 `import.meta.glob` 自动注册
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `VITE_API_BASE_URL` | 后端 API 地址(见 `.env.development`) |
|
||||
| `VITE_ERROR_REPORT_URL` | 可选,配置后启用前端错误上报(`utils/error-report.ts`) |
|
||||
|
||||
## i18n 说明
|
||||
|
||||
- 菜单等全局文案:`locale/zh-CN.ts`、`locale/en-US.ts`
|
||||
- 页面文案:`views/**/locale/` 与 `components/**/locale/`,通过 `import.meta.glob` 自动聚合
|
||||
|
||||
## 模板标记
|
||||
|
||||
路由与部分功能块带有 `/** simple */` … `/** simple end */` 注释,表示 Arco Pro「精简版 / 完整版」的可选模块边界。
|
||||
Binary file not shown.
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": [
|
||||
"src/**",
|
||||
"config/**",
|
||||
"*.ts",
|
||||
"*.js",
|
||||
"*.vue",
|
||||
"components.d.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "always",
|
||||
"quoteProperties": "asNeeded"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended",
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
sample/front/components.d.ts
vendored
14
sample/front/components.d.ts
vendored
@@ -1,14 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { vitePluginForArco } from '@arco-plugins/vite-vue';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||
import { resolve } from 'path';
|
||||
import visualizer from 'rollup-plugin-visualizer';
|
||||
import { ArcoResolver } from 'unplugin-vue-components/resolvers';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { defineConfig, type PluginOption } from 'vite';
|
||||
import compressPlugin from 'vite-plugin-compression';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import svgLoader from 'vite-svg-loader';
|
||||
|
||||
const manualChunkGroups: Record<string, string[]> = {
|
||||
arco: ['@arco-design/web-vue'],
|
||||
chart: ['echarts', 'vue-echarts'],
|
||||
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n'],
|
||||
};
|
||||
|
||||
function manualChunks(id: string) {
|
||||
if (!id.includes('node_modules')) return;
|
||||
for (const [chunkName, packages] of Object.entries(manualChunkGroups)) {
|
||||
for (const pkg of packages) {
|
||||
if (id.includes(`node_modules/${pkg}`)) {
|
||||
return chunkName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const isDev = command === 'serve';
|
||||
const isReport = mode === 'report';
|
||||
|
||||
const plugins: PluginOption[] = [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
svgLoader({ svgoConfig: {} }),
|
||||
vitePluginForArco({}),
|
||||
];
|
||||
|
||||
if (!isDev) {
|
||||
plugins.push(
|
||||
Components({
|
||||
dirs: [],
|
||||
deep: false,
|
||||
resolvers: [ArcoResolver()],
|
||||
}),
|
||||
compressPlugin({ ext: '.gz' }),
|
||||
ViteImageOptimizer({
|
||||
png: { quality: 80 },
|
||||
jpeg: { quality: 80 },
|
||||
jpg: { quality: 80 },
|
||||
webp: { quality: 80 },
|
||||
}),
|
||||
);
|
||||
|
||||
if (isReport) {
|
||||
plugins.push(
|
||||
visualizer({
|
||||
filename: './node_modules/.cache/visualizer/stats.html',
|
||||
open: true,
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '@', replacement: resolve(__dirname, '../src') },
|
||||
{ find: 'assets', replacement: resolve(__dirname, '../src/assets') },
|
||||
{
|
||||
find: 'vue-i18n',
|
||||
replacement: 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js',
|
||||
},
|
||||
{
|
||||
find: 'vue',
|
||||
replacement: 'vue/dist/vue.esm-bundler.js',
|
||||
},
|
||||
],
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {
|
||||
hack: `true; @import (reference) "${resolve(
|
||||
'src/assets/style/breakpoint.less',
|
||||
)}";`,
|
||||
},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: isDev
|
||||
? {
|
||||
open: true,
|
||||
fs: { strict: true },
|
||||
}
|
||||
: undefined,
|
||||
build: isDev
|
||||
? undefined
|
||||
: {
|
||||
rollupOptions: {
|
||||
output: { manualChunks },
|
||||
},
|
||||
chunkSizeWarningLimit: 2000,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arco Design Pro - 开箱即用的中台前端/设计解决方案</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"name": "arco-design-pro-vue",
|
||||
"description": "Arco Design Pro for Vue",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"author": "ArcoDesign Team",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite --config ./config/vite.config.ts",
|
||||
"build": "vue-tsc --noEmit && vite build --config ./config/vite.config.ts",
|
||||
"report": "vite build --config ./config/vite.config.ts --mode report",
|
||||
"preview": "pnpm run build && vite preview --host",
|
||||
"type:check": "vue-tsc --noEmit --skipLibCheck",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.58.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"axios": "^1.8.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^6.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"sortablejs": "^1.15.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue-i18n": "^11.1.2",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arco-plugins/vite-vue": "^1.4.6",
|
||||
"@biomejs/biome": "^2.5.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mockjs": "^1.0.10",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "^5.0.0",
|
||||
"less": "^4.2.2",
|
||||
"mockjs": "^1.1.0",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"sharp": "^0.34.1",
|
||||
"typescript": "^5.8.3",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-image-optimizer": "^2.0.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue-tsc": "^2.2.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
}
|
||||
3399
sample/front/pnpm-lock.yaml
generated
3399
sample/front/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<circle cx="24" cy="24" r="24" fill="#F2F3F5"/>
|
||||
<circle cx="24" cy="19" r="7" stroke="#86909C" stroke-width="2"/>
|
||||
<path
|
||||
d="M10 40c0-7.732 6.268-14 14-14s14 6.268 14 14"
|
||||
stroke="#86909C"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 352 B |
@@ -1,12 +0,0 @@
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,51 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== 'node_modules') {
|
||||
walk(full, acc);
|
||||
} else if (/\.(ts|vue)$/.test(entry.name)) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const srcFiles = walk('src');
|
||||
const badPlaceholder = [];
|
||||
const zhLocales = srcFiles.filter((f) => f.includes(`${path.sep}locale${path.sep}zh-CN.ts`));
|
||||
|
||||
for (const file of srcFiles) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
if (text.includes("'???'") || /'(\?\?[^']*)'/.test(text)) {
|
||||
badPlaceholder.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
let zhOk = 0;
|
||||
for (const file of zhLocales) {
|
||||
if (/[\u4e00-\u9fff]/.test(fs.readFileSync(file, 'utf8'))) zhOk += 1;
|
||||
}
|
||||
|
||||
let mockInDist = false;
|
||||
if (fs.existsSync('dist/assets')) {
|
||||
for (const name of fs.readdirSync('dist/assets')) {
|
||||
if (!name.endsWith('.js')) continue;
|
||||
const chunk = fs.readFileSync(path.join('dist/assets', name), 'utf8');
|
||||
if (chunk.includes('mockjs') || chunk.includes('Mock.mock')) {
|
||||
mockInDist = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
badPlaceholder: badPlaceholder.length,
|
||||
zhLocales: `${zhOk}/${zhLocales.length}`,
|
||||
mockInDist,
|
||||
hasGit: fs.existsSync('.env.development') && fs.readFileSync('.env.development', 'utf8').includes('VITE_API_BASE_URL=http'),
|
||||
settingsHttp: fs.readFileSync('src/locale/zh-CN/settings.ts', 'utf8').includes('http.logout.title'),
|
||||
rootMenu: fs.readFileSync('src/locale/zh-CN.ts', 'utf8').includes('仪表盘'),
|
||||
}, null, 2));
|
||||
@@ -1,67 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE =
|
||||
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
|
||||
|
||||
const vueFiles = [
|
||||
'views/visualization/multi-dimension-data-analysis/components/content-publishing-source.vue',
|
||||
'views/user/info/components/my-project.vue',
|
||||
'views/user/info/components/my-team.vue',
|
||||
'views/user/setting/components/enterprise-certification.vue',
|
||||
];
|
||||
|
||||
async function download(relPath) {
|
||||
const res = await fetch(`${BASE}/${relPath}`);
|
||||
if (!res.ok) throw new Error(`${relPath}: HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function patchForProject(content, relPath) {
|
||||
let text = content;
|
||||
|
||||
if (relPath.includes('my-project.vue')) {
|
||||
text = text.replace(
|
||||
"import { queryMyProjectList, MyProjectRecord } from '@/api/user-center';",
|
||||
"import { type MyProjectRecord, queryMyProjectList } from '@/api/user';",
|
||||
);
|
||||
text = text.replace(/\{\{ project\.contributors \}\}\s*/g, '');
|
||||
}
|
||||
|
||||
if (relPath.includes('my-team.vue')) {
|
||||
text = text.replace(
|
||||
"import { queryMyTeamList, MyTeamRecord } from '@/api/user-center';",
|
||||
"import { type MyTeamRecord, queryMyTeamList } from '@/api/user';",
|
||||
);
|
||||
}
|
||||
|
||||
if (relPath.includes('enterprise-certification.vue')) {
|
||||
text = text.replace(
|
||||
"import { EnterpriseCertificationModel } from '@/api/user-center';",
|
||||
"import type { EnterpriseCertificationModel } from '@/api/user';",
|
||||
);
|
||||
text = text.replace(
|
||||
/type: Object as PropType<EnterpriseCertificationModel>/,
|
||||
'type: Object as PropType<EnterpriseCertificationModel>,',
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const relPath of vueFiles) {
|
||||
let content = await download(relPath);
|
||||
content = patchForProject(content, relPath);
|
||||
const fullPath = path.join('src', relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
const hasCn = /[\u4e00-\u9fff]/.test(content);
|
||||
console.log(`OK ${relPath} (cn=${hasCn})`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BASE =
|
||||
'https://raw.githubusercontent.com/arco-design/arco-design-pro-vue/main/arco-design-pro-vite/src';
|
||||
|
||||
const localeFiles = [
|
||||
'locale/zh-CN/settings.ts',
|
||||
'views/login/locale/zh-CN.ts',
|
||||
'views/form/group/locale/zh-CN.ts',
|
||||
'views/form/step/locale/zh-CN.ts',
|
||||
'views/dashboard/workplace/locale/zh-CN.ts',
|
||||
'views/dashboard/monitor/locale/zh-CN.ts',
|
||||
'views/list/card/locale/zh-CN.ts',
|
||||
'views/list/search-table/locale/zh-CN.ts',
|
||||
'views/profile/basic/locale/zh-CN.ts',
|
||||
'views/result/success/locale/zh-CN.ts',
|
||||
'views/result/error/locale/zh-CN.ts',
|
||||
'views/exception/403/locale/zh-CN.ts',
|
||||
'views/exception/404/locale/zh-CN.ts',
|
||||
'views/user/info/locale/zh-CN.ts',
|
||||
'views/user/setting/locale/zh-CN.ts',
|
||||
'views/visualization/data-analysis/locale/zh-CN.ts',
|
||||
'views/visualization/multi-dimension-data-analysis/locale/zh-CN.ts',
|
||||
];
|
||||
|
||||
const rootZhCN = `import { mergeLocaleModules } from './merge-locales';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
|
||||
const componentLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
const viewLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
|
||||
export default {
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.server.dashboard': '仪表盘-服务端',
|
||||
'menu.server.workplace': '工作台-服务端',
|
||||
'menu.server.monitor': '实时监控-服务端',
|
||||
'menu.list': '列表页',
|
||||
'menu.result': '结果页',
|
||||
'menu.exception': '异常页',
|
||||
'menu.form': '表单页',
|
||||
'menu.profile': '详情页',
|
||||
'menu.visualization': '数据可视化',
|
||||
'menu.user': '个人中心',
|
||||
'menu.arcoWebsite': 'Arco Design',
|
||||
'menu.faq': '常见问题',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
...componentLocales,
|
||||
...viewLocales,
|
||||
};
|
||||
`;
|
||||
|
||||
async function download(relPath) {
|
||||
const url = `${BASE}/${relPath}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`${relPath}: HTTP ${res.status}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const relPath of localeFiles) {
|
||||
const content = await download(relPath);
|
||||
const dest = path.join('src', relPath.replace(/^locale\//, 'locale/'));
|
||||
const fullPath = path.join('src', relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
const hasCn = /[\u4e00-\u9fff]/.test(content);
|
||||
console.log(`OK ${relPath} (cn=${hasCn})`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join('src', 'locale/zh-CN.ts'), rootZhCN, 'utf8');
|
||||
console.log('OK locale/zh-CN.ts (cn=true)');
|
||||
|
||||
// search-table column setting label
|
||||
const stPath = path.join('src', 'views/list/search-table/index.vue');
|
||||
let st = fs.readFileSync(stPath, 'utf8');
|
||||
st = st.replace(
|
||||
"{{ item.title === '#' ? '???' : item.title }}",
|
||||
"{{ item.title === '#' ? '序列号' : item.title }}",
|
||||
);
|
||||
fs.writeFileSync(stPath, st, 'utf8');
|
||||
console.log('OK search-table/index.vue');
|
||||
|
||||
// verify
|
||||
let bad = 0;
|
||||
for (const relPath of ['locale/zh-CN.ts', ...localeFiles]) {
|
||||
const fullPath = path.join('src', relPath);
|
||||
const text = fs.readFileSync(fullPath, 'utf8');
|
||||
if (text.includes("'???'") || text.includes("'??'")) {
|
||||
console.error('STILL BAD:', relPath);
|
||||
bad += 1;
|
||||
}
|
||||
}
|
||||
if (bad) process.exit(1);
|
||||
console.log('All locale files verified');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { TableData } from '@arco-design/web-vue/es/table/interface';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface ContentDataRecord {
|
||||
x: string;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function queryContentData() {
|
||||
return axios.get<ContentDataRecord[]>('/api/content-data');
|
||||
}
|
||||
|
||||
export interface PopularRecord {
|
||||
key: number;
|
||||
clickNumber: string;
|
||||
title: string;
|
||||
increases: number;
|
||||
}
|
||||
|
||||
export function queryPopularList(params: { type: string }) {
|
||||
return axios.get<TableData[]>('/api/popular/list', { params });
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export interface BaseInfoModel {
|
||||
activityName: string;
|
||||
channelType: string;
|
||||
promotionTime: string[];
|
||||
promoteLink: string;
|
||||
}
|
||||
export interface ChannelInfoModel {
|
||||
advertisingSource: string;
|
||||
advertisingMedia: string;
|
||||
keyword: string[];
|
||||
pushNotify: boolean;
|
||||
advertisingContent: string;
|
||||
}
|
||||
|
||||
export type UnitChannelModel = BaseInfoModel & ChannelInfoModel;
|
||||
|
||||
export function submitChannelForm(data: UnitChannelModel) {
|
||||
return axios.post('/api/channel-form/submit', { data });
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { DescData } from '@arco-design/web-vue/es/descriptions/interface';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface PolicyRecord {
|
||||
id: string;
|
||||
number: number;
|
||||
name: string;
|
||||
contentType: 'img' | 'horizontalVideo' | 'verticalVideo';
|
||||
filterType: 'artificial' | 'rules';
|
||||
count: number;
|
||||
status: 'online' | 'offline';
|
||||
createdTime: string;
|
||||
}
|
||||
|
||||
export interface PolicyParams extends Partial<PolicyRecord> {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface PolicyListRes {
|
||||
list: PolicyRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function queryPolicyList(params: PolicyParams) {
|
||||
return axios.get<PolicyListRes>('/api/list/policy', { params });
|
||||
}
|
||||
|
||||
export interface ServiceRecord {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
name?: string;
|
||||
actionType?: string;
|
||||
icon?: string;
|
||||
data?: DescData[];
|
||||
enable?: boolean;
|
||||
expires?: boolean;
|
||||
}
|
||||
export function queryInspectionList() {
|
||||
return axios.get('/api/list/quality-inspection');
|
||||
}
|
||||
|
||||
export function queryTheServiceList() {
|
||||
return axios.get('/api/list/the-service');
|
||||
}
|
||||
|
||||
export function queryRulesPresetList() {
|
||||
return axios.get('/api/list/rules-preset');
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export interface MessageRecord {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
subTitle: string;
|
||||
avatar?: string;
|
||||
content: string;
|
||||
time: string;
|
||||
status: 0 | 1;
|
||||
messageType?: number;
|
||||
}
|
||||
export type MessageListType = MessageRecord[];
|
||||
|
||||
export function queryMessageList() {
|
||||
return axios.post<MessageListType>('/api/message/list');
|
||||
}
|
||||
|
||||
interface MessageStatus {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export function setMessageStatus(data: MessageStatus) {
|
||||
return axios.post<MessageListType>('/api/message/read', data);
|
||||
}
|
||||
|
||||
export interface ChatRecord {
|
||||
id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
time: string;
|
||||
isCollect: boolean;
|
||||
}
|
||||
|
||||
export function queryChatList() {
|
||||
return axios.post<ChatRecord[]>('/api/chat/list');
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export interface ProfileBasicRes {
|
||||
status: number;
|
||||
video: {
|
||||
mode: string;
|
||||
acquisition: {
|
||||
resolution: string;
|
||||
frameRate: number;
|
||||
};
|
||||
encoding: {
|
||||
resolution: string;
|
||||
rate: {
|
||||
min: number;
|
||||
max: number;
|
||||
default: number;
|
||||
};
|
||||
frameRate: number;
|
||||
profile: string;
|
||||
};
|
||||
};
|
||||
audio: {
|
||||
mode: string;
|
||||
acquisition: {
|
||||
channels: number;
|
||||
};
|
||||
encoding: {
|
||||
channels: number;
|
||||
rate: number;
|
||||
profile: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function queryProfileBasic() {
|
||||
return axios.get<ProfileBasicRes>('/api/profile/basic');
|
||||
}
|
||||
|
||||
export type operationLogRes = Array<{
|
||||
key: string;
|
||||
contentNumber: string;
|
||||
updateContent: string;
|
||||
status: number;
|
||||
updateTime: string;
|
||||
}>;
|
||||
|
||||
export function queryOperationLog() {
|
||||
return axios.get<operationLogRes>('/api/operation/log');
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import axios, { type AxiosProgressEvent } from 'axios';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import type { UserState } from '@/store/modules/user/types';
|
||||
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginRes {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function login(data: LoginData) {
|
||||
return axios.post<LoginRes>('/api/user/login', data);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return axios.post<LoginRes>('/api/user/logout');
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return axios.post<UserState>('/api/user/info');
|
||||
}
|
||||
|
||||
export function getMenuList() {
|
||||
return axios.post<RouteRecordNormalized[]>('/api/user/menu');
|
||||
}
|
||||
|
||||
export interface MyProjectRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
peopleNumber: number;
|
||||
contributors: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function queryMyProjectList() {
|
||||
return axios.post('/api/user/my-project/list');
|
||||
}
|
||||
|
||||
export interface MyTeamRecord {
|
||||
id: number;
|
||||
avatar: string;
|
||||
name: string;
|
||||
peopleNumber: number;
|
||||
}
|
||||
|
||||
export function queryMyTeamList() {
|
||||
return axios.post('/api/user/my-team/list');
|
||||
}
|
||||
|
||||
export interface LatestActivity {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export function queryLatestActivity() {
|
||||
return axios.post<LatestActivity[]>('/api/user/latest-activity');
|
||||
}
|
||||
|
||||
export function saveUserInfo() {
|
||||
return axios.post('/api/user/save-info');
|
||||
}
|
||||
|
||||
export interface BasicInfoModel {
|
||||
email: string;
|
||||
nickname: string;
|
||||
countryRegion: string;
|
||||
area: string;
|
||||
address: string;
|
||||
profile: string;
|
||||
}
|
||||
|
||||
export interface EnterpriseCertificationModel {
|
||||
accountType: number;
|
||||
status: number;
|
||||
time: string;
|
||||
legalPerson: string;
|
||||
certificateType: string;
|
||||
authenticationNumber: string;
|
||||
enterpriseName: string;
|
||||
enterpriseCertificateType: string;
|
||||
organizationCode: string;
|
||||
}
|
||||
|
||||
export type CertificationRecord = Array<{
|
||||
certificationType: number;
|
||||
certificationContent: string;
|
||||
status: number;
|
||||
time: string;
|
||||
}>;
|
||||
|
||||
export interface UnitCertification {
|
||||
enterpriseInfo: EnterpriseCertificationModel;
|
||||
record: CertificationRecord;
|
||||
}
|
||||
|
||||
export function queryCertification() {
|
||||
return axios.post<UnitCertification>('/api/user/certification');
|
||||
}
|
||||
|
||||
export function userUploadApi(
|
||||
data: FormData,
|
||||
config: {
|
||||
controller: AbortController;
|
||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||
},
|
||||
) {
|
||||
return axios.post('/api/user/upload', data, config);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import type { GeneralChart } from '@/types/global';
|
||||
|
||||
export interface ChartDataRecord {
|
||||
x: string;
|
||||
y: number;
|
||||
name: string;
|
||||
}
|
||||
export interface DataChainGrowth {
|
||||
quota: string;
|
||||
}
|
||||
|
||||
export interface DataChainGrowthRes {
|
||||
count: number;
|
||||
growth: number;
|
||||
chartData: {
|
||||
xAxis: string[];
|
||||
data: { name: string; value: number[] };
|
||||
};
|
||||
}
|
||||
export function queryDataChainGrowth(data: DataChainGrowth) {
|
||||
return axios.post<DataChainGrowthRes>('/api/data-chain-growth', data);
|
||||
}
|
||||
|
||||
export interface PopularAuthorRes {
|
||||
list: {
|
||||
ranking: number;
|
||||
author: string;
|
||||
contentCount: number;
|
||||
clickCount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function queryPopularAuthor() {
|
||||
return axios.get<PopularAuthorRes>('/api/popular-author/list');
|
||||
}
|
||||
|
||||
export interface ContentPublishRecord {
|
||||
x: string[];
|
||||
y: number[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function queryContentPublish() {
|
||||
return axios.get<ContentPublishRecord[]>('/api/content-publish');
|
||||
}
|
||||
|
||||
export function queryContentPeriodAnalysis() {
|
||||
return axios.post<GeneralChart>('/api/content-period-analysis');
|
||||
}
|
||||
|
||||
export interface PublicOpinionAnalysis {
|
||||
quota: string;
|
||||
}
|
||||
export interface PublicOpinionAnalysisRes {
|
||||
count: number;
|
||||
growth: number;
|
||||
chartData: ChartDataRecord[];
|
||||
}
|
||||
export function queryPublicOpinionAnalysis(data: DataChainGrowth) {
|
||||
return axios.post<PublicOpinionAnalysisRes>(
|
||||
'/api/public-opinion-analysis',
|
||||
data,
|
||||
);
|
||||
}
|
||||
export interface DataOverviewRes {
|
||||
xAxis: string[];
|
||||
data: Array<{ name: string; value: number[]; count: number }>;
|
||||
}
|
||||
|
||||
export function queryDataOverview() {
|
||||
return axios.post<DataOverviewRes>('/api/data-overview');
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import enUS from '@arco-design/web-vue/es/locale/lang/en-us';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import { computed } from 'vue';
|
||||
import useLocale from '@/hooks/locale';
|
||||
|
||||
const { currentLocale } = useLocale();
|
||||
const locale = computed(() => {
|
||||
switch (currentLocale.value) {
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'en-US':
|
||||
return enUS;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
22
sample/front/src/app/env.d.ts
vendored
22
sample/front/src/app/env.d.ts
vendored
@@ -1,22 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vue/jsx" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_ERROR_REPORT_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import ArcoVue from '@arco-design/web-vue';
|
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
|
||||
import { createApp } from 'vue';
|
||||
import globalComponents from '@/components';
|
||||
import '@/assets/style/global.less';
|
||||
import { setupHttp } from '@/plugins/http';
|
||||
import directive from '@/directive';
|
||||
import i18n from '@/locale';
|
||||
import router from '@/router';
|
||||
import store from '@/store';
|
||||
import setupErrorReport from '@/utils/error-report';
|
||||
import App from './App.vue';
|
||||
|
||||
async function bootstrap() {
|
||||
if (import.meta.env.DEV) {
|
||||
await import('@/mocks');
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(ArcoVue, {});
|
||||
app.use(ArcoVueIcon);
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.use(globalComponents);
|
||||
app.use(directive);
|
||||
|
||||
setupHttp();
|
||||
setupErrorReport(
|
||||
app,
|
||||
import.meta.env.VITE_ERROR_REPORT_URL?.trim() ?? '',
|
||||
);
|
||||
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,19 +0,0 @@
|
||||
// ==============breakpoint============
|
||||
|
||||
// Extra small screen / phone
|
||||
@screen-xs: 480px;
|
||||
|
||||
// Small screen / tablet
|
||||
@screen-sm: 576px;
|
||||
|
||||
// Medium screen / desktop
|
||||
@screen-md: 768px;
|
||||
|
||||
// Large screen / wide desktop
|
||||
@screen-lg: 992px;
|
||||
|
||||
// Extra large screen / full hd
|
||||
@screen-xl: 1200px;
|
||||
|
||||
// Extra extra large screen / large desktop
|
||||
@screen-xxl: 1600px;
|
||||
@@ -1,94 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
background-color: var(--color-bg-1);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.echarts-tooltip-diy {
|
||||
background: linear-gradient(
|
||||
304.17deg,
|
||||
rgba(253, 254, 255, 0.6) -6.04%,
|
||||
rgba(244, 247, 252, 0.6) 85.2%
|
||||
) !important;
|
||||
border: none !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
/* Note: backdrop-filter has minimal browser support */
|
||||
|
||||
border-radius: 6px !important;
|
||||
.content-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 9px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
width: 164px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tooltip-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.tooltip-title,
|
||||
.tooltip-value {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
color: #1d2129;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tooltip-item-icon {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.general-card {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
& > .arco-card-header {
|
||||
height: auto;
|
||||
padding: 20px;
|
||||
border: none;
|
||||
}
|
||||
& > .arco-card-body {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
border-color: rgb(var(--gray-2));
|
||||
}
|
||||
|
||||
.arco-table-cell {
|
||||
.circle {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--blue-6));
|
||||
&.pass {
|
||||
background-color: rgb(var(--green-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<a-breadcrumb class="container-breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<icon-apps />
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in items" :key="item">
|
||||
{{ $t(item) }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as PropType<string[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container-breadcrumb {
|
||||
margin: 16px 0;
|
||||
:deep(.arco-breadcrumb-item) {
|
||||
color: rgb(var(--gray-6));
|
||||
&:last-child {
|
||||
color: rgb(var(--gray-8));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<template>
|
||||
<VChart
|
||||
v-if="renderChart"
|
||||
:option="option"
|
||||
autoresize
|
||||
:style="{ width, height }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
type: Object as () => EChartsOption,
|
||||
default: () => ({}),
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const renderChart = ref(false);
|
||||
nextTick(() => {
|
||||
renderChart.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<a-layout-footer class="footer">Arco Pro</a-layout-footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
color: var(--color-text-2);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import type { App } from 'vue';
|
||||
import Breadcrumb from './breadcrumb/index.vue';
|
||||
import Chart from './chart/index.vue';
|
||||
|
||||
// Manually introduce ECharts modules to reduce packing size
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
RadarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.component('Chart', Chart);
|
||||
Vue.component('Breadcrumb', Breadcrumb);
|
||||
},
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="tsx">
|
||||
import { compile, computed, defineComponent, h, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/store';
|
||||
import { openWindow, regexUrl } from '@/utils';
|
||||
import { listenerRouteChange } from '@/utils/route-listener';
|
||||
import useMenuTree from './use-menu-tree';
|
||||
|
||||
export default defineComponent({
|
||||
emit: ['collapse'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { menuTree } = useMenuTree();
|
||||
const collapsed = computed({
|
||||
get() {
|
||||
if (appStore.device === 'desktop') return appStore.menuCollapse;
|
||||
return false;
|
||||
},
|
||||
set(value: boolean) {
|
||||
appStore.updateSettings({ menuCollapse: value });
|
||||
},
|
||||
});
|
||||
|
||||
const topMenu = computed(() => appStore.topMenu);
|
||||
const openKeys = ref<string[]>([]);
|
||||
const selectedKey = ref<string[]>([]);
|
||||
|
||||
const goto = (item: RouteRecordRaw) => {
|
||||
// Open external link
|
||||
if (regexUrl.test(item.path)) {
|
||||
openWindow(item.path);
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Eliminate external link side effects
|
||||
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
|
||||
if (route.name === item.name && !hideInMenu && !activeMenu) {
|
||||
selectedKey.value = [item.name as string];
|
||||
return;
|
||||
}
|
||||
// Trigger router change
|
||||
router.push({
|
||||
name: item.name,
|
||||
});
|
||||
};
|
||||
const findMenuOpenKeys = (target: string) => {
|
||||
const result: string[] = [];
|
||||
let isFind = false;
|
||||
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
|
||||
if (item.name === target) {
|
||||
isFind = true;
|
||||
result.push(...keys);
|
||||
return;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
item.children.forEach((el) => {
|
||||
backtrack(el, [...keys, el.name as string]);
|
||||
});
|
||||
}
|
||||
};
|
||||
menuTree.value.forEach((el: RouteRecordRaw) => {
|
||||
if (isFind) return; // Performance optimization
|
||||
backtrack(el, [el.name as string]);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
listenerRouteChange((newRoute) => {
|
||||
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
|
||||
if (requiresAuth && (!hideInMenu || activeMenu)) {
|
||||
const menuOpenKeys = findMenuOpenKeys(
|
||||
(activeMenu || newRoute.name) as string,
|
||||
);
|
||||
|
||||
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
|
||||
openKeys.value = [...keySet];
|
||||
|
||||
selectedKey.value = [
|
||||
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
|
||||
];
|
||||
}
|
||||
}, true);
|
||||
const setCollapse = (val: boolean) => {
|
||||
if (appStore.device === 'desktop')
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
|
||||
const renderSubMenu = () => {
|
||||
function travel(_route: RouteRecordRaw[], nodes = []) {
|
||||
if (_route) {
|
||||
_route.forEach((element) => {
|
||||
// This is demo, modify nodes as needed
|
||||
const icon = element?.meta?.icon
|
||||
? () => h(compile(`<${element?.meta?.icon}/>`))
|
||||
: null;
|
||||
const node =
|
||||
element?.children && element?.children.length !== 0 ? (
|
||||
<a-sub-menu
|
||||
key={element?.name}
|
||||
v-slots={{
|
||||
icon,
|
||||
title: () => h(compile(t(element?.meta?.locale || ''))),
|
||||
}}
|
||||
>
|
||||
{travel(element?.children)}
|
||||
</a-sub-menu>
|
||||
) : (
|
||||
<a-menu-item
|
||||
key={element?.name}
|
||||
v-slots={{ icon }}
|
||||
onClick={() => goto(element)}
|
||||
>
|
||||
{t(element?.meta?.locale || '')}
|
||||
</a-menu-item>
|
||||
);
|
||||
nodes.push(node as never);
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
return travel(menuTree.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<a-menu
|
||||
mode={topMenu.value ? 'horizontal' : 'vertical'}
|
||||
v-model:collapsed={collapsed.value}
|
||||
v-model:open-keys={openKeys.value}
|
||||
show-collapse-button={appStore.device !== 'mobile'}
|
||||
auto-open={false}
|
||||
selected-keys={selectedKey.value}
|
||||
auto-open-selected={true}
|
||||
level-indent={34}
|
||||
style="height: 100%;width:100%;"
|
||||
onCollapse={setCollapse}
|
||||
>
|
||||
{renderSubMenu()}
|
||||
</a-menu>
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.arco-menu-inner) {
|
||||
.arco-menu-inline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.arco-icon {
|
||||
&:not(.arco-icon-down) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { computed } from 'vue';
|
||||
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import appClientMenus from '@/router/app-menus';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
export default function useMenuTree() {
|
||||
const permission = usePermission();
|
||||
const appStore = useAppStore();
|
||||
const appRoute = computed(() => {
|
||||
if (appStore.menuFromServer) {
|
||||
return appStore.appAsyncMenus;
|
||||
}
|
||||
return appClientMenus;
|
||||
});
|
||||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
|
||||
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
|
||||
return (a.meta.order || 0) - (b.meta.order || 0);
|
||||
});
|
||||
function travel(_routes: RouteRecordRaw[], layer: number) {
|
||||
if (!_routes) return null;
|
||||
|
||||
const collector: any = _routes.map((element) => {
|
||||
// no access
|
||||
if (!permission.accessRouter(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// leaf node
|
||||
if (element.meta?.hideChildrenInMenu || !element.children) {
|
||||
element.children = [];
|
||||
return element;
|
||||
}
|
||||
|
||||
// route filter hideInMenu true
|
||||
element.children = element.children.filter(
|
||||
(x) => x.meta?.hideInMenu !== true,
|
||||
);
|
||||
|
||||
// Associated child node
|
||||
const subItem = travel(element.children, layer + 1);
|
||||
|
||||
if (subItem.length) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
// the else logic
|
||||
if (layer > 1) {
|
||||
element.children = subItem;
|
||||
return element;
|
||||
}
|
||||
|
||||
if (element.meta?.hideInMenu === false) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
return collector.filter(Boolean);
|
||||
}
|
||||
return travel(copyRouter, 0);
|
||||
});
|
||||
|
||||
return {
|
||||
menuTree,
|
||||
};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<a-spin style="display: block" :loading="loading">
|
||||
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
|
||||
<a-tab-pane v-for="item in tabList" :key="item.key">
|
||||
<template #title>
|
||||
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
|
||||
</template>
|
||||
<a-result v-if="!renderList.length" status="404">
|
||||
<template #subtitle> {{ $t('messageBox.noContent') }} </template>
|
||||
</a-result>
|
||||
<List
|
||||
:render-list="renderList"
|
||||
:unread-count="unreadCount"
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<template #extra>
|
||||
<a-button type="text" @click="emptyList">
|
||||
{{ $t('messageBox.tab.button') }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, toRefs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
type MessageListType,
|
||||
type MessageRecord,
|
||||
queryMessageList,
|
||||
setMessageStatus,
|
||||
} from '@/api/message';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import List from './list.vue';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
avatar?: string;
|
||||
}
|
||||
const { loading, setLoading } = useLoading(true);
|
||||
const messageType = ref('message');
|
||||
const { t } = useI18n();
|
||||
const messageData = reactive<{
|
||||
renderList: MessageRecord[];
|
||||
messageList: MessageRecord[];
|
||||
}>({
|
||||
renderList: [],
|
||||
messageList: [],
|
||||
});
|
||||
toRefs(messageData);
|
||||
const tabList: TabItem[] = [
|
||||
{
|
||||
key: 'message',
|
||||
title: t('messageBox.tab.title.message'),
|
||||
},
|
||||
{
|
||||
key: 'notice',
|
||||
title: t('messageBox.tab.title.notice'),
|
||||
},
|
||||
{
|
||||
key: 'todo',
|
||||
title: t('messageBox.tab.title.todo'),
|
||||
},
|
||||
];
|
||||
async function fetchSourceData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await queryMessageList();
|
||||
messageData.messageList = data;
|
||||
} catch (err) {
|
||||
// you can report use errorHandler or other
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function readMessage(data: MessageListType) {
|
||||
const ids = data.map((item) => item.id);
|
||||
await setMessageStatus({ ids });
|
||||
fetchSourceData();
|
||||
}
|
||||
const renderList = computed(() => {
|
||||
return messageData.messageList.filter(
|
||||
(item) => messageType.value === item.type,
|
||||
);
|
||||
});
|
||||
const unreadCount = computed(() => {
|
||||
return renderList.value.filter((item) => !item.status).length;
|
||||
});
|
||||
const getUnreadList = (type: string) => {
|
||||
const list = messageData.messageList.filter(
|
||||
(item) => item.type === type && !item.status,
|
||||
);
|
||||
return list;
|
||||
};
|
||||
const formatUnreadLength = (type: string) => {
|
||||
const list = getUnreadList(type);
|
||||
return list.length ? `(${list.length})` : ``;
|
||||
};
|
||||
const handleItemClick = (items: MessageListType) => {
|
||||
if (renderList.value.length) readMessage([...items]);
|
||||
};
|
||||
const emptyList = () => {
|
||||
messageData.messageList = [];
|
||||
};
|
||||
fetchSourceData();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-popover-popup-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-list-item-meta) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
:deep(.arco-tabs-nav) {
|
||||
padding: 14px 0 12px 16px;
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
:deep(.arco-tabs-content) {
|
||||
padding-top: 0;
|
||||
.arco-result-subtitle {
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item
|
||||
v-for="item in renderList"
|
||||
:key="item.id"
|
||||
action-layout="vertical"
|
||||
:style="{
|
||||
opacity: item.status ? 0.5 : 1,
|
||||
}"
|
||||
>
|
||||
<template #extra>
|
||||
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
|
||||
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
|
||||
</template>
|
||||
<div class="item-wrap" @click="onItemClick(item)">
|
||||
<a-list-item-meta>
|
||||
<template v-if="item.avatar" #avatar>
|
||||
<a-avatar shape="circle">
|
||||
<img v-if="item.avatar" :src="item.avatar" />
|
||||
<icon-desktop v-else />
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-space :size="4">
|
||||
<span>{{ item.title }}</span>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.subTitle }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<div>
|
||||
<a-typography-paragraph
|
||||
:ellipsis="{
|
||||
rows: 1,
|
||||
}"
|
||||
>{{ item.content }}</a-typography-paragraph
|
||||
>
|
||||
<a-typography-text
|
||||
v-if="item.type === 'message'"
|
||||
class="time-text"
|
||||
>
|
||||
{{ item.time }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</div>
|
||||
</a-list-item>
|
||||
<template #footer>
|
||||
<a-space
|
||||
fill
|
||||
:size="0"
|
||||
:class="{ 'add-border-top': renderList.length < showMax }"
|
||||
>
|
||||
<div class="footer-wrap">
|
||||
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
|
||||
</div>
|
||||
<div class="footer-wrap">
|
||||
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<div
|
||||
v-if="renderList.length && renderList.length < 3"
|
||||
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
|
||||
></div>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import type { MessageListType, MessageRecord } from '@/api/message';
|
||||
|
||||
const props = defineProps({
|
||||
renderList: {
|
||||
type: Array as PropType<MessageListType>,
|
||||
required: true,
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['itemClick']);
|
||||
const allRead = () => {
|
||||
emit('itemClick', [...props.renderList]);
|
||||
};
|
||||
|
||||
const onItemClick = (item: MessageRecord) => {
|
||||
if (!item.status) {
|
||||
emit('itemClick', [item]);
|
||||
}
|
||||
};
|
||||
const showMax = 3;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
:deep(.arco-list) {
|
||||
.arco-list-item {
|
||||
min-height: 86px;
|
||||
border-bottom: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
.arco-list-item-extra {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
.arco-list-item-meta-content {
|
||||
flex: 1;
|
||||
}
|
||||
.item-wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--gray-6));
|
||||
}
|
||||
.arco-empty {
|
||||
display: none;
|
||||
}
|
||||
.arco-list-footer {
|
||||
padding: 0;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
border-top: none;
|
||||
.arco-space-item {
|
||||
width: 100%;
|
||||
border-right: 1px solid rgb(var(--gray-3));
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
.add-border-top {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
.footer-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
.arco-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.add-border {
|
||||
border-top: 1px solid rgb(var(--gray-3));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +0,0 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': 'Message',
|
||||
'messageBox.tab.title.notice': 'Notice',
|
||||
'messageBox.tab.title.todo': 'Todo',
|
||||
'messageBox.tab.button': 'empty',
|
||||
'messageBox.allRead': 'All Read',
|
||||
'messageBox.viewMore': 'View More',
|
||||
'messageBox.noContent': 'No Content',
|
||||
'messageBox.switchRoles': 'Switch Roles',
|
||||
'messageBox.userCenter': 'User Center',
|
||||
'messageBox.userSettings': 'User Settings',
|
||||
'messageBox.logout': 'Logout',
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
export default {
|
||||
'messageBox.tab.title.message': '消息',
|
||||
'messageBox.tab.title.notice': '通知',
|
||||
'messageBox.tab.title.todo': '待办',
|
||||
'messageBox.tab.button': '清空',
|
||||
'messageBox.allRead': '全部已读',
|
||||
'messageBox.viewMore': '查看更多',
|
||||
'messageBox.noContent': '暂无内容',
|
||||
'messageBox.switchRoles': '切换角色',
|
||||
'messageBox.userCenter': '用户中心',
|
||||
'messageBox.userSettings': '用户设置',
|
||||
'messageBox.logout': '登出登录',
|
||||
};
|
||||
@@ -1,368 +0,0 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<div class="left-side">
|
||||
<a-space>
|
||||
<img
|
||||
alt="logo"
|
||||
src="//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image"
|
||||
/>
|
||||
<a-typography-title
|
||||
:style="{ margin: 0, fontSize: '18px' }"
|
||||
:heading="5"
|
||||
>
|
||||
Arco Pro
|
||||
</a-typography-title>
|
||||
<icon-menu-fold
|
||||
v-if="!topMenu && appStore.device === 'mobile'"
|
||||
style="font-size: 22px; cursor: pointer"
|
||||
@click="toggleDrawerMenu"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="center-side">
|
||||
<Menu v-if="topMenu" class="center-menu" />
|
||||
<div class="navbar-search">
|
||||
<a-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="$t('settings.searchPlaceholder')"
|
||||
allow-clear
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<a-button
|
||||
type="primary"
|
||||
class="navbar-search-btn"
|
||||
@click="handleSearch"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
</template>
|
||||
{{ $t('settings.search') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="right-side">
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.language')">
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setDropDownVisible"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-language />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-dropdown trigger="click" @select="changeLocale as any">
|
||||
<div ref="triggerBtn" class="trigger-btn"></div>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="item in locales"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-check v-show="item.value === currentLocale" />
|
||||
</template>
|
||||
{{ item.label }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
theme === 'light'
|
||||
? $t('settings.navbar.theme.toDark')
|
||||
: $t('settings.navbar.theme.toLight')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="handleToggleTheme"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-moon-fill v-if="theme === 'dark'" />
|
||||
<icon-sun-fill v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip :content="$t('settings.navbar.alerts')">
|
||||
<div class="message-box-trigger">
|
||||
<a-badge :count="9" dot>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="setPopoverVisible"
|
||||
>
|
||||
<icon-notification />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-popover
|
||||
trigger="click"
|
||||
:arrow-style="{ display: 'none' }"
|
||||
:content-style="{ padding: 0, minWidth: '400px' }"
|
||||
content-class="message-popover"
|
||||
>
|
||||
<div ref="refBtn" class="ref-btn"></div>
|
||||
<template #content>
|
||||
<message-box />
|
||||
</template>
|
||||
</a-popover>
|
||||
</li>
|
||||
<li>
|
||||
<a-tooltip
|
||||
:content="
|
||||
isFullscreen
|
||||
? $t('settings.navbar.screen.toExit')
|
||||
: $t('settings.navbar.screen.toFull')
|
||||
"
|
||||
>
|
||||
<a-button
|
||||
class="nav-btn"
|
||||
type="outline"
|
||||
:shape="'circle'"
|
||||
@click="toggleFullScreen"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-fullscreen-exit v-if="isFullscreen" />
|
||||
<icon-fullscreen v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</li>
|
||||
<li>
|
||||
<a-dropdown trigger="click">
|
||||
<a-avatar
|
||||
:size="32"
|
||||
:style="{ marginRight: '8px', cursor: 'pointer' }"
|
||||
>
|
||||
<img alt="avatar" :src="avatar" />
|
||||
</a-avatar>
|
||||
<template #content>
|
||||
<a-doption>
|
||||
<a-space @click="switchRoles">
|
||||
<icon-tag />
|
||||
<span>
|
||||
{{ $t('messageBox.switchRoles') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="$router.push({ name: 'Info' })">
|
||||
<icon-user />
|
||||
<span>
|
||||
{{ $t('messageBox.userCenter') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="$router.push({ name: 'Setting' })">
|
||||
<icon-settings />
|
||||
<span>
|
||||
{{ $t('messageBox.userSettings') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
<a-doption>
|
||||
<a-space @click="handleLogout">
|
||||
<icon-export />
|
||||
<span>
|
||||
{{ $t('messageBox.logout') }}
|
||||
</span>
|
||||
</a-space>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useDark, useFullscreen, useToggle } from '@vueuse/core';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import { resolveAvatarUrl } from '@/constants/avatar';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import useUser from '@/hooks/user';
|
||||
import { LOCALE_OPTIONS } from '@/locale';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import MessageBox from '../message-box/index.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const { logout } = useUser();
|
||||
const { changeLocale, currentLocale } = useLocale();
|
||||
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
|
||||
const locales = [...LOCALE_OPTIONS];
|
||||
const searchKeyword = ref('');
|
||||
const avatar = computed(() => resolveAvatarUrl(userStore.avatar));
|
||||
const theme = computed(() => {
|
||||
return appStore.theme;
|
||||
});
|
||||
const topMenu = computed(() => appStore.topMenu && appStore.menu);
|
||||
const isDark = useDark({
|
||||
selector: 'body',
|
||||
attribute: 'arco-theme',
|
||||
valueDark: 'dark',
|
||||
valueLight: 'light',
|
||||
storageKey: 'arco-theme',
|
||||
onChanged(dark: boolean) {
|
||||
// overridden default behavior
|
||||
appStore.toggleTheme(dark);
|
||||
},
|
||||
});
|
||||
const toggleTheme = useToggle(isDark);
|
||||
const handleToggleTheme = () => {
|
||||
toggleTheme();
|
||||
};
|
||||
const handleSearch = () => {
|
||||
const keyword = searchKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
return;
|
||||
}
|
||||
Message.info(`${keyword}`);
|
||||
};
|
||||
const refBtn = ref();
|
||||
const triggerBtn = ref();
|
||||
const setPopoverVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
refBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
const setDropDownVisible = () => {
|
||||
const event = new MouseEvent('click', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
triggerBtn.value.dispatchEvent(event);
|
||||
};
|
||||
const switchRoles = async () => {
|
||||
const res = await userStore.switchRoles();
|
||||
Message.success(res as string);
|
||||
};
|
||||
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void;
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.left-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.center-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.center-menu {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 4px 4px 4px 16px;
|
||||
background-color: var(--color-fill-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--primary-6));
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.12);
|
||||
}
|
||||
|
||||
:deep(.arco-input-outer) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.arco-input-wrapper) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.navbar-search-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-side {
|
||||
display: flex;
|
||||
padding-right: 20px;
|
||||
list-style: none;
|
||||
:deep(.locale-select) {
|
||||
border-radius: 20px;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-btn {
|
||||
border-color: rgb(var(--gray-2));
|
||||
color: rgb(var(--gray-8));
|
||||
font-size: 16px;
|
||||
}
|
||||
.trigger-btn,
|
||||
.ref-btn {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
}
|
||||
.trigger-btn {
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.message-popover {
|
||||
.arco-popover-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<div class="tab-bar-container">
|
||||
<a-affix ref="affixRef" :offset-top="offsetTop">
|
||||
<div class="tab-bar-box">
|
||||
<div class="tab-bar-scroll">
|
||||
<div class="tags-wrap">
|
||||
<tab-item
|
||||
v-for="(tag, index) in tagList"
|
||||
:key="tag.fullPath"
|
||||
:index="index"
|
||||
:item-data="tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-bar-operation"></div>
|
||||
</div>
|
||||
</a-affix>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useAppStore, useTabBarStore } from '@/store';
|
||||
import {
|
||||
listenerRouteChange,
|
||||
removeRouteListener,
|
||||
} from '@/utils/route-listener';
|
||||
import tabItem from './tab-item.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const affixRef = ref();
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
const offsetTop = computed(() => {
|
||||
return appStore.navbar ? 60 : 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => appStore.navbar,
|
||||
() => {
|
||||
affixRef.value.updatePosition();
|
||||
},
|
||||
);
|
||||
listenerRouteChange((route: RouteLocationNormalized) => {
|
||||
if (
|
||||
!route.meta.noAffix &&
|
||||
!tagList.value.some((tag) => tag.fullPath === route.fullPath)
|
||||
) {
|
||||
tabBarStore.updateTabList(route);
|
||||
}
|
||||
}, true);
|
||||
|
||||
onUnmounted(() => {
|
||||
removeRouteListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tab-bar-container {
|
||||
position: relative;
|
||||
background-color: var(--color-bg-2);
|
||||
.tab-bar-box {
|
||||
display: flex;
|
||||
padding: 0 0 0 20px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.tab-bar-scroll {
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.tags-wrap {
|
||||
padding: 4px 0;
|
||||
height: 48px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.arco-tag) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
&:first-child {
|
||||
.arco-tag-close-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-bar-operation {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +0,0 @@
|
||||
## 组件说明
|
||||
|
||||
该组件非官方最终设计规范,以单独组件存在。
|
||||
|
||||
同时仅仅提供最基本的功能,后续进行优化及更改。
|
||||
|
||||
|
||||
## Component description
|
||||
|
||||
The component unofficial final design specification exists as a separate component.
|
||||
|
||||
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.
|
||||
@@ -1,199 +0,0 @@
|
||||
<template>
|
||||
<a-dropdown
|
||||
trigger="contextMenu"
|
||||
:popup-max-height="false"
|
||||
@select="actionSelect"
|
||||
>
|
||||
<span
|
||||
class="arco-tag arco-tag-size-medium arco-tag-checked"
|
||||
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }"
|
||||
@click="goto(itemData)"
|
||||
>
|
||||
<span class="tag-link">
|
||||
{{ $t(itemData.title) }}
|
||||
</span>
|
||||
<span
|
||||
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
|
||||
@click.stop="tagClose(itemData, index)"
|
||||
>
|
||||
<icon-close />
|
||||
</span>
|
||||
</span>
|
||||
<template #content>
|
||||
<a-doption :disabled="disabledReload" :value="TabAction.reload">
|
||||
<icon-refresh />
|
||||
<span>刷新</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledCurrent"
|
||||
:value="TabAction.current"
|
||||
>
|
||||
<icon-close />
|
||||
<span>关闭</span>
|
||||
</a-doption>
|
||||
<a-doption :disabled="disabledLeft" :value="TabAction.left">
|
||||
<icon-to-left />
|
||||
<span>向左靠</span>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
class="sperate-line"
|
||||
:disabled="disabledRight"
|
||||
:value="TabAction.right"
|
||||
>
|
||||
<icon-to-right />
|
||||
<span>向右靠</span>
|
||||
</a-doption>
|
||||
<a-doption :value="TabAction.others">
|
||||
<icon-swap />
|
||||
<span>其它</span>
|
||||
</a-doption>
|
||||
<a-doption :value="TabAction.all">
|
||||
<icon-folder-delete />
|
||||
<span>删除</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, type PropType } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
import { useTabBarStore } from '@/store';
|
||||
import type { TagProps } from '@/store/modules/tab-bar/types';
|
||||
|
||||
enum TabAction {
|
||||
reload = 'reload',
|
||||
current = 'current',
|
||||
left = 'left',
|
||||
right = 'right',
|
||||
others = 'others',
|
||||
all = 'all',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
itemData: {
|
||||
type: Object as PropType<TagProps>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const goto = (tag: TagProps) => {
|
||||
router.push({ ...tag });
|
||||
};
|
||||
const tagList = computed(() => {
|
||||
return tabBarStore.getTabList;
|
||||
});
|
||||
|
||||
const disabledReload = computed(() => {
|
||||
return props.itemData.fullPath !== route.fullPath;
|
||||
});
|
||||
|
||||
const disabledCurrent = computed(() => {
|
||||
return props.index === 0;
|
||||
});
|
||||
|
||||
const disabledLeft = computed(() => {
|
||||
return [0, 1].includes(props.index);
|
||||
});
|
||||
|
||||
const disabledRight = computed(() => {
|
||||
return props.index === tagList.value.length - 1;
|
||||
});
|
||||
|
||||
const tagClose = (tag: TagProps, idx: number) => {
|
||||
tabBarStore.deleteTag(idx, tag);
|
||||
if (props.itemData.fullPath === route.fullPath) {
|
||||
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
|
||||
router.push({ name: latest.name });
|
||||
}
|
||||
};
|
||||
|
||||
const findCurrentRouteIndex = () => {
|
||||
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
|
||||
};
|
||||
const actionSelect = async (value: any) => {
|
||||
const { itemData, index } = props;
|
||||
const copyTagList = [...tagList.value];
|
||||
if (value === TabAction.current) {
|
||||
tagClose(itemData, index);
|
||||
} else if (value === TabAction.left) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(1, props.index - 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx < index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === TabAction.right) {
|
||||
const currentRouteIdx = findCurrentRouteIndex();
|
||||
copyTagList.splice(props.index + 1);
|
||||
|
||||
tabBarStore.freshTabList(copyTagList);
|
||||
if (currentRouteIdx > index) {
|
||||
router.push({ name: itemData.name });
|
||||
}
|
||||
} else if (value === TabAction.others) {
|
||||
const filterList = tagList.value.filter((el, idx) => {
|
||||
return idx === 0 || idx === props.index;
|
||||
});
|
||||
tabBarStore.freshTabList(filterList);
|
||||
router.push({ name: itemData.name });
|
||||
} else if (value === TabAction.reload) {
|
||||
tabBarStore.deleteCache(itemData);
|
||||
await router.push({
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
params: {
|
||||
path: route.fullPath,
|
||||
},
|
||||
});
|
||||
tabBarStore.addCache(itemData.name);
|
||||
} else {
|
||||
tabBarStore.resetTabList();
|
||||
router.push({ name: DEFAULT_ROUTE_NAME });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tag-link {
|
||||
color: var(--color-text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.link-activated {
|
||||
color: rgb(var(--link-6));
|
||||
.tag-link {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
& + .arco-tag-close-btn {
|
||||
color: rgb(var(--link-6));
|
||||
}
|
||||
}
|
||||
:deep(.arco-dropdown-option-content) {
|
||||
span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
.arco-dropdown-open {
|
||||
.tag-link {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
.arco-tag-close-btn {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
}
|
||||
.sperate-line {
|
||||
border-bottom: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"theme": "light",
|
||||
"colorWeak": false,
|
||||
"navbar": true,
|
||||
"menu": true,
|
||||
"topMenu": false,
|
||||
"hideMenu": false,
|
||||
"menuCollapse": false,
|
||||
"footer": true,
|
||||
"themeColor": "#165DFF",
|
||||
"menuWidth": 220,
|
||||
"device": "desktop",
|
||||
"tabBar": false,
|
||||
"menuFromServer": false,
|
||||
"serverMenu": []
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/** Local default avatar: linear neutral user icon. */
|
||||
export const DEFAULT_USER_AVATAR = '/avatar-default.svg';
|
||||
|
||||
const BROKEN_AVATAR_PATTERN = /pstatp\.com|vcloud\/vadmin/;
|
||||
|
||||
export function resolveAvatarUrl(url?: string) {
|
||||
if (!url || BROKEN_AVATAR_PATTERN.test(url)) {
|
||||
return DEFAULT_USER_AVATAR;
|
||||
}
|
||||
if (url.startsWith('//')) {
|
||||
return `https:${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { App } from 'vue';
|
||||
import permission from './permission';
|
||||
|
||||
export default {
|
||||
install(Vue: App) {
|
||||
Vue.directive('permission', permission);
|
||||
},
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { DirectiveBinding } from 'vue';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
|
||||
const { value } = binding;
|
||||
const userStore = useUserStore();
|
||||
const { role } = userStore;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
const permissionValues = value;
|
||||
|
||||
const hasPermission = permissionValues.includes(role);
|
||||
if (!hasPermission && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`need roles! Like v-permission="['admin','user']"`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
// for code hints
|
||||
// import { SeriesOption } from 'echarts';
|
||||
// Because there are so many configuration items, this provides a relatively convenient code hint.
|
||||
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
|
||||
type optionsFn = (isDark: boolean) => EChartsOption;
|
||||
|
||||
export default function useChartOption(sourceOption: optionsFn) {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
// echarts support https://echarts.apache.org/zh/theme-builder.html
|
||||
// It's not used here
|
||||
// TODO echarts themes
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
return sourceOption(isDark.value);
|
||||
});
|
||||
return {
|
||||
chartOption,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export default function useLoading(initValue = false) {
|
||||
const loading = ref(initValue);
|
||||
const setLoading = (value: boolean) => {
|
||||
loading.value = value;
|
||||
};
|
||||
const toggle = () => {
|
||||
loading.value = !loading.value;
|
||||
};
|
||||
return {
|
||||
loading,
|
||||
setLoading,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default function useLocale() {
|
||||
const i18 = useI18n();
|
||||
const currentLocale = computed(() => {
|
||||
return i18.locale.value;
|
||||
});
|
||||
const changeLocale = (value: string) => {
|
||||
if (i18.locale.value === value) {
|
||||
return;
|
||||
}
|
||||
i18.locale.value = value;
|
||||
localStorage.setItem('arco-locale', value);
|
||||
Message.success(i18.t('navbar.action.locale'));
|
||||
};
|
||||
return {
|
||||
currentLocale,
|
||||
changeLocale,
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
return {
|
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
|
||||
return (
|
||||
!route.meta?.requiresAuth ||
|
||||
!route.meta?.roles ||
|
||||
route.meta?.roles?.includes('*') ||
|
||||
route.meta?.roles?.includes(userStore.role)
|
||||
);
|
||||
},
|
||||
findFirstPermissionRoute(_routers: any, role = 'admin') {
|
||||
const cloneRouters = [..._routers];
|
||||
while (cloneRouters.length) {
|
||||
const firstElement = cloneRouters.shift();
|
||||
if (
|
||||
firstElement?.meta?.roles?.find((el: string[]) => {
|
||||
return el.includes('*') || el.includes(role);
|
||||
})
|
||||
)
|
||||
return { name: firstElement.name };
|
||||
if (firstElement?.children) {
|
||||
cloneRouters.push(...firstElement.children);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// You can add any rules you want
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import { ref, type UnwrapRef } from 'vue';
|
||||
import type { HttpResponse } from '@/plugins/http';
|
||||
import useLoading from './loading';
|
||||
|
||||
// use to fetch list
|
||||
// Don't use async function. It doesn't work in async function.
|
||||
// Use the bind function to add parameters
|
||||
// example: useRequest(api.bind(null, {}))
|
||||
|
||||
export default function useRequest<T>(
|
||||
api: () => Promise<AxiosResponse<HttpResponse>>,
|
||||
defaultValue = [] as unknown as T,
|
||||
isLoading = true,
|
||||
) {
|
||||
const { loading, setLoading } = useLoading(isLoading);
|
||||
const response = ref<T>(defaultValue);
|
||||
api()
|
||||
.then((res) => {
|
||||
response.value = res.data as unknown as UnwrapRef<T>;
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
return { loading, response };
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
import { addEventListen, removeEventListen } from '@/utils/event';
|
||||
|
||||
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
|
||||
|
||||
function queryDevice() {
|
||||
const rect = document.body.getBoundingClientRect();
|
||||
return rect.width - 1 < WIDTH;
|
||||
}
|
||||
|
||||
export default function useResponsive(immediate?: boolean) {
|
||||
const appStore = useAppStore();
|
||||
function resizeHandler() {
|
||||
if (!document.hidden) {
|
||||
const isMobile = queryDevice();
|
||||
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
|
||||
appStore.toggleMenu(isMobile);
|
||||
}
|
||||
}
|
||||
const debounceFn = useDebounceFn(resizeHandler, 100);
|
||||
onMounted(() => {
|
||||
if (immediate) debounceFn();
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
addEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListen(window, 'resize', debounceFn);
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/store';
|
||||
|
||||
export default function useThemes() {
|
||||
const appStore = useAppStore();
|
||||
const isDark = computed(() => {
|
||||
return appStore.theme === 'dark';
|
||||
});
|
||||
return {
|
||||
isDark,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
export default function useUser() {
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const logout = async (logoutTo?: string) => {
|
||||
await userStore.logout();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
Message.success('登出成功');
|
||||
router.push({
|
||||
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
|
||||
query: {
|
||||
...router.currentRoute.value.query,
|
||||
redirect: currentRoute.name as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
return {
|
||||
logout,
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
<template>
|
||||
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
|
||||
<div v-if="navbar" class="layout-navbar">
|
||||
<NavBar />
|
||||
</div>
|
||||
<a-layout>
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
v-if="renderMenu"
|
||||
v-show="!hideMenu"
|
||||
class="layout-sider"
|
||||
breakpoint="xl"
|
||||
:collapsed="collapsed"
|
||||
:collapsible="true"
|
||||
:width="menuWidth"
|
||||
:style="{ paddingTop: navbar ? '60px' : '' }"
|
||||
:hide-trigger="true"
|
||||
@collapse="setCollapsed"
|
||||
>
|
||||
<div class="menu-wrapper">
|
||||
<Menu />
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
<a-drawer
|
||||
v-if="hideMenu"
|
||||
:visible="drawerVisible"
|
||||
placement="left"
|
||||
:footer="false"
|
||||
mask-closable
|
||||
:closable="false"
|
||||
@cancel="drawerCancel"
|
||||
>
|
||||
<Menu />
|
||||
</a-drawer>
|
||||
<a-layout class="layout-content" :style="paddingStyle">
|
||||
<TabBar v-if="appStore.tabBar" />
|
||||
<a-layout-content>
|
||||
<PageLayout />
|
||||
</a-layout-content>
|
||||
<Footer v-if="footer" />
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Footer from '@/components/footer/index.vue';
|
||||
import Menu from '@/components/menu/index.vue';
|
||||
import NavBar from '@/components/navbar/index.vue';
|
||||
import TabBar from '@/components/tab-bar/index.vue';
|
||||
import usePermission from '@/hooks/permission';
|
||||
import useResponsive from '@/hooks/responsive';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import PageLayout from './page-layout.vue';
|
||||
|
||||
const isInit = ref(false);
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const permission = usePermission();
|
||||
useResponsive(true);
|
||||
const navbarHeight = `60px`;
|
||||
const navbar = computed(() => appStore.navbar);
|
||||
const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
|
||||
const hideMenu = computed(() => appStore.hideMenu);
|
||||
const footer = computed(() => appStore.footer);
|
||||
const menuWidth = computed(() => {
|
||||
return appStore.menuCollapse ? 48 : appStore.menuWidth;
|
||||
});
|
||||
const collapsed = computed(() => {
|
||||
return appStore.menuCollapse;
|
||||
});
|
||||
const paddingStyle = computed(() => {
|
||||
const paddingLeft =
|
||||
renderMenu.value && !hideMenu.value
|
||||
? { paddingLeft: `${menuWidth.value}px` }
|
||||
: {};
|
||||
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
|
||||
return { ...paddingLeft, ...paddingTop };
|
||||
});
|
||||
const setCollapsed = (val: boolean) => {
|
||||
if (!isInit.value) return; // for page initialization menu state problem
|
||||
appStore.updateSettings({ menuCollapse: val });
|
||||
};
|
||||
watch(
|
||||
() => userStore.role,
|
||||
(roleValue) => {
|
||||
if (roleValue && !permission.accessRouter(route))
|
||||
router.push({ name: 'notFound' });
|
||||
},
|
||||
);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerCancel = () => {
|
||||
drawerVisible.value = false;
|
||||
};
|
||||
provide('toggleDrawerMenu', () => {
|
||||
drawerVisible.value = !drawerVisible.value;
|
||||
});
|
||||
onMounted(() => {
|
||||
isInit.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@nav-size-height: 60px;
|
||||
@layout-max-width: 1100px;
|
||||
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: @nav-size-height;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 99;
|
||||
height: 100%;
|
||||
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--color-border);
|
||||
content: '';
|
||||
}
|
||||
|
||||
> :deep(.arco-layout-sider-children) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
:deep(.arco-menu) {
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 4px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 7px;
|
||||
background-color: var(--color-text-4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
min-height: 100vh;
|
||||
overflow-y: hidden;
|
||||
background-color: var(--color-fill-2);
|
||||
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade" mode="out-in" appear>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="route.meta.ignoreCache"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
<keep-alive v-else :include="cacheList">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useTabBarStore } from '@/store';
|
||||
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
const cacheList = computed(() => tabBarStore.getCacheList);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
@@ -1,30 +0,0 @@
|
||||
import { mergeLocaleModules } from './merge-locales';
|
||||
import localeSettings from './en-US/settings';
|
||||
|
||||
const componentLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/components/**/locale/en-US.ts', { eager: true }),
|
||||
);
|
||||
const viewLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/views/**/locale/en-US.ts', { eager: true }),
|
||||
);
|
||||
|
||||
export default {
|
||||
'menu.dashboard': 'Dashboard',
|
||||
'menu.server.dashboard': 'Dashboard-Server',
|
||||
'menu.server.workplace': 'Workplace-Server',
|
||||
'menu.server.monitor': 'Monitor-Server',
|
||||
'menu.list': 'List',
|
||||
'menu.result': 'Result',
|
||||
'menu.exception': 'Exception',
|
||||
'menu.form': 'Form',
|
||||
'menu.profile': 'Profile',
|
||||
'menu.visualization': 'Data Visualization',
|
||||
'menu.user': 'User Center',
|
||||
'menu.arcoWebsite': 'Arco Design',
|
||||
'menu.faq': 'FAQ',
|
||||
'navbar.docs': 'Docs',
|
||||
'navbar.action.locale': 'Switch to English',
|
||||
...localeSettings,
|
||||
...componentLocales,
|
||||
...viewLocales,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
'settings.search': 'Search',
|
||||
'settings.searchPlaceholder': 'Search menus, pages, features...',
|
||||
'settings.language': 'Language',
|
||||
'settings.navbar.theme.toLight': 'Click to use light mode',
|
||||
'settings.navbar.theme.toDark': 'Click to use dark mode',
|
||||
'settings.navbar.screen.toFull': 'Click to switch to full screen mode',
|
||||
'settings.navbar.screen.toExit': 'Click to exit the full screen mode',
|
||||
'settings.navbar.alerts': 'alerts',
|
||||
'http.error.default': 'Error',
|
||||
'http.error.request': 'Request Error',
|
||||
'http.logout.title': 'Confirm logout',
|
||||
'http.logout.content':
|
||||
'You have been logged out, you can cancel to stay on this page, or log in again',
|
||||
'http.logout.okText': 'Re-Login',
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './en-US';
|
||||
import cn from './zh-CN';
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
];
|
||||
const defaultLocale = localStorage.getItem('arco-locale') || 'zh-CN';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'en-US',
|
||||
legacy: false,
|
||||
messages: {
|
||||
'en-US': en,
|
||||
'zh-CN': cn,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,10 +0,0 @@
|
||||
type LocaleModule = { default: Record<string, string> };
|
||||
|
||||
export function mergeLocaleModules(
|
||||
modules: Record<string, LocaleModule>,
|
||||
): Record<string, string> {
|
||||
return Object.values(modules).reduce(
|
||||
(messages, module) => ({ ...messages, ...module.default }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { mergeLocaleModules } from './merge-locales';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
|
||||
const componentLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/components/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
const viewLocales = mergeLocaleModules(
|
||||
import.meta.glob('@/views/**/locale/zh-CN.ts', { eager: true }),
|
||||
);
|
||||
|
||||
export default {
|
||||
'menu.dashboard': '仪表盘',
|
||||
'menu.server.dashboard': '仪表盘-服务端',
|
||||
'menu.server.workplace': '工作台-服务端',
|
||||
'menu.server.monitor': '实时监控-服务端',
|
||||
'menu.list': '列表页',
|
||||
'menu.result': '结果页',
|
||||
'menu.exception': '异常页',
|
||||
'menu.form': '表单页',
|
||||
'menu.profile': '详情页',
|
||||
'menu.visualization': '数据可视化',
|
||||
'menu.user': '个人中心',
|
||||
'menu.arcoWebsite': 'Arco Design',
|
||||
'menu.faq': '常见问题',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
...componentLocales,
|
||||
...viewLocales,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
'settings.search': '搜索',
|
||||
'settings.searchPlaceholder': '搜索菜单、页面、功能...',
|
||||
'settings.language': '语言',
|
||||
'settings.navbar.theme.toLight': '点击切换为亮色模式',
|
||||
'settings.navbar.theme.toDark': '点击切换为暗黑模式',
|
||||
'settings.navbar.screen.toFull': '点击切换全屏模式',
|
||||
'settings.navbar.screen.toExit': '点击退出全屏模式',
|
||||
'settings.navbar.alerts': '消息通知',
|
||||
'http.error.default': '错误',
|
||||
'http.error.request': '请求错误',
|
||||
'http.logout.title': '确认登出',
|
||||
'http.logout.content':
|
||||
'您已被登出,您可以取消以停留在此页面,或重新登录',
|
||||
'http.logout.okText': '重新登录',
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import Mock from 'mockjs';
|
||||
import setupMock, { successResponseWrap } from '@/mocks/setup';
|
||||
|
||||
const haveReadIds: number[] = [];
|
||||
const getMessageList = () => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
type: 'message',
|
||||
title: '\u90d1\u6666\u6708',
|
||||
subTitle: '\u7684\u79c1\u4fe1',
|
||||
avatar:
|
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
|
||||
content: '\u5ba1\u6279\u8bf7\u6c42\u5df2\u53d1\u9001\uff0c\u8bf7\u67e5\u6536',
|
||||
time: '\u4eca\u5929 12:30:01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'message',
|
||||
title: '\u5b81\u6ce2',
|
||||
subTitle: '\u7684\u56de\u590d',
|
||||
avatar:
|
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
|
||||
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
|
||||
time: '\u4eca\u5929 12:30:01',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'message',
|
||||
title: '\u5b81\u6ce2',
|
||||
subTitle: '\u7684\u56de\u590d',
|
||||
avatar:
|
||||
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
|
||||
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'notice',
|
||||
title: '\u7eed\u8d39\u901a\u77e5',
|
||||
subTitle: '',
|
||||
avatar: '',
|
||||
content:
|
||||
'\u60a8\u7684\u4ea7\u54c1\u4f7f\u7528\u671f\u9650\u5373\u5c06\u622a\u6b62\uff0c\u5982\u9700\u7ee7\u7eed\u4f7f\u7528\u4ea7\u54c1\u8bf7\u524d\u5f80\u8d2d\u4e70',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
messageType: 3,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'notice',
|
||||
title: '\u89c4\u5219\u5f00\u901a\u6210\u529f',
|
||||
subTitle: '',
|
||||
avatar: '',
|
||||
content:
|
||||
'\u5185\u5bb9\u5c4f\u853d\u89c4\u5219\u4e8e 2021-12-01 \u5f00\u901a\u6210\u529f\u5e76\u751f\u6548',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
messageType: 1,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'todo',
|
||||
title: '\u8d28\u68c0\u961f\u5217\u53d8\u66f4',
|
||||
subTitle: '',
|
||||
avatar: '',
|
||||
content:
|
||||
'\u5185\u5bb9\u8d28\u68c0\u961f\u5217\u4e8e 2021-12-01 19:50:23 \u8fdb\u884c\u53d8\u66f4\uff0c\u8bf7\u91cd\u65b0\u63d0\u4ea4',
|
||||
time: '\u4eca\u5929 12:20:01',
|
||||
messageType: 0,
|
||||
},
|
||||
].map((item) => ({
|
||||
...item,
|
||||
status: haveReadIds.indexOf(item.id) === -1 ? 0 : 1,
|
||||
}));
|
||||
};
|
||||
|
||||
setupMock({
|
||||
setup: () => {
|
||||
Mock.mock(/\/api\/message\/list/, () => {
|
||||
return successResponseWrap(getMessageList());
|
||||
});
|
||||
|
||||
Mock.mock(/\/api\/message\/read/, (params: { body: string }) => {
|
||||
const { ids } = JSON.parse(params.body);
|
||||
haveReadIds.push(...(ids || []));
|
||||
return successResponseWrap(true);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import Mock from 'mockjs';
|
||||
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import setupMock, {
|
||||
failResponseWrap,
|
||||
successResponseWrap,
|
||||
} from '@/mocks/setup';
|
||||
|
||||
export interface MockParams {
|
||||
url: string;
|
||||
type: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
setupMock({
|
||||
setup() {
|
||||
Mock.mock(/\/api\/user\/info/, () => {
|
||||
if (isLogin()) {
|
||||
const role = window.localStorage.getItem('userRole') || 'admin';
|
||||
return successResponseWrap({
|
||||
name: 'admin',
|
||||
avatar: DEFAULT_USER_AVATAR,
|
||||
email: 'wangliqun@email.com',
|
||||
job: 'frontend',
|
||||
jobName: '\u524d\u7aef\u827a\u672f\u5bb6',
|
||||
organization: 'Frontend',
|
||||
organizationName: '\u524d\u7aef',
|
||||
location: 'beijing',
|
||||
locationName: '\u5317\u4eac',
|
||||
introduction: '\u4eba\u7206\u723d\uff0c\u6027\u6e29\u7eaf',
|
||||
personalWebsite: 'https://www.arco.design',
|
||||
phone: '150****0000',
|
||||
registrationDate: '2013-05-10 12:10:00',
|
||||
accountId: '15012312300',
|
||||
certification: 1,
|
||||
role,
|
||||
});
|
||||
}
|
||||
return failResponseWrap(null, '\u672a\u767b\u5f55', 50008);
|
||||
});
|
||||
|
||||
Mock.mock(/\/api\/user\/login/, (params: MockParams) => {
|
||||
const { username, password } = JSON.parse(params.body);
|
||||
if (!username) {
|
||||
return failResponseWrap(
|
||||
null,
|
||||
'\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a',
|
||||
50000,
|
||||
);
|
||||
}
|
||||
if (!password) {
|
||||
return failResponseWrap(null, '\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a', 50000);
|
||||
}
|
||||
if (username === 'admin' && password === 'admin') {
|
||||
window.localStorage.setItem('userRole', 'admin');
|
||||
return successResponseWrap({ token: '12345' });
|
||||
}
|
||||
if (username === 'user' && password === 'user') {
|
||||
window.localStorage.setItem('userRole', 'user');
|
||||
return successResponseWrap({ token: '54321' });
|
||||
}
|
||||
return failResponseWrap(
|
||||
null,
|
||||
'\u8d26\u53f7\u6216\u8005\u5bc6\u7801\u9519\u8bef',
|
||||
50000,
|
||||
);
|
||||
});
|
||||
|
||||
Mock.mock(/\/api\/user\/logout/, () => successResponseWrap(null));
|
||||
Mock.mock(/\/api\/user\/menu/, () => {
|
||||
const menuList = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
meta: {
|
||||
locale: 'menu.server.dashboard',
|
||||
requiresAuth: true,
|
||||
icon: 'icon-dashboard',
|
||||
order: 1,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'workplace',
|
||||
name: 'Workplace',
|
||||
meta: {
|
||||
locale: 'menu.server.workplace',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'https://arco.design',
|
||||
name: 'arcoWebsite',
|
||||
meta: {
|
||||
locale: 'menu.arcoWebsite',
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return successResponseWrap(menuList);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import Mock from 'mockjs';
|
||||
import './handlers/user';
|
||||
import './handlers/message-box';
|
||||
|
||||
import.meta.glob('@/views/**/mock.ts', { eager: true });
|
||||
|
||||
Mock.setup({
|
||||
timeout: '600-1000',
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import debug from '@/utils/env';
|
||||
|
||||
export default ({ mock, setup }: { mock?: boolean; setup: () => void }) => {
|
||||
if (mock !== false && debug) setup();
|
||||
};
|
||||
|
||||
export const successResponseWrap = (data: unknown) => {
|
||||
return {
|
||||
data,
|
||||
status: 'ok',
|
||||
msg: '请求成功',
|
||||
code: 20000,
|
||||
};
|
||||
};
|
||||
|
||||
export const failResponseWrap = (
|
||||
data: unknown,
|
||||
msg: string,
|
||||
code = 50000,
|
||||
) => {
|
||||
return {
|
||||
data,
|
||||
status: 'fail',
|
||||
msg,
|
||||
code,
|
||||
};
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import i18n from '@/locale';
|
||||
import { useUserStore } from '@/store';
|
||||
import { getToken } from '@/utils/auth';
|
||||
|
||||
export interface HttpResponse<T = unknown> {
|
||||
status: number;
|
||||
msg: string;
|
||||
code: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
function t(key: string) {
|
||||
return i18n.global.t(key);
|
||||
}
|
||||
|
||||
export function setupHttp() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL?.trim();
|
||||
if (baseURL) {
|
||||
axios.defaults.baseURL = baseURL;
|
||||
}
|
||||
|
||||
axios.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response: AxiosResponse<HttpResponse>) => {
|
||||
const res = response.data;
|
||||
if (res.code !== 20000) {
|
||||
Message.error({
|
||||
content: res.msg || t('http.error.default'),
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
if (
|
||||
[50008, 50012, 50014].includes(res.code) &&
|
||||
response.config.url !== '/api/user/info'
|
||||
) {
|
||||
Modal.error({
|
||||
title: t('http.logout.title'),
|
||||
content: t('http.logout.content'),
|
||||
okText: t('http.logout.okText'),
|
||||
async onOk() {
|
||||
const userStore = useUserStore();
|
||||
await userStore.logout();
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(res.msg || t('http.error.default')));
|
||||
}
|
||||
return res as unknown as AxiosResponse<HttpResponse>;
|
||||
},
|
||||
(error) => {
|
||||
Message.error({
|
||||
content: error.msg || t('http.error.request'),
|
||||
duration: 5 * 1000,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { appExternalRoutes, appRoutes } from '../routes';
|
||||
|
||||
const mixinRoutes = [...appRoutes, ...appExternalRoutes];
|
||||
|
||||
const appClientMenus = mixinRoutes.map((el) => {
|
||||
const { name, path, meta, redirect, children } = el;
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
meta,
|
||||
redirect,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
||||
export default appClientMenus;
|
||||
@@ -1,18 +0,0 @@
|
||||
export const WHITE_LIST = [
|
||||
{ name: 'notFound', children: [] },
|
||||
{ name: 'login', children: [] },
|
||||
];
|
||||
|
||||
export const NOT_FOUND = {
|
||||
name: 'notFound',
|
||||
};
|
||||
|
||||
export const REDIRECT_ROUTE_NAME = 'Redirect';
|
||||
|
||||
export const DEFAULT_ROUTE_NAME = 'Workplace';
|
||||
|
||||
export const DEFAULT_ROUTE = {
|
||||
title: 'menu.dashboard.workplace',
|
||||
name: DEFAULT_ROUTE_NAME,
|
||||
fullPath: '/dashboard/workplace',
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Router } from 'vue-router';
|
||||
import { setRouteEmitter } from '@/utils/route-listener';
|
||||
import setupPermissionGuard from './permission';
|
||||
import setupUserLoginInfoGuard from './userLoginInfo';
|
||||
|
||||
function setupPageGuard(router: Router) {
|
||||
router.beforeEach(async (to) => {
|
||||
// emit route change
|
||||
setRouteEmitter(to);
|
||||
});
|
||||
}
|
||||
|
||||
export default function createRouteGuard(router: Router) {
|
||||
setupPageGuard(router);
|
||||
setupUserLoginInfoGuard(router);
|
||||
setupPermissionGuard(router);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import type { RouteRecordNormalized, Router } from 'vue-router';
|
||||
|
||||
import usePermission from '@/hooks/permission';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
import { NOT_FOUND, WHITE_LIST } from '../constants';
|
||||
import { appRoutes } from '../routes';
|
||||
|
||||
export default function setupPermissionGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const Permission = usePermission();
|
||||
const permissionsAllow = Permission.accessRouter(to);
|
||||
if (appStore.menuFromServer) {
|
||||
// 针对来自服务端的菜单配置进行处理
|
||||
// Handle routing configuration from the server
|
||||
|
||||
// 根据需要自行完善来源于服务端的菜单配置的permission逻辑
|
||||
// Refine the permission logic from the server's menu configuration as needed
|
||||
if (
|
||||
!appStore.appAsyncMenus.length &&
|
||||
!WHITE_LIST.find((el) => el.name === to.name)
|
||||
) {
|
||||
await appStore.fetchServerMenuConfig();
|
||||
}
|
||||
const serverMenuConfig = [...appStore.appAsyncMenus, ...WHITE_LIST];
|
||||
|
||||
let exist = false;
|
||||
while (serverMenuConfig.length && !exist) {
|
||||
const element = serverMenuConfig.shift();
|
||||
if (element?.name === to.name) exist = true;
|
||||
|
||||
if (element?.children) {
|
||||
serverMenuConfig.push(
|
||||
...(element.children as unknown as RouteRecordNormalized[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (exist && permissionsAllow) {
|
||||
next();
|
||||
} else next(NOT_FOUND);
|
||||
} else if (permissionsAllow) {
|
||||
next();
|
||||
} else {
|
||||
const destination =
|
||||
Permission.findFirstPermissionRoute(appRoutes, userStore.role) ||
|
||||
NOT_FOUND;
|
||||
next(destination);
|
||||
}
|
||||
NProgress.done();
|
||||
});
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import type { LocationQueryRaw, Router } from 'vue-router';
|
||||
|
||||
import { useUserStore } from '@/store';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
|
||||
export default function setupUserLoginInfoGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start();
|
||||
const userStore = useUserStore();
|
||||
if (isLogin()) {
|
||||
if (userStore.role) {
|
||||
next();
|
||||
} else {
|
||||
try {
|
||||
await userStore.info();
|
||||
next();
|
||||
} catch (error) {
|
||||
await userStore.logout();
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (to.name === 'login') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next({
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.name,
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import 'nprogress/nprogress.css';
|
||||
|
||||
import createRouteGuard from './guard';
|
||||
import { appRoutes } from './routes';
|
||||
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: 'login',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
...appRoutes,
|
||||
REDIRECT_MAIN,
|
||||
NOT_FOUND_ROUTE,
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
createRouteGuard(router);
|
||||
|
||||
export default router;
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import { REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||
|
||||
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
|
||||
|
||||
export const REDIRECT_MAIN: RouteRecordRaw = {
|
||||
path: '/redirect',
|
||||
name: 'redirectWrapper',
|
||||
component: DEFAULT_LAYOUT,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/redirect/:path',
|
||||
name: REDIRECT_ROUTE_NAME,
|
||||
component: () => import('@/views/redirect/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'notFound',
|
||||
component: () => import('@/views/not-found/index.vue'),
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
path: 'https://arco.design',
|
||||
name: 'arcoWebsite',
|
||||
meta: {
|
||||
locale: 'menu.arcoWebsite',
|
||||
icon: 'icon-link',
|
||||
requiresAuth: true,
|
||||
order: 8,
|
||||
},
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
path: 'https://arco.design/vue/docs/pro/faq',
|
||||
name: 'faq',
|
||||
meta: {
|
||||
locale: 'menu.faq',
|
||||
icon: 'icon-question-circle',
|
||||
requiresAuth: true,
|
||||
order: 9,
|
||||
},
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
const modules = import.meta.glob('./modules/*.ts', { eager: true });
|
||||
const externalModules = import.meta.glob('./externalModules/*.ts', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function formatModules(_modules: any, result: RouteRecordNormalized[]) {
|
||||
Object.keys(_modules).forEach((key) => {
|
||||
const defaultModule = _modules[key].default;
|
||||
if (!defaultModule) return;
|
||||
const moduleList = Array.isArray(defaultModule)
|
||||
? [...defaultModule]
|
||||
: [defaultModule];
|
||||
result.push(...moduleList);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export const appRoutes: RouteRecordNormalized[] = formatModules(modules, []);
|
||||
|
||||
export const appExternalRoutes: RouteRecordNormalized[] = formatModules(
|
||||
externalModules,
|
||||
[],
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user