Compare commits
7
Commits
75cedbe97b
...
63d8889bee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d8889bee | ||
|
|
98a5343e53 | ||
|
|
1876c56008 | ||
|
|
073d210a92 | ||
|
|
23067edb8f | ||
|
|
09dbb27b66 | ||
|
|
b3a2a843bb |
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 灵犀 · 环境变量示例(入库,禁止写真实密钥)
|
||||||
|
# 使用:cp .env.example .env.local(工程规范 09 ENV-09)
|
||||||
|
# 注意:本地 compose 的宿主端口为 15432/16379/19000/19001,避免与本机既有 PG/Redis 冲突。
|
||||||
|
|
||||||
|
# ---------- 运行环境 ----------
|
||||||
|
NODE_ENV=development
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# ---------- 数据库(PostgreSQL 18) ----------
|
||||||
|
DATABASE_URL=postgresql://lingxi:lingxi_dev_password@127.0.0.1:15432/lingxi_dev?schema=public
|
||||||
|
|
||||||
|
# ---------- Redis(7.4) ----------
|
||||||
|
REDIS_URL=redis://127.0.0.1:16379
|
||||||
|
|
||||||
|
# ---------- 对象存储(MinIO,S3 协议) ----------
|
||||||
|
S3_ENDPOINT=http://127.0.0.1:19000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET_ASSETS=assets
|
||||||
|
S3_BUCKET_RENDERS=renders
|
||||||
|
S3_BUCKET_TEMP=temp
|
||||||
|
S3_ACCESS_KEY=lingxi
|
||||||
|
S3_SECRET_KEY=lingxi_dev_password
|
||||||
|
|
||||||
|
# ---------- 认证(本地 dev 值,生产由密钥管理注入) ----------
|
||||||
|
JWT_SECRET=dev-only-jwt-secret-change-me-32chars
|
||||||
|
JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32chars
|
||||||
|
|
||||||
|
# ---------- API ----------
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
WEB_ORIGIN=http://127.0.0.1:5173
|
||||||
|
MAX_UPLOAD_MB=500
|
||||||
|
|
||||||
|
# ---------- Worker ----------
|
||||||
|
QUEUE=default
|
||||||
|
WORKER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ----------
|
||||||
|
VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1
|
||||||
|
# A0 登录壳没有接后端鉴权,置 true 时登录页写入本地演示会话以便查看布局;A1 接入真实登录后删除。
|
||||||
|
VITE_PUBLIC_DEMO_SESSION=true
|
||||||
|
|
||||||
|
# ---------- 可观测 ----------
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
|
||||||
|
# ---------- 功能开关 ----------
|
||||||
|
FEATURE_VIDEO=false
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, 'feat/**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: 质量门禁
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: lingxi
|
||||||
|
POSTGRES_PASSWORD: lingxi_dev_password
|
||||||
|
POSTGRES_DB: lingxi_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U lingxi -d lingxi_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
LOG_LEVEL: error
|
||||||
|
DATABASE_URL: postgresql://lingxi:lingxi_dev_password@127.0.0.1:5432/lingxi_test?schema=public
|
||||||
|
REDIS_URL: redis://127.0.0.1:6379
|
||||||
|
JWT_SECRET: ci-only-access-secret
|
||||||
|
JWT_REFRESH_SECRET: ci-only-refresh-secret
|
||||||
|
WEB_ORIGIN: http://127.0.0.1:4173
|
||||||
|
VITE_PUBLIC_API_BASE_URL: http://127.0.0.1:3000/api/v1
|
||||||
|
VITE_PUBLIC_DEMO_SESSION: 'true'
|
||||||
|
PLAYWRIGHT_CHANNEL: ''
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 安装 pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.34.9
|
||||||
|
|
||||||
|
- name: 安装 Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24.15.0
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 格式检查
|
||||||
|
run: pnpm format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: 类型检查
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: 单元测试与覆盖率
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: 构建
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: 数据库迁移
|
||||||
|
run: pnpm exec prisma migrate deploy
|
||||||
|
|
||||||
|
- name: 集成测试
|
||||||
|
run: pnpm test:int
|
||||||
|
|
||||||
|
- name: 导出 OpenAPI 快照并检查破坏性变更
|
||||||
|
run: |
|
||||||
|
pnpm openapi:export
|
||||||
|
pnpm openapi:diff
|
||||||
|
|
||||||
|
- name: 安装 Playwright 浏览器
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: 端到端冒烟
|
||||||
|
run: pnpm test:e2e
|
||||||
|
|
||||||
|
- name: 归档测试产物
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-artifacts
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: |
|
||||||
|
coverage
|
||||||
|
apps/*/coverage
|
||||||
|
packages/*/coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
openapi/lingxi.openapi.json
|
||||||
@@ -62,7 +62,16 @@ temp/
|
|||||||
~$*
|
~$*
|
||||||
*.~lock.*
|
*.~lock.*
|
||||||
|
|
||||||
|
# ========== 测试产物 ==========
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
# ========== 项目自定义 ==========
|
# ========== 项目自定义 ==========
|
||||||
# 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库
|
# 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库
|
||||||
参考项目/
|
参考项目/
|
||||||
项目参考/
|
项目参考/
|
||||||
|
# 沙箱环境下的本地 pnpm 10 启动器与设计探索产物
|
||||||
|
.tools/
|
||||||
|
design/
|
||||||
|
# Vite 缓存
|
||||||
|
.vite/
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec commitlint --edit "$1"
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec lint-staged
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm typecheck
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const quote = (file) => JSON.stringify(file);
|
||||||
|
|
||||||
|
// 生成的声明文件(如 openapi.d.ts)被 ESLint 忽略,跳过 lint 只做格式化,
|
||||||
|
// 避免 lint-staged 因 "File ignored" 警告而阻断提交。
|
||||||
|
const lintScripts = (filenames) => {
|
||||||
|
const lintable = filenames.filter((file) => !file.endsWith('.d.ts'));
|
||||||
|
const commands = [];
|
||||||
|
if (lintable.length > 0) {
|
||||||
|
commands.push(`eslint --fix --max-warnings=0 ${lintable.map(quote).join(' ')}`);
|
||||||
|
}
|
||||||
|
commands.push(`prettier --write ${filenames.map(quote).join(' ')}`);
|
||||||
|
return commands;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'*.{ts,tsx,js,mjs,cjs}': lintScripts,
|
||||||
|
'*.{json,md,css,scss,yml,yaml}': ['prettier --write'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.turbo/
|
||||||
|
.tools/
|
||||||
|
design/
|
||||||
|
docs/
|
||||||
|
项目参考/
|
||||||
|
openapi/
|
||||||
|
prisma/migrations/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# 项目总纲保持原排版,避免格式化产生无关 diff
|
||||||
|
AGENTS.md
|
||||||
@@ -10,14 +10,15 @@
|
|||||||
|
|
||||||
## 文档索引
|
## 文档索引
|
||||||
|
|
||||||
| 文档 | 路径 | 版本 | 作用 |
|
| 文档 | 路径 | 版本 | 作用 |
|
||||||
| --- | --- | --- | --- |
|
| ------------ | ------------------------------------------------------------------------------------------------------ | -------------------- | ---------------------------------------------------------------------- |
|
||||||
| **项目总纲** | [AGENTS.md](AGENTS.md) | V1.0 | 硬约束、技术栈、仓库结构、命令契约、流程 |
|
| **项目总纲** | [AGENTS.md](AGENTS.md) | V1.0 | 硬约束、技术栈、仓库结构、命令契约、流程 |
|
||||||
| **PRD** | [灵犀-信息流广告素材一站式生产平台PRD.md](docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md) | **V1.6(最新)** | 需求、业务规则(R-01~R-30)、验收标准 |
|
| **PRD** | [灵犀-信息流广告素材一站式生产平台PRD.md](docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md) | **V1.6(最新)** | 需求、业务规则(R-01~R-30)、验收标准 |
|
||||||
| **技术架构** | [灵犀_信息流素材生产平台_技术架构_V0.1.md](docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md) | **V0.4(内部版本)** | 技术选型、分层模块、数据模型、接口、ADR |
|
| **技术架构** | [灵犀_信息流素材生产平台_技术架构_V0.1.md](docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md) | **V0.4(内部版本)** | 技术选型、分层模块、数据模型、接口、ADR |
|
||||||
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
|
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
|
||||||
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
|
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
|
||||||
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
|
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
|
||||||
|
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
|
||||||
|
|
||||||
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
|
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
|
||||||
|
|
||||||
@@ -25,24 +26,24 @@
|
|||||||
|
|
||||||
## 核心链路(五步)
|
## 核心链路(五步)
|
||||||
|
|
||||||
| 步骤 | 动作 | 产出 |
|
| 步骤 | 动作 | 产出 |
|
||||||
| --- | --- | --- |
|
| ---- | ---------------------------------------------- | -------------------- |
|
||||||
| 1 | 建行业 / 客户信息,选行业包并套客户资产包 | 归属根节点就绪 |
|
| 1 | 建行业 / 客户信息,选行业包并套客户资产包 | 归属根节点就绪 |
|
||||||
| 2 | 要素卡 + 卖点库 → 脚本生成 / 校核 / 审核入库 | 已入库脚本(带编号) |
|
| 2 | 要素卡 + 卖点库 → 脚本生成 / 校核 / 审核入库 | 已入库脚本(带编号) |
|
||||||
| 3 | 图片批次(模板 × 商品图 × 规格)/ 视频逐镜生成 | 候选素材 |
|
| 3 | 图片批次(模板 × 商品图 × 规格)/ 视频逐镜生成 | 候选素材 |
|
||||||
| 4 | 按组并排快筛,勾选保留项 | 待导出素材 |
|
| 4 | 按组并排快筛,勾选保留项 | 待导出素材 |
|
||||||
| 5 | 导出(固定目录 + 命名规则)+ 自动归档 | 可投素材 + 交付清单 |
|
| 5 | 导出(固定目录 + 命名规则)+ 自动归档 | 可投素材 + 交付清单 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 功能模块(PRD V1.6)
|
## 功能模块(PRD V1.6)
|
||||||
|
|
||||||
| 模块 | 定位 |
|
| 模块 | 定位 |
|
||||||
| --- | --- |
|
| ------------ | ------------------------------------------------------------------------ |
|
||||||
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
|
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
|
||||||
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
|
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
|
||||||
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
|
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
|
||||||
| **平台管理** | 组织账号、客户、角色权限、AI 配置、数据字典、审计、行业包与表单设计器 |
|
| **平台管理** | 组织账号、客户、角色权限、AI 配置、数据字典、审计、行业包与表单设计器 |
|
||||||
|
|
||||||
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
|
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
|
||||||
|
|
||||||
@@ -60,20 +61,37 @@ infra/ compose、Nginx、监控、备份
|
|||||||
docs/ PRD / 技术架构 / 立项建议 / 工程规范
|
docs/ PRD / 技术架构 / 立项建议 / 工程规范
|
||||||
```
|
```
|
||||||
|
|
||||||
当前仓库处于**规范与设计阶段**,尚未建代码;建仓必须遵循 [AGENTS.md §5](AGENTS.md) 的结构与依赖方向。
|
当前已完成 **A0 工程骨架与契约基线**(monorepo、API/Worker/Web 骨架、Prisma 平台表、shared 契约包、UI 包、布局与登录壳、CI 与冒烟测试);业务模块从 A1 起按开发计划推进。目录与依赖方向见 [AGENTS.md §5](AGENTS.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 本地开发(建仓后适用)
|
## 本地开发
|
||||||
|
|
||||||
|
前置:Node.js `24.15.0`(`.nvmrc`)、pnpm `10.x`、Docker。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
pnpm compose:up # PostgreSQL 18 / Redis 7.4 / MinIO
|
cp .env.local .env # Prisma CLI 只读取 .env(两者均已 gitignore)
|
||||||
pnpm db:migrate
|
pnpm compose:up # PostgreSQL 18 / Redis 7.4 / S3 兼容存储
|
||||||
pnpm dev
|
pnpm db:migrate # 首次建表;后续由 Prisma 迁移管理
|
||||||
|
pnpm db:seed # 幂等写入 system_configs 基线
|
||||||
|
pnpm dev # 并行启动 api(3000) / web(5173) / worker
|
||||||
```
|
```
|
||||||
|
|
||||||
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。
|
- **端口**:为避免与本机已有容器冲突,compose 宿主端口为 PostgreSQL `15432`、Redis `16379`、S3 `19000/19001`,容器内仍是 `5432/6379/9000/9001`;`.env.example` 已按宿主端口给出连接串。
|
||||||
|
- **对象存储**:当前 Docker 环境拉取不到 `minio/minio` 镜像(registry denied、二进制 410),A0 的 compose 使用 `adobe/s3mock` 并提供 `assets / renders / temp / archive` 四个初始桶;CI 或生产接入真实 MinIO 时只需替换 `infra/docker-compose.dev.yaml` 的 `s3` 服务。
|
||||||
|
- **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。
|
||||||
|
- **演示登录**:A0 尚未接鉴权(A1 实现),`VITE_PUBLIC_DEMO_SESSION=true` 时登录页使用本地演示会话进入工作台;A1 接入真实登录后删除。
|
||||||
|
|
||||||
|
常用校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint && pnpm typecheck && pnpm test # 本地门禁
|
||||||
|
pnpm build && pnpm test:e2e # 构建与端到端冒烟
|
||||||
|
pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检查
|
||||||
|
```
|
||||||
|
|
||||||
|
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
|
"test:int": "vitest run --config vitest.int.config.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "11.3.1",
|
||||||
|
"@fastify/helmet": "13.2.0",
|
||||||
|
"@fastify/static": "10.1.8",
|
||||||
|
"@lingxi/shared": "workspace:*",
|
||||||
|
"@nestjs/common": "11.2.8",
|
||||||
|
"@nestjs/core": "11.2.8",
|
||||||
|
"@nestjs/platform-fastify": "11.2.8",
|
||||||
|
"@nestjs/swagger": "11.4.7",
|
||||||
|
"@prisma/client": "6.19.3",
|
||||||
|
"dotenv": "18.0.9",
|
||||||
|
"fastify": "5.12.7",
|
||||||
|
"ioredis": "5.11.1",
|
||||||
|
"nestjs-pino": "5.3.1",
|
||||||
|
"pino": "10.4.0",
|
||||||
|
"pino-http": "11.0.0",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"zod": "3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/testing": "11.2.8",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"@vitest/coverage-v8": "3.2.7",
|
||||||
|
"pino-pretty": "13.2.0",
|
||||||
|
"tsx": "4.23.15",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
||||||
|
import type { AppConfig } from './config/env';
|
||||||
|
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||||
|
import { RedisModule } from './infra/redis/redis.module';
|
||||||
|
import { PlatformModule } from './modules/platform/platform.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
LoggerModule.forRootAsync({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
useFactory: (config: AppConfig) => ({
|
||||||
|
pinoHttp: {
|
||||||
|
level: config.logLevel,
|
||||||
|
genReqId: (request, response) => {
|
||||||
|
const header = request.headers[REQUEST_ID_HEADER];
|
||||||
|
const existing = Array.isArray(header) ? header[0] : header;
|
||||||
|
const traceId = typeof existing === 'string' && existing.length > 0 ? existing : ulid();
|
||||||
|
response.setHeader(REQUEST_ID_HEADER, traceId);
|
||||||
|
return traceId;
|
||||||
|
},
|
||||||
|
customProps: (request) => ({ trace_id: request.id }),
|
||||||
|
redact: {
|
||||||
|
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
||||||
|
censor: '[redacted]',
|
||||||
|
},
|
||||||
|
autoLogging: {
|
||||||
|
ignore: (request) => request.url === '/health',
|
||||||
|
},
|
||||||
|
...(config.nodeEnv === 'development' && process.env.VITEST === undefined
|
||||||
|
? {
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: { singleLine: true, translateTime: 'SYS:HH:MM:ss.l' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
RedisModule,
|
||||||
|
PlatformModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import helmet from '@fastify/helmet';
|
||||||
|
import { API_PREFIX } from '@lingxi/shared';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
|
import { APP_CONFIG } from './config/config.module';
|
||||||
|
import type { AppConfig } from './config/env';
|
||||||
|
import { buildOpenApiDocument, setupOpenApi } from './openapi/document';
|
||||||
|
|
||||||
|
export interface CreatedApiApp {
|
||||||
|
app: NestFastifyApplication;
|
||||||
|
config: AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建并配置 API 应用(测试与 OpenAPI 导出复用,不负责监听端口) */
|
||||||
|
export async function createApp(): Promise<CreatedApiApp> {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ trustProxy: true, bodyLimit: 1024 * 1024 }),
|
||||||
|
{ bufferLogs: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = app.get<AppConfig>(APP_CONFIG);
|
||||||
|
app.useLogger(app.get(Logger));
|
||||||
|
|
||||||
|
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
app.enableCors({
|
||||||
|
origin: config.webOrigins,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 安全响应头;Swagger UI 需要内联脚本,CSP 在关闭文档页后再开启
|
||||||
|
await app.register(helmet, { contentSecurityPolicy: false });
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
return { app, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildOpenApiDocument, setupOpenApi };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ERROR_MESSAGES, type ErrorCode, httpStatusOf } from '@lingxi/shared';
|
||||||
|
|
||||||
|
export interface AppExceptionOptions {
|
||||||
|
code: ErrorCode;
|
||||||
|
message?: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务异常:由统一异常过滤器序列化为 { code, message, details, trace_id }。
|
||||||
|
* 业务代码禁止直接 throw new Error 表达可预期错误。
|
||||||
|
*/
|
||||||
|
export class AppException extends Error {
|
||||||
|
readonly code: ErrorCode;
|
||||||
|
readonly status: number;
|
||||||
|
readonly details?: unknown;
|
||||||
|
|
||||||
|
constructor(options: AppExceptionOptions) {
|
||||||
|
super(options.message ?? ERROR_MESSAGES[options.code]);
|
||||||
|
this.name = 'AppException';
|
||||||
|
this.code = options.code;
|
||||||
|
this.status = httpStatusOf(options.code);
|
||||||
|
this.details = options.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, type TestApp } from '../../testing/test-app';
|
||||||
|
|
||||||
|
describe('统一异常过滤器(冒烟)', () => {
|
||||||
|
let testApp: TestApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await createTestApp({ includeErrorRoutes: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AppException 输出业务错误码与 trace_id', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/app-error' });
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
const body = response.json<{ code: number; message: string; trace_id: string }>();
|
||||||
|
expect(body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
expect(body.message).toBe('当前状态不允许该操作');
|
||||||
|
expect(body.trace_id.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HttpException 404 映射为 40401', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/http-404' });
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知异常收敛为 50001,不泄露内部信息', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/boom' });
|
||||||
|
expect(response.statusCode).toBe(500);
|
||||||
|
const body = response.json<{ code: number; message: string; trace_id: string }>();
|
||||||
|
expect(body.code).toBe(ErrorCode.INTERNAL_ERROR);
|
||||||
|
expect(body.message).toBe('服务内部错误');
|
||||||
|
expect(body.trace_id.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未注册路由返回统一 404 错误体', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/not-exists' });
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { type ApiErrorBody, ErrorCode, httpStatusOf, isErrorCode } from '@lingxi/shared';
|
||||||
|
import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../errors/app-exception';
|
||||||
|
|
||||||
|
interface RequestWithTrace {
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
method?: string;
|
||||||
|
log?: {
|
||||||
|
error: (payload: unknown, message?: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyLike {
|
||||||
|
status: (code: number) => { send: (body: unknown) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeFromHttpStatus(status: number): ErrorCode {
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
return ErrorCode.PARAM_INVALID;
|
||||||
|
case 401:
|
||||||
|
return ErrorCode.UNAUTHENTICATED;
|
||||||
|
case 403:
|
||||||
|
return ErrorCode.PERMISSION_DENIED;
|
||||||
|
case 404:
|
||||||
|
return ErrorCode.NOT_FOUND;
|
||||||
|
case 409:
|
||||||
|
return ErrorCode.STATE_NOT_ALLOWED;
|
||||||
|
case 422:
|
||||||
|
return ErrorCode.BUSINESS_RULE_VIOLATION;
|
||||||
|
case 429:
|
||||||
|
return ErrorCode.RATE_LIMITED;
|
||||||
|
case 502:
|
||||||
|
case 503:
|
||||||
|
return ErrorCode.DEPENDENCY_UNAVAILABLE;
|
||||||
|
case 504:
|
||||||
|
return ErrorCode.TIMEOUT;
|
||||||
|
default:
|
||||||
|
return status >= 400 && status < 500 ? ErrorCode.PARAM_INVALID : ErrorCode.INTERNAL_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFromHttpException(exception: HttpException): string {
|
||||||
|
const response = exception.getResponse();
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if ('message' in response) {
|
||||||
|
const message = response.message;
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (Array.isArray(message) && message.length > 0 && typeof message[0] === 'string') {
|
||||||
|
return message.join('; ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exception.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常过滤器:所有错误统一输出 { code, message, details, trace_id }(工程规范 04)。
|
||||||
|
* trace_id 与 Pino 的 request id 同源,响应用户报障时可反查日志。
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const context = host.switchToHttp();
|
||||||
|
const request = context.getRequest<RequestWithTrace>();
|
||||||
|
const reply = context.getResponse<ReplyLike>();
|
||||||
|
const traceId = request.id ?? '';
|
||||||
|
|
||||||
|
let status = 500;
|
||||||
|
let body: ApiErrorBody;
|
||||||
|
|
||||||
|
if (exception instanceof AppException) {
|
||||||
|
status = exception.status;
|
||||||
|
body = {
|
||||||
|
code: exception.code,
|
||||||
|
message: exception.message,
|
||||||
|
...(exception.details === undefined ? {} : { details: exception.details }),
|
||||||
|
trace_id: traceId,
|
||||||
|
};
|
||||||
|
} else if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const code = codeFromHttpStatus(status);
|
||||||
|
body = {
|
||||||
|
code,
|
||||||
|
message: status >= 500 ? '服务内部错误' : messageFromHttpException(exception),
|
||||||
|
trace_id: traceId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
body = {
|
||||||
|
code: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: '服务内部错误',
|
||||||
|
trace_id: traceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
request.log?.error(
|
||||||
|
{
|
||||||
|
err: exception,
|
||||||
|
trace_id: traceId,
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
code: body.code,
|
||||||
|
},
|
||||||
|
'request failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(status).send(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供测试或未来的 OpenAPI 生成复用:确认错误码与 HTTP 状态一致 */
|
||||||
|
export function httpStatusForCode(code: number): number {
|
||||||
|
return isErrorCode(code) ? httpStatusOf(code) : 500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/** OpenAPI 错误响应模型(工程规范 04 统一错误体) */
|
||||||
|
export class ErrorResponseDto {
|
||||||
|
@ApiProperty({ type: Number, description: '错误码(见 ErrorCode 常量)', example: 42202 })
|
||||||
|
code!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, description: '可直接展示给用户的提示', example: '内容触发合规拦截' })
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: Object,
|
||||||
|
description: '补充信息(字段错误、命中规则等),结构由错误码决定',
|
||||||
|
required: false,
|
||||||
|
example: { issues: [{ path: 'name', message: 'Required' }] },
|
||||||
|
})
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
description: '请求追踪 ID,与日志同源',
|
||||||
|
example: '01JABCDEF0123456789ABCDEFG',
|
||||||
|
})
|
||||||
|
trace_id!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppException } from '../errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from './zod-validation.pipe';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(2),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ZodValidationPipe', () => {
|
||||||
|
it('通过校验并返回转换后的值', () => {
|
||||||
|
const pipe = new ZodValidationPipe(schema);
|
||||||
|
expect(pipe.transform({ name: '素材', limit: '30' })).toEqual({ name: '素材', limit: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺省值生效', () => {
|
||||||
|
const pipe = new ZodValidationPipe(schema);
|
||||||
|
expect(pipe.transform({ name: '素材' })).toEqual({ name: '素材', limit: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('校验失败抛 40001,details 带字段错误', () => {
|
||||||
|
const pipe = new ZodValidationPipe(schema);
|
||||||
|
try {
|
||||||
|
pipe.transform({ name: 'x' });
|
||||||
|
throw new Error('应当抛错');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
const appError = error instanceof AppException ? error : null;
|
||||||
|
expect(appError?.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
expect(appError?.status).toBe(400);
|
||||||
|
expect(appError?.details).toMatchObject({
|
||||||
|
issues: [{ path: 'name' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { type PipeTransform } from '@nestjs/common';
|
||||||
|
import type { ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
import { AppException } from '../errors/app-exception';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod 校验管道(静态 DTO):body / query / params 共用。
|
||||||
|
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
|
||||||
|
*/
|
||||||
|
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
||||||
|
constructor(private readonly schema: ZodSchema<T>) {}
|
||||||
|
|
||||||
|
transform(value: unknown): T {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '参数校验失败',
|
||||||
|
details: {
|
||||||
|
issues: result.error.issues.map((issue) => ({
|
||||||
|
path: issue.path.join('.'),
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { type AppConfig, loadEnv } from './env';
|
||||||
|
|
||||||
|
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_CONFIG,
|
||||||
|
useFactory: (): AppConfig => loadEnv(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [APP_CONFIG],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { loadEnv } from './env';
|
||||||
|
|
||||||
|
const validEnv: NodeJS.ProcessEnv = {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
|
||||||
|
REDIS_URL: 'redis://127.0.0.1:16379',
|
||||||
|
WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('loadEnv', () => {
|
||||||
|
it('解析必填项并给可选项默认值', () => {
|
||||||
|
const config = loadEnv(validEnv);
|
||||||
|
expect(config.nodeEnv).toBe('test');
|
||||||
|
expect(config.isProduction).toBe(false);
|
||||||
|
expect(config.port).toBe(3000);
|
||||||
|
expect(config.host).toBe('0.0.0.0');
|
||||||
|
expect(config.logLevel).toBe('info');
|
||||||
|
expect(config.maxUploadMb).toBe(500);
|
||||||
|
expect(config.featureVideo).toBe(false);
|
||||||
|
expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字符串数字会被转成 number', () => {
|
||||||
|
const config = loadEnv({ ...validEnv, PORT: '8080', MAX_UPLOAD_MB: '200' });
|
||||||
|
expect(config.port).toBe(8080);
|
||||||
|
expect(config.maxUploadMb).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少 DATABASE_URL 时抛错并带字段名', () => {
|
||||||
|
expect(() => loadEnv({ ...validEnv, DATABASE_URL: undefined })).toThrow(/DATABASE_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝非 postgres 连接串', () => {
|
||||||
|
expect(() => loadEnv({ ...validEnv, DATABASE_URL: 'mysql://localhost/db' })).toThrow(
|
||||||
|
/DATABASE_URL/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝非法 LOG_LEVEL', () => {
|
||||||
|
expect(() => loadEnv({ ...validEnv, LOG_LEVEL: 'verbose' })).toThrow(/LOG_LEVEL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FEATURE_VIDEO=true 转为布尔值', () => {
|
||||||
|
expect(loadEnv({ ...validEnv, FEATURE_VIDEO: 'true' }).featureVideo).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
HOST: z.string().min(1).default('0.0.0.0'),
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
DATABASE_URL: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith('postgresql://') || value.startsWith('postgres://'),
|
||||||
|
'DATABASE_URL 必须是 postgresql:// 连接串',
|
||||||
|
),
|
||||||
|
REDIS_URL: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
|
||||||
|
'REDIS_URL 必须是 redis:// 连接串',
|
||||||
|
),
|
||||||
|
WEB_ORIGIN: z.string().default('http://127.0.0.1:5173'),
|
||||||
|
MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10240).default(500),
|
||||||
|
FEATURE_VIDEO: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RawEnv = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
nodeEnv: RawEnv['NODE_ENV'];
|
||||||
|
isProduction: boolean;
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
logLevel: RawEnv['LOG_LEVEL'];
|
||||||
|
databaseUrl: string;
|
||||||
|
redisUrl: string;
|
||||||
|
webOrigins: string[];
|
||||||
|
maxUploadMb: number;
|
||||||
|
featureVideo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
|
const result = envSchema.safeParse(source);
|
||||||
|
if (!result.success) {
|
||||||
|
const details = result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new Error(`环境变量校验失败:${details}`);
|
||||||
|
}
|
||||||
|
const env = result.data;
|
||||||
|
return {
|
||||||
|
nodeEnv: env.NODE_ENV,
|
||||||
|
isProduction: env.NODE_ENV === 'production',
|
||||||
|
port: env.PORT,
|
||||||
|
host: env.HOST,
|
||||||
|
logLevel: env.LOG_LEVEL,
|
||||||
|
databaseUrl: env.DATABASE_URL,
|
||||||
|
redisUrl: env.REDIS_URL,
|
||||||
|
webOrigins: env.WEB_ORIGIN.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0),
|
||||||
|
maxUploadMb: env.MAX_UPLOAD_MB,
|
||||||
|
featureVideo: env.FEATURE_VIDEO,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { config as loadDotenvFile } from 'dotenv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
|
||||||
|
* 只做加载,不做校验(校验见 config/env.ts)。
|
||||||
|
*/
|
||||||
|
export function loadDotenv(): void {
|
||||||
|
for (const file of ['.env.local', '.env']) {
|
||||||
|
const path = resolve(process.cwd(), file);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
loadDotenvFile({ path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from './bootstrap';
|
||||||
|
import { loadDotenv } from './config/load-dotenv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 集成测试:需要 pnpm compose:up 后真实 PG / Redis。
|
||||||
|
* 单独运行:pnpm test:int
|
||||||
|
*/
|
||||||
|
describe('GET /health(集成)', () => {
|
||||||
|
let created: CreatedApiApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
loadDotenv();
|
||||||
|
created = await createApp();
|
||||||
|
await created.app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await created.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('真实依赖健康时返回 200', async () => {
|
||||||
|
const response = await created.app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json<{
|
||||||
|
status: string;
|
||||||
|
checks: { database: { status: string }; redis: { status: string } };
|
||||||
|
}>();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.checks.database.status).toBe('up');
|
||||||
|
expect(body.checks.redis.status).toBe('up');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, type TestApp } from './testing/test-app';
|
||||||
|
|
||||||
|
describe('GET /health(冒烟)', () => {
|
||||||
|
let testApp: TestApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('依赖健康时返回 200 与检查明细', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json<{
|
||||||
|
status: string;
|
||||||
|
checks: { database: { status: string }; redis: { status: string } };
|
||||||
|
}>();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.checks.database.status).toBe('up');
|
||||||
|
expect(body.checks.redis.status).toBe('up');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health(依赖异常)', () => {
|
||||||
|
it('数据库不可用时返回 503 与统一错误体', async () => {
|
||||||
|
const testApp = await createTestApp({ databaseUp: false });
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
const body = response.json<{ code: number; message: string; trace_id: string }>();
|
||||||
|
expect(body.code).toBe(ErrorCode.DEPENDENCY_UNAVAILABLE);
|
||||||
|
expect(body.message).toBe('依赖服务不可用');
|
||||||
|
expect(body.trace_id.length).toBeGreaterThan(0);
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { testConfig } from '../testing/test-app';
|
||||||
|
import { PrismaService } from './prisma/prisma.service';
|
||||||
|
import { RedisService } from './redis/redis.service';
|
||||||
|
|
||||||
|
describe('PrismaService', () => {
|
||||||
|
it('关闭应用时断开 Prisma 连接', async () => {
|
||||||
|
const service = new PrismaService(testConfig);
|
||||||
|
const disconnect = vi.spyOn(service, '$disconnect').mockResolvedValue();
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RedisService', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ping 返回 PONG', async () => {
|
||||||
|
const service = new RedisService(testConfig);
|
||||||
|
const client = service.getClient();
|
||||||
|
vi.spyOn(client, 'connect').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(client, 'ping').mockResolvedValue('PONG');
|
||||||
|
await expect(service.ping()).resolves.toBe('PONG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('关闭应用时断开 Redis 连接', () => {
|
||||||
|
const service = new RedisService(testConfig);
|
||||||
|
const client = service.getClient();
|
||||||
|
const disconnect = vi.spyOn(client, 'disconnect').mockReturnValue(undefined);
|
||||||
|
service.onModuleDestroy();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
import { PRISMA } from '../tokens';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: PRISMA,
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
useFactory: (config: AppConfig): PrismaService => new PrismaService(config),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PRISMA],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma 封装。
|
||||||
|
* 不主动 $connect:Prisma 首次查询时才建连接,这样 OpenAPI 导出、单元测试不需要真实数据库;
|
||||||
|
* /health 会执行 SELECT 1 做真实检查。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||||
|
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||||
|
super({
|
||||||
|
datasources: { db: { url: config.databaseUrl } },
|
||||||
|
log: config.isProduction ? ['error'] : ['warn', 'error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
import { REDIS } from '../tokens';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: REDIS,
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
useFactory: (config: AppConfig): RedisService => new RedisService(config),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [REDIS],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 客户端封装。
|
||||||
|
* lazyConnect:不在应用启动时连接,避免 OpenAPI 导出 / 单测依赖真实 Redis;
|
||||||
|
* /health 调 ping 时才会真正连接。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleDestroy {
|
||||||
|
private readonly client: Redis;
|
||||||
|
|
||||||
|
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||||
|
this.client = new Redis(config.redisUrl, {
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
});
|
||||||
|
this.client.on('error', () => {
|
||||||
|
// 连接错误由健康检查与请求日志暴露,这里只避免 ioredis 抛未处理异常
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): Redis {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<'PONG'> {
|
||||||
|
if (this.client.status === 'wait') {
|
||||||
|
await this.client.connect();
|
||||||
|
}
|
||||||
|
await this.client.ping();
|
||||||
|
return 'PONG';
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.client.status !== 'end') {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** 依赖注入 token:避免跨模块直接依赖具体实现 */
|
||||||
|
export const PRISMA = Symbol('PRISMA');
|
||||||
|
export const REDIS = Symbol('REDIS');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { createApp, setupOpenApi } from './bootstrap';
|
||||||
|
import { loadDotenv } from './config/load-dotenv';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
loadDotenv();
|
||||||
|
const { app, config } = await createApp();
|
||||||
|
await app.init();
|
||||||
|
setupOpenApi(app, config);
|
||||||
|
await app.listen(config.port, config.host);
|
||||||
|
|
||||||
|
const logger = app.get(Logger);
|
||||||
|
logger.log(`灵犀 API 已启动:http://${config.host}:${config.port}(文档 /api/docs)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap().catch((error: unknown) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`API 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
import { type HealthReport, HealthResponseDto } from './health.types';
|
||||||
|
|
||||||
|
@ApiTags('platform')
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '健康检查',
|
||||||
|
description: '真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: HealthResponseDto, description: '全部依赖健康' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 503,
|
||||||
|
description: '依赖不可用(错误码 50002),details 内附各依赖检查结果',
|
||||||
|
})
|
||||||
|
async check(): Promise<HealthReport> {
|
||||||
|
return this.healthService.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [HealthService],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ErrorCode, nowIso } from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { APP_CONFIG } from '../../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../../config/env';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import type { RedisService } from '../../../infra/redis/redis.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { REDIS } from '../../../infra/tokens';
|
||||||
|
import type { ComponentHealth, HealthReport } from './health.types';
|
||||||
|
|
||||||
|
const API_VERSION = '0.1.0';
|
||||||
|
const CHECK_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
|
function withTimeout<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`检查超时(${String(timeoutMs)}ms)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
void task.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查:真实检查数据库(SELECT 1)与 Redis(PING),不只看端口(技术架构 §4.1.1)。
|
||||||
|
* 任一依赖不可用时返回 503 + 统一错误体,details 里带完整检查快照。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(REDIS) private readonly redis: RedisService,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check(): Promise<HealthReport> {
|
||||||
|
const [database, redis] = await Promise.all([this.checkDatabase(), this.checkRedis()]);
|
||||||
|
const report: HealthReport = {
|
||||||
|
status: database.status === 'up' && redis.status === 'up' ? 'ok' : 'degraded',
|
||||||
|
env: this.config.nodeEnv,
|
||||||
|
version: API_VERSION,
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
checks: { database, redis },
|
||||||
|
timestamp: nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (report.status === 'degraded') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
|
||||||
|
message: '依赖服务不可用',
|
||||||
|
details: report.checks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkDatabase(): Promise<ComponentHealth> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await withTimeout(this.prisma.$queryRaw`SELECT 1`, CHECK_TIMEOUT_MS);
|
||||||
|
return { status: 'up', latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkRedis(): Promise<ComponentHealth> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await withTimeout(this.redis.ping(), CHECK_TIMEOUT_MS);
|
||||||
|
return { status: 'up', latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export interface ComponentHealth {
|
||||||
|
status: 'up' | 'down';
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthReport {
|
||||||
|
status: 'ok' | 'degraded';
|
||||||
|
env: string;
|
||||||
|
version: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
checks: {
|
||||||
|
database: ComponentHealth;
|
||||||
|
redis: ComponentHealth;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComponentHealthDto {
|
||||||
|
@ApiProperty({ type: String, enum: ['up', 'down'], example: 'up' })
|
||||||
|
status!: 'up' | 'down';
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, description: '检查耗时(毫秒)', example: 3 })
|
||||||
|
latencyMs!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, required: false, description: '失败原因(仅 down 时返回)' })
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthChecksDto {
|
||||||
|
@ApiProperty({ type: ComponentHealthDto })
|
||||||
|
database!: ComponentHealthDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ComponentHealthDto })
|
||||||
|
redis!: ComponentHealthDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthResponseDto {
|
||||||
|
@ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' })
|
||||||
|
status!: 'ok' | 'degraded';
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: 'development' })
|
||||||
|
env!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '0.1.0' })
|
||||||
|
version!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, description: '进程运行秒数', example: 128 })
|
||||||
|
uptimeSeconds!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: HealthChecksDto })
|
||||||
|
checks!: HealthChecksDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '2026-11-02T08:00:00.000Z' })
|
||||||
|
timestamp!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。
|
||||||
|
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [HealthModule],
|
||||||
|
exports: [HealthModule],
|
||||||
|
})
|
||||||
|
export class PlatformModule {}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, type TestApp,testConfig } from '../testing/test-app';
|
||||||
|
import { buildOpenApiDocument } from './document';
|
||||||
|
|
||||||
|
describe('OpenAPI 产物(冒烟)', () => {
|
||||||
|
let testApp: TestApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('版本为 3.1 且包含健康检查路由', () => {
|
||||||
|
const document = buildOpenApiDocument(testApp.app, testConfig);
|
||||||
|
expect(document.openapi).toBe('3.1.0');
|
||||||
|
expect(Object.keys(document.paths).some((path) => path.endsWith('/health'))).toBe(true);
|
||||||
|
expect(document.info.version).toBe('0.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('包含全部错误码枚举与统一错误体', () => {
|
||||||
|
const document = buildOpenApiDocument(testApp.app, testConfig);
|
||||||
|
const schemas = document.components?.schemas ?? {};
|
||||||
|
expect(schemas.ErrorCode).toBeDefined();
|
||||||
|
expect(schemas.ErrorResponse).toBeDefined();
|
||||||
|
const errorCodeSchema = schemas.ErrorCode;
|
||||||
|
expect(errorCodeSchema && 'enum' in errorCodeSchema ? errorCodeSchema.enum : []).toHaveLength(
|
||||||
|
Object.values(ErrorCode).length,
|
||||||
|
);
|
||||||
|
expect('x-error-codes' in document).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ErrorResponseDto } from '../common/http/error-response.dto';
|
||||||
|
import type { AppConfig } from '../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 OpenAPI 文档。
|
||||||
|
* - 版本固定 3.1(技术架构:OpenAPI 3.1 + oasdiff breaking 检测)
|
||||||
|
* - 错误码以 ErrorCode schema + x-error-codes 扩展显式写入产物,供前端 codegen 与契约比对
|
||||||
|
*/
|
||||||
|
export function buildOpenApiDocument(app: INestApplication, config: AppConfig): OpenAPIObject {
|
||||||
|
const builder = new DocumentBuilder()
|
||||||
|
.setTitle('灵犀 API')
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
'信息流广告素材生产平台后端 API。',
|
||||||
|
'',
|
||||||
|
'统一约定:',
|
||||||
|
'- 前缀 /api/v1(健康检查 /health 除外)',
|
||||||
|
'- 错误体 { code, message, details, trace_id }',
|
||||||
|
'- 列表响应 { items, next_cursor }',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
.setVersion('0.1.0')
|
||||||
|
.setOpenAPIVersion('3.1.0')
|
||||||
|
.addServer('/api/v1', 'API v1')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, builder, {
|
||||||
|
extraModels: [ErrorResponseDto],
|
||||||
|
});
|
||||||
|
|
||||||
|
const components = document.components ?? {};
|
||||||
|
const schemas = components.schemas ?? {};
|
||||||
|
|
||||||
|
schemas.ErrorCode = {
|
||||||
|
type: 'integer',
|
||||||
|
description: '业务错误码(只允许使用 @lingxi/shared 中登记的错误码)',
|
||||||
|
enum: Object.values(ErrorCode),
|
||||||
|
};
|
||||||
|
schemas.ErrorResponse = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['code', 'message', 'trace_id'],
|
||||||
|
properties: {
|
||||||
|
code: { $ref: '#/components/schemas/ErrorCode' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
details: { description: '补充信息,结构由错误码决定' },
|
||||||
|
trace_id: { type: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.components = { ...components, schemas };
|
||||||
|
Object.assign(document, {
|
||||||
|
'x-error-codes': Object.values(ErrorCode).map((code) => ({ code })),
|
||||||
|
'x-api-config': {
|
||||||
|
env: config.nodeEnv,
|
||||||
|
featureVideo: config.featureVideo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupOpenApi(app: INestApplication, config: AppConfig): OpenAPIObject {
|
||||||
|
const document = buildOpenApiDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: 'api/openapi.json',
|
||||||
|
});
|
||||||
|
return document;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { createApp } from '../bootstrap';
|
||||||
|
import { loadDotenv } from '../config/load-dotenv';
|
||||||
|
import { buildOpenApiDocument } from '../openapi/document';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
loadDotenv();
|
||||||
|
const { app, config } = await createApp();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const document = buildOpenApiDocument(app, config);
|
||||||
|
const outputDir = resolve(process.cwd(), 'openapi');
|
||||||
|
const outputFile = resolve(outputDir, 'lingxi.openapi.json');
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
await writeFile(outputFile, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
process.stdout.write(`OpenAPI 已导出:${outputFile}\n`);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`OpenAPI 导出失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { API_PREFIX, ErrorCode } from '@lingxi/shared';
|
||||||
|
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { type Mock, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../common/errors/app-exception';
|
||||||
|
import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter';
|
||||||
|
import { APP_CONFIG } from '../config/config.module';
|
||||||
|
import type { AppConfig } from '../config/env';
|
||||||
|
import { PRISMA, REDIS } from '../infra/tokens';
|
||||||
|
import { HealthController } from '../modules/platform/health/health.controller';
|
||||||
|
import { HealthService } from '../modules/platform/health/health.service';
|
||||||
|
|
||||||
|
export const testConfig: AppConfig = {
|
||||||
|
nodeEnv: 'test',
|
||||||
|
isProduction: false,
|
||||||
|
port: 3000,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
logLevel: 'error',
|
||||||
|
databaseUrl: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
|
||||||
|
redisUrl: 'redis://127.0.0.1:16379',
|
||||||
|
webOrigins: ['http://127.0.0.1:5173'],
|
||||||
|
maxUploadMb: 500,
|
||||||
|
featureVideo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller('__test')
|
||||||
|
class ThrowingController {
|
||||||
|
@Get('app-error')
|
||||||
|
appError(): never {
|
||||||
|
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '当前状态不允许该操作' });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('http-404')
|
||||||
|
notFound(): never {
|
||||||
|
throw new NotFoundException('资源不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('boom')
|
||||||
|
boom(): never {
|
||||||
|
throw new Error('unexpected failure');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestAppOptions {
|
||||||
|
databaseUp?: boolean;
|
||||||
|
redisUp?: boolean;
|
||||||
|
includeErrorRoutes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestApp {
|
||||||
|
app: NestFastifyApplication;
|
||||||
|
prisma: { $queryRaw: Mock };
|
||||||
|
redis: { ping: Mock };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
|
||||||
|
const prisma = {
|
||||||
|
$queryRaw:
|
||||||
|
options.databaseUp === false
|
||||||
|
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:15432'))
|
||||||
|
: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
ping:
|
||||||
|
options.redisUp === false
|
||||||
|
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379'))
|
||||||
|
: vi.fn().mockResolvedValue('PONG'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers:
|
||||||
|
options.includeErrorRoutes === true
|
||||||
|
? [HealthController, ThrowingController]
|
||||||
|
: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
{ provide: PRISMA, useValue: prisma },
|
||||||
|
{ provide: REDIS, useValue: redis },
|
||||||
|
{ provide: APP_CONFIG, useValue: testConfig },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
return { app, prisma, redis };
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.int.spec.ts", "src/scripts/**"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"useDefineForClassFields": false
|
||||||
|
},
|
||||||
|
"include": ["src", "vitest.config.ts", "vitest.int.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
exclude: ['**/*.int.spec.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov', 'json-summary'],
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'],
|
||||||
|
thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.int.spec.ts'],
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 30_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>灵犀 · 信息流素材生产平台</title>
|
||||||
|
</head>
|
||||||
|
<body theme-mode="light">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||||
|
"preview": "vite preview --port 4173 --strictPort",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-ui-19": "2.104.0",
|
||||||
|
"@lingxi/shared": "workspace:*",
|
||||||
|
"@lingxi/ui": "workspace:*",
|
||||||
|
"@tanstack/react-query": "5.105.1",
|
||||||
|
"react": "19.3.1",
|
||||||
|
"react-dom": "19.3.1",
|
||||||
|
"react-router": "7.18.5",
|
||||||
|
"zod": "3.25.76",
|
||||||
|
"zustand": "5.0.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "16.3.3",
|
||||||
|
"@types/react": "19.3.0",
|
||||||
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@vitejs/plugin-react": "5.2.0",
|
||||||
|
"@vitest/coverage-v8": "3.2.7",
|
||||||
|
"jsdom": "28.1.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "6.4.5",
|
||||||
|
"vitest": "3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
import { ApiError, request, setUnauthorizedHandler } from './client';
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyResponse = (status: number): Response => new Response(null, { status });
|
||||||
|
|
||||||
|
function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
|
||||||
|
useAuthStore.getState().setSession({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' },
|
||||||
|
permissions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizationOf(init: RequestInit | undefined): string | null {
|
||||||
|
return init?.headers instanceof Headers ? init.headers.get('Authorization') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUrl(input: string | URL | Request | undefined): string {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (input instanceof URL) {
|
||||||
|
return input.href;
|
||||||
|
}
|
||||||
|
return input?.url ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('请求层', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
|
useAuthStore.getState().clearSession();
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注入 Bearer token 并解析 JSON', async () => {
|
||||||
|
signIn();
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValue(jsonResponse({ status: 'ok' }));
|
||||||
|
|
||||||
|
await expect(request<{ status: string }>('/health')).resolves.toEqual({ status: 'ok' });
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(authorizationOf(init)).toBe('Bearer token-1');
|
||||||
|
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toBe('http://127.0.0.1:3000/api/v1/health');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auth: false 时不注入 token', async () => {
|
||||||
|
signIn();
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
|
||||||
|
await request('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email: 'a', password: 'b' },
|
||||||
|
});
|
||||||
|
expect(authorizationOf(fetchMock.mock.calls[0]?.[1])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('204 返回 undefined', async () => {
|
||||||
|
signIn();
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(emptyResponse(204));
|
||||||
|
await expect(request('/resource')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('统一错误体解析为 ApiError,保留 trace_id', async () => {
|
||||||
|
signIn();
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
jsonResponse(
|
||||||
|
{ code: 42201, message: '业务校验失败', details: { field: 'name' }, trace_id: 'trace-1' },
|
||||||
|
422,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const error = await request('/resource').catch((caught: unknown) => caught);
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
const apiError = error instanceof ApiError ? error : null;
|
||||||
|
expect(apiError?.code).toBe(42201);
|
||||||
|
expect(apiError?.status).toBe(422);
|
||||||
|
expect(apiError?.traceId).toBe('trace-1');
|
||||||
|
expect(apiError?.details).toEqual({ field: 'name' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 时单飞刷新并重放原请求', async () => {
|
||||||
|
signIn('expired-token', 'refresh-1');
|
||||||
|
let refreshCalls = 0;
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
|
||||||
|
const url = toUrl(input);
|
||||||
|
const authorization = authorizationOf(init);
|
||||||
|
if (url.endsWith('/auth/refresh')) {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({ accessToken: 'new-token', refreshToken: 'refresh-2' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (authorization === 'Bearer expired-token') {
|
||||||
|
return Promise.resolve(emptyResponse(401));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([request('/a'), request('/b')]);
|
||||||
|
expect(first).toEqual({ ok: true });
|
||||||
|
expect(second).toEqual({ ok: true });
|
||||||
|
expect(refreshCalls).toBe(1);
|
||||||
|
expect(useAuthStore.getState().accessToken).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('刷新失败时清空会话并触发未授权回调', async () => {
|
||||||
|
signIn('expired-token', 'refresh-1');
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
setUnauthorizedHandler(onUnauthorized);
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||||
|
const url = toUrl(input);
|
||||||
|
return Promise.resolve(
|
||||||
|
url.endsWith('/auth/refresh') ? emptyResponse(401) : emptyResponse(401),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(request('/secure')).rejects.toBeInstanceOf(ApiError);
|
||||||
|
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||||
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('把请求体序列化为 JSON', async () => {
|
||||||
|
signIn();
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
|
||||||
|
await request('/resource', { method: 'POST', body: { name: '素材' } });
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(init?.body).toBe(JSON.stringify({ name: '素材' }));
|
||||||
|
expect(init?.method).toBe('POST');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { apiErrorBodySchema } from '@lingxi/shared';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'http://127.0.0.1:3000/api/v1';
|
||||||
|
|
||||||
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
method?: HttpMethod;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** 登录、刷新等接口不需要携带 token */
|
||||||
|
auth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorInit {
|
||||||
|
code: number;
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
traceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 前端统一 API 错误:与服务端错误体 {code,message,details,trace_id} 对齐 */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly code: number;
|
||||||
|
readonly status: number;
|
||||||
|
readonly details?: unknown;
|
||||||
|
readonly traceId?: string;
|
||||||
|
|
||||||
|
constructor(init: ApiErrorInit) {
|
||||||
|
super(init.message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = init.code;
|
||||||
|
this.status = init.status;
|
||||||
|
this.details = init.details;
|
||||||
|
this.traceId = init.traceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unauthorizedHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||||
|
unauthorizedHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl(): string {
|
||||||
|
const configured = import.meta.env.VITE_PUBLIC_API_BASE_URL;
|
||||||
|
return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(path: string): string {
|
||||||
|
if (/^https?:\/\//.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return `${baseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeBody(body: unknown): BodyInit | undefined {
|
||||||
|
if (body === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof body === 'string' ||
|
||||||
|
body instanceof FormData ||
|
||||||
|
body instanceof Blob ||
|
||||||
|
body instanceof URLSearchParams ||
|
||||||
|
body instanceof ArrayBuffer
|
||||||
|
) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
return JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCode(status: number): number {
|
||||||
|
if (status === 401) return 40101;
|
||||||
|
if (status === 403) return 40301;
|
||||||
|
if (status === 404) return 40401;
|
||||||
|
if (status === 409) return 40903;
|
||||||
|
if (status === 429) return 42901;
|
||||||
|
if (status >= 500) return 50001;
|
||||||
|
return 40001;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toApiError(response: Response): Promise<ApiError> {
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
const parsed = apiErrorBodySchema.safeParse(payload);
|
||||||
|
if (parsed.success) {
|
||||||
|
return new ApiError({
|
||||||
|
code: parsed.data.code,
|
||||||
|
status: response.status,
|
||||||
|
message: parsed.data.message,
|
||||||
|
details: parsed.data.details,
|
||||||
|
traceId: parsed.data.trace_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new ApiError({
|
||||||
|
code: fallbackCode(response.status),
|
||||||
|
status: response.status,
|
||||||
|
message: `请求失败(HTTP ${response.status})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */
|
||||||
|
export function refreshSession(): Promise<boolean> {
|
||||||
|
refreshPromise ??= doRefresh().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRefresh(): Promise<boolean> {
|
||||||
|
const { refreshToken, clearSession } = useAuthStore.getState();
|
||||||
|
if (refreshToken === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(buildUrl('/auth/refresh'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
clearSession();
|
||||||
|
unauthorizedHandler?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload: unknown = await response.json().catch(() => null);
|
||||||
|
const accessToken =
|
||||||
|
typeof payload === 'object' && payload !== null && 'accessToken' in payload
|
||||||
|
? payload.accessToken
|
||||||
|
: null;
|
||||||
|
const nextRefreshToken =
|
||||||
|
typeof payload === 'object' && payload !== null && 'refreshToken' in payload
|
||||||
|
? payload.refreshToken
|
||||||
|
: null;
|
||||||
|
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
||||||
|
clearSession();
|
||||||
|
unauthorizedHandler?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
useAuthStore.getState().setTokens({
|
||||||
|
accessToken,
|
||||||
|
refreshToken: typeof nextRefreshToken === 'string' ? nextRefreshToken : refreshToken,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send<T>(path: string, options: RequestOptions, allowRefresh: boolean): Promise<T> {
|
||||||
|
const { accessToken } = useAuthStore.getState();
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
const body = serializeBody(options.body);
|
||||||
|
if (body !== undefined && !(body instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
if (options.auth !== false && accessToken !== null) {
|
||||||
|
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildUrl(path), {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 && allowRefresh && options.auth !== false) {
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (refreshed) {
|
||||||
|
return send<T>(path, options, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await toApiError(response);
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务请求入口:自动注入 token、401 单飞刷新、统一错误体解析 */
|
||||||
|
export function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
return send<T>(path, options, true);
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
'/health': {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 健康检查
|
||||||
|
* @description 真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。
|
||||||
|
*/
|
||||||
|
get: operations['HealthController_check'];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
ComponentHealthDto: {
|
||||||
|
/**
|
||||||
|
* @example up
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: 'up' | 'down';
|
||||||
|
/**
|
||||||
|
* @description 检查耗时(毫秒)
|
||||||
|
* @example 3
|
||||||
|
*/
|
||||||
|
latencyMs: number;
|
||||||
|
/** @description 失败原因(仅 down 时返回) */
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
HealthChecksDto: {
|
||||||
|
database: components['schemas']['ComponentHealthDto'];
|
||||||
|
redis: components['schemas']['ComponentHealthDto'];
|
||||||
|
};
|
||||||
|
HealthResponseDto: {
|
||||||
|
/**
|
||||||
|
* @example ok
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
status: 'ok' | 'degraded';
|
||||||
|
/** @example development */
|
||||||
|
env: string;
|
||||||
|
/** @example 0.1.0 */
|
||||||
|
version: string;
|
||||||
|
/**
|
||||||
|
* @description 进程运行秒数
|
||||||
|
* @example 128
|
||||||
|
*/
|
||||||
|
uptimeSeconds: number;
|
||||||
|
checks: components['schemas']['HealthChecksDto'];
|
||||||
|
/** @example 2026-11-02T08:00:00.000Z */
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
ErrorResponseDto: {
|
||||||
|
/**
|
||||||
|
* @description 错误码(见 ErrorCode 常量)
|
||||||
|
* @example 42202
|
||||||
|
*/
|
||||||
|
code: number;
|
||||||
|
/**
|
||||||
|
* @description 可直接展示给用户的提示
|
||||||
|
* @example 内容触发合规拦截
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
/**
|
||||||
|
* @description 补充信息(字段错误、命中规则等),结构由错误码决定
|
||||||
|
* @example {
|
||||||
|
* "issues": [
|
||||||
|
* {
|
||||||
|
* "path": "name",
|
||||||
|
* "message": "Required"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
details?: Record<string, never>;
|
||||||
|
/**
|
||||||
|
* @description 请求追踪 ID,与日志同源
|
||||||
|
* @example 01JABCDEF0123456789ABCDEFG
|
||||||
|
*/
|
||||||
|
trace_id: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @description 业务错误码(只允许使用 @lingxi/shared 中登记的错误码)
|
||||||
|
* @enum {integer}
|
||||||
|
*/
|
||||||
|
ErrorCode:
|
||||||
|
| 40001
|
||||||
|
| 40101
|
||||||
|
| 40102
|
||||||
|
| 40103
|
||||||
|
| 40301
|
||||||
|
| 40302
|
||||||
|
| 40303
|
||||||
|
| 40304
|
||||||
|
| 40401
|
||||||
|
| 40901
|
||||||
|
| 40902
|
||||||
|
| 40903
|
||||||
|
| 42201
|
||||||
|
| 42202
|
||||||
|
| 42203
|
||||||
|
| 42901
|
||||||
|
| 42902
|
||||||
|
| 50001
|
||||||
|
| 50002
|
||||||
|
| 50003;
|
||||||
|
ErrorResponse: {
|
||||||
|
code: components['schemas']['ErrorCode'];
|
||||||
|
message: string;
|
||||||
|
/** @description 补充信息,结构由错误码决定 */
|
||||||
|
details?: unknown;
|
||||||
|
trace_id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
HealthController_check: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description 全部依赖健康 */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['HealthResponseDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description 依赖不可用(错误码 50002),details 内附各依赖检查结果 */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { notifyError } from '@lingxi/ui';
|
||||||
|
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { ApiError } from './client';
|
||||||
|
|
||||||
|
function reportError(error: unknown): void {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyError(error.message, error.traceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyError(error instanceof Error ? error.message : '网络请求失败,请稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||||
|
if (failureCount >= 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return error.status >= 500 || error.status === 429;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({ onError: reportError }),
|
||||||
|
mutationCache: new MutationCache({ onError: reportError }),
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: shouldRetry,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { PageSkeleton } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Suspense, useMemo, useState } from 'react';
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { filterNavByPermissions, findActiveNav, NAV_MODULES } from '@/navigation/nav-config';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
import { PageBreadcrumbs } from './PageBreadcrumbs';
|
||||||
|
import { SideNav } from './SideNav';
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
|
||||||
|
export function AppLayout(): ReactNode {
|
||||||
|
const location = useLocation();
|
||||||
|
const permissions = useAuthStore((state) => state.permissions);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const modules = useMemo(
|
||||||
|
() => filterNavByPermissions(NAV_MODULES, new Set(permissions)),
|
||||||
|
[permissions],
|
||||||
|
);
|
||||||
|
const active = useMemo(
|
||||||
|
() => findActiveNav(modules, location.pathname),
|
||||||
|
[modules, location.pathname],
|
||||||
|
);
|
||||||
|
const activeModule = active?.module ?? modules[0];
|
||||||
|
|
||||||
|
if (!activeModule) {
|
||||||
|
return <Navigate to={ROUTES.forbidden} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={collapsed ? 'lx-shell lx-shell--collapsed' : 'lx-shell'}
|
||||||
|
data-testid="app-shell"
|
||||||
|
>
|
||||||
|
<TopBar modules={modules} activeModuleKey={activeModule.key} />
|
||||||
|
<div className="lx-shell__body">
|
||||||
|
<SideNav
|
||||||
|
module={activeModule}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggle={() => {
|
||||||
|
setCollapsed((value) => !value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<main className="lx-workspace">
|
||||||
|
<PageBreadcrumbs pathname={location.pathname} />
|
||||||
|
<div className="lx-page">
|
||||||
|
<Suspense fallback={<PageSkeleton rows={8} />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
||||||
|
|
||||||
|
export interface PageBreadcrumbsProps {
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
||||||
|
const entries = getBreadcrumbs(NAV_MODULES, pathname);
|
||||||
|
return (
|
||||||
|
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||||
|
{entries.map((entry, index) => {
|
||||||
|
const isLast = index === entries.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={entry.key} className="lx-breadcrumbs__entry">
|
||||||
|
{index > 0 ? <span className="lx-breadcrumbs__sep">/</span> : null}
|
||||||
|
{entry.path !== undefined && !isLast ? (
|
||||||
|
<Link to={entry.path} className="lx-breadcrumbs__link">
|
||||||
|
{entry.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className={isLast ? 'lx-breadcrumbs__current' : 'lx-breadcrumbs__label'}>
|
||||||
|
{entry.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { IconBack, IconMenu } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { NavLink } from 'react-router';
|
||||||
|
|
||||||
|
import type { NavModule } from '@/navigation/nav-config';
|
||||||
|
|
||||||
|
export interface SideNavProps {
|
||||||
|
module: NavModule;
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideNav({ module, collapsed, onToggle }: SideNavProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<aside className="lx-sidebar" aria-label="功能导航">
|
||||||
|
<div className="lx-sidebar__heading">
|
||||||
|
<span className="lx-sidebar__title">{module.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-icon-button lx-sidebar__toggle"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
>
|
||||||
|
{collapsed ? <IconMenu size={18} /> : <IconBack size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="lx-sidebar__nav">
|
||||||
|
{module.items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.key}
|
||||||
|
to={item.path}
|
||||||
|
end={item.path === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? 'lx-sidebar__item is-active' : 'lx-sidebar__item'
|
||||||
|
}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<span className="lx-sidebar__item-icon">
|
||||||
|
<Icon size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-sidebar__item-label">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="lx-sidebar__footer">
|
||||||
|
<span className="lx-sidebar__env" title="当前环境:本地开发">
|
||||||
|
<span className="lx-sidebar__env-dot" aria-hidden="true" />
|
||||||
|
<span className="lx-sidebar__env-text">本地开发</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@lingxi/ui/icons';
|
||||||
|
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import type { NavModule } from '@/navigation/nav-config';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
export interface TopBarProps {
|
||||||
|
modules: readonly NavModule[];
|
||||||
|
activeModuleKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleTabs({
|
||||||
|
modules,
|
||||||
|
activeModuleKey,
|
||||||
|
}: {
|
||||||
|
modules: readonly NavModule[];
|
||||||
|
activeModuleKey: string;
|
||||||
|
}): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<nav className="lx-topbar__modules" aria-label="模块导航">
|
||||||
|
{modules.map((module) => {
|
||||||
|
const active = module.key === activeModuleKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={module.key}
|
||||||
|
type="button"
|
||||||
|
className={active ? 'lx-topbar__module is-active' : 'lx-topbar__module'}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(module.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{module.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMenu(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const clearSession = useAuthStore((state) => state.clearSession);
|
||||||
|
const [openPath, setOpenPath] = useState<string | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const open = openPath === location.pathname;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: MouseEvent): void => {
|
||||||
|
if (menuRef.current !== null && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setOpenPath(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onPointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onPointerDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lx-user" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-user__trigger"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => {
|
||||||
|
setOpenPath(open ? null : location.pathname);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-user__avatar">{user?.name.slice(0, 1) ?? '灵'}</span>
|
||||||
|
<span className="lx-user__meta">
|
||||||
|
<span className="lx-user__name">{user?.name ?? '未登录'}</span>
|
||||||
|
<span className="lx-user__role">{user?.roleName ?? '访客'}</span>
|
||||||
|
</span>
|
||||||
|
<IconChevronDown size={14} className="lx-user__chevron" />
|
||||||
|
</button>
|
||||||
|
<UserMenuDropdown
|
||||||
|
open={open}
|
||||||
|
email={user?.email ?? '—'}
|
||||||
|
onLogout={() => {
|
||||||
|
clearSession();
|
||||||
|
void navigate(ROUTES.login, { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMenuDropdown({
|
||||||
|
open,
|
||||||
|
email,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
email: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
}): ReactNode {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="lx-user__menu" role="menu">
|
||||||
|
<div className="lx-user__menu-header">{email}</div>
|
||||||
|
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
|
||||||
|
<IconLogout size={16} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ modules, activeModuleKey }: TopBarProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<header className="lx-topbar">
|
||||||
|
<div className="lx-topbar__brand">
|
||||||
|
<span className="lx-topbar__mark">灵</span>
|
||||||
|
<span className="lx-topbar__brand-text">
|
||||||
|
<span className="lx-topbar__name">灵犀</span>
|
||||||
|
<span className="lx-topbar__tag">素材生产平台</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModuleTabs modules={modules} activeModuleKey={activeModuleKey} />
|
||||||
|
|
||||||
|
<div className="lx-topbar__actions">
|
||||||
|
<button type="button" className="lx-icon-button" aria-label="全局搜索" title="全局搜索">
|
||||||
|
<IconSearch size={18} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="lx-icon-button" aria-label="任务通知" title="任务通知">
|
||||||
|
<IconNotification size={18} />
|
||||||
|
<span className="lx-icon-button__dot" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import '@douyinfe/semi-ui-19/react19-adapter';
|
||||||
|
import '@lingxi/ui/semi.css';
|
||||||
|
import '@lingxi/ui/theme/tokens.css';
|
||||||
|
import '@lingxi/ui/styles.css';
|
||||||
|
import '@/styles/global.css';
|
||||||
|
import '@/styles/layout.css';
|
||||||
|
import '@/styles/pages.css';
|
||||||
|
|
||||||
|
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||||
|
import { localeZhCN } from '@lingxi/ui';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router';
|
||||||
|
|
||||||
|
import { setUnauthorizedHandler } from '@/api/client';
|
||||||
|
import { queryClient } from '@/api/query-client';
|
||||||
|
import { router } from '@/router';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
void router.navigate(ROUTES.login, { replace: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
|
||||||
|
if (container === null) {
|
||||||
|
throw new Error('找不到应用根节点 #root');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<LocaleProvider locale={localeZhCN}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</LocaleProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { PermissionCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { filterNavByPermissions, findActiveNav, getBreadcrumbs, NAV_MODULES } from './nav-config';
|
||||||
|
|
||||||
|
describe('导航配置', () => {
|
||||||
|
it('拥有全部权限时展示全部模块与菜单', () => {
|
||||||
|
const permissions = new Set(
|
||||||
|
NAV_MODULES.flatMap((module) => module.items.map((item) => item.permission)).filter(
|
||||||
|
(code): code is PermissionCode => code !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const modules = filterNavByPermissions(NAV_MODULES, permissions);
|
||||||
|
expect(modules.map((module) => module.key)).toEqual(NAV_MODULES.map((module) => module.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少权限点时过滤菜单,模块没有子菜单时整组隐藏', () => {
|
||||||
|
const modules = filterNavByPermissions(NAV_MODULES, new Set(['script:read']));
|
||||||
|
const script = modules.find((module) => module.key === 'script');
|
||||||
|
expect(script?.items.map((item) => item.key)).toEqual(['library']);
|
||||||
|
expect(modules.some((module) => module.key === 'platform')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只有无权限要求的菜单时仍然可见', () => {
|
||||||
|
const modules = filterNavByPermissions(NAV_MODULES, new Set());
|
||||||
|
expect(modules.map((module) => module.key)).toEqual(['dashboard']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按路径定位当前模块与菜单', () => {
|
||||||
|
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||||
|
expect(active?.module.key).toBe('material');
|
||||||
|
expect(active?.item?.label).toBe('快筛');
|
||||||
|
expect(findActiveNav(NAV_MODULES, '/not-exists')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('面包屑从导航配置派生', () => {
|
||||||
|
expect(getBreadcrumbs(NAV_MODULES, '/')).toEqual([{ key: 'home', label: '工作台', path: '/' }]);
|
||||||
|
const breadcrumbs = getBreadcrumbs(NAV_MODULES, '/platform/users');
|
||||||
|
expect(breadcrumbs.map((entry) => entry.label)).toEqual(['工作台', '平台', '用户与组织']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { Permission, type PermissionCode } from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
IconAsset,
|
||||||
|
IconCheck,
|
||||||
|
IconClock,
|
||||||
|
IconCopy,
|
||||||
|
IconDelivery,
|
||||||
|
IconDocs,
|
||||||
|
IconExport,
|
||||||
|
IconFilter,
|
||||||
|
IconGlobal,
|
||||||
|
IconGrid,
|
||||||
|
IconHome,
|
||||||
|
IconImageAdd,
|
||||||
|
IconMaterial,
|
||||||
|
IconPlay,
|
||||||
|
IconRobot,
|
||||||
|
IconScript,
|
||||||
|
IconSettings,
|
||||||
|
IconShield,
|
||||||
|
IconStack,
|
||||||
|
IconTask,
|
||||||
|
IconUser,
|
||||||
|
IconVideo,
|
||||||
|
IconView,
|
||||||
|
} from '@lingxi/ui/icons';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export type NavIcon = ComponentType<{ size?: number | string; className?: string }>;
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: NavIcon;
|
||||||
|
/** 未配置权限点的菜单对所有登录用户可见 */
|
||||||
|
permission?: PermissionCode;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavModule {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
icon: NavIcon;
|
||||||
|
permission?: PermissionCode;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶部模块菜单 + 左侧功能菜单的唯一配置源(A0-FE-03)。
|
||||||
|
* 新增页面时在这里登记;侧边栏与面包屑都从这里派生,禁止在页面内写死导航。
|
||||||
|
*/
|
||||||
|
export const NAV_MODULES: readonly NavModule[] = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
label: '工作台',
|
||||||
|
path: '/',
|
||||||
|
icon: IconHome,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'overview',
|
||||||
|
label: '工作台',
|
||||||
|
path: '/',
|
||||||
|
icon: IconHome,
|
||||||
|
description: '今天的生产任务、待审核脚本与最近素材',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'script',
|
||||||
|
label: '脚本',
|
||||||
|
path: '/scripts/library',
|
||||||
|
icon: IconScript,
|
||||||
|
permission: Permission.SCRIPT_READ,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'library',
|
||||||
|
label: '脚本库',
|
||||||
|
path: '/scripts/library',
|
||||||
|
icon: IconDocs,
|
||||||
|
permission: Permission.SCRIPT_READ,
|
||||||
|
description: '按客户、行业与状态检索已生成脚本',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'framework-cards',
|
||||||
|
label: '要素卡',
|
||||||
|
path: '/scripts/framework-cards',
|
||||||
|
icon: IconTask,
|
||||||
|
permission: Permission.SCRIPT_WRITE,
|
||||||
|
description: '维护行业包的要素卡字段与填写规则',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'templates',
|
||||||
|
label: '脚本模板',
|
||||||
|
path: '/scripts/templates',
|
||||||
|
icon: IconStack,
|
||||||
|
permission: Permission.SCRIPT_WRITE,
|
||||||
|
description: '结构模板、镜头模板与提示词模板',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'batch',
|
||||||
|
label: '批量生成',
|
||||||
|
path: '/scripts/batch',
|
||||||
|
icon: IconRobot,
|
||||||
|
permission: Permission.SCRIPT_WRITE,
|
||||||
|
description: '按要素卡批量生成脚本并排队审核',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'review',
|
||||||
|
label: '脚本审核',
|
||||||
|
path: '/scripts/review',
|
||||||
|
icon: IconCheck,
|
||||||
|
permission: Permission.SCRIPT_REVIEW,
|
||||||
|
description: '审核生成结果,标记低质量脚本',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'material',
|
||||||
|
label: '素材',
|
||||||
|
path: '/material/image-compose',
|
||||||
|
icon: IconMaterial,
|
||||||
|
permission: Permission.MATERIAL_READ,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'image-compose',
|
||||||
|
label: '图片合成',
|
||||||
|
path: '/material/image-compose',
|
||||||
|
icon: IconImageAdd,
|
||||||
|
permission: Permission.MATERIAL_RENDER,
|
||||||
|
description: 'PSD 套版、抠图与确定性合成',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'video',
|
||||||
|
label: '视频生产',
|
||||||
|
path: '/material/video',
|
||||||
|
icon: IconVideo,
|
||||||
|
permission: Permission.MATERIAL_RENDER,
|
||||||
|
description: '逐镜生成、合成与成片预览',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tasks',
|
||||||
|
label: '生产任务',
|
||||||
|
path: '/material/tasks',
|
||||||
|
icon: IconTask,
|
||||||
|
permission: Permission.MATERIAL_READ,
|
||||||
|
description: '任务进度、失败原因与重试',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quick-review',
|
||||||
|
label: '快筛',
|
||||||
|
path: '/material/quick-review',
|
||||||
|
icon: IconFilter,
|
||||||
|
permission: Permission.MATERIAL_REVIEW,
|
||||||
|
description: '按快捷键快速筛选候选素材',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'preview',
|
||||||
|
label: '成片预览',
|
||||||
|
path: '/material/preview',
|
||||||
|
icon: IconPlay,
|
||||||
|
permission: Permission.MATERIAL_READ,
|
||||||
|
description: '播放成片并核对规格与标识',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'asset',
|
||||||
|
label: '资产',
|
||||||
|
path: '/assets/library',
|
||||||
|
icon: IconAsset,
|
||||||
|
permission: Permission.ASSET_READ,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'library',
|
||||||
|
label: '资产库',
|
||||||
|
path: '/assets/library',
|
||||||
|
icon: IconAsset,
|
||||||
|
permission: Permission.ASSET_READ,
|
||||||
|
description: '图片、视频、音频与模板资产',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'templates',
|
||||||
|
label: '模板库',
|
||||||
|
path: '/assets/templates',
|
||||||
|
icon: IconCopy,
|
||||||
|
permission: Permission.ASSET_READ,
|
||||||
|
description: 'PSD 模板与版式配置',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pools',
|
||||||
|
label: '人物与场景',
|
||||||
|
path: '/assets/pools',
|
||||||
|
icon: IconUser,
|
||||||
|
permission: Permission.ASSET_READ,
|
||||||
|
description: '人物、场景与风格池',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prompts',
|
||||||
|
label: '提示词模板',
|
||||||
|
path: '/assets/prompts',
|
||||||
|
icon: IconRobot,
|
||||||
|
permission: Permission.ASSET_READ,
|
||||||
|
description: '视频与图片生成的提示词版本',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delivery',
|
||||||
|
label: '交付',
|
||||||
|
path: '/delivery/export',
|
||||||
|
icon: IconDelivery,
|
||||||
|
permission: Permission.DELIVERY_READ,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'export',
|
||||||
|
label: '导出任务',
|
||||||
|
path: '/delivery/export',
|
||||||
|
icon: IconExport,
|
||||||
|
permission: Permission.DELIVERY_EXPORT,
|
||||||
|
description: '勾选素材导出并按批次归档',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'records',
|
||||||
|
label: '导出记录',
|
||||||
|
path: '/delivery/records',
|
||||||
|
icon: IconClock,
|
||||||
|
permission: Permission.DELIVERY_READ,
|
||||||
|
description: '导出批次、签名链接与归档状态',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manifests',
|
||||||
|
label: '交付清单',
|
||||||
|
path: '/delivery/manifests',
|
||||||
|
icon: IconTask,
|
||||||
|
permission: Permission.DELIVERY_READ,
|
||||||
|
description: '交付清单、客户签收与留痕',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'platform',
|
||||||
|
label: '平台',
|
||||||
|
path: '/platform/users',
|
||||||
|
icon: IconSettings,
|
||||||
|
permission: Permission.USER_MANAGE,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
label: '用户与组织',
|
||||||
|
path: '/platform/users',
|
||||||
|
icon: IconUser,
|
||||||
|
permission: Permission.USER_MANAGE,
|
||||||
|
description: '账号、组织与登录状态',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
label: '角色权限',
|
||||||
|
path: '/platform/roles',
|
||||||
|
icon: IconShield,
|
||||||
|
permission: Permission.ROLE_MANAGE,
|
||||||
|
description: '角色、权限点与职责互斥',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'customers',
|
||||||
|
label: '客户',
|
||||||
|
path: '/platform/customers',
|
||||||
|
icon: IconGlobal,
|
||||||
|
permission: Permission.CUSTOMER_MANAGE,
|
||||||
|
description: '客户、行业归属与数据范围',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'industries',
|
||||||
|
label: '行业树',
|
||||||
|
path: '/platform/industries',
|
||||||
|
icon: IconStack,
|
||||||
|
permission: Permission.INDUSTRY_MANAGE,
|
||||||
|
description: '行业分类树与行业包绑定',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dicts',
|
||||||
|
label: '字典',
|
||||||
|
path: '/platform/dicts',
|
||||||
|
icon: IconDocs,
|
||||||
|
permission: Permission.DICT_MANAGE,
|
||||||
|
description: '核心字典与业务枚举',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audit',
|
||||||
|
label: '审计日志',
|
||||||
|
path: '/platform/audit',
|
||||||
|
icon: IconView,
|
||||||
|
permission: Permission.AUDIT_READ,
|
||||||
|
description: '关键操作留痕与导出记录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ai',
|
||||||
|
label: '模型接入',
|
||||||
|
path: '/platform/ai',
|
||||||
|
icon: IconRobot,
|
||||||
|
permission: Permission.AI_CONFIGURE,
|
||||||
|
description: '供应商、路由与预算阈值',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
label: '系统配置',
|
||||||
|
path: '/platform/settings',
|
||||||
|
icon: IconSettings,
|
||||||
|
permission: Permission.SYSTEM_CONFIGURE,
|
||||||
|
description: '阈值、上限与功能开关',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'industry-packs',
|
||||||
|
label: '行业包',
|
||||||
|
path: '/platform/industry-packs',
|
||||||
|
icon: IconGrid,
|
||||||
|
permission: Permission.INDUSTRY_MANAGE,
|
||||||
|
description: '行业包版本、发布与灰度',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'forms',
|
||||||
|
label: '表单设计器',
|
||||||
|
path: '/platform/forms',
|
||||||
|
icon: IconGrid,
|
||||||
|
permission: Permission.FORM_DESIGN,
|
||||||
|
description: '受控字段白名单与表单布局',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'customer-fields',
|
||||||
|
label: '客户字段',
|
||||||
|
path: '/platform/customer-fields',
|
||||||
|
icon: IconTask,
|
||||||
|
permission: Permission.CUSTOMER_MANAGE,
|
||||||
|
description: '客户级扩展字段与审批',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'compliance',
|
||||||
|
label: '合规规则',
|
||||||
|
path: '/platform/compliance',
|
||||||
|
icon: IconShield,
|
||||||
|
permission: Permission.SYSTEM_CONFIGURE,
|
||||||
|
description: '禁忌词、极限词与合规规则集',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function filterNavByPermissions(
|
||||||
|
modules: readonly NavModule[],
|
||||||
|
permissions: ReadonlySet<string>,
|
||||||
|
): NavModule[] {
|
||||||
|
return modules
|
||||||
|
.map((module) => {
|
||||||
|
if (module.permission && !permissions.has(module.permission)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const items = module.items.filter(
|
||||||
|
(item) => item.permission === undefined || permissions.has(item.permission),
|
||||||
|
);
|
||||||
|
return items.length > 0 ? { ...module, items } : null;
|
||||||
|
})
|
||||||
|
.filter((module): module is NavModule => module !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNavModule(
|
||||||
|
modules: readonly NavModule[],
|
||||||
|
moduleKey: string,
|
||||||
|
): NavModule | undefined {
|
||||||
|
return modules.find((module) => module.key === moduleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveNav {
|
||||||
|
module: NavModule;
|
||||||
|
item: NavItem | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiveNav(modules: readonly NavModule[], pathname: string): ActiveNav | null {
|
||||||
|
const normalized = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
|
||||||
|
for (const module of modules) {
|
||||||
|
const item = module.items.find((candidate) => candidate.path === normalized);
|
||||||
|
if (item) {
|
||||||
|
return { module, item };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBreadcrumbs(modules: readonly NavModule[], pathname: string): BreadcrumbEntry[] {
|
||||||
|
const active = findActiveNav(modules, pathname);
|
||||||
|
if (!active) {
|
||||||
|
return [{ key: 'home', label: '工作台', path: '/' }];
|
||||||
|
}
|
||||||
|
const entries: BreadcrumbEntry[] = [{ key: 'home', label: '工作台', path: '/' }];
|
||||||
|
if (active.module.key !== 'dashboard') {
|
||||||
|
entries.push({ key: active.module.key, label: active.module.label });
|
||||||
|
}
|
||||||
|
if (active.item && active.item.path !== '/' && active.item.key !== active.module.items[0]?.key) {
|
||||||
|
entries.push({ key: active.item.key, label: active.item.label, path: active.item.path });
|
||||||
|
} else if (active.item && active.item.path !== '/') {
|
||||||
|
entries.push({ key: active.item.key, label: active.item.label, path: active.item.path });
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { IconAlert } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface ErrorLayoutProps {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
traceId?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorLayout({
|
||||||
|
code,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
traceId,
|
||||||
|
actions,
|
||||||
|
}: ErrorLayoutProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<main className="lx-error-page" data-testid={`error-${code}`}>
|
||||||
|
<div className="lx-error-page__code" aria-hidden="true">
|
||||||
|
{code}
|
||||||
|
</div>
|
||||||
|
<div className="lx-error-page__body">
|
||||||
|
<span className="lx-error-page__icon">
|
||||||
|
<IconAlert size={22} />
|
||||||
|
</span>
|
||||||
|
<p className="lx-eyebrow">灵犀 · 访问异常</p>
|
||||||
|
<h1 className="lx-error-page__title">{title}</h1>
|
||||||
|
<p className="lx-error-page__description">{description}</p>
|
||||||
|
{traceId !== undefined ? (
|
||||||
|
<p className="lx-error-page__trace">
|
||||||
|
trace_id:<code>{traceId}</code>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="lx-error-page__actions">{actions}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { ErrorLayout } from './ErrorLayout';
|
||||||
|
|
||||||
|
export function ForbiddenPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<ErrorLayout
|
||||||
|
code="403"
|
||||||
|
title="没有访问权限"
|
||||||
|
description="你的账号没有该页面的权限点。如需开通,请联系平台管理员调整角色。"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.home);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回工作台
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回上一页
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { ErrorLayout } from './ErrorLayout';
|
||||||
|
|
||||||
|
export function NotFoundPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<ErrorLayout
|
||||||
|
code="404"
|
||||||
|
title="页面不存在"
|
||||||
|
description="地址可能已变更或输入有误。可以回到工作台,从顶部模块菜单重新进入。"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.home);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回工作台
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { ErrorLayout } from './ErrorLayout';
|
||||||
|
|
||||||
|
export function ServerErrorPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const traceId = searchParams.get('trace_id') ?? undefined;
|
||||||
|
return (
|
||||||
|
<ErrorLayout
|
||||||
|
code="500"
|
||||||
|
title="服务暂时不可用"
|
||||||
|
description="请求处理失败,请稍后重试。若问题持续出现,请把 trace_id 提供给平台管理员排查。"
|
||||||
|
traceId={traceId}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.home);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
返回工作台
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
import { ErrorLayout } from './ErrorLayout';
|
||||||
|
|
||||||
|
export function UnauthorizedPage(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<ErrorLayout
|
||||||
|
code="401"
|
||||||
|
title="请先登录"
|
||||||
|
description="当前页面需要登录后访问。登录成功后会自动回到工作台。"
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(ROUTES.login);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
|
import {
|
||||||
|
IconChart,
|
||||||
|
IconChevronRight,
|
||||||
|
IconClock,
|
||||||
|
IconDownload,
|
||||||
|
IconExport,
|
||||||
|
IconImageAdd,
|
||||||
|
IconPlay,
|
||||||
|
IconScript,
|
||||||
|
IconTask,
|
||||||
|
} from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
interface ProductionTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
progress: number;
|
||||||
|
status: 'running' | 'review' | 'done';
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASKS: readonly ProductionTask[] = [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
name: '夏日防晒 · 图片合成批次',
|
||||||
|
kind: '图片合成',
|
||||||
|
progress: 68,
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: '12 分钟前',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
name: '通勤穿搭 · 视频逐镜生成',
|
||||||
|
kind: '视频生产',
|
||||||
|
progress: 42,
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: '26 分钟前',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-3',
|
||||||
|
name: '会员日 · 脚本批量生成',
|
||||||
|
kind: '脚本',
|
||||||
|
progress: 100,
|
||||||
|
status: 'review',
|
||||||
|
updatedAt: '1 小时前',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-4',
|
||||||
|
name: '春季新品 · 快筛候选集',
|
||||||
|
kind: '快筛',
|
||||||
|
progress: 100,
|
||||||
|
status: 'done',
|
||||||
|
updatedAt: '今天 10:24',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<ProductionTask['status'], string> = {
|
||||||
|
running: '进行中',
|
||||||
|
review: '待审核',
|
||||||
|
done: '已完成',
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = [
|
||||||
|
{
|
||||||
|
key: 'script',
|
||||||
|
label: '新建脚本',
|
||||||
|
hint: '从要素卡开始',
|
||||||
|
path: '/scripts/batch',
|
||||||
|
icon: IconScript,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
label: '图片合成',
|
||||||
|
hint: 'PSD 套版与抠图',
|
||||||
|
path: '/material/image-compose',
|
||||||
|
icon: IconImageAdd,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'video',
|
||||||
|
label: '视频生产',
|
||||||
|
hint: '逐镜生成成片',
|
||||||
|
path: '/material/video',
|
||||||
|
icon: IconPlay,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'export',
|
||||||
|
label: '导出素材',
|
||||||
|
hint: '勾选后导出并归档',
|
||||||
|
path: '/delivery/export',
|
||||||
|
icon: IconExport,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RECENT_ASSETS = [
|
||||||
|
{ id: 'a1', name: '防晒喷雾 · 主图 01', spec: '1080 × 1920', tone: 1 },
|
||||||
|
{ id: 'a2', name: '通勤穿搭 · 封面', spec: '1080 × 1920', tone: 2 },
|
||||||
|
{ id: 'a3', name: '会员日 · 利益点', spec: '1080 × 1920', tone: 3 },
|
||||||
|
{ id: 'a4', name: '春季新品 · 场景 A', spec: '1080 × 1920', tone: 4 },
|
||||||
|
{ id: 'a5', name: '防晒喷雾 · 细节', spec: '1080 × 1080', tone: 5 },
|
||||||
|
{ id: 'a6', name: '通勤穿搭 · 结尾卡', spec: '1080 × 1920', tone: 6 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function HomeHero({ userName }: { userName: string }): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<header className="lx-home__hero">
|
||||||
|
<div className="lx-home__hero-copy">
|
||||||
|
<h1>工作台</h1>
|
||||||
|
<p className="lx-home__hero-meta">
|
||||||
|
<span>{userName},欢迎回来</span>
|
||||||
|
<span className="lx-home__hero-dot" aria-hidden="true" />
|
||||||
|
<span>2026 年 11 月 2 日 周一</span>
|
||||||
|
<span className="lx-home__hero-dot" aria-hidden="true" />
|
||||||
|
<span>今日已产出 18 条素材</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="lx-home__hero-actions">
|
||||||
|
<Button
|
||||||
|
icon={<IconExport size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
void navigate('/delivery/export');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
导出报表
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
icon={<IconImageAdd size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
void navigate('/material/image-compose');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建生产任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRICS = [
|
||||||
|
{
|
||||||
|
key: 'output',
|
||||||
|
label: '今日产出',
|
||||||
|
value: '128',
|
||||||
|
unit: '条',
|
||||||
|
foot: '较昨日 +12.4%',
|
||||||
|
trend: '+12.4%',
|
||||||
|
icon: IconChart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'running',
|
||||||
|
label: '进行中任务',
|
||||||
|
value: '6',
|
||||||
|
unit: '个',
|
||||||
|
foot: '2 个排队中 · 预计 4:12 完成',
|
||||||
|
trend: '',
|
||||||
|
icon: IconTask,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'review',
|
||||||
|
label: '待审核',
|
||||||
|
value: '24',
|
||||||
|
unit: '条',
|
||||||
|
foot: '8 条已超 2 小时',
|
||||||
|
trend: '',
|
||||||
|
icon: IconClock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'export',
|
||||||
|
label: '本周导出',
|
||||||
|
value: '512',
|
||||||
|
unit: '条',
|
||||||
|
foot: '较上周 +8.1%',
|
||||||
|
trend: '+8.1%',
|
||||||
|
icon: IconDownload,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function HomeMetrics(): ReactNode {
|
||||||
|
return (
|
||||||
|
<section className="lx-metrics" aria-label="生产概览">
|
||||||
|
{METRICS.map((metric) => {
|
||||||
|
const Icon = metric.icon;
|
||||||
|
return (
|
||||||
|
<article className="lx-metric" key={metric.key}>
|
||||||
|
<div className="lx-metric__head">
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<span className="lx-metric__icon">
|
||||||
|
<Icon size={16} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="lx-metric__value">
|
||||||
|
{metric.value}
|
||||||
|
<span className="lx-metric__unit">{metric.unit}</span>
|
||||||
|
</p>
|
||||||
|
<p className="lx-metric__foot">
|
||||||
|
{metric.trend === '' ? (
|
||||||
|
metric.foot
|
||||||
|
) : (
|
||||||
|
<span className="lx-metric__trend">{metric.trend}</span>
|
||||||
|
)}
|
||||||
|
{metric.trend === '' ? null : ` · ${metric.foot}`}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskRow({ task }: { task: ProductionTask }): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-table__row" role="row">
|
||||||
|
<span className="lx-table__name" role="cell">
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
<span className="lx-table__muted" role="cell">
|
||||||
|
{task.kind}
|
||||||
|
</span>
|
||||||
|
<span className="lx-table__progress" role="cell">
|
||||||
|
<span className="lx-progress" aria-hidden="true">
|
||||||
|
<span className="lx-progress__bar" style={{ width: `${task.progress}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-progress__value">{task.progress}%</span>
|
||||||
|
</span>
|
||||||
|
<span role="cell">
|
||||||
|
<span className={`lx-chip lx-chip--${task.status}`}>{STATUS_LABEL[task.status]}</span>
|
||||||
|
</span>
|
||||||
|
<span className="lx-table__muted" role="cell">
|
||||||
|
{task.updatedAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskPanel(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<section className="lx-panel lx-home__tasks" aria-labelledby="home-tasks-title">
|
||||||
|
<header className="lx-panel__head">
|
||||||
|
<div>
|
||||||
|
<h2 id="home-tasks-title">生产任务</h2>
|
||||||
|
<p>按最近更新时间排序</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-link-button"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate('/material/tasks');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看全部
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lx-table" role="table" aria-label="进行中的任务">
|
||||||
|
<div className="lx-table__head" role="row">
|
||||||
|
<span role="columnheader">任务</span>
|
||||||
|
<span role="columnheader">类型</span>
|
||||||
|
<span role="columnheader">进度</span>
|
||||||
|
<span role="columnheader">状态</span>
|
||||||
|
<span role="columnheader">更新时间</span>
|
||||||
|
</div>
|
||||||
|
{TASKS.map((task) => (
|
||||||
|
<TaskRow key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickPanel(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<aside className="lx-panel lx-home__quick" aria-labelledby="home-quick-title">
|
||||||
|
<header className="lx-panel__head">
|
||||||
|
<div>
|
||||||
|
<h2 id="home-quick-title">快速开始</h2>
|
||||||
|
<p>常用生产入口</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="lx-quick-list">
|
||||||
|
{QUICK_ACTIONS.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.key}
|
||||||
|
type="button"
|
||||||
|
className="lx-quick-item"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate(action.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="lx-quick-item__icon">
|
||||||
|
<Icon size={18} />
|
||||||
|
</span>
|
||||||
|
<span className="lx-quick-item__copy">
|
||||||
|
<span className="lx-quick-item__label">{action.label}</span>
|
||||||
|
<span className="lx-quick-item__hint">{action.hint}</span>
|
||||||
|
</span>
|
||||||
|
<IconChevronRight size={16} className="lx-quick-item__arrow" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentAssetsPanel(): ReactNode {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<section className="lx-panel lx-home__recent" aria-labelledby="home-recent-title">
|
||||||
|
<header className="lx-panel__head">
|
||||||
|
<div>
|
||||||
|
<h2 id="home-recent-title">最近素材</h2>
|
||||||
|
<p>今天产出的图片与视频封面</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lx-link-button"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate('/assets/library');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
打开资产库
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lx-asset-grid">
|
||||||
|
{RECENT_ASSETS.map((asset) => (
|
||||||
|
<button
|
||||||
|
key={asset.id}
|
||||||
|
type="button"
|
||||||
|
className="lx-asset-tile"
|
||||||
|
onClick={() => {
|
||||||
|
void navigate('/assets/library');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`lx-asset-tile__preview lx-asset-tile__preview--${asset.tone}`} />
|
||||||
|
<span className="lx-asset-tile__meta">
|
||||||
|
<span className="lx-asset-tile__name">{asset.name}</span>
|
||||||
|
<span className="lx-asset-tile__spec">{asset.spec}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage(): ReactNode {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
return (
|
||||||
|
<div className="lx-home">
|
||||||
|
<HomeHero userName={user?.name ?? '管理员'} />
|
||||||
|
<HomeMetrics />
|
||||||
|
<div className="lx-home__grid">
|
||||||
|
<TaskPanel />
|
||||||
|
<QuickPanel />
|
||||||
|
</div>
|
||||||
|
<RecentAssetsPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function LoginBrandPanel(): ReactNode {
|
||||||
|
return (
|
||||||
|
<header className="lx-login__topbar">
|
||||||
|
<div className="lx-login__brand">
|
||||||
|
<span className="lx-login__mark">灵</span>
|
||||||
|
<span className="lx-login__wordmark">灵犀</span>
|
||||||
|
<span className="lx-login__brand-sub">素材生产平台 · LINGXI</span>
|
||||||
|
</div>
|
||||||
|
<span className="lx-login__badge">内部系统 · 仅限授权成员</span>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Button, Checkbox, Input } from '@douyinfe/semi-ui-19';
|
||||||
|
import { IconLock, IconMail } from '@lingxi/ui/icons';
|
||||||
|
import type { ReactNode, SyntheticEvent } from 'react';
|
||||||
|
|
||||||
|
export interface LoginFormCardProps {
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
submitting: boolean;
|
||||||
|
onAccountChange: (value: string) => void;
|
||||||
|
onPasswordChange: (value: string) => void;
|
||||||
|
onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginFormHeader(): ReactNode {
|
||||||
|
return (
|
||||||
|
<header className="lx-login__form-head">
|
||||||
|
<h2>登录灵犀</h2>
|
||||||
|
<p>使用企业账号登录素材生产平台</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginFormFooter(): ReactNode {
|
||||||
|
return (
|
||||||
|
<footer className="lx-login__form-foot">
|
||||||
|
<span className="lx-login__form-foot-dot" aria-hidden="true" />
|
||||||
|
账号由平台管理员开通,如需协助请联系 IT 服务台
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginFields({
|
||||||
|
account,
|
||||||
|
password,
|
||||||
|
onAccountChange,
|
||||||
|
onPasswordChange,
|
||||||
|
}: Pick<
|
||||||
|
LoginFormCardProps,
|
||||||
|
'account' | 'password' | 'onAccountChange' | 'onPasswordChange'
|
||||||
|
>): ReactNode {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="lx-field">
|
||||||
|
<span className="lx-field__label">账号</span>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
value={account}
|
||||||
|
onChange={onAccountChange}
|
||||||
|
placeholder="请输入账号或邮箱"
|
||||||
|
prefix={<IconMail size={16} />}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="lx-field">
|
||||||
|
<span className="lx-field__label">密码</span>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={onPasswordChange}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
prefix={<IconLock size={16} />}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="lx-login__form-meta">
|
||||||
|
<Checkbox defaultChecked>记住我(7 天)</Checkbox>
|
||||||
|
<button type="button" className="lx-link-button">
|
||||||
|
忘记密码?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginFormCard({
|
||||||
|
account,
|
||||||
|
password,
|
||||||
|
submitting,
|
||||||
|
onAccountChange,
|
||||||
|
onPasswordChange,
|
||||||
|
onSubmit,
|
||||||
|
}: LoginFormCardProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="lx-login__form">
|
||||||
|
<LoginFormHeader />
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} data-testid="login-form">
|
||||||
|
<LoginFields
|
||||||
|
account={account}
|
||||||
|
password={password}
|
||||||
|
onAccountChange={onAccountChange}
|
||||||
|
onPasswordChange={onPasswordChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
theme="solid"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
loading={submitting}
|
||||||
|
data-testid="login-submit"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<LoginFormFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { LoginBrandPanel } from './LoginBrandPanel';
|
||||||
|
import { LoginFormCard } from './LoginFormCard';
|
||||||
|
import { useLoginForm } from './use-login-form';
|
||||||
|
|
||||||
|
export default function LoginPage(): ReactNode {
|
||||||
|
const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="lx-login">
|
||||||
|
<LoginBrandPanel />
|
||||||
|
<section className="lx-login__panel">
|
||||||
|
<LoginFormCard
|
||||||
|
account={account}
|
||||||
|
password={password}
|
||||||
|
submitting={submitting}
|
||||||
|
onAccountChange={setAccount}
|
||||||
|
onPasswordChange={setPassword}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
void handleSubmit(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<footer className="lx-login__footer">
|
||||||
|
<span>© 2026 灵犀 · 内部系统</span>
|
||||||
|
<span className="lx-login__footer-links">
|
||||||
|
<span>隐私政策</span>
|
||||||
|
<span>服务条款</span>
|
||||||
|
<span>华东一区</span>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { notifyError, notifyWarning } from '@lingxi/ui';
|
||||||
|
import { type SyntheticEvent, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
import { ApiError, request } from '@/api/client';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
import type { AuthUser } from '@/stores/auth-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
user: AuthUser;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false';
|
||||||
|
|
||||||
|
export function useLoginForm(): {
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
submitting: boolean;
|
||||||
|
setAccount: (value: string) => void;
|
||||||
|
setPassword: (value: string) => void;
|
||||||
|
handleSubmit: (event: SyntheticEvent<HTMLFormElement>) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const signInDemo = useAuthStore((state) => state.signInDemo);
|
||||||
|
const setSession = useAuthStore((state) => state.setSession);
|
||||||
|
const [account, setAccount] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (account.trim() === '' || password === '') {
|
||||||
|
notifyWarning('请输入账号和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (demoEnabled) {
|
||||||
|
signInDemo();
|
||||||
|
await navigate(ROUTES.home, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = await request<LoginResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email: account.trim(), password },
|
||||||
|
});
|
||||||
|
setSession({
|
||||||
|
accessToken: session.accessToken,
|
||||||
|
refreshToken: session.refreshToken ?? null,
|
||||||
|
user: session.user,
|
||||||
|
permissions: session.permissions,
|
||||||
|
});
|
||||||
|
await navigate(ROUTES.home, { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
notifyError(
|
||||||
|
error instanceof Error ? error.message : '登录失败,请稍后重试',
|
||||||
|
error instanceof ApiError ? error.traceId : undefined,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { account, password, submitting, setAccount, setPassword, handleSubmit };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { PageEmpty } from '@lingxi/ui';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
filterNavByPermissions,
|
||||||
|
findNavModule,
|
||||||
|
NAV_MODULES,
|
||||||
|
type NavItem,
|
||||||
|
} from '@/navigation/nav-config';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
function resolvePage(
|
||||||
|
moduleKey: string | undefined,
|
||||||
|
itemKey: string | undefined,
|
||||||
|
): { moduleLabel: string; item: NavItem } | null {
|
||||||
|
if (moduleKey === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const permissions = new Set(useAuthStore.getState().permissions);
|
||||||
|
const modules = filterNavByPermissions(NAV_MODULES, permissions);
|
||||||
|
const module = findNavModule(modules, moduleKey);
|
||||||
|
if (module === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const item = module.items.find((candidate) => candidate.key === itemKey) ?? module.items[0];
|
||||||
|
return item === undefined ? null : { moduleLabel: module.label, item };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkspacePage(): ReactNode {
|
||||||
|
const { moduleKey, itemKey } = useParams<{ moduleKey: string; itemKey?: string }>();
|
||||||
|
const page = resolvePage(moduleKey, itemKey);
|
||||||
|
|
||||||
|
if (page === null) {
|
||||||
|
return (
|
||||||
|
<section className="lx-panel lx-workspace-missing">
|
||||||
|
<p className="lx-eyebrow">404</p>
|
||||||
|
<h1>页面不存在</h1>
|
||||||
|
<p>该功能可能已调整或你没有访问权限。</p>
|
||||||
|
<Link to={ROUTES.home} className="lx-link-button">
|
||||||
|
返回工作台
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="lx-workspace-page">
|
||||||
|
<header className="lx-page-header">
|
||||||
|
<div>
|
||||||
|
<p className="lx-eyebrow">{page.moduleLabel}</p>
|
||||||
|
<h1>{page.item.label}</h1>
|
||||||
|
<p className="lx-page-header__description">{page.item.description}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lx-panel lx-panel--empty">
|
||||||
|
<PageEmpty />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createBrowserRouter } from 'react-router';
|
||||||
|
|
||||||
|
import { AppLayout } from '@/layout/AppLayout';
|
||||||
|
import { ForbiddenPage } from '@/pages/errors/ForbiddenPage';
|
||||||
|
import { NotFoundPage } from '@/pages/errors/NotFoundPage';
|
||||||
|
import { ServerErrorPage } from '@/pages/errors/ServerErrorPage';
|
||||||
|
import { UnauthorizedPage } from '@/pages/errors/UnauthorizedPage';
|
||||||
|
import { ROUTES } from '@/routes';
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: ROUTES.login,
|
||||||
|
lazy: async () => ({
|
||||||
|
Component: (await import('@/pages/login/LoginPage')).default,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <AppLayout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
lazy: async () => ({
|
||||||
|
Component: (await import('@/pages/home/HomePage')).default,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':moduleKey/:itemKey?',
|
||||||
|
lazy: async () => ({
|
||||||
|
Component: (await import('@/pages/workspace/WorkspacePage')).default,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: ROUTES.unauthorized, element: <UnauthorizedPage /> },
|
||||||
|
{ path: ROUTES.forbidden, element: <ForbiddenPage /> },
|
||||||
|
{ path: ROUTES.notFound, element: <NotFoundPage /> },
|
||||||
|
{ path: ROUTES.serverError, element: <ServerErrorPage /> },
|
||||||
|
{ path: '*', element: <NotFoundPage /> },
|
||||||
|
]);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** 前端路由常量(工程规范 04:路由集中管理,禁止散落字符串) */
|
||||||
|
export const ROUTES = {
|
||||||
|
home: '/',
|
||||||
|
login: '/login',
|
||||||
|
unauthorized: '/401',
|
||||||
|
forbidden: '/403',
|
||||||
|
notFound: '/404',
|
||||||
|
serverError: '/500',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { PERMISSION_CODES } from '@lingxi/shared';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
roleName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
user: AuthUser | null;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSessionInput {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string | null;
|
||||||
|
user: AuthUser;
|
||||||
|
permissions?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState extends AuthSession {
|
||||||
|
setSession: (input: SetSessionInput) => void;
|
||||||
|
setTokens: (tokens: { accessToken: string; refreshToken?: string | null }) => void;
|
||||||
|
clearSession: () => void;
|
||||||
|
signInDemo: () => void;
|
||||||
|
hasPermission: (code: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'lingxi.auth.session';
|
||||||
|
|
||||||
|
const EMPTY_SESSION: AuthSession = {
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
permissions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEMO_USER: AuthUser = {
|
||||||
|
id: '01J0DEMO000000000000000000',
|
||||||
|
name: '灵犀管理员',
|
||||||
|
email: 'admin@lingxi.local',
|
||||||
|
roleName: '平台管理员',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthUser(value: unknown): value is AuthUser {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
typeof value.id === 'string' &&
|
||||||
|
typeof value.name === 'string' &&
|
||||||
|
typeof value.email === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthSession(value: unknown): value is AuthSession {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
(typeof value.accessToken === 'string' || value.accessToken === null) &&
|
||||||
|
(typeof value.refreshToken === 'string' || value.refreshToken === null) &&
|
||||||
|
(isAuthUser(value.user) || value.user === null) &&
|
||||||
|
Array.isArray(value.permissions) &&
|
||||||
|
value.permissions.every((permission) => typeof permission === 'string')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPersistedSession(): AuthSession {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return EMPTY_SESSION;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === null) {
|
||||||
|
return EMPTY_SESSION;
|
||||||
|
}
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return isAuthSession(parsed) ? parsed : EMPTY_SESSION;
|
||||||
|
} catch {
|
||||||
|
return EMPTY_SESSION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSession(session: AuthSession): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (session.accessToken === null) {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||||
|
} catch {
|
||||||
|
// 本地存储不可用(隐私模式等)时退化为内存会话,不影响登录流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()((set, get) => ({
|
||||||
|
...readPersistedSession(),
|
||||||
|
setSession: (input) => {
|
||||||
|
const session: AuthSession = {
|
||||||
|
accessToken: input.accessToken,
|
||||||
|
refreshToken: input.refreshToken ?? null,
|
||||||
|
user: input.user,
|
||||||
|
permissions: [...(input.permissions ?? PERMISSION_CODES)],
|
||||||
|
};
|
||||||
|
persistSession(session);
|
||||||
|
set(session);
|
||||||
|
},
|
||||||
|
setTokens: (tokens) => {
|
||||||
|
const current = get();
|
||||||
|
const session: AuthSession = {
|
||||||
|
...current,
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken ?? current.refreshToken,
|
||||||
|
};
|
||||||
|
persistSession(session);
|
||||||
|
set({ accessToken: session.accessToken, refreshToken: session.refreshToken });
|
||||||
|
},
|
||||||
|
clearSession: () => {
|
||||||
|
persistSession(EMPTY_SESSION);
|
||||||
|
set(EMPTY_SESSION);
|
||||||
|
},
|
||||||
|
signInDemo: () => {
|
||||||
|
const session: AuthSession = {
|
||||||
|
accessToken: 'demo-access-token',
|
||||||
|
refreshToken: 'demo-refresh-token',
|
||||||
|
user: DEMO_USER,
|
||||||
|
permissions: [...PERMISSION_CODES],
|
||||||
|
};
|
||||||
|
persistSession(session);
|
||||||
|
set(session);
|
||||||
|
},
|
||||||
|
hasPermission: (code) => get().permissions.includes(code),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--lx-font-sans);
|
||||||
|
font-size: var(--lx-font-size-md);
|
||||||
|
line-height: var(--lx-line-height-normal);
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
background: var(--lx-color-bg-app);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--lx-color-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--lx-radius-pill);
|
||||||
|
background: var(--lx-color-border-strong);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 基础排版 ---------- */
|
||||||
|
|
||||||
|
.lx-eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-link-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
transition: color var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-link-button:hover {
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 面板 ---------- */
|
||||||
|
|
||||||
|
.lx-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
padding: var(--lx-space-5);
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
box-shadow: var(--lx-shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-panel__head h2 {
|
||||||
|
font-size: var(--lx-font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: var(--lx-line-height-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-panel__head p {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 标签 / 进度 ---------- */
|
||||||
|
|
||||||
|
.lx-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-chip::before {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-chip--running {
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
background: var(--lx-color-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-chip--review {
|
||||||
|
color: var(--lx-color-warning);
|
||||||
|
background: #fff4e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-chip--done {
|
||||||
|
color: var(--lx-color-success);
|
||||||
|
background: #e8f8ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-chip--failed {
|
||||||
|
color: var(--lx-color-danger);
|
||||||
|
background: #ffecec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-progress {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 96px;
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--lx-radius-pill);
|
||||||
|
background: #eef1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-progress__bar {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-progress__value {
|
||||||
|
width: 40px;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
|
||||||
|
.lx-table {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__head,
|
||||||
|
.lx-table__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1.6fr) 110px 170px 110px 120px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
padding: 0 var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__head {
|
||||||
|
height: 40px;
|
||||||
|
border-bottom: 1px solid var(--lx-color-border);
|
||||||
|
background: var(--lx-color-bg-subtle);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__row {
|
||||||
|
min-height: 52px;
|
||||||
|
border-bottom: 1px solid var(--lx-color-border);
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
transition: background var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__row:hover {
|
||||||
|
background: var(--lx-color-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__muted {
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-table__progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--lx-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 字段 ---------- */
|
||||||
|
|
||||||
|
.lx-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-field__label {
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-field .semi-input-wrapper {
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
/* 方向 A · 云白蓝:白顶栏 + 左栏 + 浅灰工作区 */
|
||||||
|
|
||||||
|
.lx-shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--lx-color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶栏 ---------- */
|
||||||
|
|
||||||
|
.lx-topbar {
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 var(--lx-topbar-height);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--lx-space-6);
|
||||||
|
height: var(--lx-topbar-height);
|
||||||
|
padding: 0 var(--lx-space-6) 0 var(--lx-space-5);
|
||||||
|
border-bottom: 1px solid var(--lx-color-border);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__brand {
|
||||||
|
display: flex;
|
||||||
|
min-width: 184px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__mark {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #1677ff, #4096ff);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__name {
|
||||||
|
font-size: var(--lx-font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__tag {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__brand-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__modules {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--lx-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__module {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: var(--lx-font-size-md);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
transition: color var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__module:hover {
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__module.is-active {
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__module.is-active::after {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 12px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
background: var(--lx-color-brand);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--lx-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-icon-button {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
transition:
|
||||||
|
color var(--lx-motion-fast) var(--lx-ease-out),
|
||||||
|
background var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-icon-button:hover {
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
background: var(--lx-color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-icon-button__dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
right: 7px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border: 1.5px solid var(--lx-color-bg-panel);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lx-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 用户菜单 ---------- */
|
||||||
|
|
||||||
|
.lx-user {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__trigger {
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 6px 0 4px;
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
transition: background var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__trigger:hover {
|
||||||
|
background: var(--lx-color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lx-color-brand-soft);
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__name {
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__chevron {
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
width: 208px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
background: var(--lx-color-bg-elevated);
|
||||||
|
box-shadow: var(--lx-shadow-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__menu-header {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--lx-color-border);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__menu-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__menu-item:hover {
|
||||||
|
background: var(--lx-color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 主体 ---------- */
|
||||||
|
|
||||||
|
.lx-shell__body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 var(--lx-sidebar-width);
|
||||||
|
width: var(--lx-sidebar-width);
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--lx-color-border);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
transition: width var(--lx-motion-base) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-shell--collapsed .lx-sidebar {
|
||||||
|
flex-basis: var(--lx-sidebar-collapsed-width);
|
||||||
|
width: var(--lx-sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__heading {
|
||||||
|
display: flex;
|
||||||
|
height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--lx-space-4) 0 var(--lx-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__title {
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__toggle:hover {
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
background: var(--lx-color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__nav {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__item {
|
||||||
|
display: flex;
|
||||||
|
min-height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
transition:
|
||||||
|
color var(--lx-motion-fast) var(--lx-ease-out),
|
||||||
|
background var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__item:hover {
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
background: var(--lx-color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__item.is-active {
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
background: var(--lx-color-brand-soft);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__item-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__footer {
|
||||||
|
padding: var(--lx-space-4) var(--lx-space-5);
|
||||||
|
border-top: 1px solid var(--lx-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__env {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__env-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lx-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-shell--collapsed .lx-sidebar__title,
|
||||||
|
.lx-shell--collapsed .lx-sidebar__item-label,
|
||||||
|
.lx-shell--collapsed .lx-sidebar__env-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-shell--collapsed .lx-sidebar__heading {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-shell--collapsed .lx-sidebar__item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-shell--collapsed .lx-sidebar__footer {
|
||||||
|
padding: var(--lx-space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 工作区 ---------- */
|
||||||
|
|
||||||
|
.lx-workspace {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 44px;
|
||||||
|
height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 var(--lx-content-padding);
|
||||||
|
border-bottom: 1px solid var(--lx-color-border);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-breadcrumbs__entry {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-breadcrumbs__link {
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-breadcrumbs__link:hover {
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-breadcrumbs__current {
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-breadcrumbs__sep {
|
||||||
|
color: #c9cdd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--lx-content-padding);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1080px) {
|
||||||
|
.lx-topbar__tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar {
|
||||||
|
flex-basis: var(--lx-sidebar-collapsed-width);
|
||||||
|
width: var(--lx-sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__title,
|
||||||
|
.lx-sidebar__item-label,
|
||||||
|
.lx-sidebar__env-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__heading {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-sidebar__item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.lx-topbar {
|
||||||
|
gap: var(--lx-space-3);
|
||||||
|
padding: 0 var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__brand {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-topbar__tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-user__meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-content-padding,
|
||||||
|
.lx-page {
|
||||||
|
padding: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
/* 方向 A · 云白蓝:登录页与业务页 */
|
||||||
|
|
||||||
|
/* ---------- 登录页 ---------- */
|
||||||
|
|
||||||
|
.lx-login {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 96px var(--lx-space-6) 72px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 0%, #eef5ff 0%, transparent 46%), var(--lx-color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__topbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
height: 72px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--lx-space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__mark {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #1677ff, #4096ff);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__wordmark {
|
||||||
|
font-size: var(--lx-font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__brand-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__badge::before {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lx-color-success);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
box-shadow: 0 8px 28px rgba(31, 35, 41, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--lx-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form-head h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form-head p {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--lx-space-3);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: var(--lx-space-4);
|
||||||
|
border-top: 1px solid var(--lx-color-border);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__form-foot-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
flex: 0 0 5px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lx-color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__footer {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--lx-space-8);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-login__footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 工作台 ---------- */
|
||||||
|
|
||||||
|
.lx-home {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__hero h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__hero-meta {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 6px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__hero-dot {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--lx-color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--lx-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
box-shadow: var(--lx-shadow-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
background: var(--lx-color-brand-soft);
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric__value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric__unit {
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric__foot {
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-metric__trend {
|
||||||
|
color: var(--lx-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 316px;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__tasks {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__quick {
|
||||||
|
gap: var(--lx-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--lx-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color var(--lx-motion-fast) var(--lx-ease-out),
|
||||||
|
background var(--lx-motion-fast) var(--lx-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item:hover {
|
||||||
|
border-color: var(--lx-color-brand);
|
||||||
|
background: #f8fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
background: var(--lx-color-brand-soft);
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item__copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item__label {
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item__hint {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-quick-item__arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__recent {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: var(--lx-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview {
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-md);
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview--1 {
|
||||||
|
background: linear-gradient(150deg, #dbeafe, #93c5fd 55%, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview--2 {
|
||||||
|
background: linear-gradient(150deg, #e0f2fe, #bae6fd 55%, #7dd3fc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview--3 {
|
||||||
|
background: linear-gradient(150deg, #fef3c7, #fde68a 55%, #fcd34d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview--4 {
|
||||||
|
background: linear-gradient(150deg, #dcfce7, #bbf7d0 55%, #86efac);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview--5 {
|
||||||
|
background: linear-gradient(150deg, #fae8ff, #f5d0fe 55%, #e9a8f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__preview--6 {
|
||||||
|
background: linear-gradient(150deg, #ffe4e6, #fecdd3 55%, #fda4af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-primary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-tile__spec {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 占位工作区 / 错误页 ---------- */
|
||||||
|
|
||||||
|
.lx-page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: var(--lx-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-page-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-page-header p {
|
||||||
|
font-size: var(--lx-font-size-sm);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-panel--empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-workspace-missing {
|
||||||
|
display: flex;
|
||||||
|
min-height: 320px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--lx-space-3);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-workspace-missing strong {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 70vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--lx-space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__body {
|
||||||
|
display: flex;
|
||||||
|
max-width: 480px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--lx-color-brand-soft);
|
||||||
|
color: var(--lx-color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__code {
|
||||||
|
margin-top: var(--lx-space-4);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__title {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__description {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__trace {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--lx-color-border);
|
||||||
|
border-radius: var(--lx-radius-sm);
|
||||||
|
background: var(--lx-color-bg-panel);
|
||||||
|
font-size: var(--lx-font-size-xs);
|
||||||
|
color: var(--lx-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__trace code {
|
||||||
|
font-family: var(--lx-font-mono);
|
||||||
|
color: var(--lx-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-error-page__actions {
|
||||||
|
display: flex;
|
||||||
|
margin-top: var(--lx-space-5);
|
||||||
|
gap: var(--lx-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1200px) {
|
||||||
|
.lx-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 720px) {
|
||||||
|
.lx-metrics {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-asset-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lx-home__hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_PUBLIC_API_BASE_URL?: string;
|
||||||
|
readonly VITE_PUBLIC_DEMO_SESSION?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 4173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.spec.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov', 'json-summary'],
|
||||||
|
include: ['src/api/**/*.ts', 'src/stores/**/*.ts', 'src/navigation/**/*.ts'],
|
||||||
|
exclude: ['src/api/generated/**', 'src/**/*.spec.{ts,tsx}'],
|
||||||
|
thresholds: {
|
||||||
|
lines: 70,
|
||||||
|
functions: 70,
|
||||||
|
statements: 70,
|
||||||
|
branches: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lingxi/shared": "workspace:*",
|
||||||
|
"dotenv": "18.0.9",
|
||||||
|
"ioredis": "5.11.1",
|
||||||
|
"pino": "10.4.0",
|
||||||
|
"zod": "3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"tsx": "4.23.15",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
REDIS_URL: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
|
||||||
|
'REDIS_URL 必须是 redis:// 连接串',
|
||||||
|
),
|
||||||
|
QUEUE: z.string().min(1).default('default'),
|
||||||
|
WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface WorkerConfig {
|
||||||
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
|
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
||||||
|
redisUrl: string;
|
||||||
|
queue: string;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): WorkerConfig {
|
||||||
|
const result = envSchema.safeParse(source);
|
||||||
|
if (!result.success) {
|
||||||
|
const details = result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new Error(`Worker 环境变量校验失败:${details}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nodeEnv: result.data.NODE_ENV,
|
||||||
|
logLevel: result.data.LOG_LEVEL,
|
||||||
|
redisUrl: result.data.REDIS_URL,
|
||||||
|
queue: result.data.QUEUE,
|
||||||
|
concurrency: result.data.WORKER_CONCURRENCY,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { config as loadDotenvFile } from 'dotenv';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
import { loadWorkerConfig } from './config';
|
||||||
|
|
||||||
|
function loadDotenv(): void {
|
||||||
|
for (const file of ['.env.local', '.env']) {
|
||||||
|
const path = resolve(process.cwd(), file);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
loadDotenvFile({ path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
loadDotenv();
|
||||||
|
const config = loadWorkerConfig();
|
||||||
|
const logger = pino({ level: config.logLevel, base: { service: 'lingxi-worker' } });
|
||||||
|
|
||||||
|
const redis = new Redis(config.redisUrl, {
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
});
|
||||||
|
redis.on('error', (error: Error) => {
|
||||||
|
logger.error({ err: error }, 'redis connection error');
|
||||||
|
});
|
||||||
|
|
||||||
|
await redis.connect();
|
||||||
|
await redis.ping();
|
||||||
|
logger.info({ queue: config.queue, concurrency: config.concurrency }, 'worker 已启动,等待任务');
|
||||||
|
|
||||||
|
// A0 只验证进程与依赖可用;BullMQ 消费者在 M1 批次接入(QUEUE 环境变量决定队列)
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
logger.debug({ queue: config.queue }, 'worker heartbeat');
|
||||||
|
}, 30_000);
|
||||||
|
heartbeat.unref();
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
logger.info({ signal }, 'worker 正在退出');
|
||||||
|
await redis.quit();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void shutdown('SIGINT');
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void shutdown('SIGTERM');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** @type {import('@commitlint/types').UserConfig} */
|
||||||
|
export default {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [
|
||||||
|
2,
|
||||||
|
'always',
|
||||||
|
[
|
||||||
|
'feat',
|
||||||
|
'fix',
|
||||||
|
'docs',
|
||||||
|
'style',
|
||||||
|
'refactor',
|
||||||
|
'perf',
|
||||||
|
'test',
|
||||||
|
'build',
|
||||||
|
'ci',
|
||||||
|
'chore',
|
||||||
|
'revert',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'subject-case': [0],
|
||||||
|
'subject-empty': [2, 'never'],
|
||||||
|
'header-max-length': [2, 'always', 72],
|
||||||
|
'subject-full-stop': [2, 'never', '。'],
|
||||||
|
'body-max-line-length': [0],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 灵犀 · A0 工程骨架与契约基线 实施报告 V0.1
|
||||||
|
|
||||||
|
> 对应开发计划:`docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md` §6.1「A0 工程骨架与契约基线」
|
||||||
|
> 实施分支:`feat/a0-skeleton`(基于 `docs/dev-plan-v0.1`)
|
||||||
|
> 日期:2026-11-02
|
||||||
|
|
||||||
|
## 1. 完成范围
|
||||||
|
|
||||||
|
| WBS | 状态 | 落地内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A0-INF-01 Monorepo / 工具链 | 完成 | pnpm workspace + Turborepo;TS strict、ESLint 9 flat + Prettier + commitlint + lint-staged + husky;`.nvmrc` / `engines` / `packageManager` / preinstall 仅允许 pnpm;目录与 AGENTS.md §5 一致 |
|
||||||
|
| A0-INF-02 本地依赖与迁移种子 | 完成(种子范围受限,见 §4.4) | `infra/docker-compose.dev.yaml`(PG 18 / Redis 7.4 / S3 兼容服务)+ 健康检查;`.env.example`;Prisma 初始化与首版迁移;`system_configs` 幂等种子 |
|
||||||
|
| A0-INF-03 CI 与质量门禁 | 完成 | `.gitea/workflows/ci.yml`:install → format → lint → typecheck → test → build → migrate → test:int → openapi:export/diff → e2e;产物归档;失败阻断 |
|
||||||
|
| A0-BE-01 API 骨架 | 完成 | NestJS 11 + Fastify;Pino + `trace_id`;统一错误体 `{code,message,details,trace_id}`;`GET /health`;`/api/docs` 与 OpenAPI JSON;Zod 管道;CORS / Helmet |
|
||||||
|
| A0-BE-02 Prisma 基线 | 完成 | `PrismaService` 封装;`outbox_events` / `idempotency_keys` / `system_configs`;ULID `char(26)`、软删/时间字段约定;首版迁移可重复执行 |
|
||||||
|
| A0-SH-01 shared 包 | 完成 | 20 个错误码(40001–50003)、权限点常量与 `模块:动作` 规范、ULID、游标分页、时间/命名工具、通用 Zod schema;前后端共同引用 |
|
||||||
|
| A0-FE-01 Web 骨架 | 完成 | Vite 6 + React 19 + Router 7 + TanStack Query 5 + Zustand 5;Semi `@douyinfe/semi-ui-19` 与 React19 适配入口;路径别名;fetch 请求层(token 注入、单飞 401 刷新、统一错误提示) |
|
||||||
|
| A0-FE-02 UI 包基线 | 完成 | Semi 二次封装(表格/查询表单/弹窗/空态/错误态/加载骨架);`icons.ts` 统一图标出口;locale;主题 token;ESLint 拦截越过出口的图标/UI 库与内联色值 |
|
||||||
|
| A0-FE-03 布局与登录壳 | 完成 | 顶部模块菜单 + 左侧功能菜单 + 面包屑;用户菜单、任务角标占位;401/403/404/500 页;登录页;导航集中配置并按权限过滤 |
|
||||||
|
| A0-QA-01 冒烟与报告 | 完成 | 健康检查冒烟、OpenAPI 冒烟、Playwright 登录/403/404/模块切换 4 条;覆盖率报告;CI 归档 |
|
||||||
|
|
||||||
|
## 2. 关键实现位置
|
||||||
|
|
||||||
|
- 契约与工具:`packages/shared/src/`(`errors/codes.ts`、`errors/api-error.ts`、`permissions.ts`、`ulid.ts`、`naming.ts`、`schemas/`)
|
||||||
|
- UI 基线:`packages/ui/src/`(`icons.ts`、`components/`、`theme/tokens.css`、`locale.ts`)
|
||||||
|
- API:`apps/api/src/`(`bootstrap.ts`、`common/filters`、`common/pipes`、`modules/platform/health/`、`openapi/document.ts`、`scripts/export-openapi.ts`)
|
||||||
|
- 数据:`prisma/schema.prisma`、`prisma/migrations/20261102083445_init_platform_baseline/`、`prisma/seed.ts`、`prisma/SEED.md`
|
||||||
|
- Web:`apps/web/src/`(`layout/`、`navigation/nav-config.ts`、`api/client.ts`、`pages/`、`stores/auth-store.ts`、`styles/`)
|
||||||
|
- 基础设施:`infra/docker-compose.dev.yaml`、`infra/compose.dev.yaml`、`.env.example`
|
||||||
|
- 契约快照:`openapi/lingxi.openapi.json`、`apps/web/src/api/generated/openapi.d.ts`
|
||||||
|
|
||||||
|
## 3. 本地门禁实测
|
||||||
|
|
||||||
|
| 命令 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm format:check` | 通过 |
|
||||||
|
| `pnpm lint` | 通过(0 error / 0 warning) |
|
||||||
|
| `pnpm typecheck` | 通过(10/10 turbo tasks) |
|
||||||
|
| `pnpm test` | 通过(shared 42、api 20、web 12) |
|
||||||
|
| `pnpm build` | 通过(8/8 turbo tasks) |
|
||||||
|
| `pnpm test:int` | 通过(真实 PostgreSQL + Redis,`/health` 200) |
|
||||||
|
| `pnpm test:e2e` | 通过(4/4,Playwright/Chrome) |
|
||||||
|
| `pnpm openapi:export` + `pnpm openapi:diff` | 通过;快照含 20 个错误码,OpenAPI `3.1.0` |
|
||||||
|
| `pnpm compose:up` | 通过;`lingxi-pg` / `lingxi-redis` / `lingxi-s3` 均 healthy |
|
||||||
|
| API 启动 | `/health` 200、`/api/docs` 200、`/api/openapi.json` 200 |
|
||||||
|
| Worker 启动 | 连接 Redis 成功,收到 SIGTERM 后正常退出 |
|
||||||
|
| 种子幂等 | 连续执行两次,`system_configs` 保持 7 条 |
|
||||||
|
|
||||||
|
## 4. 与文档的差异与待确认
|
||||||
|
|
||||||
|
### 4.1 宿主端口(环境适配)
|
||||||
|
|
||||||
|
本机已有容器占用 5432 / 6379,A0 compose 宿主端口改为 **15432(PG)/ 16379(Redis)/ 19000–19001(S3)**,容器内端口不变。`.env.example` 已按宿主端口提供连接串;CI 使用 GitHub Actions services 的标准端口。
|
||||||
|
|
||||||
|
### 4.2 对象存储镜像不可得(环境适配)
|
||||||
|
|
||||||
|
当前 Docker 环境无法拉取 `minio/minio` 与 `minio/mc`(registry denied),`dl.min.io` 二进制返回 410。A0 使用 **`adobe/s3mock`** 并在启动时创建 `assets / renders / temp / archive` 四个桶,仅用于本地开发;CI / 生产接入真实 MinIO 时替换 compose 中 `s3` 服务即可,应用侧仍只依赖 S3 协议与环境变量。
|
||||||
|
|
||||||
|
### 4.3 演示登录(A0 临时)
|
||||||
|
|
||||||
|
A1 才实现真实鉴权。A0 登录页在 `VITE_PUBLIC_DEMO_SESSION` 非 `false` 时使用本地演示会话进入布局,便于验收布局与权限过滤;A1 接入真实登录后删除该分支。
|
||||||
|
|
||||||
|
### 4.4 种子范围【待确认 Q-A0-01】
|
||||||
|
|
||||||
|
开发计划 A0-INF-02 要求种子包含「超管、示例角色、核心字典、电商行业包占位」,但 `users / roles / dict_* / industry_packages` 等表分别在 A1 / A3 / A4 / B0 批次建立。A0 不提前建业务表,仅 seed `system_configs` 7 项,并在 `prisma/SEED.md` 登记待补种子清单。**请确认是否接受该拆分**;若要求 A0 内建表,则需调整开发计划批次顺序。
|
||||||
|
|
||||||
|
### 4.5 错误码 HTTP 映射
|
||||||
|
|
||||||
|
工程规范 04 未规定 50002 / 50003 的 HTTP 状态。A0 将 `50002 依赖不可用` 映射为 **503**(与 `/health` 依赖异常返回值一致),`50003 超时` 映射为 504;如需改为 502 / 504,请确认后调整 `packages/shared/src/errors/codes.ts` 与 `codes.spec.ts`。
|
||||||
|
|
||||||
|
### 4.6 Prisma CLI 与环境文件
|
||||||
|
|
||||||
|
Prisma CLI 只自动读取 `.env`,不读取 `.env.local`。本地需执行 `cp .env.local .env`(两者均在 `.gitignore`);应用与种子脚本同时兼容 `.env.local` 与 `.env`。
|
||||||
|
|
||||||
|
### 4.7 OpenAPI 破坏性检查实现
|
||||||
|
|
||||||
|
npm 上 `oasdiff` 为占位包(0.0.1-security),A0 暂用自研 `scripts/openapi-diff.mjs` 做路径/操作/字段级破坏性检查;CI 可后续替换为 oasdiff 官方二进制,命令仍为 `pnpm openapi:diff`。
|
||||||
|
|
||||||
|
### 4.8 前端视觉方向
|
||||||
|
|
||||||
|
已按 oil-ui-pro 流程产出两轮方案:
|
||||||
|
- v1(概念化:剪辑台 / 稿纸 / 调度台)被否决;
|
||||||
|
- v2(传统且现代化:云白蓝 / 石墨 / 暖白青)经产品负责人选择 **A · 云白蓝**,已落地到 `packages/ui/src/theme/tokens.css`、`apps/web/src/styles/`、`apps/web/index.html`:
|
||||||
|
- 顶部模块菜单 + 左侧功能菜单 + 面包屑;
|
||||||
|
- 白色顶栏、`#F5F7FA` 工作区、白卡片、`#1677FF` 主色、6px 圆角;
|
||||||
|
- 工作台:4 指标卡 + 生产任务表 + 快速入口 + 最近素材;
|
||||||
|
- 登录页:左上品牌、居中白卡、浅灰背景。
|
||||||
|
|
||||||
|
设计探索产物位于 `design/a0-ui-v2/`(含 6 屏、对比页与截图),按 `.gitignore` 约定不入库,仅本地留存评审。
|
||||||
|
|
||||||
|
## 5. 已知风险与后续动作
|
||||||
|
|
||||||
|
1. **A1 必须移除演示登录**并接入真实 JWT / 刷新令牌 / 权限守卫。
|
||||||
|
2. 种子业务数据待 A1 / A3 / A4 / B0 表就绪后补齐(Q-A0-01)。
|
||||||
|
3. MinIO 本地替代方案需在具备镜像的环境回归;生产 compose 与 CI 镜像另行处理。
|
||||||
|
4. Web 主包约 599 kB(Semi 全量 CSS 689 kB),A0 接受;M1 前做路由级拆分与 Semi 按需样式。
|
||||||
|
5. CI 首次运行需在 Gitea Actions 上验证 runner 是否支持 services 与 Playwright 浏览器安装。
|
||||||
|
|
||||||
|
## 6. 下一步(A1)
|
||||||
|
|
||||||
|
按开发计划进入 A1:identity / client 模块、真实登录与 RBAC、`ScopeContext`、客户与组织基础表、登录与权限三态测试。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
async function signIn(page: Page): Promise<void> {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByPlaceholder('请输入账号或邮箱').fill('admin');
|
||||||
|
await page.getByPlaceholder('请输入密码').fill('demo1234');
|
||||||
|
await page.getByTestId('login-submit').click();
|
||||||
|
await expect(page.getByTestId('app-shell')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('登录页打开并进入工作台', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
|
||||||
|
await signIn(page);
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('navigation', { name: '模块导航' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('403 页可演示', async ({ page }) => {
|
||||||
|
await page.goto('/403');
|
||||||
|
await expect(page.getByTestId('error-403')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('模块切换与侧边栏、面包屑联动', async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await page.getByRole('button', { name: '素材', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/material/);
|
||||||
|
await page.getByRole('link', { name: '快筛', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: '快筛' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('navigation', { name: '面包屑' })).toContainText('素材');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未知路由展示 404', async ({ page }) => {
|
||||||
|
await page.goto('/404');
|
||||||
|
await expect(page.getByTestId('error-404')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// 灵犀 ESLint 9 flat config
|
||||||
|
// 依据:docs/工程规范/03-格式化与Lint.md
|
||||||
|
// 分层:根规则(全仓库)→ apps/web(React)→ packages/ui(图标统一出口)→ scripts(允许 console)
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import importX from 'eslint-plugin-import-x';
|
||||||
|
import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
const UI_LIB_MESSAGE = 'UI 组件库只能用 @douyinfe/semi-ui-19(工程规范 03 §3.2);禁装其他组件库。';
|
||||||
|
const ICON_MESSAGE =
|
||||||
|
'图标只能从 @lingxi/ui/icons 按语义名导入;禁止直接引用图标库或内联 SVG(AGENTS §3.3)。';
|
||||||
|
const COLOR_MESSAGE =
|
||||||
|
'业务代码禁止颜色字面量;颜色只能定义在 packages/ui/src/theme/ 的 token 中(工程规范 03 §3.2)。';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/coverage/**',
|
||||||
|
'**/.turbo/**',
|
||||||
|
'.tools/**',
|
||||||
|
'design/**',
|
||||||
|
'docs/**',
|
||||||
|
'openapi/**',
|
||||||
|
'prisma/migrations/**',
|
||||||
|
'项目参考/**',
|
||||||
|
'playwright-report/**',
|
||||||
|
'test-results/**',
|
||||||
|
'**/*.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
{
|
||||||
|
name: 'lingxi/base',
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.browser, ...globals.es2022 },
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'import-x': importX,
|
||||||
|
'simple-import-sort': simpleImportSort,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import-x/resolver': {
|
||||||
|
typescript: {
|
||||||
|
project: ['./tsconfig.json', './apps/*/tsconfig.json', './packages/*/tsconfig.json'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
||||||
|
rules: {
|
||||||
|
// ---- TypeScript 硬约束(AGENTS §3.2)----
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'error',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/restrict-template-expressions': [
|
||||||
|
'error',
|
||||||
|
{ allowNumber: true, allowBoolean: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/naming-convention': [
|
||||||
|
'error',
|
||||||
|
{ selector: 'typeLike', format: ['PascalCase'] },
|
||||||
|
{ selector: 'enumMember', format: ['UPPER_CASE'] },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||||
|
// ---- 导入与依赖方向 ----
|
||||||
|
'import-x/no-cycle': ['error', { maxDepth: 8, ignoreExternal: false }],
|
||||||
|
'import-x/no-self-import': 'error',
|
||||||
|
'simple-import-sort/imports': 'error',
|
||||||
|
'simple-import-sort/exports': 'error',
|
||||||
|
// ---- 通用 ----
|
||||||
|
'no-console': 'error',
|
||||||
|
eqeqeq: ['error', 'always'],
|
||||||
|
'no-var': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
complexity: ['warn', { max: 15 }],
|
||||||
|
'max-lines-per-function': [
|
||||||
|
'warn',
|
||||||
|
{ max: 50, skipBlankLines: true, skipComments: true, IIFEs: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 纯 JS 配置文件不做类型检查
|
||||||
|
{
|
||||||
|
name: 'lingxi/js-config',
|
||||||
|
files: ['**/*.{js,mjs,cjs}'],
|
||||||
|
extends: [tseslint.configs.disableTypeChecked],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
'import-x/no-cycle': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 应用层:Web(React 19)
|
||||||
|
{
|
||||||
|
name: 'lingxi/apps-web',
|
||||||
|
files: ['apps/web/**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
'jsx-a11y': jsxA11y,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
'jsx-a11y/alt-text': 'error',
|
||||||
|
'jsx-a11y/no-autofocus': 'warn',
|
||||||
|
'@typescript-eslint/no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{ group: ['@douyinfe/semi-ui', '@douyinfe/semi-ui/*'], message: UI_LIB_MESSAGE },
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'@remixicon/react',
|
||||||
|
'react-icons',
|
||||||
|
'react-icons/*',
|
||||||
|
'lucide-react',
|
||||||
|
'lucide',
|
||||||
|
'semi-icons',
|
||||||
|
'@douyinfe/semi-icons',
|
||||||
|
'@heroicons/*',
|
||||||
|
'@ant-design/icons',
|
||||||
|
],
|
||||||
|
message: ICON_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'antd',
|
||||||
|
'@arco-design/web-react',
|
||||||
|
'element-plus',
|
||||||
|
'naive-ui',
|
||||||
|
'@mui/*',
|
||||||
|
'@chakra-ui/*',
|
||||||
|
],
|
||||||
|
message: UI_LIB_MESSAGE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/^#(?:[0-9a-fA-F]{3,8})$/]',
|
||||||
|
message: COLOR_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/^rgba?\\(/]',
|
||||||
|
message: COLOR_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: "JSXOpeningElement[name.name='svg']",
|
||||||
|
message: ICON_MESSAGE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// packages/ui:图标统一出口(只有 icons.ts 可直接引用 Remix Icon)
|
||||||
|
{
|
||||||
|
name: 'lingxi/ui-icons',
|
||||||
|
files: ['packages/ui/src/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{ patterns: [{ group: ['@remixicon/react'], message: ICON_MESSAGE }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'lingxi/ui-icons-exit',
|
||||||
|
files: ['packages/ui/src/icons.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-restricted-imports': ['error', { patterns: [] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Nest 模块装饰器类按框架约定是空类
|
||||||
|
{
|
||||||
|
name: 'lingxi/nest-modules',
|
||||||
|
files: ['apps/api/**/*.module.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-extraneous-class': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 脚本层:允许 console(CLI 输出)
|
||||||
|
{
|
||||||
|
name: 'lingxi/scripts',
|
||||||
|
files: ['scripts/**/*.{ts,mjs,js}', '**/scripts/**/*.ts', 'prisma/seed.ts', 'e2e/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 测试文件:允许更松的表达
|
||||||
|
{
|
||||||
|
name: 'lingxi/tests',
|
||||||
|
files: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.int.spec.ts', 'e2e/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
|
'max-lines-per-function': 'off',
|
||||||
|
complexity: 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 技术架构 §4.1.1 引用的路径为 infra/compose.dev.yaml,这里做 include 兼容。
|
||||||
|
# 实际定义在 infra/docker-compose.dev.yaml(开发计划 §6.1 与工程规范 README 的命名)。
|
||||||
|
include:
|
||||||
|
- docker-compose.dev.yaml
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 灵犀 · 本地开发环境编排(PG18 / Redis 7.4 / S3 兼容对象存储)
|
||||||
|
# 用法:pnpm compose:up / pnpm compose:down
|
||||||
|
# 说明:本机已有其他 PG/Redis 占用 5432/6379,这里宿主端口统一改用 15432/16379,
|
||||||
|
# 容器内端口保持标准值;对应 DATABASE_URL / REDIS_URL 见根目录 .env.example。
|
||||||
|
name: lingxi-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
container_name: lingxi-pg
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: lingxi
|
||||||
|
POSTGRES_PASSWORD: lingxi_dev_password
|
||||||
|
POSTGRES_DB: lingxi_dev
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- '15432:5432'
|
||||||
|
volumes:
|
||||||
|
# PG18+ 镜像要求挂载 /var/lib/postgresql(数据放在版本子目录,便于 pg_upgrade)
|
||||||
|
- lingxi-pg-data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U lingxi -d lingxi_dev']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
container_name: lingxi-redis
|
||||||
|
command: ['redis-server', '--appendonly', 'yes']
|
||||||
|
ports:
|
||||||
|
- '16379:6379'
|
||||||
|
volumes:
|
||||||
|
- lingxi-redis-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# 对象存储:技术架构要求 MinIO(S3 协议)。2026-11 实测 minio/minio 与 minio/mc
|
||||||
|
# 官方镜像在当前网络/仓库均无法拉取(registry denied),quay.io 需鉴权;
|
||||||
|
# 本地开发改用 S3 兼容的 Adobe S3Mock,接口与四桶(assets/renders/temp/archive)保持一致,
|
||||||
|
# S3_ENDPOINT 仍指向 19000。CI/生产接入真实 MinIO,切换时仅替换本服务。
|
||||||
|
s3:
|
||||||
|
image: adobe/s3mock:latest
|
||||||
|
container_name: lingxi-s3
|
||||||
|
environment:
|
||||||
|
COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: assets,renders,temp,archive
|
||||||
|
COM_ADOBE_TESTING_S3MOCK_STORE_REGION: us-east-1
|
||||||
|
COM_ADOBE_TESTING_S3MOCK_STORE_ROOT: /s3mockroot
|
||||||
|
COM_ADOBE_TESTING_S3MOCK_STORE_RETAIN_FILES_ON_EXIT: 'true'
|
||||||
|
ports:
|
||||||
|
- '19000:9090'
|
||||||
|
- '19001:9191'
|
||||||
|
volumes:
|
||||||
|
- lingxi-s3-data:/s3mockroot
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:9090/ || exit 1']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
lingxi-pg-data:
|
||||||
|
lingxi-redis-data:
|
||||||
|
lingxi-s3-data:
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"paths": {
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
||||||
|
"operationId": "HealthController_check",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "全部依赖健康",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HealthResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "依赖不可用(错误码 50002),details 内附各依赖检查结果"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "健康检查",
|
||||||
|
"tags": [
|
||||||
|
"platform"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"title": "灵犀 API",
|
||||||
|
"description": "信息流广告素材生产平台后端 API。\n\n统一约定:\n- 前缀 /api/v1(健康检查 /health 除外)\n- 错误体 { code, message, details, trace_id }\n- 列表响应 { items, next_cursor }",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"contact": {}
|
||||||
|
},
|
||||||
|
"tags": [],
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "/api/v1",
|
||||||
|
"description": "API v1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearer": {
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT",
|
||||||
|
"type": "http"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"ComponentHealthDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"up",
|
||||||
|
"down"
|
||||||
|
],
|
||||||
|
"example": "up"
|
||||||
|
},
|
||||||
|
"latencyMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "检查耗时(毫秒)",
|
||||||
|
"example": 3
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "失败原因(仅 down 时返回)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"latencyMs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HealthChecksDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"database": {
|
||||||
|
"$ref": "#/components/schemas/ComponentHealthDto"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"$ref": "#/components/schemas/ComponentHealthDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"database",
|
||||||
|
"redis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HealthResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ok",
|
||||||
|
"degraded"
|
||||||
|
],
|
||||||
|
"example": "ok"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "development"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0.1.0"
|
||||||
|
},
|
||||||
|
"uptimeSeconds": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "进程运行秒数",
|
||||||
|
"example": 128
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"$ref": "#/components/schemas/HealthChecksDto"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-11-02T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"env",
|
||||||
|
"version",
|
||||||
|
"uptimeSeconds",
|
||||||
|
"checks",
|
||||||
|
"timestamp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ErrorResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "错误码(见 ErrorCode 常量)",
|
||||||
|
"example": 42202
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "可直接展示给用户的提示",
|
||||||
|
"example": "内容触发合规拦截"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "补充信息(字段错误、命中规则等),结构由错误码决定",
|
||||||
|
"example": {
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"path": "name",
|
||||||
|
"message": "Required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trace_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "请求追踪 ID,与日志同源",
|
||||||
|
"example": "01JABCDEF0123456789ABCDEFG"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"code",
|
||||||
|
"message",
|
||||||
|
"trace_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ErrorCode": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "业务错误码(只允许使用 @lingxi/shared 中登记的错误码)",
|
||||||
|
"enum": [
|
||||||
|
40001,
|
||||||
|
40101,
|
||||||
|
40102,
|
||||||
|
40103,
|
||||||
|
40301,
|
||||||
|
40302,
|
||||||
|
40303,
|
||||||
|
40304,
|
||||||
|
40401,
|
||||||
|
40901,
|
||||||
|
40902,
|
||||||
|
40903,
|
||||||
|
42201,
|
||||||
|
42202,
|
||||||
|
42203,
|
||||||
|
42901,
|
||||||
|
42902,
|
||||||
|
50001,
|
||||||
|
50002,
|
||||||
|
50003
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ErrorResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"code",
|
||||||
|
"message",
|
||||||
|
"trace_id"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"$ref": "#/components/schemas/ErrorCode"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"description": "补充信息,结构由错误码决定"
|
||||||
|
},
|
||||||
|
"trace_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-error-codes": [
|
||||||
|
{
|
||||||
|
"code": 40001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40102
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40103
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40301
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40302
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40303
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40304
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40401
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40901
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40902
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 40903
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 42201
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 42202
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 42203
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 42901
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 42902
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 50001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 50002
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": 50003
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-api-config": {
|
||||||
|
"env": "development",
|
||||||
|
"featureVideo": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "lingxi",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "灵犀 · 信息流广告素材一站式生产平台",
|
||||||
|
"packageManager": "pnpm@10.34.9",
|
||||||
|
"engines": {
|
||||||
|
"node": "24.15.0",
|
||||||
|
"pnpm": ">=10.0.0 <11.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"preinstall": "node scripts/only-allow-pnpm.mjs",
|
||||||
|
"prepare": "husky",
|
||||||
|
"dev": "turbo run dev --parallel",
|
||||||
|
"build": "turbo run build",
|
||||||
|
"lint": "eslint . --max-warnings=0",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"typecheck": "turbo run typecheck && tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "turbo run test",
|
||||||
|
"test:int": "turbo run test:int",
|
||||||
|
"test:e2e": "turbo run build && playwright test",
|
||||||
|
"compose:up": "docker compose -f infra/docker-compose.dev.yaml up -d --wait",
|
||||||
|
"compose:down": "docker compose -f infra/docker-compose.dev.yaml down",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:reset": "prisma migrate reset --force",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"openapi:export": "tsx --tsconfig apps/api/tsconfig.json apps/api/src/scripts/export-openapi.ts && openapi-typescript openapi/lingxi.openapi.json -o apps/web/src/api/generated/openapi.d.ts",
|
||||||
|
"openapi:diff": "node scripts/openapi-diff.mjs",
|
||||||
|
"postinstall": "prisma generate"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "20.5.3",
|
||||||
|
"@commitlint/config-conventional": "20.5.3",
|
||||||
|
"@eslint/js": "9.39.5",
|
||||||
|
"@playwright/test": "1.59.1",
|
||||||
|
"@prisma/client": "6.19.3",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"@types/react": "19.3.0",
|
||||||
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@vitest/coverage-v8": "3.2.7",
|
||||||
|
"dotenv": "18.0.9",
|
||||||
|
"eslint": "9.39.5",
|
||||||
|
"eslint-import-resolver-typescript": "4.4.5",
|
||||||
|
"eslint-plugin-import-x": "4.17.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "0.5.7",
|
||||||
|
"eslint-plugin-simple-import-sort": "14.0.0",
|
||||||
|
"globals": "17.14.0",
|
||||||
|
"husky": "9.1.7",
|
||||||
|
"lint-staged": "16.4.0",
|
||||||
|
"openapi-typescript": "7.13.0",
|
||||||
|
"prettier": "3.9.10",
|
||||||
|
"prisma": "6.19.3",
|
||||||
|
"tsup": "8.5.1",
|
||||||
|
"tsx": "4.23.15",
|
||||||
|
"turbo": "2.11.10",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.72.1",
|
||||||
|
"vitest": "3.2.7"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/ai-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "8.5.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @lingxi/ai-sdk 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||||
|
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||||
|
*/
|
||||||
|
export const PACKAGE_NAME = '@lingxi/ai-sdk';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/form-engine",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "8.5.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @lingxi/form-engine 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||||
|
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||||
|
*/
|
||||||
|
export const PACKAGE_NAME = '@lingxi/form-engine';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user