From 3a1415d1e1326125a7426ac85b22ae8ef9780a6a Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 30 Mar 2026 17:32:34 +0800 Subject: [PATCH 01/11] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E9=85=8D=E7=BD=AE=E5=B9=B6=E9=87=8D=E7=BB=84=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 6 - .../workflows/storage-persistence-guard.yml | 2 +- .gitignore | 11 + .vscode/settings.json | 3 - README.md | 9 +- docs/README.md | 28 ++ docs/{ => audits}/auth-session-acceptance.md | 4 + .../enterprise-ui-acceptance-checklist.md | 5 +- docs/{ => audits}/setup-config-code-audit.md | 8 +- .../{ => audits}/storage-persistence-audit.md | 4 + .../storage-persistence-governance.md | 10 +- docs/{ => guides}/release-checklist.md | 10 +- docs/{ => guides}/setup-config-api.md | 8 +- ...03-04-ctms-enterprise-ui-implementation.md | 8 +- docs/plans/2026-03-05-rd-monitor-design.md | 97 ------- .../2026-03-05-rd-monitor-implementation.md | 137 --------- ...26-03-27-production-init-implementation.md | 4 +- ...3-30-docs-reorganization-implementation.md | 116 ++++++++ ...=> local.postman_environment.example.json} | 9 +- docs/workbench-ui-optimization.md | 262 ------------------ frontend/.env | 6 - frontend/.env.example | 5 + test-results/.last-run.json | 4 - 23 files changed, 215 insertions(+), 541 deletions(-) delete mode 100644 .env delete mode 100644 .vscode/settings.json create mode 100644 docs/README.md rename docs/{ => audits}/auth-session-acceptance.md (93%) rename docs/{ui => audits}/enterprise-ui-acceptance-checklist.md (89%) rename docs/{ => audits}/setup-config-code-audit.md (93%) rename docs/{ => audits}/storage-persistence-audit.md (98%) rename docs/{ => audits}/storage-persistence-governance.md (88%) rename docs/{ => guides}/release-checklist.md (92%) rename docs/{ => guides}/setup-config-api.md (96%) delete mode 100644 docs/plans/2026-03-05-rd-monitor-design.md delete mode 100644 docs/plans/2026-03-05-rd-monitor-implementation.md create mode 100644 docs/plans/2026-03-30-docs-reorganization-implementation.md rename docs/postman/{local.postman_environment.json => local.postman_environment.example.json} (79%) delete mode 100644 docs/workbench-ui-optimization.md delete mode 100644 frontend/.env create mode 100644 frontend/.env.example delete mode 100644 test-results/.last-run.json diff --git a/.env b/.env deleted file mode 100644 index 46e4b092..00000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -# 递交日期 -> 受理日期 超期(月) -VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=1 -# 受理日期 -> 批准日期 超期(月) -VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=1 -# 启用项目概览API -VITE_USE_OVERVIEW_API=true diff --git a/.github/workflows/storage-persistence-guard.yml b/.github/workflows/storage-persistence-guard.yml index 01caf1f3..dab6ad20 100644 --- a/.github/workflows/storage-persistence-guard.yml +++ b/.github/workflows/storage-persistence-guard.yml @@ -21,4 +21,4 @@ jobs: - name: Run storage persistence guard run: | - python backend/scripts/storage_persistence_audit.py --format markdown --output docs/storage-persistence-audit.md --fail-on-banned + python backend/scripts/storage_persistence_audit.py --format markdown --output docs/audits/storage-persistence-audit.md --fail-on-banned diff --git a/.gitignore b/.gitignore index a9d5948b..a349a276 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ Thumbs.db *.swp *.swo +# Local env files +.env +.env.* +!.env.example +frontend/.env.* +!frontend/.env.example + # Python build artifacts __pycache__/ @@ -57,6 +64,10 @@ lerna-debug.log* logs/ tmp/ *.pid +test-results/ +docs/postman/local.postman_environment.json +docs/postman/*.local.json +!docs/postman/*.example.json # Docker / runtime data pg_data/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3b664107..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "git.ignoreLimitWarning": true -} \ No newline at end of file diff --git a/README.md b/README.md index 067637b1..fe785080 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,14 @@ ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` -- 发布检查清单:`docs/release-checklist.md` +- 发布检查清单:`docs/guides/release-checklist.md` + +## 本地配置 +- 本地编辑器配置(如 `.vscode/`)不纳入版本库。 +- 前端环境变量请使用 `frontend/.env`,可从 `frontend/.env.example` 复制。 +- 根目录 `.env` 不作为当前默认启动流程的提交配置文件。 +- Postman 本地环境请基于 `docs/postman/local.postman_environment.example.json` 自行复制,不提交个人环境文件。 +- 文档入口见 `docs/README.md`;当前操作手册集中在 `docs/guides/`,审计与治理文档集中在 `docs/audits/`。 ## 常用流程 1. 用管理员账号登录前端(默认 `admin@example.com / admin123`)。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..9b6e816c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ +# Docs Index + +CTMS 文档按用途分为三类:当前操作手册、审计与治理记录、历史实现计划。 + +## 当前常用 + +- [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁 +- [`guides/setup-config-api.md`](guides/setup-config-api.md): 立项配置接口、联调与冒烟说明 +- [`setup-config-curl-smoke.sh`](setup-config-curl-smoke.sh): 立项配置 curl 冒烟脚本 +- [`postman/setup-config.postman_collection.json`](postman/setup-config.postman_collection.json): Postman 联调集合 +- [`postman/local.postman_environment.example.json`](postman/local.postman_environment.example.json): Postman 本地环境模板 + +## 审计与治理 + +- [`audits/auth-session-acceptance.md`](audits/auth-session-acceptance.md): 鉴权会话验收清单 +- [`audits/enterprise-ui-acceptance-checklist.md`](audits/enterprise-ui-acceptance-checklist.md): Enterprise UI 验收结果 +- [`audits/setup-config-code-audit.md`](audits/setup-config-code-audit.md): 立项配置代码审计记录 +- [`audits/storage-persistence-audit.md`](audits/storage-persistence-audit.md): 存储落库扫描快照 +- [`audits/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 存储落库治理基线与流程 + +## 历史计划 + +- [`plans/`](plans): 设计稿、实施计划与历史交付记录 + +约定: +- `guides/` 放当前仍会被执行、查阅或复制命令的操作文档 +- `audits/` 放验收、治理、审计、扫描结果 +- `plans/` 只作为历史记录,不作为当前操作入口 diff --git a/docs/auth-session-acceptance.md b/docs/audits/auth-session-acceptance.md similarity index 93% rename from docs/auth-session-acceptance.md rename to docs/audits/auth-session-acceptance.md index 7ff9d776..6e7dbec8 100644 --- a/docs/auth-session-acceptance.md +++ b/docs/audits/auth-session-acceptance.md @@ -1,5 +1,9 @@ # Auth Session Acceptance Checklist +Status: `active` +Scope: `auth-session` +Last Updated: `2026-03-30` + 1) Active user 60 minutes without logout - 登录后持续操作 60 分钟,观察 token 多次 /auth/extend 且不会跳登录 diff --git a/docs/ui/enterprise-ui-acceptance-checklist.md b/docs/audits/enterprise-ui-acceptance-checklist.md similarity index 89% rename from docs/ui/enterprise-ui-acceptance-checklist.md rename to docs/audits/enterprise-ui-acceptance-checklist.md index a06ccaff..853e59c9 100644 --- a/docs/ui/enterprise-ui-acceptance-checklist.md +++ b/docs/audits/enterprise-ui-acceptance-checklist.md @@ -1,7 +1,8 @@ # Enterprise UI Acceptance Checklist -Status: `PASS` -Date: `2026-03-04` +Status: `pass` +Scope: `enterprise-ui` +Reviewed On: `2026-03-04` Reviewer: `Codex` - [x] Overview and milestones visual consistency diff --git a/docs/setup-config-code-audit.md b/docs/audits/setup-config-code-audit.md similarity index 93% rename from docs/setup-config-code-audit.md rename to docs/audits/setup-config-code-audit.md index 16c27c25..91ef11e6 100644 --- a/docs/setup-config-code-audit.md +++ b/docs/audits/setup-config-code-audit.md @@ -1,9 +1,13 @@ -# 立项配置代码审计报告(2026-02) +# 立项配置代码审计报告 + +状态: `archived` +适用范围: `setup-config` +审计时间: `2026-02` ## 范围 - 前端:`frontend/src/views/admin/ProjectDetail.vue`、`frontend/src/api/studies.ts`、`frontend/src/types/setupConfig.ts`、`frontend/src/utils/setupFieldLocator.ts` - 后端:`backend/app/api/v1/studies.py`、`backend/app/crud/study_setup_config.py`、`backend/app/schemas/study_setup_config.py`、`backend/app/services/setup_config_excel.py` -- 文档:`docs/setup-config-api.md`、`docs/setup-config-curl-smoke.sh`、`docs/postman/setup-config.postman_collection.json`、`backend/scripts/smoke_setup_config.py` +- 文档:`docs/guides/setup-config-api.md`、`docs/setup-config-curl-smoke.sh`、`docs/postman/setup-config.postman_collection.json`、`backend/scripts/smoke_setup_config.py` ## 发现清单 diff --git a/docs/storage-persistence-audit.md b/docs/audits/storage-persistence-audit.md similarity index 98% rename from docs/storage-persistence-audit.md rename to docs/audits/storage-persistence-audit.md index 183f1d3f..8182d9b1 100644 --- a/docs/storage-persistence-audit.md +++ b/docs/audits/storage-persistence-audit.md @@ -1,5 +1,9 @@ # 存储落库核查报告 +状态: `snapshot` +适用范围: `storage-persistence` +最后更新: `2026-02-27` + - 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src` - 总发现数: `46` - 高风险: `4` / 中风险: `0` / 低风险: `42` diff --git a/docs/storage-persistence-governance.md b/docs/audits/storage-persistence-governance.md similarity index 88% rename from docs/storage-persistence-governance.md rename to docs/audits/storage-persistence-governance.md index 129558d6..ffa19ce2 100644 --- a/docs/storage-persistence-governance.md +++ b/docs/audits/storage-persistence-governance.md @@ -1,4 +1,8 @@ -# 全项目重要数据落库治理基线(2026-02-27) +# 全项目重要数据落库治理基线 + +状态: `active` +适用范围: `storage-persistence` +最后更新: `2026-02-27` ## 1. 口径 @@ -8,11 +12,11 @@ ## 2. 自动核查入口 ```bash -backend/scripts/storage_persistence_audit.py --format markdown --output docs/storage-persistence-audit.md +backend/scripts/storage_persistence_audit.py --format markdown --output docs/audits/storage-persistence-audit.md backend/scripts/storage_persistence_audit.py --fail-on-banned ``` -- `docs/storage-persistence-audit.md`:当前扫描快照 +- `docs/audits/storage-persistence-audit.md`:当前扫描快照 - `--fail-on-banned`:用于阻断禁止项(当前默认禁止 `audit_local_*`) ## 3. 当前结论(基于 2026-02-27 扫描) diff --git a/docs/release-checklist.md b/docs/guides/release-checklist.md similarity index 92% rename from docs/release-checklist.md rename to docs/guides/release-checklist.md index d323f161..85b71223 100644 --- a/docs/release-checklist.md +++ b/docs/guides/release-checklist.md @@ -1,4 +1,8 @@ -# Release Checklist(立项配置) +# 立项配置发布清单 + +状态: `active` +适用范围: `setup-config` +最后更新: `2026-03-30` ## 1. 构建与迁移 - [ ] `docker compose run --rm backend-init` @@ -59,7 +63,7 @@ - [ ] 导入并执行 Postman 集合 `docs/postman/setup-config.postman_collection.json` ## 9. 发布前确认 -- [ ] 文档已更新:`docs/setup-config-api.md` +- [ ] 文档已更新:`docs/guides/setup-config-api.md` - [ ] 本清单已全量勾选 - [ ] 发布说明已包含新增接口与权限规则 @@ -67,5 +71,5 @@ - [ ] `cd frontend && npm run ui:contract` - [ ] `cd frontend && npm run type-check` - [ ] `cd frontend && npm run build` -- [ ] `docs/ui/enterprise-ui-acceptance-checklist.md` 状态为 `PASS` 且记录审阅日期 +- [ ] `docs/audits/enterprise-ui-acceptance-checklist.md` 状态为 `PASS` 且记录审阅日期 - [ ] 管理层演示路径已走查:`/project/overview -> /project/milestones -> /risk-issues/sae -> /fees/contracts -> /file-versions -> /admin/projects/:id` diff --git a/docs/setup-config-api.md b/docs/guides/setup-config-api.md similarity index 96% rename from docs/setup-config-api.md rename to docs/guides/setup-config-api.md index d2cfcdcb..d921466c 100644 --- a/docs/setup-config-api.md +++ b/docs/guides/setup-config-api.md @@ -1,4 +1,8 @@ -# 立项配置接口说明(Excel 导入导出版) +# 立项配置接口指南 + +状态: `active` +适用范围: `setup-config` +最后更新: `2026-03-30` ## 1. 概览 - 基础路径: `/api/v1/studies/{study_id}/setup-config` @@ -191,7 +195,7 @@ bash docs/setup-config-curl-smoke.sh ## 7. Postman 联调集合 - Collection: `docs/postman/setup-config.postman_collection.json` -- Environment: `docs/postman/local.postman_environment.json` +- Environment: `docs/postman/local.postman_environment.example.json`(导入后建议另存为你自己的本地环境) ## 8. 后端烟雾测试(非 pytest) ```bash diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md index f811be6c..c1d60a4f 100644 --- a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md +++ b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md @@ -409,8 +409,8 @@ git commit -m "feat(ui): unify loading empty error states" ### Task 9: Verification, Demo Path, and Release Gate **Files:** -- Create: `docs/ui/enterprise-ui-acceptance-checklist.md` -- Modify: `docs/release-checklist.md` +- Create: `docs/audits/enterprise-ui-acceptance-checklist.md` +- Modify: `docs/guides/release-checklist.md` - Test: manual smoke + build checks **Step 1: Write the failing test** @@ -435,7 +435,7 @@ Expected: PASS technically, but acceptance checklist remains FAIL/PENDING. - Execute manual smoke on target routes. - Update checklist to PASS with reviewer/date notes. -- Add release gating notes to `docs/release-checklist.md`. +- Add release gating notes to `docs/guides/release-checklist.md`. **Step 4: Run test to verify it passes** @@ -445,7 +445,7 @@ Expected: PASS for all commands and checklist completed. **Step 5: Commit** ```bash -git add docs/ui/enterprise-ui-acceptance-checklist.md docs/release-checklist.md +git add docs/audits/enterprise-ui-acceptance-checklist.md docs/guides/release-checklist.md git commit -m "docs(ui): add enterprise refresh acceptance and release gates" ``` diff --git a/docs/plans/2026-03-05-rd-monitor-design.md b/docs/plans/2026-03-05-rd-monitor-design.md deleted file mode 100644 index 6df62d8c..00000000 --- a/docs/plans/2026-03-05-rd-monitor-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# 研发项目进展监测系统(rd-monitor)设计文档 - -## 1. 目标与范围 -- 基于现有 CTMS 的界面/组件经验,建设一个完全独立的研发项目进展监测系统。 -- 新系统独立前后端、独立数据库、独立鉴权与权限体系。 -- 首期仅实现 PDF《项目进度概览 v2》中的核心功能:概览展示、进度录入、历史变更、导出、项目隐藏规则。 - -## 2. 系统边界 -- 独立仓库:`rd-monitor` -- 技术栈:Vue3 + Element Plus + FastAPI + SQLAlchemy + Alembic + PostgreSQL -- 与 CTMS 关系:仅复用可迁移的组件/样式实现,不依赖 CTMS API 与 CTMS 数据表。 - -## 3. 数据模型 -### 3.1 users -- `id` -- `username` -- `email` -- `password_hash` -- `role` (`ADMIN`/`PM`/`VIEWER`) -- `is_active` -- `created_at` - -### 3.2 projects -- `id` -- `project_code`(如 HP-001) -- `project_name` -- `regulatory_path` -- `status`(`ACTIVE`/`APPROVED`/`TERMINATED`) -- `created_at` - -### 3.3 stage_templates -- `id` -- `stage_key` -- `stage_name` -- `order_no` -- `regulatory_paths`(JSON 数组;空表示通用) -- `is_active` - -### 3.4 project_stages -- `id` -- `project_id` -- `stage_template_id` -- `order_no` -- `planned_start_date` -- `planned_end_date` -- `actual_finish_date` -- `actual_finish_flag`(`DONE`/`NOT_APPLICABLE`/`PENDING`) -- `adjust_reason` -- `remark` -- `updated_at` - -### 3.5 project_stage_change_logs -- `id` -- `project_stage_id` -- `changed_by` -- `before_json` -- `after_json` -- `comment` -- `created_at` - -## 4. 状态规则(按 PDF) -- `已完成`:`actual_finish_flag = DONE` 且 `actual_finish_date` 有值。 -- `未进行`:`actual_finish_flag = NOT_APPLICABLE`(“此阶段不开展”)。 -- `正进行`:当前阶段未完成/未进行,且上一阶段状态为 `已完成`。 -- `未开始`:其余情况。 -- 概览显示日期:使用 `planned_end_date`。 -- 项目隐藏:`APPROVED` 或 `TERMINATED` 不在概览列表中显示。 - -## 5. 页面与交互 -- 登录页(JWT) -- 概览页:按项目展示阶段节点、计划完成日期、状态图例 -- 录入页:项目阶段表格编辑,支持“此阶段不开展” -- 历史页:阶段变更历史时间线/表格 -- 导出:授权用户导出 CSV - -## 6. 权限模型 -- `ADMIN`:用户/项目/模板管理 + 全部导出 -- `PM`:项目进度录入、历史查看、导出 -- `VIEWER`:只读(概览、历史) - -## 7. API(首期) -- `POST /api/v1/auth/login` -- `GET /api/v1/auth/me` -- `GET /api/v1/projects` -- `POST /api/v1/projects`(ADMIN) -- `GET /api/v1/stage-templates` -- `POST /api/v1/stage-templates`(ADMIN) -- `GET /api/v1/projects/{id}/stages` -- `PATCH /api/v1/projects/{id}/stages/{stageId}`(PM/ADMIN) -- `GET /api/v1/projects/{id}/stages/history` -- `GET /api/v1/projects/stages/export?format=csv`(PM/ADMIN) - -## 8. 验收标准 -- 状态计算规则与 PDF 一致 -- 录入保存会生成历史记录 -- 导出字段与录入字段一致 -- `APPROVED/TERMINATED` 项目不在概览展示 diff --git a/docs/plans/2026-03-05-rd-monitor-implementation.md b/docs/plans/2026-03-05-rd-monitor-implementation.md deleted file mode 100644 index 3f4c1189..00000000 --- a/docs/plans/2026-03-05-rd-monitor-implementation.md +++ /dev/null @@ -1,137 +0,0 @@ -# rd-monitor Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 搭建独立仓库 `rd-monitor`,实现研发项目进展监测 MVP(独立鉴权、项目阶段录入、状态计算、历史留痕、CSV 导出、前端可用界面)。 - -**Architecture:** 后端采用 FastAPI + SQLAlchemy + PostgreSQL,提供鉴权、项目阶段、历史、导出 API;前端采用 Vue3 + Element Plus,提供登录、概览、录入、历史页面。通过 Docker Compose 启动前后端与独立 PostgreSQL。 - -**Tech Stack:** Vue3, Vite, Element Plus, FastAPI, SQLAlchemy, Alembic, PostgreSQL, JWT, Pytest - ---- - -### Task 1: 初始化仓库结构与运行骨架 - -**Files:** -- Create: `/Users/zcc/MyCTMS/rd-monitor/README.md` -- Create: `/Users/zcc/MyCTMS/rd-monitor/docker-compose.yml` -- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/requirements.txt` -- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/.env.example` -- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/package.json` -- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/vite.config.ts` - -**Step 1: Write the failing test** -- 编写后端 smoke 测试,断言 `/healthz` 返回 200(此时接口不存在,应失败)。 - -**Step 2: Run test to verify it fails** -- Run: `cd /Users/zcc/MyCTMS/rd-monitor/backend && pytest -q` -- Expected: FAIL(缺少 app 或路由)。 - -**Step 3: Write minimal implementation** -- 建立 FastAPI app 与 `/healthz` 路由,补全基础依赖与 compose。 - -**Step 4: Run test to verify it passes** -- Run: `cd /Users/zcc/MyCTMS/rd-monitor/backend && pytest -q` -- Expected: PASS。 - -**Step 5: Commit** -- `git add ... && git commit -m "chore: bootstrap rd-monitor skeleton"` - -### Task 2: 实现后端核心领域模型与状态计算 - -**Files:** -- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/app/models/*.py` -- Create: `/Users/zcc/MyCTMS/rd-monitor/backend/app/services/progress.py` -- Test: `/Users/zcc/MyCTMS/rd-monitor/backend/tests/test_progress_status.py` - -**Step 1: Write the failing test** -- 用测试覆盖阶段状态计算(已完成/未进行/正进行/未开始)与项目过滤规则。 - -**Step 2: Run test to verify it fails** -- Run: `pytest tests/test_progress_status.py -q` -- Expected: FAIL。 - -**Step 3: Write minimal implementation** -- 实现状态计算函数与项目过滤函数。 - -**Step 4: Run test to verify it passes** -- Run: `pytest tests/test_progress_status.py -q` -- Expected: PASS。 - -**Step 5: Commit** -- `git commit -m "feat: add progress status domain rules"` - -### Task 3: 实现鉴权、项目阶段 API、历史记录与导出 - -**Files:** -- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/api/*.py` -- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/schemas/*.py` -- Create/Modify: `/Users/zcc/MyCTMS/rd-monitor/backend/app/core/security.py` -- Test: `/Users/zcc/MyCTMS/rd-monitor/backend/tests/test_stage_api.py` - -**Step 1: Write the failing test** -- 测试登录、查询项目阶段、更新阶段后历史新增、导出 CSV、权限拦截。 - -**Step 2: Run test to verify it fails** -- Run: `pytest tests/test_stage_api.py -q` -- Expected: FAIL。 - -**Step 3: Write minimal implementation** -- 完成相关 API 与依赖。 - -**Step 4: Run test to verify it passes** -- Run: `pytest tests/test_stage_api.py -q` -- Expected: PASS。 - -**Step 5: Commit** -- `git commit -m "feat: implement progress APIs with auth history and export"` - -### Task 4: 实现前端登录、概览、录入、历史页面 - -**Files:** -- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/views/*.vue` -- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/router/index.ts` -- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/api/*.ts` -- Create: `/Users/zcc/MyCTMS/rd-monitor/frontend/src/components/*.vue` - -**Step 1: Write the failing test** -- 首期用类型检查/构建作为回归保护,先运行应失败(页面未实现)。 - -**Step 2: Run test to verify it fails** -- Run: `cd /Users/zcc/MyCTMS/rd-monitor/frontend && npm run build` -- Expected: FAIL。 - -**Step 3: Write minimal implementation** -- 实现可登录、可浏览项目阶段、可编辑阶段、可看历史、可导出。 - -**Step 4: Run test to verify it passes** -- Run: `npm run build` -- Expected: PASS。 - -**Step 5: Commit** -- `git commit -m "feat: add frontend pages for monitoring workflow"` - -### Task 5: 联调与最终验证 - -**Files:** -- Modify: `/Users/zcc/MyCTMS/rd-monitor/README.md` - -**Step 1: Write the failing test** -- 先运行端到端 smoke(未配置好前应失败)。 - -**Step 2: Run test to verify it fails** -- Run: `docker compose up --build -d && curl -f http://localhost:8010/healthz` -- Expected: 初次 FAIL 或服务未就绪。 - -**Step 3: Write minimal implementation** -- 修正配置/环境变量/文档。 - -**Step 4: Run test to verify it passes** -- Run: `docker compose up --build -d` -- Run: `curl -f http://localhost:8010/healthz` -- Run: `cd backend && pytest -q` -- Run: `cd frontend && npm run build` -- Expected: 全部 PASS。 - -**Step 5: Commit** -- `git commit -m "docs: finalize rd-monitor setup and verification"` diff --git a/docs/plans/2026-03-27-production-init-implementation.md b/docs/plans/2026-03-27-production-init-implementation.md index 02233657..fc7a11c1 100644 --- a/docs/plans/2026-03-27-production-init-implementation.md +++ b/docs/plans/2026-03-27-production-init-implementation.md @@ -112,7 +112,7 @@ git commit -m "feat: add production init entrypoint" **Files:** - Modify: `docker-compose.yaml` - Modify: `README.md` -- Modify: `docs/release-checklist.md` +- Modify: `docs/guides/release-checklist.md` - Optionally modify: `database/init.sql` **Step 1: Write failing verification** @@ -136,7 +136,7 @@ Expected: PASS with the new init workflow represented in configuration or docume **Step 5: Commit** ```bash -git add docker-compose.yaml README.md docs/release-checklist.md database/init.sql +git add docker-compose.yaml README.md docs/guides/release-checklist.md database/init.sql git commit -m "docs: define production database init flow" ``` diff --git a/docs/plans/2026-03-30-docs-reorganization-implementation.md b/docs/plans/2026-03-30-docs-reorganization-implementation.md new file mode 100644 index 00000000..57c16d46 --- /dev/null +++ b/docs/plans/2026-03-30-docs-reorganization-implementation.md @@ -0,0 +1,116 @@ +# Docs Reorganization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reorganize `docs/` into stable long-lived sections, remove obsolete files, normalize document headers, and repair repository references. + +**Architecture:** Keep `docs/plans/` as historical implementation records, move active operating material into `docs/guides/`, move review and governance material into `docs/audits/`, keep `docs/postman/` for collection assets, and add `docs/README.md` as the top-level navigation entrypoint. + +**Tech Stack:** Markdown, shell file operations, ripgrep + +--- + +### Task 1: Create the target docs structure + +**Files:** +- Create: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/README.md` +- Create: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/guides/` +- Create: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/audits/` + +**Step 1: Write the failing test** + +Manual expectation: `docs/README.md`, `docs/guides/`, and `docs/audits/` do not yet exist. + +**Step 2: Run test to verify it fails** + +Run: `find docs -maxdepth 1 -type d -o -type f | sort` +Expected: FAIL to show the new structure. + +**Step 3: Write minimal implementation** + +Create the new directories and the docs index file. + +**Step 4: Run test to verify it passes** + +Run: `find docs -maxdepth 2 | sort` +Expected: PASS with `docs/README.md`, `docs/guides`, and `docs/audits`. + +### Task 2: Move active docs into semantic directories + +**Files:** +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/guides/release-checklist.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/guides/setup-config-api.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/audits/auth-session-acceptance.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/audits/setup-config-code-audit.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/audits/storage-persistence-audit.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/audits/storage-persistence-governance.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/audits/enterprise-ui-acceptance-checklist.md` + +**Step 1: Write the failing test** + +Manual expectation: the documents still live in mixed root paths. + +**Step 2: Run test to verify it fails** + +Run: `find docs -maxdepth 2 -type f | sort` +Expected: FAIL to show the desired grouping. + +**Step 3: Write minimal implementation** + +Move each document into `guides/` or `audits/`. + +**Step 4: Run test to verify it passes** + +Run: `find docs -maxdepth 2 -type f | sort` +Expected: PASS with the regrouped files. + +### Task 3: Remove obsolete docs and local artifacts + +**Files:** +- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/workbench-ui-optimization.md` +- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/plans/2026-03-05-rd-monitor-design.md` +- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/plans/2026-03-05-rd-monitor-implementation.md` + +**Step 1: Write the failing test** + +Manual expectation: the obsolete files still appear in `git status`. + +**Step 2: Run test to verify it fails** + +Run: `git status --short docs` +Expected: FAIL because the obsolete docs are still present in tracked history. + +**Step 3: Write minimal implementation** + +Remove the obsolete files from the working tree and index. + +**Step 4: Run test to verify it passes** + +Run: `find docs -maxdepth 2 -type f | sort` +Expected: PASS without the removed files. + +### Task 4: Normalize titles, metadata, and references + +**Files:** +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/README.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/README.md` +- Modify: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/.gitignore` +- Modify: moved docs under `docs/guides/` and `docs/audits/` + +**Step 1: Write the failing test** + +Manual expectation: old paths are still referenced and several docs use inconsistent headings. + +**Step 2: Run test to verify it fails** + +Run: `rg -n "docs/(release-checklist|setup-config-api|storage-persistence|auth-session-acceptance|ui/enterprise-ui-acceptance-checklist)" README.md docs backend frontend .github` +Expected: FAIL with old path references. + +**Step 3: Write minimal implementation** + +Update headings, add concise metadata blocks where useful, write `docs/README.md`, and fix references to the new paths. + +**Step 4: Run test to verify it passes** + +Run: `rg -n "docs/(release-checklist|setup-config-api|storage-persistence|auth-session-acceptance|ui/enterprise-ui-acceptance-checklist)" README.md docs backend frontend .github` +Expected: PASS with only the new organized paths. diff --git a/docs/postman/local.postman_environment.json b/docs/postman/local.postman_environment.example.json similarity index 79% rename from docs/postman/local.postman_environment.json rename to docs/postman/local.postman_environment.example.json index 1d109194..89b3263c 100644 --- a/docs/postman/local.postman_environment.json +++ b/docs/postman/local.postman_environment.example.json @@ -1,10 +1,9 @@ { - "id": "9f9bb57a-6de8-4e31-a7a4-e0c0e7c0d119", - "name": "CTMS Local", + "name": "CTMS Local Example", "values": [ { "key": "base_url", "value": "http://localhost", "type": "default", "enabled": true }, { "key": "email", "value": "admin@example.com", "type": "default", "enabled": true }, - { "key": "password", "value": "admin123", "type": "secret", "enabled": true }, + { "key": "password", "value": "admin123", "type": "default", "enabled": true }, { "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "type": "default", "enabled": true }, { "key": "token", "value": "", "type": "default", "enabled": true }, { "key": "setup_version", "value": "1", "type": "default", "enabled": true }, @@ -18,7 +17,5 @@ "enabled": true } ], - "_postman_variable_scope": "environment", - "_postman_exported_at": "2026-02-09T00:00:00.000Z", - "_postman_exported_using": "Codex" + "_postman_variable_scope": "environment" } diff --git a/docs/workbench-ui-optimization.md b/docs/workbench-ui-optimization.md deleted file mode 100644 index b72ef215..00000000 --- a/docs/workbench-ui-optimization.md +++ /dev/null @@ -1,262 +0,0 @@ -# 工作台UI优化文档 - -## 📅 优化日期 -2026-01-20 - -## 🎯 优化目标 -提升工作台界面的视觉吸引力和用户体验,使其更加现代化、专业且富有活力,同时保持后端功能不变。 - -## ✨ 主要优化内容 - -### 1. 头部区域全面升级 - -#### 优化前 -- 简单的白色背景 -- 基础的文字标题 -- 单色角色徽章 - -#### 优化后 -- **渐变背景**:采用紫色渐变(#667eea → #764ba2),增强品牌感 -- **装饰图案**:添加SVG网格图案背景,增加层次感 -- **视觉效果**: - - 标题字体更大(32px),加粗(800),白色文字带阴影 - - 副标题使用半透明白色 - - 玻璃态角色徽章(backdrop-filter: blur) - - 整体阴影提升(0 8px 32px) -- **动画入场**:添加fadeIn动画,页面加载更流畅 - -```css -.workbench-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 16px; - box-shadow: 0 8px 32px rgba(102, 126, 234, 0.2); -} -``` - -### 2. 卡片组件视觉增强 - -#### SectionCard (任务列表卡片) - -**新增功能图标**: -- 今日待办:日历图标 📅 -- 已逾期事项:警告图标 ⚠️ -- 需要处理的事项:文档图标 📄 -- 图标采用渐变背景,圆角设计,带动态旋转效果 - -**样式优化**: -- 卡片背景渐变(#ffffff → #fafcff) -- 边框透明度处理(rgba(102, 126, 234, 0.12)) -- Hover效果:上浮2px + 阴影加深 -- 圆角增大至16px - -**列表项交互**: -- 左侧渐变色条指示器(hover时从0变为4px) -- 背景渐变hover效果 -- 标题颜色变化(hover时变为#667eea) -- 整体右移6px的动画 -- 状态标签缩放效果(scale 1.05) - -```css -.list-item:hover::before { - width: 4px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -} -``` - -### 3. 快捷入口美化 - -#### 优化前 -- 小图标(40px) -- 单色边框 -- 简单hover效果 - -#### 优化后 -- **卡片整体**: - - 渐变背景(#ffffff → #f8f9ff) - - 悬浮提升效果 - - 头部渐变装饰 - -- **图标设计**: - - 尺寸增大至56px - - 紫色渐变背景 - - 白色图标(26px) - - 光晕阴影效果 - - 边框高光(::before伪元素) - -- **交互动效**: - - Hover时图标旋转5度并放大 - - 背景色反转(白底紫字) - - 整体卡片上浮4px并缩放1.02倍 - - 文字变白色并放大1.05倍 - - 缓动函数:cubic-bezier(0.22, 1, 0.36, 1) - -```css -.quick-item:hover .quick-icon-box { - background: #ffffff; - color: #667eea; - transform: scale(1.1) rotate(5deg); -} -``` - -### 4. 通知卡片精细化 - -#### 样式升级 -- 渐变背景(#ffffff → #fef7ff,偏粉紫色) -- 标题渐变文字效果 -- 分隔线设计(底部2px边框) -- 卡片整体hover上浮 - -#### 通知项优化 -- 白色背景 + 淡紫色边框 -- Hover效果: - - 渐变背景色 - - 右移4px - - 边框颜色加深 - - 阴影出现 -- 版本号徽章:渐变背景 + 白色文字 + 阴影 - -```css -.notification-version { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #ffffff; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); -} -``` - -### 5. 状态标签优化 - -#### 差异化设计 -- **危险状态**(逾期): - - 渐变背景:rgba(194, 75, 75, 0.12) → rgba(194, 75, 75, 0.08) - - 红色文字 + 红色边框 - -- **信息状态**(普通): - - 渐变背景:rgba(100, 116, 139, 0.12) → rgba(100, 116, 139, 0.08) - - 灰蓝色文字 + 灰蓝色边框 - -- Hover时标签缩放1.05倍并添加阴影 - -### 6. 动画与过渡 - -#### 全局动画 -- 页面入场fadeIn动画(0.5s) -- 所有交互元素使用cubic-bezier缓动 -- 过渡时间统一为0.3s或0.4s - -#### 微交互 -- 按钮hover时右移(transform: translateX(4px)) -- 卡片hover时上浮(transform: translateY(-2px)) -- 图标旋转和缩放组合 - -## 🎨 设计系统 - -### 颜色方案 -```css -/* 主渐变色 */ ---gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - -/* 卡片背景 */ ---card-bg-1: linear-gradient(135deg, #ffffff 0%, #fafcff 100%); ---card-bg-2: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%); ---card-bg-3: linear-gradient(135deg, #ffffff 0%, #fef7ff 100%); - -/* 透明边框 */ ---border-primary: rgba(102, 126, 234, 0.12); ---border-primary-hover: rgba(102, 126, 234, 0.2); -``` - -### 圆角规范 -- 小元素:10px - 12px -- 卡片:16px -- 徽章/标签:12px(pill shape) - -### 阴影层级 -```css -/* 默认 */ ---shadow-card: 0 4px 20px rgba(0, 0, 0, 0.04); - -/* Hover */ ---shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.08); - -/* 图标 */ ---shadow-icon: 0 8px 20px rgba(102, 126, 234, 0.3); -``` - -## 📊 优化效果对比 - -### 视觉层次 -- ✅ 头部从单调变为吸引眼球的焦点 -- ✅ 卡片从扁平变为有层次感的立体设计 -- ✅ 图标从普通变为品牌化的视觉元素 - -### 用户体验 -- ✅ 所有交互元素都有明确的视觉反馈 -- ✅ Hover效果统一且流畅 -- ✅ 功能区域通过图标和颜色快速识别 - -### 品牌感 -- ✅ 统一的紫色渐变主题贯穿始终 -- ✅ 现代化的glassmorphism和渐变设计 -- ✅ 从B端管理系统升级为专业且有设计感的应用 - -## 🔧 技术实现 - -### 关键技术 -1. **CSS渐变**:linear-gradient实现丰富的色彩层次 -2. **Backdrop Filter**:玻璃态效果(需浏览器支持) -3. **CSS Transform**:实现流畅的动画效果 -4. **Cubic-bezier**:自然的缓动曲线 -5. **伪元素**:::before实现装饰性元素 - -### 兼容性 -- 主要浏览器现代版本均支持 -- backdrop-filter在Safari和Chrome中效果最佳 -- 渐变文字需要-webkit前缀 - -### 性能优化 -- 使用transform代替position动画(GPU加速) -- 合理使用will-change(仅在必要时) -- 阴影使用透明度而非模糊半径过大的值 - -## 📁 修改文件清单 - -1. **MyWorkbench.vue** - - 优化整体布局和间距 - - 添加头部渐变背景 - - 美化快捷入口和通知卡片 - - 统一样式主题 - -2. **SectionCard.vue** - - 添加功能图标 - - 优化卡片样式和hover效果 - - 增强列表项交互 - - 差异化状态标签 - -## 🎯 后续优化建议 - -1. **响应式优化**:针对平板和手机端进一步优化布局 -2. **深色模式**:添加dark mode支持 -3. **骨架屏**:优化loading状态的显示 -4. **空状态**:美化空数据时的占位图 -5. **数据可视化**:添加统计图表展示项目进度 - -## ✅ 验证清单 - -- [x] 头部渐变背景正常显示 -- [x] 卡片图标正确显示 -- [x] 快捷入口hover动画流畅 -- [x] 列表项交互反馈明确 -- [x] 通知卡片样式优化 -- [x] 所有动画性能良好 -- [x] 响应式布局正常工作 -- [x] 后端功能完全不受影响 - -## 🌟 总结 - -此次UI优化在**不改动后端代码**的前提下,通过纯前端样式优化,将工作台从普通的管理界面提升为具有现代感和专业度的高品质应用界面。 - -主要成就: -- 🎨 视觉吸引力提升80%+ -- ⚡ 用户体验优化显著 -- 🏷️ 品牌识别度大幅增强 -- 💯 保持100%功能完整性 diff --git a/frontend/.env b/frontend/.env deleted file mode 100644 index 46e4b092..00000000 --- a/frontend/.env +++ /dev/null @@ -1,6 +0,0 @@ -# 递交日期 -> 受理日期 超期(月) -VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=1 -# 受理日期 -> 批准日期 超期(月) -VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=1 -# 启用项目概览API -VITE_USE_OVERVIEW_API=true diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..73a9682d --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Frontend feature flags and timeline overrides +VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 +VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 +VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 +VITE_USE_OVERVIEW_API=false diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 5fca3f84..00000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "failed", - "failedTests": [] -} \ No newline at end of file From 27ba5eabfff3e080b4632361763225c4f7a87fd9 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 30 Mar 2026 21:02:51 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=86=85=20nginx=20=E5=B9=B6=E6=8B=86=E5=88=86?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 +-- backend/app/main.py | 15 +++ backend/tests/test_registration.py | 14 +++ docker-compose.yaml | 21 ++-- docs/guides/release-checklist.md | 8 +- .../2026-03-27-production-compose-design.md | 15 +-- ...03-27-production-compose-implementation.md | 34 +++--- .../2026-03-27-production-init-design.md | 2 +- ...26-03-27-production-init-implementation.md | 4 +- docs/plans/2026-03-30-remove-nginx-design.md | 21 ++++ .../2026-03-30-remove-nginx-implementation.md | 114 ++++++++++++++++++ ...26-03-30-tcloud-private-registry-design.md | 27 ++--- .../2026-03-30-tcloud-private-registry.md | 8 +- frontend/.env.example | 1 + frontend/Caddyfile | 5 + frontend/Dockerfile | 10 +- frontend/src/api/axios.ts | 5 +- frontend/src/api/baseUrl.test.ts | 19 +++ frontend/src/api/baseUrl.ts | 12 ++ nginx/Dockerfile | 14 --- nginx/nginx.conf | 65 ---------- scripts/build-and-push-registry.sh | 13 +- 22 files changed, 287 insertions(+), 160 deletions(-) create mode 100644 docs/plans/2026-03-30-remove-nginx-design.md create mode 100644 docs/plans/2026-03-30-remove-nginx-implementation.md create mode 100644 frontend/Caddyfile create mode 100644 frontend/src/api/baseUrl.test.ts create mode 100644 frontend/src/api/baseUrl.ts delete mode 100644 nginx/Dockerfile delete mode 100644 nginx/nginx.conf diff --git a/README.md b/README.md index b8ecf344..d9f70323 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,21 @@ - 生产入口:`docker-compose.yaml` - 初始化方式:`docker compose run --rm backend-init` - 启动方式:`docker compose up -d --build` -- 对外入口:Nginx 提供前端静态资源,并反代后端 API +- 运行拓扑:`frontend`、`backend`、`db` +- 对外入口:仓库外部统一反向代理;建议将 `/` 转发到 `frontend`,将 `/api` 与 `/health` 转发到 `backend` - 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql` - 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 - 验证方式: - `docker compose config` - - `curl -i http://127.0.0.1/` - - `curl -i http://127.0.0.1/health` + - `curl -i http://127.0.0.1:4173/` + - `curl -i http://127.0.0.1:8000/health` ## 腾讯云私有镜像仓库 -- GitHub Actions 会在推送到 `dev`、`release`、`release/*` 和 `main` 时,通过 SSH 连接腾讯云服务器,在服务器本机完成 `backend` 和 `nginx` 镜像的构建与推送。 -- 私有仓库地址默认使用 `${REGISTRY_HOST}`,仓库命名为 `${REGISTRY_HOST}/ctms/ctms-backend` 和 `${REGISTRY_HOST}/ctms/ctms-nginx`。 +- GitHub Actions 会在推送到 `dev`、`release`、`release/*` 和 `main` 时,通过 SSH 连接腾讯云服务器,在服务器本机完成 `backend` 和 `frontend` 镜像的构建与推送。 +- 私有仓库地址默认使用 `${REGISTRY_HOST}`,仓库命名为 `${REGISTRY_HOST}/ctms/ctms-backend` 和 `${REGISTRY_HOST}/ctms/ctms-frontend`。 - `dev` 分支发布 `dev-latest` 和 `dev-`,`release` 分支发布 `rc-release` 和 `rc-`,`release/*` 分支发布 `rc-` 和 `rc-`,`main` 分支发布 `latest` 和 `sha-`。 - 部署机需要先 `docker login :5000`,并使用私有仓库的 `htpasswd` 账号密码完成认证。 -- `backend-init` 复用 `BACKEND_IMAGE`,所以生产机可在设置 `REGISTRY_HOST`,或直接指定 `BACKEND_IMAGE` 和 `NGINX_IMAGE` 后执行 `docker compose pull && docker compose run --rm backend-init && docker compose up -d`。 +- `backend-init` 复用 `BACKEND_IMAGE`,所以生产机可在设置 `REGISTRY_HOST`,或直接指定 `BACKEND_IMAGE` 和 `FRONTEND_IMAGE` 后执行 `docker compose pull && docker compose run --rm backend-init && docker compose up -d`。 - 本地开发仍可继续使用 `docker compose up -d --build` 走本地构建。 ## 账号与注册 @@ -35,8 +36,11 @@ - 普通成员(无项目角色):仅浏览 ## 访问方式 -- 前端:`http://localhost`(Nginx 直接托管前端静态文件) -- 后端 API:同域 `/api/v1/*`(已在前端代理) +- 前端:`http://localhost:4173` +- 后端 API:`http://localhost:8000/api/v1/*` +- 生产环境建议通过外部反向代理提供同域访问,前端代码默认请求同域 `/api/v1/*` +- 如果本地直接访问 `http://localhost:4173`,可在 `frontend/.env` 中设置 `VITE_API_BASE_URL=http://localhost:8000`,让前端直连后端 API +- `docker compose` 本地构建前端容器时,默认会把 `VITE_API_BASE_URL` 注入为 `http://localhost:8000`;如果是生产构建,请显式覆盖为空值或你的正式 API 地址 ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` diff --git a/backend/app/main.py b/backend/app/main.py index 28304e0d..fd415f37 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -166,6 +166,21 @@ def create_app() -> FastAPI: register_exception_handlers(app) + @app.get( + "/", + tags=["health"], + summary="服务入口说明", + description="返回后端服务入口说明,避免将根路径误认为前端页面。", + ) + async def root() -> dict[str, str]: + return { + "service": "ctms-backend", + "status": "ok", + "health": "/health", + "docs": "/docs", + "api": "/api/v1", + } + @app.get( "/health", tags=["health"], diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 1921fe1b..6603a54e 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -62,6 +62,20 @@ async def test_register_creates_pending_user(client_and_db): assert user.status == UserStatus.PENDING +@pytest.mark.asyncio +async def test_root_returns_service_metadata(client_and_db): + client, _ = client_and_db + resp = await client.get("/") + assert resp.status_code == 200 + assert resp.json() == { + "service": "ctms-backend", + "status": "ok", + "health": "/health", + "docs": "/docs", + "api": "/api/v1", + } + + @pytest.mark.asyncio async def test_login_blocked_before_approval(client_and_db): client, SessionLocal = client_and_db diff --git a/docker-compose.yaml b/docker-compose.yaml index 0cc84fcd..160ec7b1 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -52,22 +52,17 @@ services: networks: - ctms_net - nginx: - image: ${NGINX_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-nginx:latest} + frontend: + image: ${FRONTEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-frontend:latest} build: - context: . - dockerfile: nginx/Dockerfile - container_name: ctms_nginx + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8000} + container_name: ctms_frontend restart: always ports: - - "80:80" # HTTP - - "443:443" # HTTPS - - "8888:8888" - volumes: - - ./nginx/certs:/etc/nginx/certs:ro - - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro - depends_on: - - backend + - "4173:4173" networks: - ctms_net diff --git a/docs/guides/release-checklist.md b/docs/guides/release-checklist.md index a98a5efa..41bfa740 100644 --- a/docs/guides/release-checklist.md +++ b/docs/guides/release-checklist.md @@ -10,10 +10,10 @@ - [ ] `cd frontend && npm run build` ## 1.1 腾讯云私有镜像校验 -- [ ] `dev` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-nginx` 的 `dev-latest` / `dev-` 标签已出现在私有仓库 -- [ ] `release` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-nginx` 的 `rc-release` / `rc-` 标签已出现在私有仓库 -- [ ] `release/*` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-nginx` 的 `rc-` / `rc-` 标签已出现在私有仓库 -- [ ] `main` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-nginx` 的 `latest` / `sha-` 标签已出现在私有仓库 +- [ ] `dev` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `dev-latest` / `dev-` 标签已出现在私有仓库 +- [ ] `release` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `rc-release` / `rc-` 标签已出现在私有仓库 +- [ ] `release/*` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `rc-` / `rc-` 标签已出现在私有仓库 +- [ ] `main` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `latest` / `sha-` 标签已出现在私有仓库 - [ ] GitHub Actions 已成功 SSH 登录腾讯云服务器并执行远程构建脚本 - [ ] 部署机已执行 `docker login :5000`,且使用的是私有仓库 `htpasswd` 账号密码 diff --git a/docs/plans/2026-03-27-production-compose-design.md b/docs/plans/2026-03-27-production-compose-design.md index 46109937..470307e3 100644 --- a/docs/plans/2026-03-27-production-compose-design.md +++ b/docs/plans/2026-03-27-production-compose-design.md @@ -4,24 +4,25 @@ **Decisions** - Keep single-host `docker-compose` deployment. -- Convert the frontend to a static build served directly by Nginx. +- Keep the frontend as an independently deployable runtime image. - Keep PostgreSQL and the existing `pg_data` persistence model. - Keep one backend container and remove development-only runtime behavior. - Update the existing `docker-compose.yaml` in place instead of introducing a separate production file. +- Leave public ingress to an external reverse proxy outside this repository. **Architecture** - `db` remains a long-lived PostgreSQL service with the existing bind-mounted data directory. -- `backend` runs from the backend image default command and exposes only the application port needed inside the compose network. -- `nginx` becomes the public entrypoint. It serves the built frontend assets and proxies `/api/` and `/health` to the backend service. -- The frontend is no longer a long-running service in production. Its build artifacts are produced during the image build and copied into the Nginx image. +- `backend` runs from the backend image default command and exposes port `8000`. +- `frontend` runs from its own image and serves the SPA on a dedicated frontend port. +- An external reverse proxy becomes the public entrypoint and routes browser traffic to `frontend` and API traffic to `backend`. **Cleanup Scope** - Remove development-only compose services such as toolbox/init containers. - Remove development-only backend startup overrides such as `debugpy` and `uvicorn --reload`. -- Remove development-only frontend runtime behavior such as `npm run dev`, bind mounts, and exposed Vite ports. +- Remove development-only frontend runtime behavior such as bind mounts and hot-reload-only startup behavior. - Keep data persistence and core service names stable where practical to limit operational change. **Operational Notes** - Updating `docker-compose.yaml` in place means local development will no longer use the previous hot-reload setup by default. -- Frontend SPA routing must be handled by Nginx with `try_files`. -- Verification is done through `docker compose config`, image builds, container startup, and HTTP health checks. +- Frontend SPA routing and same-origin `/api` behavior must be handled by the external reverse proxy. +- Verification is done through `docker compose config`, image builds, container startup, and direct HTTP health checks. diff --git a/docs/plans/2026-03-27-production-compose-implementation.md b/docs/plans/2026-03-27-production-compose-implementation.md index 9a39add1..d1688a3d 100644 --- a/docs/plans/2026-03-27-production-compose-implementation.md +++ b/docs/plans/2026-03-27-production-compose-implementation.md @@ -2,11 +2,11 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Convert the repository's only `docker-compose.yaml` from development deployment to production deployment using static frontend assets served by Nginx. +**Goal:** Convert the repository's only `docker-compose.yaml` from development deployment to production deployment using standalone frontend and backend runtime images. -**Architecture:** The backend runs as a production FastAPI container, the frontend is built into static assets during image build, and Nginx serves the SPA while reverse proxying backend routes. PostgreSQL remains persistent through the existing `pg_data` bind mount. +**Architecture:** The backend runs as a production FastAPI container, the frontend is built into a standalone runtime image that serves the SPA itself, and an external reverse proxy routes browser and API traffic to the correct container. PostgreSQL remains persistent through the existing `pg_data` bind mount. -**Tech Stack:** Docker Compose, FastAPI, Vue/Vite, Nginx, PostgreSQL +**Tech Stack:** Docker Compose, FastAPI, Vue/Vite, Node.js, PostgreSQL --- @@ -19,27 +19,27 @@ Update the Dockerfile so a Node build stage runs dependency install and `npm run build`, then a lightweight stage exposes the built `dist` directory for downstream use instead of running `npm run dev`. -**Step 2: Keep the output suitable for Nginx consumption** +**Step 2: Keep the output suitable for frontend runtime consumption** -Ensure the final stage stores frontend assets in a predictable directory such as `/usr/share/nginx/html`. +Ensure the final stage stores frontend assets in a predictable directory and starts a static server on a stable port. **Step 3: Verify the image definition is syntactically valid** Run: `docker compose config` Expected: compose renders successfully with no Dockerfile-related errors. -### Task 2: Productionize Nginx Runtime +### Task 2: Productionize Frontend Runtime **Files:** -- Modify: `nginx/nginx.conf` +- Modify: `frontend/Dockerfile` **Step 1: Add static frontend serving** -Set the web root to the built frontend assets and add `try_files $uri $uri/ /index.html;` for SPA routing. +Use the runtime image to expose a frontend port and serve the compiled SPA directly. -**Step 2: Keep backend reverse proxy behavior** +**Step 2: Leave reverse proxy behavior to external infrastructure** -Preserve `/api/` and `/health` proxying to `backend:8000` with standard forwarded headers. +Do not embed repository-owned reverse proxy configuration; document that `/` routes to `frontend` and `/api` plus `/health` route to `backend`. **Step 3: Verify config structure through compose-backed startup** @@ -55,13 +55,13 @@ Expected: compose renders successfully. Delete `frontend`, `frontend-init`, and `backend-init`, and remove bind mounts, debug ports, and development commands that are only useful for hot reload or scaffolding. -**Step 2: Wire backend and nginx for production** +**Step 2: Wire backend and frontend for production** Allow the backend to use its Dockerfile default command, keep the database dependency, and expose only the ports needed for the production stack. -**Step 3: Build frontend assets into nginx** +**Step 3: Add standalone frontend service** -Point the Nginx service build to a production image definition that includes the built frontend output. +Point the frontend service build to `frontend/Dockerfile` and give it a registry-backed image reference. **Step 4: Verify rendered compose** @@ -79,12 +79,12 @@ Document that `docker-compose.yaml` is now the production deployment entrypoint. **Step 2: Add minimal verification commands** -Document `docker compose up -d --build`, plus health checks for `/` and `/health`. +Document `docker compose up -d --build`, plus direct health checks for the frontend and backend containers. **Step 3: Verify behavior** Run: `docker compose up -d --build` -Expected: `db`, `backend`, and `nginx` start successfully. +Expected: `db`, `backend`, and `frontend` start successfully. ### Task 5: End-to-End Verification @@ -103,8 +103,8 @@ Expected: PASS **Step 3: Check HTTP endpoints** -Run: `curl -i http://127.0.0.1/` +Run: `curl -i http://127.0.0.1:4173/` Expected: `200 OK` and HTML payload -Run: `curl -i http://127.0.0.1/health` +Run: `curl -i http://127.0.0.1:8000/health` Expected: successful backend health response diff --git a/docs/plans/2026-03-27-production-init-design.md b/docs/plans/2026-03-27-production-init-design.md index 53b5d3ca..9c8ed7eb 100644 --- a/docs/plans/2026-03-27-production-init-design.md +++ b/docs/plans/2026-03-27-production-init-design.md @@ -25,7 +25,7 @@ **Operational Flow** 1. Start PostgreSQL with an empty data directory. 2. Run the one-shot backend initialization command. -3. Start long-lived backend and Nginx services. +3. Start long-lived backend and frontend services behind the external reverse proxy. 4. Log in with `admin@huapont.cn / admin123`, then change the password. **Non-Goals** diff --git a/docs/plans/2026-03-27-production-init-implementation.md b/docs/plans/2026-03-27-production-init-implementation.md index fc7a11c1..5698b4af 100644 --- a/docs/plans/2026-03-27-production-init-implementation.md +++ b/docs/plans/2026-03-27-production-init-implementation.md @@ -163,7 +163,7 @@ Expected: migrations run successfully and the protected admin is ensured. **Step 4: Validate service health** Run: `docker compose up -d` -Expected: `db`, `backend`, and `nginx` start successfully. +Expected: `db`, `backend`, and `frontend` start successfully. -Run: `curl -i http://127.0.0.1/health` +Run: `curl -i http://127.0.0.1:8000/health` Expected: `200 OK` diff --git a/docs/plans/2026-03-30-remove-nginx-design.md b/docs/plans/2026-03-30-remove-nginx-design.md new file mode 100644 index 00000000..e07bcaa5 --- /dev/null +++ b/docs/plans/2026-03-30-remove-nginx-design.md @@ -0,0 +1,21 @@ +# Remove In-Repo Nginx Design + +**Goal:** Remove all in-repository Nginx runtime, build, publish, and documentation responsibilities while keeping the frontend as an independently deployable image behind an external reverse proxy. + +**Decisions** +- Remove the `nginx/` directory and every repository-owned Nginx configuration reference. +- Keep `docker-compose.yaml` as the deployment entrypoint, but change the production topology to `frontend + backend + db`. +- Keep `frontend` as a standalone runtime image that serves the built SPA directly. +- Keep `backend` as the only API container and let the external reverse proxy route `/api` and `/health` to it. +- Update historical docs so they describe the current topology instead of preserving obsolete Nginx-based guidance. + +**Architecture** +- `db` remains the persistent PostgreSQL service. +- `backend` remains the FastAPI service exposed on port `8000`. +- `frontend` becomes a standalone container image built from `frontend/Dockerfile` and serves the compiled SPA on its own port. +- The external reverse proxy is now the only public entrypoint and is responsible for routing browser traffic to `frontend` and API traffic to `backend`. + +**Operational Notes** +- The frontend still assumes same-origin API access, so the external proxy must route `/api/*` and `/health` to `backend`. +- The repository publish flow should only build and push `ctms-backend` and `ctms-frontend`. +- Verification should prove that no runtime, CI, or document references to repository-managed Nginx remain. diff --git a/docs/plans/2026-03-30-remove-nginx-implementation.md b/docs/plans/2026-03-30-remove-nginx-implementation.md new file mode 100644 index 00000000..1c530f5f --- /dev/null +++ b/docs/plans/2026-03-30-remove-nginx-implementation.md @@ -0,0 +1,114 @@ +# Remove In-Repo Nginx Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove repository-managed Nginx and replace it with a standalone frontend image plus backend image that are intended to sit behind an external reverse proxy. + +**Architecture:** The compose stack runs `db`, `backend`, and `frontend`. The frontend image builds the Vite SPA and serves it directly from its own container, while the backend continues serving the API on port `8000`. External infrastructure performs the only reverse proxy routing. + +**Tech Stack:** Docker Compose, FastAPI, Vue/Vite, Node.js, PostgreSQL, GitHub Actions + +--- + +### Task 1: Define the frontend runtime image + +**Files:** +- Modify: `frontend/Dockerfile` + +**Step 1: Keep the multi-stage frontend build** + +Retain the existing build stage so `npm ci` and `npm run build` still produce `dist`. + +**Step 2: Turn the final image into a runnable frontend container** + +Use a lightweight runtime image that copies `dist`, exposes a stable frontend port, and starts a static file server suitable for SPA delivery. + +**Step 3: Verify Dockerfile shape** + +Run: `sed -n '1,200p' frontend/Dockerfile` +Expected: final stage exposes a frontend port and contains a runnable `CMD`. + +### Task 2: Replace nginx topology with frontend topology + +**Files:** +- Modify: `docker-compose.yaml` + +**Step 1: Remove the `nginx` service** + +Delete the Nginx image, build, ports, and volume mounts. + +**Step 2: Add a `frontend` service** + +Build from `frontend/Dockerfile`, define a private-registry-backed `FRONTEND_IMAGE` default, and expose the frontend runtime port. + +**Step 3: Verify rendered compose** + +Run: `docker compose config` +Expected: only `db`, `backend`, `backend-init`, and `frontend` render. + +### Task 3: Update image publishing + +**Files:** +- Modify: `scripts/build-and-push-registry.sh` +- Modify: `.github/workflows/publish-images.yml` + +**Step 1: Publish `frontend` instead of `nginx`** + +Build and push `${REGISTRY_HOST}/ctms/ctms-frontend` alongside `${REGISTRY_HOST}/ctms/ctms-backend`. + +**Step 2: Keep branch-based tag logic unchanged** + +Preserve the existing `dev`, `release`, `release/*`, and `main` tag conventions. + +**Step 3: Verify script syntax** + +Run: `bash -n scripts/build-and-push-registry.sh` +Expected: PASS + +### Task 4: Rewrite docs to match the new topology + +**Files:** +- Modify: `README.md` +- Modify: `docs/guides/release-checklist.md` +- Modify: `docs/plans/2026-03-27-production-compose-design.md` +- Modify: `docs/plans/2026-03-27-production-compose-implementation.md` +- Modify: `docs/plans/2026-03-27-production-init-design.md` +- Modify: `docs/plans/2026-03-27-production-init-implementation.md` +- Modify: `docs/plans/2026-03-30-tcloud-private-registry-design.md` +- Modify: `docs/plans/2026-03-30-tcloud-private-registry.md` + +**Step 1: Remove repository-managed Nginx language** + +Replace every statement that says Nginx is the public entrypoint, serves the SPA, or is a published runtime image. + +**Step 2: Document the external reverse proxy expectation** + +Describe the runtime as frontend and backend containers behind an external proxy. + +**Step 3: Verify search results** + +Run: `rg -n --hidden -g '!/.git' -S "nginx|ctms-nginx|NGINX_IMAGE|Nginx" README.md docker-compose.yaml .github scripts docs` +Expected: no active repository-managed Nginx references remain. + +### Task 5: Remove obsolete nginx files and verify end-to-end cleanup + +**Files:** +- Delete: `nginx/Dockerfile` +- Delete: `nginx/nginx.conf` + +**Step 1: Delete the obsolete files** + +Remove the Nginx directory because nothing in the repository should depend on it anymore. + +**Step 2: Validate compose and script syntax** + +Run: `docker compose config` +Expected: PASS + +Run: `bash -n scripts/build-and-push-registry.sh` +Expected: PASS + +**Step 3: Validate frontend and backend image references** + +Run: `sed -n '1,160p' README.md` +Expected: deployment instructions mention `frontend` and `backend`, not `nginx`. diff --git a/docs/plans/2026-03-30-tcloud-private-registry-design.md b/docs/plans/2026-03-30-tcloud-private-registry-design.md index 8509bfc1..8e059230 100644 --- a/docs/plans/2026-03-30-tcloud-private-registry-design.md +++ b/docs/plans/2026-03-30-tcloud-private-registry-design.md @@ -1,9 +1,9 @@ # Tencent Cloud Private Registry Design -**Goal:** Add CI that automatically builds and pushes private `backend` and `nginx` images to a self-hosted Docker Registry running on a Tencent Cloud CVM, with `dev` publishing test tags, `release` and `release/*` publishing release-candidate tags, and `main` publishing formal tags. +**Goal:** Add CI that automatically builds and pushes private `backend` and `frontend` images to a self-hosted Docker Registry running on a Tencent Cloud CVM, with `dev` publishing test tags, `release` and `release/*` publishing release-candidate tags, and `main` publishing formal tags. **Decisions** -- Publish only two runtime images: `/ctms/ctms-backend` and `/ctms/ctms-nginx`. +- Publish only two runtime images: `/ctms/ctms-backend` and `/ctms/ctms-frontend`. - Keep the private registry on the Tencent Cloud CVM as a self-hosted `registry:2` instance protected by `htpasswd`. - Trigger automation on pushes to `dev`, `release`, `release/*`, and `main`. - Use GitHub Actions only as the orchestrator. Actual Docker build and push happen on the Tencent Cloud server over SSH. @@ -18,34 +18,33 @@ - A new GitHub Actions workflow triggers on pushes to `dev`, `release`, `release/*`, and `main`. - The workflow authenticates to the Tencent Cloud server using SSH credentials stored in GitHub Secrets. - The workflow syncs the repository contents to a fixed build directory such as `/opt/ctms-build/`. -- A server-local script logs in to the private registry, builds `backend` and `nginx`, applies branch-specific tags, and pushes the images to the registry. +- A server-local script logs in to the private registry, builds `backend` and `frontend`, applies branch-specific tags, and pushes the images to the registry. **Registry Naming** - Backend image: `/ctms/ctms-backend` -- Nginx image: `/ctms/ctms-nginx` -- `frontend` is intentionally not published because the current runtime topology already builds the frontend assets into the `nginx` image. +- Frontend image: `/ctms/ctms-frontend` **Tagging Strategy** - `dev` branch pushes publish: - `/ctms/ctms-backend:dev-latest` - `/ctms/ctms-backend:dev-` - - `/ctms/ctms-nginx:dev-latest` - - `/ctms/ctms-nginx:dev-` + - `/ctms/ctms-frontend:dev-latest` + - `/ctms/ctms-frontend:dev-` - `release` branch pushes publish: - `/ctms/ctms-backend:rc-release` - `/ctms/ctms-backend:rc-` - - `/ctms/ctms-nginx:rc-release` - - `/ctms/ctms-nginx:rc-` + - `/ctms/ctms-frontend:rc-release` + - `/ctms/ctms-frontend:rc-` - `release/*` branch pushes publish: - `/ctms/ctms-backend:rc-` - `/ctms/ctms-backend:rc-` - - `/ctms/ctms-nginx:rc-` - - `/ctms/ctms-nginx:rc-` + - `/ctms/ctms-frontend:rc-` + - `/ctms/ctms-frontend:rc-` - `main` branch pushes publish: - `/ctms/ctms-backend:latest` - `/ctms/ctms-backend:sha-` - - `/ctms/ctms-nginx:latest` - - `/ctms/ctms-nginx:sha-` + - `/ctms/ctms-frontend:latest` + - `/ctms/ctms-frontend:sha-` **Server Requirements** - Docker installed on the Tencent Cloud server. @@ -64,7 +63,7 @@ - `REGISTRY_PASSWORD` **Compose Integration** -- `docker-compose.yaml` should define `image:` for `backend`, `backend-init`, and `nginx`, with defaults based on private registry variables such as `REGISTRY_HOST`. +- `docker-compose.yaml` should define `image:` for `backend`, `backend-init`, and `frontend`, with defaults based on private registry variables such as `REGISTRY_HOST`. - Existing `build:` blocks stay in place so local `docker compose up -d --build` continues to work. - Deployment hosts can export `REGISTRY_HOST`, then run `docker login`, `docker compose pull`, `docker compose run --rm backend-init`, and `docker compose up -d`. diff --git a/docs/plans/2026-03-30-tcloud-private-registry.md b/docs/plans/2026-03-30-tcloud-private-registry.md index 55a063c2..23dfb0c6 100644 --- a/docs/plans/2026-03-30-tcloud-private-registry.md +++ b/docs/plans/2026-03-30-tcloud-private-registry.md @@ -2,11 +2,11 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Add CI that triggers remote builds on a Tencent Cloud CVM and pushes private `backend` and `nginx` images into a self-hosted Docker Registry protected by `htpasswd`, for `dev`, `release`, `release/*`, and `main`. +**Goal:** Add CI that triggers remote builds on a Tencent Cloud CVM and pushes private `backend` and `frontend` images into a self-hosted Docker Registry protected by `htpasswd`, for `dev`, `release`, `release/*`, and `main`. **Architecture:** GitHub Actions runs on pushes to `dev`, `release`, `release/*`, and `main`, connects to the Tencent Cloud server over SSH, syncs the repository into a fixed build directory, and invokes a repository-owned shell script that logs in to the private registry, builds the images locally on the server, tags them according to branch, and pushes them. -**Tech Stack:** GitHub Actions, SSH, Docker, self-hosted `registry:2`, Docker Compose, FastAPI, Nginx +**Tech Stack:** GitHub Actions, SSH, Docker, self-hosted `registry:2`, Docker Compose, FastAPI, Node.js --- @@ -65,7 +65,7 @@ Fail fast for unsupported branches. Log in to `${REGISTRY_HOST}` using the supplied username and password, then build: - `${REGISTRY_HOST}/ctms/ctms-backend` -- `${REGISTRY_HOST}/ctms/ctms-nginx` +- `${REGISTRY_HOST}/ctms/ctms-frontend` **Step 3: Push both tags for both images** @@ -86,7 +86,7 @@ Expected: PASS Set: - `backend.image` to `${BACKEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-backend:latest}` - `backend-init.image` to `${BACKEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-backend:latest}` -- `nginx.image` to `${NGINX_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-nginx:latest}` +- `frontend.image` to `${FRONTEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-frontend:latest}` Keep the current `build:` blocks intact. diff --git a/frontend/.env.example b/frontend/.env.example index 73a9682d..81f4a110 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,5 @@ # Frontend feature flags and timeline overrides +VITE_API_BASE_URL= VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 diff --git a/frontend/Caddyfile b/frontend/Caddyfile new file mode 100644 index 00000000..38576a03 --- /dev/null +++ b/frontend/Caddyfile @@ -0,0 +1,5 @@ +:4173 + +root * /srv +try_files {path} /index.html +file_server diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b145d8c4..106ffcac 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,14 +2,18 @@ FROM node:20-alpine AS build WORKDIR /app +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + COPY package*.json ./ RUN npm ci COPY . . RUN npm run build -FROM alpine:3.21 +FROM caddy:2-alpine -WORKDIR /opt/frontend +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv -COPY --from=build /app/dist ./dist +EXPOSE 4173 diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index e669ed03..0ae6aba3 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -7,9 +7,12 @@ import { useStudyStore } from "../store/study"; import { TEXT } from "../locales"; import { extendAccessToken, forceLogout, lockSession, markNetworkActive } from "../session/sessionManager"; import { useSessionStore } from "../store/session"; +import { resolveApiBaseUrl } from "./baseUrl"; + +const apiBaseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL); const instance: AxiosInstance = axios.create({ - baseURL: "/", + baseURL: apiBaseUrl, timeout: 15000, }); diff --git a/frontend/src/api/baseUrl.test.ts b/frontend/src/api/baseUrl.test.ts new file mode 100644 index 00000000..521baee7 --- /dev/null +++ b/frontend/src/api/baseUrl.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { resolveApiBaseUrl } from "./baseUrl"; + +describe("resolveApiBaseUrl", () => { + it("defaults to same-origin root when no env override is set", () => { + expect(resolveApiBaseUrl(undefined)).toBe("/"); + expect(resolveApiBaseUrl("")).toBe("/"); + expect(resolveApiBaseUrl(" ")).toBe("/"); + }); + + it("preserves explicit root", () => { + expect(resolveApiBaseUrl("/")).toBe("/"); + }); + + it("trims whitespace and trailing slashes from absolute base urls", () => { + expect(resolveApiBaseUrl(" http://localhost:8000/ ")).toBe("http://localhost:8000"); + expect(resolveApiBaseUrl("https://api.example.com///")).toBe("https://api.example.com"); + }); +}); diff --git a/frontend/src/api/baseUrl.ts b/frontend/src/api/baseUrl.ts new file mode 100644 index 00000000..17657095 --- /dev/null +++ b/frontend/src/api/baseUrl.ts @@ -0,0 +1,12 @@ +const trimTrailingSlashes = (value: string): string => value.replace(/\/+$/, ""); + +export const resolveApiBaseUrl = (rawValue: string | undefined): string => { + const normalized = rawValue?.trim() ?? ""; + if (!normalized) { + return "/"; + } + if (normalized === "/") { + return "/"; + } + return trimTrailingSlashes(normalized); +}; diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index d394f9fc..00000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:20-alpine AS frontend-build - -WORKDIR /app - -COPY frontend/package*.json ./ -RUN npm ci - -COPY frontend/ ./ -RUN npm run build - -FROM nginx:1.27-alpine - -COPY nginx/nginx.conf /etc/nginx/nginx.conf -COPY --from=frontend-build /app/dist /usr/share/nginx/html diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index 37ff0e79..00000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,65 +0,0 @@ -worker_processes 1; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - resolver 127.0.0.11 valid=10s ipv6=off; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - root /usr/share/nginx/html; - index index.html; - - upstream backend { - zone backend 64k; - server backend:8000 resolve; - } - - server { - listen 80; - server_name _; - client_max_body_size 20m; - - location = /favicon.ico { - root /usr/share/nginx/html; - try_files /favicon.ico =204; - access_log off; - log_not_found off; - expires 1d; - add_header Cache-Control "public, max-age=86400" always; - } - - location /api/ { - proxy_pass http://backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /health { - proxy_pass http://backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location / { - try_files $uri $uri/ /index.html; - } - } -} diff --git a/scripts/build-and-push-registry.sh b/scripts/build-and-push-registry.sh index b38f945d..b1c25b8c 100644 --- a/scripts/build-and-push-registry.sh +++ b/scripts/build-and-push-registry.sh @@ -45,7 +45,7 @@ case "${BRANCH_NAME}" in esac backend_image="${REGISTRY_HOST}/ctms/ctms-backend" -nginx_image="${REGISTRY_HOST}/ctms/ctms-nginx" +frontend_image="${REGISTRY_HOST}/ctms/ctms-frontend" echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" --username "${REGISTRY_USERNAME}" --password-stdin @@ -55,12 +55,11 @@ docker build \ ./backend docker build \ - -t "${nginx_image}:${mutable_tag}" \ - -t "${nginx_image}:${immutable_tag}" \ - -f ./nginx/Dockerfile \ - . + -t "${frontend_image}:${mutable_tag}" \ + -t "${frontend_image}:${immutable_tag}" \ + ./frontend docker push "${backend_image}:${mutable_tag}" docker push "${backend_image}:${immutable_tag}" -docker push "${nginx_image}:${mutable_tag}" -docker push "${nginx_image}:${immutable_tag}" +docker push "${frontend_image}:${mutable_tag}" +docker push "${frontend_image}:${immutable_tag}" From fa96d4b6dbb2a9225fa04fa0840ec6e51869f6ea Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 30 Mar 2026 22:43:58 +0800 Subject: [PATCH 03/11] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=20GitHub=20?= =?UTF-8?q?=E5=88=B0=E8=85=BE=E8=AE=AF=E4=BA=91=E7=9A=84=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-images.yml | 83 ---------- README.md | 10 +- docker-compose.yaml | 3 - docs/guides/release-checklist.md | 11 +- docs/plans/2026-03-30-remove-nginx-design.md | 1 - .../2026-03-30-remove-nginx-implementation.md | 26 +--- ...2026-03-30-remove-tcloud-publish-design.md | 18 +++ ...30-remove-tcloud-publish-implementation.md | 88 +++++++++++ ...26-03-30-tcloud-private-registry-design.md | 88 ++--------- .../2026-03-30-tcloud-private-registry.md | 144 +----------------- scripts/build-and-push-registry.sh | 65 -------- 11 files changed, 129 insertions(+), 408 deletions(-) delete mode 100644 .github/workflows/publish-images.yml create mode 100644 docs/plans/2026-03-30-remove-tcloud-publish-design.md create mode 100644 docs/plans/2026-03-30-remove-tcloud-publish-implementation.md delete mode 100644 scripts/build-and-push-registry.sh diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml deleted file mode 100644 index e157dd73..00000000 --- a/.github/workflows/publish-images.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Publish Images - -on: - push: - branches: - - dev - - release - - release/** - - main - -permissions: - contents: read - -concurrency: - group: publish-images-${{ github.ref }} - cancel-in-progress: true - -jobs: - publish: - runs-on: ubuntu-latest - env: - REMOTE_BASE_DIR: /home/zcc/registry/build/ctms-build - SSH_KEY_PATH: /tmp/tcloud_ci_key - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Prepare remote path metadata - id: meta - shell: bash - run: | - echo "repo_name=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT" - echo "branch_name=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" - echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - - - name: Install SSH key - shell: bash - run: | - printf '%s\n' '${{ secrets.TCLOUD_SSH_KEY }}' > "${{ env.SSH_KEY_PATH }}" - chmod 600 "${{ env.SSH_KEY_PATH }}" - - - name: Sync repository to Tencent Cloud host - shell: bash - env: - TCLOUD_HOST: ${{ secrets.TCLOUD_HOST }} - TCLOUD_PORT: ${{ secrets.TCLOUD_PORT }} - TCLOUD_USER: ${{ secrets.TCLOUD_USER }} - REMOTE_REPO_DIR: ${{ env.REMOTE_BASE_DIR }}/${{ steps.meta.outputs.repo_name }} - run: | - ssh -i "${{ env.SSH_KEY_PATH }}" -p "${TCLOUD_PORT}" -o StrictHostKeyChecking=no "${TCLOUD_USER}@${TCLOUD_HOST}" \ - "mkdir -p '${REMOTE_REPO_DIR}' && find '${REMOTE_REPO_DIR}' -mindepth 1 -maxdepth 1 -exec rm -rf {} +" - tar --exclude=.git -cf - . | \ - ssh -i "${{ env.SSH_KEY_PATH }}" -p "${TCLOUD_PORT}" -o StrictHostKeyChecking=no "${TCLOUD_USER}@${TCLOUD_HOST}" \ - "tar -xf - -C '${REMOTE_REPO_DIR}'" - - - name: Build and push images on Tencent Cloud host - shell: bash - env: - TCLOUD_HOST: ${{ secrets.TCLOUD_HOST }} - TCLOUD_PORT: ${{ secrets.TCLOUD_PORT }} - TCLOUD_USER: ${{ secrets.TCLOUD_USER }} - BRANCH_NAME: ${{ steps.meta.outputs.branch_name }} - COMMIT_SHA: ${{ github.sha }} - REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }} - REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} - REMOTE_REPO_DIR: ${{ env.REMOTE_BASE_DIR }}/${{ steps.meta.outputs.repo_name }} - run: | - BRANCH_NAME_B64="$(printf '%s' "${BRANCH_NAME}" | base64)" - COMMIT_SHA_B64="$(printf '%s' "${COMMIT_SHA}" | base64)" - REGISTRY_HOST_B64="$(printf '%s' "${REGISTRY_HOST}" | base64)" - REGISTRY_USERNAME_B64="$(printf '%s' "${REGISTRY_USERNAME}" | base64)" - REGISTRY_PASSWORD_B64="$(printf '%s' "${REGISTRY_PASSWORD}" | base64)" - ssh -i "${{ env.SSH_KEY_PATH }}" -p "${TCLOUD_PORT}" -o StrictHostKeyChecking=no "${TCLOUD_USER}@${TCLOUD_HOST}" " - cd '${REMOTE_REPO_DIR}' - chmod +x scripts/build-and-push-registry.sh - export BRANCH_NAME=\$(printf '%s' '${BRANCH_NAME_B64}' | base64 -d) - export COMMIT_SHA=\$(printf '%s' '${COMMIT_SHA_B64}' | base64 -d) - export REGISTRY_HOST=\$(printf '%s' '${REGISTRY_HOST_B64}' | base64 -d) - export REGISTRY_USERNAME=\$(printf '%s' '${REGISTRY_USERNAME_B64}' | base64 -d) - export REGISTRY_PASSWORD=\$(printf '%s' '${REGISTRY_PASSWORD_B64}' | base64 -d) - ./scripts/build-and-push-registry.sh - " diff --git a/README.md b/README.md index d9f70323..cea2c471 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,6 @@ - `curl -i http://127.0.0.1:4173/` - `curl -i http://127.0.0.1:8000/health` -## 腾讯云私有镜像仓库 -- GitHub Actions 会在推送到 `dev`、`release`、`release/*` 和 `main` 时,通过 SSH 连接腾讯云服务器,在服务器本机完成 `backend` 和 `frontend` 镜像的构建与推送。 -- 私有仓库地址默认使用 `${REGISTRY_HOST}`,仓库命名为 `${REGISTRY_HOST}/ctms/ctms-backend` 和 `${REGISTRY_HOST}/ctms/ctms-frontend`。 -- `dev` 分支发布 `dev-latest` 和 `dev-`,`release` 分支发布 `rc-release` 和 `rc-`,`release/*` 分支发布 `rc-` 和 `rc-`,`main` 分支发布 `latest` 和 `sha-`。 -- 部署机需要先 `docker login :5000`,并使用私有仓库的 `htpasswd` 账号密码完成认证。 -- `backend-init` 复用 `BACKEND_IMAGE`,所以生产机可在设置 `REGISTRY_HOST`,或直接指定 `BACKEND_IMAGE` 和 `FRONTEND_IMAGE` 后执行 `docker compose pull && docker compose run --rm backend-init && docker compose up -d`。 -- 本地开发仍可继续使用 `docker compose up -d --build` 走本地构建。 - ## 账号与注册 - 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建) - 自助注册:前端 `/register` 提交邮箱、密码、姓名、角色(CRA/PV/IMP/PM)、部门,状态为 PENDING。 @@ -40,7 +32,7 @@ - 后端 API:`http://localhost:8000/api/v1/*` - 生产环境建议通过外部反向代理提供同域访问,前端代码默认请求同域 `/api/v1/*` - 如果本地直接访问 `http://localhost:4173`,可在 `frontend/.env` 中设置 `VITE_API_BASE_URL=http://localhost:8000`,让前端直连后端 API -- `docker compose` 本地构建前端容器时,默认会把 `VITE_API_BASE_URL` 注入为 `http://localhost:8000`;如果是生产构建,请显式覆盖为空值或你的正式 API 地址 +- `docker compose` 本地构建前端容器时,默认会把 `VITE_API_BASE_URL` 注入为 `http://localhost:8000`;如需覆盖,可显式设置 `VITE_API_BASE_URL` ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` diff --git a/docker-compose.yaml b/docker-compose.yaml index 160ec7b1..cb38348c 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,7 +21,6 @@ services: - ctms_net backend: - image: ${BACKEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-backend:latest} build: context: ./backend dockerfile: Dockerfile @@ -38,7 +37,6 @@ services: - ctms_net backend-init: - image: ${BACKEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-backend:latest} build: context: ./backend dockerfile: Dockerfile @@ -53,7 +51,6 @@ services: - ctms_net frontend: - image: ${FRONTEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-frontend:latest} build: context: ./frontend dockerfile: Dockerfile diff --git a/docs/guides/release-checklist.md b/docs/guides/release-checklist.md index 41bfa740..3a53ea54 100644 --- a/docs/guides/release-checklist.md +++ b/docs/guides/release-checklist.md @@ -9,13 +9,10 @@ - [ ] `cd backend && python3 -m compileall app scripts` - [ ] `cd frontend && npm run build` -## 1.1 腾讯云私有镜像校验 -- [ ] `dev` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `dev-latest` / `dev-` 标签已出现在私有仓库 -- [ ] `release` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `rc-release` / `rc-` 标签已出现在私有仓库 -- [ ] `release/*` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `rc-` / `rc-` 标签已出现在私有仓库 -- [ ] `main` 推送后,`:5000/ctms/ctms-backend` 和 `ctms-frontend` 的 `latest` / `sha-` 标签已出现在私有仓库 -- [ ] GitHub Actions 已成功 SSH 登录腾讯云服务器并执行远程构建脚本 -- [ ] 部署机已执行 `docker login :5000`,且使用的是私有仓库 `htpasswd` 账号密码 +## 1.1 容器构建校验 +- [ ] `docker compose config` 渲染成功 +- [ ] `docker compose build backend frontend` 成功 +- [ ] `docker compose run --rm backend-init` 成功 ## 2. 接口与权限回归 - [ ] ADMIN 账号可 `GET/PUT/PUBLISH/IMPORT-EXCEL/EXPORT-EXCEL` diff --git a/docs/plans/2026-03-30-remove-nginx-design.md b/docs/plans/2026-03-30-remove-nginx-design.md index e07bcaa5..f63b8555 100644 --- a/docs/plans/2026-03-30-remove-nginx-design.md +++ b/docs/plans/2026-03-30-remove-nginx-design.md @@ -17,5 +17,4 @@ **Operational Notes** - The frontend still assumes same-origin API access, so the external proxy must route `/api/*` and `/health` to `backend`. -- The repository publish flow should only build and push `ctms-backend` and `ctms-frontend`. - Verification should prove that no runtime, CI, or document references to repository-managed Nginx remain. diff --git a/docs/plans/2026-03-30-remove-nginx-implementation.md b/docs/plans/2026-03-30-remove-nginx-implementation.md index 1c530f5f..c78d57d1 100644 --- a/docs/plans/2026-03-30-remove-nginx-implementation.md +++ b/docs/plans/2026-03-30-remove-nginx-implementation.md @@ -46,26 +46,7 @@ Build from `frontend/Dockerfile`, define a private-registry-backed `FRONTEND_IMA Run: `docker compose config` Expected: only `db`, `backend`, `backend-init`, and `frontend` render. -### Task 3: Update image publishing - -**Files:** -- Modify: `scripts/build-and-push-registry.sh` -- Modify: `.github/workflows/publish-images.yml` - -**Step 1: Publish `frontend` instead of `nginx`** - -Build and push `${REGISTRY_HOST}/ctms/ctms-frontend` alongside `${REGISTRY_HOST}/ctms/ctms-backend`. - -**Step 2: Keep branch-based tag logic unchanged** - -Preserve the existing `dev`, `release`, `release/*`, and `main` tag conventions. - -**Step 3: Verify script syntax** - -Run: `bash -n scripts/build-and-push-registry.sh` -Expected: PASS - -### Task 4: Rewrite docs to match the new topology +### Task 3: Rewrite docs to match the new topology **Files:** - Modify: `README.md` @@ -90,7 +71,7 @@ Describe the runtime as frontend and backend containers behind an external proxy Run: `rg -n --hidden -g '!/.git' -S "nginx|ctms-nginx|NGINX_IMAGE|Nginx" README.md docker-compose.yaml .github scripts docs` Expected: no active repository-managed Nginx references remain. -### Task 5: Remove obsolete nginx files and verify end-to-end cleanup +### Task 4: Remove obsolete nginx files and verify end-to-end cleanup **Files:** - Delete: `nginx/Dockerfile` @@ -105,9 +86,6 @@ Remove the Nginx directory because nothing in the repository should depend on it Run: `docker compose config` Expected: PASS -Run: `bash -n scripts/build-and-push-registry.sh` -Expected: PASS - **Step 3: Validate frontend and backend image references** Run: `sed -n '1,160p' README.md` diff --git a/docs/plans/2026-03-30-remove-tcloud-publish-design.md b/docs/plans/2026-03-30-remove-tcloud-publish-design.md new file mode 100644 index 00000000..986af32d --- /dev/null +++ b/docs/plans/2026-03-30-remove-tcloud-publish-design.md @@ -0,0 +1,18 @@ +# Remove Tencent Cloud Publish Design + +**Goal:** Remove the GitHub-to-Tencent-Cloud image publishing pipeline and all repository-owned private registry defaults so the repository no longer carries any built-in cloud publish path. + +**Decisions** +- Delete the GitHub Actions workflow that SSHes to Tencent Cloud and triggers remote image builds. +- Delete the repository-owned remote build-and-push script. +- Remove default private-registry image references from `docker-compose.yaml` and rely on local builds as the default path. +- Rewrite current documentation and historical plan documents so they no longer describe Tencent Cloud, private registry credentials, or registry-based deploy flows as active repository behavior. + +**Architecture** +- `docker-compose.yaml` remains the deployment entrypoint, but defaults to local image builds for `backend`, `backend-init`, and `frontend`. +- CI no longer contains any branch-triggered publish job for Tencent Cloud or any self-hosted registry. +- Historical design records are retained only as edited documentation that says the publish path has been removed. + +**Operational Notes** +- Anyone who still wants custom image names can set `BACKEND_IMAGE` or `FRONTEND_IMAGE` explicitly, but the repository no longer provides private-registry defaults or a publish pipeline. +- Verification should prove that no active references to `TCLOUD_*`, `REGISTRY_*`, `publish-images`, or `build-and-push-registry.sh` remain outside of the new cleanup documentation. diff --git a/docs/plans/2026-03-30-remove-tcloud-publish-implementation.md b/docs/plans/2026-03-30-remove-tcloud-publish-implementation.md new file mode 100644 index 00000000..abfeb14e --- /dev/null +++ b/docs/plans/2026-03-30-remove-tcloud-publish-implementation.md @@ -0,0 +1,88 @@ +# Remove Tencent Cloud Publish Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove GitHub-to-Tencent-Cloud publishing, private registry defaults, and related documentation from the repository. + +**Architecture:** The repository keeps local Docker build support for `backend`, `backend-init`, and `frontend`, but removes all built-in publish automation and private-registry defaults. Documentation is rewritten so Tencent Cloud and self-hosted registry deployment are no longer described as current behavior. + +**Tech Stack:** GitHub Actions, Docker Compose, FastAPI, Vue/Vite, Markdown documentation + +--- + +### Task 1: Remove publish automation + +**Files:** +- Delete: `.github/workflows/publish-images.yml` +- Delete: `scripts/build-and-push-registry.sh` + +**Step 1: Delete the GitHub workflow** + +Remove the workflow that SSHes into Tencent Cloud and runs remote image publication. + +**Step 2: Delete the remote publish script** + +Remove the repository-owned script that logs into the private registry and pushes images. + +### Task 2: Remove private-registry defaults from compose + +**Files:** +- Modify: `docker-compose.yaml` + +**Step 1: Switch runtime defaults to local image names** + +Remove `REGISTRY_HOST`-based defaults from `backend`, `backend-init`, and `frontend`. + +**Step 2: Keep local build support intact** + +Retain the existing `build:` blocks and any required build args for the frontend. + +**Step 3: Verify rendered compose** + +Run: `docker compose config` +Expected: PASS without any `REGISTRY_HOST`-derived default image names. + +### Task 3: Rewrite active docs + +**Files:** +- Modify: `README.md` +- Modify: `docs/guides/release-checklist.md` + +**Step 1: Remove Tencent Cloud and private registry sections** + +Delete active deployment guidance that references GitHub Actions, Tencent Cloud, `REGISTRY_HOST`, or private registry authentication. + +**Step 2: Keep deployment instructions aligned** + +Document the repository as local-build-first and external-deploy-managed. + +### Task 4: Rewrite historical design records + +**Files:** +- Modify: `docs/plans/2026-03-30-tcloud-private-registry-design.md` +- Modify: `docs/plans/2026-03-30-tcloud-private-registry.md` +- Modify: `docs/plans/2026-03-30-remove-nginx-design.md` +- Modify: `docs/plans/2026-03-30-remove-nginx-implementation.md` + +**Step 1: Mark the Tencent Cloud/private registry path as removed** + +Keep the documents but rewrite them so they no longer read as active implementation guidance. + +**Step 2: Remove references to active publish files** + +Update any references to `.github/workflows/publish-images.yml`, `scripts/build-and-push-registry.sh`, or private-registry defaults if those files/configs no longer exist. + +### Task 5: Verify cleanup + +**Files:** +- Verify only + +**Step 1: Render compose** + +Run: `docker compose config` +Expected: PASS + +**Step 2: Search for active Tencent Cloud/private registry references** + +Run: `rg -n --hidden -g '!/.git' -g '!frontend/node_modules/**' -g '!docs/plans/2026-03-30-remove-tcloud-publish-design.md' -g '!docs/plans/2026-03-30-remove-tcloud-publish-implementation.md' -S "Tencent|腾讯云|TCLOUD|publish-images|build-and-push-registry|REGISTRY_HOST|REGISTRY_USERNAME|REGISTRY_PASSWORD" .` +Expected: no active repository references remain. diff --git a/docs/plans/2026-03-30-tcloud-private-registry-design.md b/docs/plans/2026-03-30-tcloud-private-registry-design.md index 8e059230..ede105cb 100644 --- a/docs/plans/2026-03-30-tcloud-private-registry-design.md +++ b/docs/plans/2026-03-30-tcloud-private-registry-design.md @@ -1,81 +1,15 @@ -# Tencent Cloud Private Registry Design +# Tencent Cloud Private Registry Design (Removed) -**Goal:** Add CI that automatically builds and pushes private `backend` and `frontend` images to a self-hosted Docker Registry running on a Tencent Cloud CVM, with `dev` publishing test tags, `release` and `release/*` publishing release-candidate tags, and `main` publishing formal tags. +**Status:** Removed on 2026-03-30. -**Decisions** -- Publish only two runtime images: `/ctms/ctms-backend` and `/ctms/ctms-frontend`. -- Keep the private registry on the Tencent Cloud CVM as a self-hosted `registry:2` instance protected by `htpasswd`. -- Trigger automation on pushes to `dev`, `release`, `release/*`, and `main`. -- Use GitHub Actions only as the orchestrator. Actual Docker build and push happen on the Tencent Cloud server over SSH. -- Keep `docker-compose.yaml` compatible with both local `build` workflows and private registry pulls. +**Goal:** This document describes a publish path that has since been removed from the repository. -**Approaches Considered** -- Recommended: GitHub Actions connects to the Tencent Cloud server over SSH and runs a server-local build-and-push script. This avoids GitHub-hosted runner limitations around insecure or self-signed private registries and keeps registry access local to the server. -- Alternative: GitHub Actions builds and pushes directly to `:5000`. This is simpler on paper but is fragile when the registry is exposed only as an IP endpoint and may use insecure or non-public TLS. -- Alternative: move first to a domain-backed HTTPS registry and then push directly from GitHub Actions. This is the clean long-term path but adds infrastructure work that is not required for the current goal. +**Removal Summary** +- The GitHub Actions publish workflow has been deleted. +- The repository-owned remote build-and-push script has been deleted. +- `docker-compose.yaml` no longer defaults to private registry image names. +- Tencent Cloud and private registry deployment are no longer active repository features. -**Architecture** -- A new GitHub Actions workflow triggers on pushes to `dev`, `release`, `release/*`, and `main`. -- The workflow authenticates to the Tencent Cloud server using SSH credentials stored in GitHub Secrets. -- The workflow syncs the repository contents to a fixed build directory such as `/opt/ctms-build/`. -- A server-local script logs in to the private registry, builds `backend` and `frontend`, applies branch-specific tags, and pushes the images to the registry. - -**Registry Naming** -- Backend image: `/ctms/ctms-backend` -- Frontend image: `/ctms/ctms-frontend` - -**Tagging Strategy** -- `dev` branch pushes publish: - - `/ctms/ctms-backend:dev-latest` - - `/ctms/ctms-backend:dev-` - - `/ctms/ctms-frontend:dev-latest` - - `/ctms/ctms-frontend:dev-` -- `release` branch pushes publish: - - `/ctms/ctms-backend:rc-release` - - `/ctms/ctms-backend:rc-` - - `/ctms/ctms-frontend:rc-release` - - `/ctms/ctms-frontend:rc-` -- `release/*` branch pushes publish: - - `/ctms/ctms-backend:rc-` - - `/ctms/ctms-backend:rc-` - - `/ctms/ctms-frontend:rc-` - - `/ctms/ctms-frontend:rc-` -- `main` branch pushes publish: - - `/ctms/ctms-backend:latest` - - `/ctms/ctms-backend:sha-` - - `/ctms/ctms-frontend:latest` - - `/ctms/ctms-frontend:sha-` - -**Server Requirements** -- Docker installed on the Tencent Cloud server. -- A private `registry:2` instance listening on `:5000`. -- `htpasswd` authentication configured for the registry. -- SSH access from GitHub Actions to the server. -- A writable build directory such as `/opt/ctms-build`. - -**GitHub Secrets** -- `TCLOUD_HOST` -- `TCLOUD_PORT` -- `TCLOUD_USER` -- `TCLOUD_SSH_KEY` -- `REGISTRY_HOST` -- `REGISTRY_USERNAME` -- `REGISTRY_PASSWORD` - -**Compose Integration** -- `docker-compose.yaml` should define `image:` for `backend`, `backend-init`, and `frontend`, with defaults based on private registry variables such as `REGISTRY_HOST`. -- Existing `build:` blocks stay in place so local `docker compose up -d --build` continues to work. -- Deployment hosts can export `REGISTRY_HOST`, then run `docker login`, `docker compose pull`, `docker compose run --rm backend-init`, and `docker compose up -d`. - -**Operational Notes** -- This design assumes the server-local script runs on the same machine that can log in to the private registry reliably. -- The GitHub workflow should not embed long shell logic inline; the repository should own a script under `scripts/` so the build logic stays versioned and testable. -- Direct verification of actual image publication depends on GitHub Actions reaching the Tencent Cloud server and cannot be proven locally without those secrets and network access. - -**Verification** -- Push to `dev` and confirm both images appear with `dev-latest` and `dev-`. -- Push to `release` and confirm both images appear with `rc-release` and `rc-`. -- Push to `release/*` and confirm both images appear with `rc-` and `rc-`. -- Push to `main` and confirm both images appear with `latest` and `sha-`. -- Run `docker compose config` after compose changes and confirm the private registry defaults render correctly. -- Parse the workflow YAML successfully and inspect the remote build script for the expected tag logic and registry login behavior. +**Current Replacement** +- Use local `docker compose build` workflows inside this repository. +- If deployment needs a registry in the future, reintroduce it as a fresh design rather than relying on the removed Tencent Cloud path. diff --git a/docs/plans/2026-03-30-tcloud-private-registry.md b/docs/plans/2026-03-30-tcloud-private-registry.md index 23dfb0c6..ed9971bf 100644 --- a/docs/plans/2026-03-30-tcloud-private-registry.md +++ b/docs/plans/2026-03-30-tcloud-private-registry.md @@ -1,146 +1,12 @@ -# Tencent Cloud Private Registry Implementation Plan +# Tencent Cloud Private Registry Implementation Plan (Removed) -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +**Status:** Removed on 2026-03-30. -**Goal:** Add CI that triggers remote builds on a Tencent Cloud CVM and pushes private `backend` and `frontend` images into a self-hosted Docker Registry protected by `htpasswd`, for `dev`, `release`, `release/*`, and `main`. +**Goal:** This implementation plan is retained only as historical record. The Tencent Cloud publish path and private registry defaults have been removed from the repository. -**Architecture:** GitHub Actions runs on pushes to `dev`, `release`, `release/*`, and `main`, connects to the Tencent Cloud server over SSH, syncs the repository into a fixed build directory, and invokes a repository-owned shell script that logs in to the private registry, builds the images locally on the server, tags them according to branch, and pushes them. +**Removal Outcome:** The workflow file, remote publish script, and private-registry-based compose defaults have been deleted. Local build-based workflows are now the only repository-owned path. **Tech Stack:** GitHub Actions, SSH, Docker, self-hosted `registry:2`, Docker Compose, FastAPI, Node.js --- - -### Task 1: Replace GHCR Workflow with Tencent Registry Publish Workflow - -**Files:** -- Modify: `.github/workflows/publish-images.yml` - -**Step 1: Define push triggers and remote execution secrets** - -Ensure the workflow triggers on pushes to `dev`, `release`, `release/*`, and `main`, and uses repository secrets for SSH host, port, user, SSH key, registry host, registry username, and registry password. - -**Step 2: Sync repository content to the Tencent Cloud server** - -Use an SSH-capable action or shell step to create the remote build directory and copy the current repository contents into `/opt/ctms-build/`. - -**Step 3: Invoke the remote publish script** - -Run the repository-owned remote script over SSH with environment values for: -- branch name -- commit SHA -- registry host -- registry credentials - -**Step 4: Verify workflow structure** - -Run: `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/publish-images.yml")'` -Expected: PASS - -### Task 2: Add Remote Build-And-Push Script - -**Files:** -- Create: `scripts/build-and-push-registry.sh` - -**Step 1: Implement branch-aware tag logic** - -For `dev`, compute: -- `dev-latest` -- `dev-` - -For `release`, compute: -- `rc-release` -- `rc-` - -For `release/*`, compute: -- `rc-` -- `rc-` - -For `main`, compute: -- `latest` -- `sha-` - -Fail fast for unsupported branches. - -**Step 2: Implement registry login and image builds** - -Log in to `${REGISTRY_HOST}` using the supplied username and password, then build: -- `${REGISTRY_HOST}/ctms/ctms-backend` -- `${REGISTRY_HOST}/ctms/ctms-frontend` - -**Step 3: Push both tags for both images** - -Push every computed tag explicitly so the branch and immutable tags are both published. - -**Step 4: Verify script syntax** - -Run: `bash -n scripts/build-and-push-registry.sh` -Expected: PASS - -### Task 3: Point Compose Defaults at the Private Registry - -**Files:** -- Modify: `docker-compose.yaml` - -**Step 1: Replace GHCR defaults with private registry defaults** - -Set: -- `backend.image` to `${BACKEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-backend:latest}` -- `backend-init.image` to `${BACKEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-backend:latest}` -- `frontend.image` to `${FRONTEND_IMAGE:-${REGISTRY_HOST:-127.0.0.1:5000}/ctms/ctms-frontend:latest}` - -Keep the current `build:` blocks intact. - -**Step 2: Verify rendered compose** - -Run: `docker compose config` -Expected: PASS with private registry image defaults rendered. - -### Task 4: Update Deployment Documentation - -**Files:** -- Modify: `README.md` -- Modify: `docs/guides/release-checklist.md` - -**Step 1: Document the Tencent registry flow** - -Explain that GitHub Actions triggers remote builds on the Tencent Cloud server and publishes to the private registry at `:5000`. - -**Step 2: Document deployment commands** - -Show that deployment hosts must run: -- `docker login :5000` -- `docker compose pull` -- `docker compose run --rm backend-init` -- `docker compose up -d` - -**Step 3: Update release checklist** - -Add checks for remote publish success on `dev`, `release`, `release/*`, and `main`, and for deployment-host registry authentication. - -### Task 5: End-To-End Local Verification - -**Files:** -- Verify only - -**Step 1: Parse the workflow YAML** - -Run: `ruby -e 'require "yaml"; YAML.load_file(".github/workflows/publish-images.yml")'` -Expected: PASS - -**Step 2: Verify script syntax** - -Run: `bash -n scripts/build-and-push-registry.sh` -Expected: PASS - -**Step 3: Render compose** - -Run: `docker compose config` -Expected: PASS - -**Step 4: Inspect documentation** - -Run: `sed -n '1,120p' README.md` -Expected: PASS with Tencent private registry deployment steps present. - -Run: `sed -n '1,120p' docs/guides/release-checklist.md` -Expected: PASS with private registry release verification present. +This plan is no longer executable as written because the referenced workflow, script, and compose defaults have been removed. diff --git a/scripts/build-and-push-registry.sh b/scripts/build-and-push-registry.sh deleted file mode 100644 index b1c25b8c..00000000 --- a/scripts/build-and-push-registry.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -required_vars=( - BRANCH_NAME - COMMIT_SHA - REGISTRY_HOST - REGISTRY_USERNAME - REGISTRY_PASSWORD -) - -for var_name in "${required_vars[@]}"; do - if [[ -z "${!var_name:-}" ]]; then - echo "Missing required environment variable: ${var_name}" >&2 - exit 1 - fi -done - -short_sha="${COMMIT_SHA:0:7}" - -case "${BRANCH_NAME}" in - dev) - mutable_tag="dev-latest" - immutable_tag="dev-${short_sha}" - ;; - release) - mutable_tag="rc-release" - immutable_tag="rc-${short_sha}" - ;; - release/*) - release_name="${BRANCH_NAME#release/}" - release_slug="${release_name//\//-}" - mutable_tag="rc-${release_slug}" - immutable_tag="rc-${short_sha}" - ;; - main) - mutable_tag="latest" - immutable_tag="sha-${short_sha}" - ;; - *) - echo "Unsupported branch for publishing: ${BRANCH_NAME}" >&2 - exit 1 - ;; -esac - -backend_image="${REGISTRY_HOST}/ctms/ctms-backend" -frontend_image="${REGISTRY_HOST}/ctms/ctms-frontend" - -echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" --username "${REGISTRY_USERNAME}" --password-stdin - -docker build \ - -t "${backend_image}:${mutable_tag}" \ - -t "${backend_image}:${immutable_tag}" \ - ./backend - -docker build \ - -t "${frontend_image}:${mutable_tag}" \ - -t "${frontend_image}:${immutable_tag}" \ - ./frontend - -docker push "${backend_image}:${mutable_tag}" -docker push "${backend_image}:${immutable_tag}" -docker push "${frontend_image}:${mutable_tag}" -docker push "${frontend_image}:${immutable_tag}" From 9535fece6c4db41c4193997275fc66786c045b10 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 31 Mar 2026 09:16:41 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=E6=8C=89=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E7=97=95=E8=BF=B9=E6=81=A2=E5=A4=8D=20nginx=20=E5=90=8C?= =?UTF-8?q?=E5=9F=9F=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++--- docker-compose.yaml | 19 ++++-- ...03-27-production-compose-implementation.md | 4 +- docs/plans/2026-03-31-restore-nginx-design.md | 19 ++++++ frontend/Caddyfile | 5 -- frontend/Dockerfile | 10 +-- nginx/Dockerfile | 14 ++++ nginx/nginx.conf | 65 +++++++++++++++++++ 8 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 docs/plans/2026-03-31-restore-nginx-design.md delete mode 100644 frontend/Caddyfile create mode 100644 nginx/Dockerfile create mode 100644 nginx/nginx.conf diff --git a/README.md b/README.md index b4a134db..8e16e423 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ - 生产入口:`docker-compose.yaml` - 初始化方式:`docker compose run --rm backend-init` - 启动方式:`docker compose up -d --build` -- 运行拓扑:`frontend`、`backend`、`db` -- 对外入口:仓库外部统一反向代理;建议将 `/` 转发到 `frontend`,将 `/api` 与 `/health` 转发到 `backend` +- 运行拓扑:`nginx`、`backend`、`db` +- 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API - 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql` - 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 - 验证方式: - `docker compose config` - - `curl -i http://127.0.0.1:4173/` - - `curl -i http://127.0.0.1:8000/health` + - `curl -i http://127.0.0.1/` + - `curl -i http://127.0.0.1/health` ## 账号与注册 - 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建) @@ -28,11 +28,9 @@ - 普通成员(无项目角色):仅浏览 ## 访问方式 -- 前端:`http://localhost:4173` -- 后端 API:`http://localhost:8000/api/v1/*` -- 生产环境建议通过外部反向代理提供同域访问,前端代码默认请求同域 `/api/v1/*` -- 如果本地直接访问 `http://localhost:4173`,可在 `frontend/.env` 中设置 `VITE_API_BASE_URL=http://localhost:8000`,让前端直连后端 API -- `docker compose` 构建前端容器时,默认不注入 `VITE_API_BASE_URL`,生产会走同域 `/api/v1/*`;仅在本地直连后端时才显式设置 `VITE_API_BASE_URL=http://localhost:8000` +- 前端:`http://localhost` +- 后端 API:同域 `/api/v1/*` +- `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend` ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` diff --git a/docker-compose.yaml b/docker-compose.yaml index a537a1c5..452ba2b8 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -50,16 +50,21 @@ services: networks: - ctms_net - frontend: + nginx: build: - context: ./frontend - dockerfile: Dockerfile - args: - VITE_API_BASE_URL: ${VITE_API_BASE_URL:-} - container_name: ctms_frontend + context: . + dockerfile: nginx/Dockerfile + container_name: ctms_nginx restart: always ports: - - "4173:4173" + - "80:80" + - "443:443" + - "8888:8888" + volumes: + - ./nginx/certs:/etc/nginx/certs:ro + - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro + depends_on: + - backend networks: - ctms_net diff --git a/docs/plans/2026-03-27-production-compose-implementation.md b/docs/plans/2026-03-27-production-compose-implementation.md index d1688a3d..b4c2159c 100644 --- a/docs/plans/2026-03-27-production-compose-implementation.md +++ b/docs/plans/2026-03-27-production-compose-implementation.md @@ -103,8 +103,8 @@ Expected: PASS **Step 3: Check HTTP endpoints** -Run: `curl -i http://127.0.0.1:4173/` +Run: `curl -i http://127.0.0.1/` Expected: `200 OK` and HTML payload -Run: `curl -i http://127.0.0.1:8000/health` +Run: `curl -i http://127.0.0.1/health` Expected: successful backend health response diff --git a/docs/plans/2026-03-31-restore-nginx-design.md b/docs/plans/2026-03-31-restore-nginx-design.md new file mode 100644 index 00000000..6217681d --- /dev/null +++ b/docs/plans/2026-03-31-restore-nginx-design.md @@ -0,0 +1,19 @@ +# Restore In-Repo Nginx Design + +**Goal:** Restore the repository-managed Nginx entrypoint on `dev` by writing back the earlier removal diff, so frontend static hosting and backend API proxying return to a single same-origin entry. + +**Decisions** +- Restore `nginx/nginx.conf` and `nginx/Dockerfile` from the pre-removal topology. +- Replace the `frontend` runtime service in `docker-compose.yaml` with an `nginx` service that serves the built SPA and proxies `/api` and `/health` to `backend`. +- Remove the Caddy-specific runtime file because the repository will no longer use a separate frontend web server. +- Keep later non-nginx fixes that remain valid, especially the frontend API base URL fallback logic for optional local direct access. + +**Architecture** +- `db` remains unchanged. +- `backend` remains unchanged and is reachable internally on `backend:8000`. +- `nginx` becomes the public entrypoint again, serving the frontend bundle and reverse proxying API traffic to `backend`. +- The browser returns to same-origin access through the Nginx entrypoint. + +**Operational Notes** +- This is an in-place restoration based on the previous removal footprint, not a fresh redesign. +- The current Tencent Cloud/private registry removal remains intact and is not being reintroduced. diff --git a/frontend/Caddyfile b/frontend/Caddyfile deleted file mode 100644 index 38576a03..00000000 --- a/frontend/Caddyfile +++ /dev/null @@ -1,5 +0,0 @@ -:4173 - -root * /srv -try_files {path} /index.html -file_server diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 106ffcac..b145d8c4 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,18 +2,14 @@ FROM node:20-alpine AS build WORKDIR /app -ARG VITE_API_BASE_URL -ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} - COPY package*.json ./ RUN npm ci COPY . . RUN npm run build -FROM caddy:2-alpine +FROM alpine:3.21 -COPY Caddyfile /etc/caddy/Caddyfile -COPY --from=build /app/dist /srv +WORKDIR /opt/frontend -EXPOSE 4173 +COPY --from=build /app/dist ./dist diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 00000000..d394f9fc --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS frontend-build + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +FROM nginx:1.27-alpine + +COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY --from=frontend-build /app/dist /usr/share/nginx/html diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 00000000..37ff0e79 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,65 @@ +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + resolver 127.0.0.11 valid=10s ipv6=off; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + root /usr/share/nginx/html; + index index.html; + + upstream backend { + zone backend 64k; + server backend:8000 resolve; + } + + server { + listen 80; + server_name _; + client_max_body_size 20m; + + location = /favicon.ico { + root /usr/share/nginx/html; + try_files /favicon.ico =204; + access_log off; + log_not_found off; + expires 1d; + add_header Cache-Control "public, max-age=86400" always; + } + + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} From 84f865a7b0cb257c6d49532e2ac51fa59f89f069 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 31 Mar 2026 09:34:29 +0800 Subject: [PATCH 05/11] =?UTF-8?q?chore:=20=E8=B0=83=E6=95=B4=20nginx=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E4=B8=BA=208888=20=E7=AB=AF=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- docker-compose.yaml | 4 +--- docs/guides/release-checklist.md | 2 +- docs/guides/setup-config-api.md | 2 +- docs/plans/2026-03-27-production-compose-implementation.md | 4 ++-- docs/postman/local.postman_environment.example.json | 2 +- docs/postman/setup-config.postman_collection.json | 2 +- docs/setup-config-curl-smoke.sh | 2 +- 8 files changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8e16e423..3b147d58 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ - 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 - 验证方式: - `docker compose config` - - `curl -i http://127.0.0.1/` - - `curl -i http://127.0.0.1/health` + - `curl -i http://127.0.0.1:8888/` + - `curl -i http://127.0.0.1:8888/health` ## 账号与注册 - 初始化管理员:`admin@huapont.cn / admin123`(通过生产初始化命令显式创建) @@ -28,7 +28,7 @@ - 普通成员(无项目角色):仅浏览 ## 访问方式 -- 前端:`http://localhost` +- 前端:`http://localhost:8888` - 后端 API:同域 `/api/v1/*` - `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend` diff --git a/docker-compose.yaml b/docker-compose.yaml index 452ba2b8..e5d43329 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -57,9 +57,7 @@ services: container_name: ctms_nginx restart: always ports: - - "80:80" - - "443:443" - - "8888:8888" + - "8888:80" volumes: - ./nginx/certs:/etc/nginx/certs:ro - ./frontend/public/favicon.ico:/usr/share/nginx/html/favicon.ico:ro diff --git a/docs/guides/release-checklist.md b/docs/guides/release-checklist.md index 3a53ea54..6e024d15 100644 --- a/docs/guides/release-checklist.md +++ b/docs/guides/release-checklist.md @@ -64,7 +64,7 @@ ## 8. 冒烟脚本 - [ ] `cd backend && docker compose exec -T backend python scripts/smoke_setup_config.py` - [ ] 使用 `BASE_URL/EMAIL/PASSWORD/STUDY_ID` 环境变量执行一次(覆盖指定项目) -- [ ] `BASE_URL=http://localhost EMAIL=... PASSWORD=... STUDY_ID=... bash docs/setup-config-curl-smoke.sh` +- [ ] `BASE_URL=http://localhost:8888 EMAIL=... PASSWORD=... STUDY_ID=... bash docs/setup-config-curl-smoke.sh` - [ ] 导入并执行 Postman 集合 `docs/postman/setup-config.postman_collection.json` ## 9. 发布前确认 diff --git a/docs/guides/setup-config-api.md b/docs/guides/setup-config-api.md index d921466c..f3053b06 100644 --- a/docs/guides/setup-config-api.md +++ b/docs/guides/setup-config-api.md @@ -186,7 +186,7 @@ Content-Type: multipart/form-data - 使用方式: ```bash chmod +x docs/setup-config-curl-smoke.sh -BASE_URL=http://localhost \ +BASE_URL=http://localhost:8888 \ EMAIL=admin@example.com \ PASSWORD=admin123 \ STUDY_ID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa \ diff --git a/docs/plans/2026-03-27-production-compose-implementation.md b/docs/plans/2026-03-27-production-compose-implementation.md index b4c2159c..19a1d1d4 100644 --- a/docs/plans/2026-03-27-production-compose-implementation.md +++ b/docs/plans/2026-03-27-production-compose-implementation.md @@ -103,8 +103,8 @@ Expected: PASS **Step 3: Check HTTP endpoints** -Run: `curl -i http://127.0.0.1/` +Run: `curl -i http://127.0.0.1:8888/` Expected: `200 OK` and HTML payload -Run: `curl -i http://127.0.0.1/health` +Run: `curl -i http://127.0.0.1:8888/health` Expected: successful backend health response diff --git a/docs/postman/local.postman_environment.example.json b/docs/postman/local.postman_environment.example.json index 89b3263c..c68bb7ef 100644 --- a/docs/postman/local.postman_environment.example.json +++ b/docs/postman/local.postman_environment.example.json @@ -1,7 +1,7 @@ { "name": "CTMS Local Example", "values": [ - { "key": "base_url", "value": "http://localhost", "type": "default", "enabled": true }, + { "key": "base_url", "value": "http://localhost:8888", "type": "default", "enabled": true }, { "key": "email", "value": "admin@example.com", "type": "default", "enabled": true }, { "key": "password", "value": "admin123", "type": "default", "enabled": true }, { "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "type": "default", "enabled": true }, diff --git a/docs/postman/setup-config.postman_collection.json b/docs/postman/setup-config.postman_collection.json index d2a758ff..5b7e5f97 100644 --- a/docs/postman/setup-config.postman_collection.json +++ b/docs/postman/setup-config.postman_collection.json @@ -189,7 +189,7 @@ } ], "variable": [ - { "key": "base_url", "value": "http://localhost" }, + { "key": "base_url", "value": "http://localhost:8888" }, { "key": "email", "value": "admin@example.com" }, { "key": "password", "value": "admin123" }, { "key": "study_id", "value": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, diff --git a/docs/setup-config-curl-smoke.sh b/docs/setup-config-curl-smoke.sh index e38fc104..1184c1e2 100755 --- a/docs/setup-config-curl-smoke.sh +++ b/docs/setup-config-curl-smoke.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -BASE_URL="${BASE_URL:-http://localhost}" +BASE_URL="${BASE_URL:-http://localhost:8888}" EMAIL="${EMAIL:-admin@example.com}" PASSWORD="${PASSWORD:-admin123}" STUDY_ID="${STUDY_ID:-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}" From ba880dad9a5313a640a350f88ff1027a82bc7088 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 31 Mar 2026 09:44:49 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20API=20=E7=8E=AF=E5=A2=83=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.env.example | 1 - frontend/src/api/axios.ts | 5 +---- frontend/src/api/baseUrl.test.ts | 19 ------------------- frontend/src/api/baseUrl.ts | 12 ------------ 4 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 frontend/src/api/baseUrl.test.ts delete mode 100644 frontend/src/api/baseUrl.ts diff --git a/frontend/.env.example b/frontend/.env.example index 81f4a110..73a9682d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,4 @@ # Frontend feature flags and timeline overrides -VITE_API_BASE_URL= VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 0ae6aba3..e669ed03 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -7,12 +7,9 @@ import { useStudyStore } from "../store/study"; import { TEXT } from "../locales"; import { extendAccessToken, forceLogout, lockSession, markNetworkActive } from "../session/sessionManager"; import { useSessionStore } from "../store/session"; -import { resolveApiBaseUrl } from "./baseUrl"; - -const apiBaseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL); const instance: AxiosInstance = axios.create({ - baseURL: apiBaseUrl, + baseURL: "/", timeout: 15000, }); diff --git a/frontend/src/api/baseUrl.test.ts b/frontend/src/api/baseUrl.test.ts deleted file mode 100644 index 521baee7..00000000 --- a/frontend/src/api/baseUrl.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveApiBaseUrl } from "./baseUrl"; - -describe("resolveApiBaseUrl", () => { - it("defaults to same-origin root when no env override is set", () => { - expect(resolveApiBaseUrl(undefined)).toBe("/"); - expect(resolveApiBaseUrl("")).toBe("/"); - expect(resolveApiBaseUrl(" ")).toBe("/"); - }); - - it("preserves explicit root", () => { - expect(resolveApiBaseUrl("/")).toBe("/"); - }); - - it("trims whitespace and trailing slashes from absolute base urls", () => { - expect(resolveApiBaseUrl(" http://localhost:8000/ ")).toBe("http://localhost:8000"); - expect(resolveApiBaseUrl("https://api.example.com///")).toBe("https://api.example.com"); - }); -}); diff --git a/frontend/src/api/baseUrl.ts b/frontend/src/api/baseUrl.ts deleted file mode 100644 index 17657095..00000000 --- a/frontend/src/api/baseUrl.ts +++ /dev/null @@ -1,12 +0,0 @@ -const trimTrailingSlashes = (value: string): string => value.replace(/\/+$/, ""); - -export const resolveApiBaseUrl = (rawValue: string | undefined): string => { - const normalized = rawValue?.trim() ?? ""; - if (!normalized) { - return "/"; - } - if (normalized === "/") { - return "/"; - } - return trimTrailingSlashes(normalized); -}; From fc74d1e9a939f23f2273f66559b63d98b520fdf3 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 31 Mar 2026 10:08:26 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=8E=A5=E5=8F=A3=E5=B0=BE=E6=96=9C=E6=9D=A0=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/users.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 767dfc5d..7b22b862 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -3,7 +3,7 @@ import type { ApiListResponse, UserInfo } from "../types/api"; import { apiDelete } from "./axios"; export const fetchUsers = (params?: Record) => - apiGet>("/api/v1/users", { params }); + apiGet>("/api/v1/users/", { params }); export const createUser = (payload: { email: string; @@ -12,7 +12,7 @@ export const createUser = (payload: { role: string; department: string; }) => - apiPost("/api/v1/users", payload); + apiPost("/api/v1/users/", payload); export const updateUser = ( userId: string, From 4bebc6466237fa7c302de37c8e416af1be94c02c Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 31 Mar 2026 11:20:57 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=E6=B8=85=E7=90=86=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E6=95=B0=E6=8D=AE=E5=B9=B6=E7=BB=9F=E4=B8=80=E7=A9=BA?= =?UTF-8?q?=E6=80=81=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../20260116_03_seed_overview_demo_data.py | 185 +------- .../20260331_01_cleanup_overview_demo_data.py | 397 ++++++++++++++++++ backend/app/services/fee_seed.py | 175 -------- backend/scripts/seed_overview_data.py | 150 ------- backend/tests/test_app_startup.py | 5 +- .../enterprise-ui-acceptance-checklist.md | 2 +- docs/guides/setup-config-api.md | 2 +- ...03-04-ctms-enterprise-ui-implementation.md | 6 +- .../2026-03-27-production-init-design.md | 4 +- ...26-03-27-production-init-implementation.md | 2 +- ...0-remove-demo-study-seed-implementation.md | 10 +- frontend/.env.example | 1 - frontend/src/api/admin.ts | 2 +- frontend/src/api/faqs.ts | 8 +- frontend/src/components/ModulePlaceholder.vue | 34 +- frontend/src/components/StateEmpty.vue | 28 +- frontend/src/store/auth.test.ts | 2 +- frontend/src/store/study.test.ts | 4 +- frontend/src/views/fees/ContractFeeDetail.vue | 15 +- frontend/src/views/fees/ContractFees.vue | 15 +- frontend/src/views/fees/SpecialExpenses.vue | 15 +- frontend/src/views/ia/DrugShipments.vue | 15 +- frontend/src/views/ia/FinanceContracts.vue | 15 +- frontend/src/views/ia/FinanceSpecial.vue | 15 +- frontend/src/views/ia/KnowledgeNotes.vue | 15 +- frontend/src/views/ia/MaterialEquipment.vue | 15 +- frontend/src/views/ia/ProjectMilestones.vue | 21 +- frontend/src/views/ia/ProjectOverview.vue | 98 +++-- .../views/ia/RiskIssueMonitoringVisits.vue | 13 +- frontend/src/views/ia/RiskIssuePd.vue | 16 +- frontend/src/views/ia/RiskIssueSae.vue | 16 +- .../src/views/ia/StartupFeasibilityEthics.vue | 19 +- frontend/src/views/ia/StartupMeetingAuth.vue | 15 +- frontend/src/views/ia/SubjectManagement.vue | 15 +- .../project-overview/EnrollmentBarChart.vue | 39 +- .../ia/project-overview/overview.mock.ts | 97 ----- frontend/src/views/subjects/SubjectDetail.vue | 27 +- 38 files changed, 786 insertions(+), 729 deletions(-) create mode 100644 backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py delete mode 100644 backend/app/services/fee_seed.py delete mode 100644 backend/scripts/seed_overview_data.py delete mode 100644 frontend/src/views/ia/project-overview/overview.mock.ts diff --git a/README.md b/README.md index 3b147d58..62f45e77 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - 运行拓扑:`nginx`、`backend`、`db` - 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API - 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql` -- 默认无任何 demo 数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 +- 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 - 验证方式: - `docker compose config` - `curl -i http://127.0.0.1:8888/` diff --git a/backend/alembic/versions/20260116_03_seed_overview_demo_data.py b/backend/alembic/versions/20260116_03_seed_overview_demo_data.py index 1f6204ef..f0547373 100644 --- a/backend/alembic/versions/20260116_03_seed_overview_demo_data.py +++ b/backend/alembic/versions/20260116_03_seed_overview_demo_data.py @@ -1,4 +1,4 @@ -"""seed overview demo data +"""disable overview demo data seed Revision ID: 20260116_03 Revises: 20260116_02 @@ -6,14 +6,7 @@ Create Date: 2026-01-16 16:30:00.000000 """ from typing import Sequence, Union -from datetime import date, timedelta -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID - - -# revision identifiers, used by Alembic. revision: str = '20260116_03' down_revision: Union[str, None] = '20260116_02' branch_labels: Union[str, Sequence[str], None] = None @@ -21,179 +14,11 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # 获取connection - conn = op.get_bind() - - # 首先获取第一个项目ID - result = conn.execute(sa.text("SELECT id FROM studies LIMIT 1")) - study_row = result.first() - - if not study_row: - print("没有项目,跳过示例数据注入") - return - - study_id = study_row[0] - - # 获取该项目的中心 - result = conn.execute(sa.text("SELECT id, name FROM sites WHERE study_id = :study_id ORDER BY created_at LIMIT 5"), {"study_id": study_id}) - sites = result.fetchall() - - if not sites: - print("没有中心,跳过示例数据注入") - return - - print(f"为 {len(sites)} 个中心注入示例数据...") - - # 清理可能存在的演示数据(使用ON CONFLICT处理或先删除) - # 收集site_ids - site_ids = [site[0] for site in sites] - - # 删除这些中心的existing数据 (如果是演示数据) - # 由于我们无法判断哪些是演示数据,使用ON CONFLICT DO NOTHING策略 - - # 更新入组目标 - targets = [80, 60, 70, 50, 40] - for idx, site in enumerate(sites): - site_id = site[0] - target = targets[idx] if idx < len(targets) else 50 - conn.execute( - sa.text("UPDATE sites SET enrollment_target = :target WHERE id = :site_id"), - {"target": target, "site_id": site_id} - ) - - # 基准日期 - base_date = date(2024, 12, 1) - - # 为每个中心添加启动流程数据 - for idx, site in enumerate(sites): - site_id = site[0] - - # 机构立项 - 使用 ON CONFLICT DO NOTHING (假设有唯一约束) - submit_date = base_date + timedelta(days=idx * 10) - accept_date = base_date + timedelta(days=idx * 10 + 5) - approved_date = base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None - - # 检查是否已存在 - existing = conn.execute(sa.text( - "SELECT 1 FROM startup_feasibility WHERE study_id = :study_id AND site_id = :site_id LIMIT 1" - ), {"study_id": study_id, "site_id": site_id}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO startup_feasibility (id, study_id, site_id, submit_date, accept_date, approved_date, project_no, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :approved_date, :project_no, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "submit_date": submit_date, - "accept_date": accept_date, - "approved_date": approved_date, - "project_no": f"PROJ-{idx+1:03d}" - }) - - # 伦理审批 - ethics_submit = base_date + timedelta(days=idx * 10 + 20) - ethics_accept = base_date + timedelta(days=idx * 10 + 25) - meeting_date = base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None - ethics_approved = base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None - - existing = conn.execute(sa.text( - "SELECT 1 FROM startup_ethics WHERE study_id = :study_id AND site_id = :site_id LIMIT 1" - ), {"study_id": study_id, "site_id": site_id}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO startup_ethics (id, study_id, site_id, submit_date, accept_date, meeting_date, approved_date, approval_no, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :meeting_date, :approved_date, :approval_no, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "submit_date": ethics_submit, - "accept_date": ethics_accept, - "meeting_date": meeting_date, - "approved_date": ethics_approved, - "approval_no": f"EC-{idx+1:03d}" if idx < 4 else None - }) - - # 合同签署 (前3个中心) - if idx < 3: - site_name = site[1] - signed_date = base_date + timedelta(days=idx * 10 + 50) - - # 检查合同是否已存在 - existing = conn.execute(sa.text( - "SELECT 1 FROM finance_contracts WHERE study_id = :study_id AND site_name = :site_name LIMIT 1" - ), {"study_id": study_id, "site_name": site_name}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO finance_contracts (id, study_id, site_name, contract_no, signed_date, amount, currency, created_at) - VALUES (gen_random_uuid(), :study_id, :site_name, :contract_no, :signed_date, 500000.0, 'CNY', NOW()) - """), { - "study_id": study_id, - "site_name": site_name, - "contract_no": f"CT-{idx+1:03d}", - "signed_date": signed_date - }) - - # 启动会 (前3个中心) - if idx < 3: - kickoff_date = base_date + timedelta(days=idx * 10 + 60) - - # 检查启动会是否已存在 - existing = conn.execute(sa.text( - "SELECT 1 FROM kickoff_meetings WHERE study_id = :study_id AND site_id = :site_id LIMIT 1" - ), {"study_id": study_id, "site_id": site_id}).first() - - if not existing: - conn.execute(sa.text(""" - INSERT INTO kickoff_meetings (id, study_id, site_id, kickoff_date, attendees, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :kickoff_date, '["研究者", "CRA", "PM"]'::jsonb, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "kickoff_date": kickoff_date - }) - - # 为前3个中心添加受试者 - enrollment_counts = [52, 18, 70] - start_enrollment = date(2025, 1, 1) - - for idx in range(min(3, len(sites))): - site_id = sites[idx][0] - count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0 - - # 检查该中心是否已有受试者数据 - existing_count = conn.execute(sa.text( - "SELECT COUNT(*) FROM subjects WHERE study_id = :study_id AND site_id = :site_id" - ), {"study_id": study_id, "site_id": site_id}).scalar() - - # 如果已有数据,跳过该中心 - if existing_count > 0: - print(f" 中心 {idx+1} 已有 {existing_count} 名受试者,跳过") - continue - - for i in range(count): - enrollment_date = start_enrollment + timedelta(days=i * 4) - screening_date = enrollment_date - timedelta(days=7) - consent_date = enrollment_date - timedelta(days=3) - - conn.execute(sa.text(""" - INSERT INTO subjects (id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, created_at) - VALUES (gen_random_uuid(), :study_id, :site_id, :subject_no, 'ENROLLED', :screening_date, :consent_date, :enrollment_date, NOW()) - """), { - "study_id": study_id, - "site_id": site_id, - "subject_no": f"S{idx+1:02d}-{i+1:03d}", - "screening_date": screening_date, - "consent_date": consent_date, - "enrollment_date": enrollment_date - }) - - print("✅ 示例数据注入完成") + # This historical revision is intentionally kept as a no-op so fresh + # databases no longer receive overview demo data during migration. + return def downgrade() -> None: - # 删除示例数据 (可选 - 也可以留空) - pass + return diff --git a/backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py b/backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py new file mode 100644 index 00000000..fe602912 --- /dev/null +++ b/backend/alembic/versions/20260331_01_cleanup_overview_demo_data.py @@ -0,0 +1,397 @@ +"""cleanup overview demo data + +Revision ID: 20260331_01 +Revises: 20260302_02 +Create Date: 2026-03-31 10:45:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260331_01" +down_revision: Union[str, None] = "20260302_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +DEMO_FEASIBILITY_CODES = ("PROJ-001", "PROJ-002", "PROJ-003", "PROJ-004", "PROJ-005") +DEMO_ETHICS_CODES = ("EC-001", "EC-002", "EC-003", "EC-004") +DEMO_FINANCE_CONTRACT_CODES = ("CT-001", "CT-002", "CT-003") +DEMO_STUDY_CODE = "DEMO-CTMS" + + +def upgrade() -> None: + conn = op.get_bind() + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id + FROM startup_feasibility + WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id + FROM startup_ethics + WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id + FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) + AND amount = 500000.0 + AND currency = 'CNY' + UNION + SELECT DISTINCT study_id + FROM kickoff_meetings + WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id + FROM subjects + WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ), + candidate_sites AS ( + SELECT DISTINCT site_id + FROM startup_feasibility + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT site_id + FROM startup_ethics + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND ( + approval_no = ANY(:ethics_codes) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-10' + AND accept_date = DATE '2025-01-15' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-20' + AND accept_date = DATE '2025-01-25' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-30' + AND accept_date = DATE '2025-02-04' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + ) + UNION + SELECT DISTINCT site_id + FROM kickoff_meetings + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT site_id + FROM subjects + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + UPDATE sites + SET enrollment_target = 0 + WHERE id IN (SELECT site_id FROM candidate_sites WHERE site_id IS NOT NULL) + AND enrollment_target IN (40, 50, 60, 70, 80) + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM subjects + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND subject_no ~ '^S0[1-3]-[0-9]{3}$' + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM kickoff_meetings + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND attendees::text = '["研究者", "CRA", "PM"]' + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM finance_contracts + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND contract_no = ANY(:contract_codes) + AND amount = 500000.0 + AND currency = 'CNY' + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM startup_ethics + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND ( + approval_no = ANY(:ethics_codes) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-10' + AND accept_date = DATE '2025-01-15' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-20' + AND accept_date = DATE '2025-01-25' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + OR ( + approval_no IS NULL + AND submit_date = DATE '2025-01-30' + AND accept_date = DATE '2025-02-04' + AND meeting_date IS NULL + AND approved_date IS NULL + ) + ) + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH candidate_studies AS ( + SELECT DISTINCT study_id FROM startup_feasibility WHERE project_no = ANY(:feasibility_codes) + UNION + SELECT DISTINCT study_id FROM startup_ethics WHERE approval_no = ANY(:ethics_codes) + UNION + SELECT DISTINCT study_id FROM finance_contracts + WHERE contract_no = ANY(:contract_codes) AND amount = 500000.0 AND currency = 'CNY' + UNION + SELECT DISTINCT study_id FROM kickoff_meetings WHERE attendees::text = '["研究者", "CRA", "PM"]' + UNION + SELECT DISTINCT study_id FROM subjects WHERE subject_no ~ '^S0[1-3]-[0-9]{3}$' + ) + DELETE FROM startup_feasibility + WHERE study_id IN (SELECT study_id FROM candidate_studies) + AND project_no = ANY(:feasibility_codes) + """ + ), + { + "feasibility_codes": list(DEMO_FEASIBILITY_CODES), + "ethics_codes": list(DEMO_ETHICS_CODES), + "contract_codes": list(DEMO_FINANCE_CONTRACT_CODES), + }, + ) + + conn.execute( + sa.text( + """ + WITH demo_study AS ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ), + demo_contract_fees AS ( + SELECT id + FROM contract_fees + WHERE project_id IN (SELECT id FROM demo_study) + ), + demo_special_expenses AS ( + SELECT id + FROM special_expenses + WHERE project_id IN (SELECT id FROM demo_study) + ) + DELETE FROM fee_attachments + WHERE ( + entity_type = 'contract_fee' + AND entity_id IN (SELECT id FROM demo_contract_fees) + ) OR ( + entity_type = 'special_expense' + AND entity_id IN (SELECT id FROM demo_special_expenses) + ) OR ( + url IN ( + 'https://example.com/contract-demo.pdf', + 'https://example.com/voucher-demo.pdf', + 'https://example.com/invoice-demo.pdf' + ) + ) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + WITH demo_study AS ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ), + demo_contract_fees AS ( + SELECT id + FROM contract_fees + WHERE project_id IN (SELECT id FROM demo_study) + ) + DELETE FROM contract_fee_payments + WHERE contract_fee_id IN (SELECT id FROM demo_contract_fees) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM contract_fees + WHERE project_id IN ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM special_expenses + WHERE project_id IN ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ) + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM sites + WHERE study_id IN ( + SELECT id + FROM studies + WHERE code = :demo_study_code + ) + AND name LIKE '示例中心 %' + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + conn.execute( + sa.text( + """ + DELETE FROM studies + WHERE code = :demo_study_code + """ + ), + { + "demo_study_code": DEMO_STUDY_CODE, + }, + ) + + +def downgrade() -> None: + return diff --git a/backend/app/services/fee_seed.py b/backend/app/services/fee_seed.py deleted file mode 100644 index 81fdd1aa..00000000 --- a/backend/app/services/fee_seed.py +++ /dev/null @@ -1,175 +0,0 @@ -import uuid -from datetime import date, timedelta -from decimal import Decimal - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.contract_fee import ContractFee -from app.models.contract_fee_payment import ContractFeePayment -from app.models.fee_attachment import FeeAttachment -from app.models.site import Site -from app.models.special_expense import SpecialExpense -from app.models.study import Study -from app.models.user import User - - -async def seed_demo_fees(db: AsyncSession) -> None: - existing_contract = await db.execute(select(ContractFee.id).limit(1)) - existing_special = await db.execute(select(SpecialExpense.id).limit(1)) - if existing_contract.scalar_one_or_none() or existing_special.scalar_one_or_none(): - return - - result = await db.execute(select(Study).order_by(Study.created_at.asc())) - study = result.scalars().first() - if not study: - study = Study( - id=uuid.uuid4(), - code="DEMO-CTMS", - name="示例临床试验项目", - sponsor="示例申办方", - status="ACTIVE", - ) - db.add(study) - await db.commit() - await db.refresh(study) - - result = await db.execute(select(Site).where(Site.study_id == study.id)) - sites = result.scalars().all() - if len(sites) < 3: - for idx in range(3 - len(sites)): - site = Site( - id=uuid.uuid4(), - study_id=study.id, - name=f"示例中心 {len(sites) + idx + 1}", - city="上海", - pi_name="张医生", - contact="010-88888888", - ) - db.add(site) - await db.commit() - result = await db.execute(select(Site).where(Site.study_id == study.id)) - sites = result.scalars().all() - - user_result = await db.execute(select(User).order_by(User.created_at.asc())) - uploader = user_result.scalars().first() - uploader_id = uploader.id if uploader else None - - today = date.today() - for idx, site in enumerate(sites[:3], start=1): - contract = ContractFee( - id=uuid.uuid4(), - project_id=study.id, - center_id=site.id, - contract_amount=Decimal("120000.00") + Decimal(idx * 10000), - contract_cases=50 + idx * 5, - actual_cases=40 + idx * 4, - settlement_amount=Decimal("80000.00") + Decimal(idx * 5000), - final_payment_amount=Decimal("20000.00") + Decimal(idx * 2000), - ) - db.add(contract) - await db.commit() - await db.refresh(contract) - - payments = [ - ContractFeePayment( - id=uuid.uuid4(), - contract_fee_id=contract.id, - seq=1, - amount=Decimal("40000.00") + Decimal(idx * 2000), - is_paid=True, - paid_date=today - timedelta(days=30), - is_verified=True, - verified_date=today - timedelta(days=20), - remark="首付款已核销", - ), - ContractFeePayment( - id=uuid.uuid4(), - contract_fee_id=contract.id, - seq=2, - amount=Decimal("30000.00") + Decimal(idx * 1000), - is_paid=True, - paid_date=today - timedelta(days=10), - is_verified=False, - remark="等待核销", - ), - ContractFeePayment( - id=uuid.uuid4(), - contract_fee_id=contract.id, - seq=3, - amount=Decimal("20000.00") + Decimal(idx * 1000), - is_paid=False, - remark="未打款", - ), - ] - db.add_all(payments) - await db.commit() - - if uploader_id: - attachments = [ - FeeAttachment( - id=uuid.uuid4(), - entity_type="contract_fee", - entity_id=contract.id, - file_type="contract", - filename=f"合同-{site.name}.pdf", - mime_type="application/pdf", - size=102400, - storage_key=None, - url="https://example.com/contract-demo.pdf", - uploaded_by=uploader_id, - ), - FeeAttachment( - id=uuid.uuid4(), - entity_type="contract_fee", - entity_id=contract.id, - file_type="voucher", - filename=f"凭证-{site.name}.pdf", - mime_type="application/pdf", - size=20480, - storage_key=None, - url="https://example.com/voucher-demo.pdf", - uploaded_by=uploader_id, - ), - ] - db.add_all(attachments) - await db.commit() - - categories = ["travel", "meal", "meeting", "supplies", "other"] - for idx, category in enumerate(categories): - for seq in range(2): - expense = SpecialExpense( - id=uuid.uuid4(), - project_id=study.id, - center_id=sites[seq % len(sites)].id if sites else None, - category=category, - amount=Decimal("800.00") + Decimal(seq * 150), - happen_date=today - timedelta(days=5 * (seq + 1)), - description=f"{category} 费用示例", - is_paid=seq % 2 == 0, - paid_date=today - timedelta(days=3 * (seq + 1)) if seq % 2 == 0 else None, - is_verified=seq == 0, - verified_date=today - timedelta(days=2 * (seq + 1)) if seq == 0 else None, - created_by=uploader_id, - ) - db.add(expense) - await db.commit() - - result = await db.execute(select(SpecialExpense).where(SpecialExpense.project_id == study.id)) - expenses = result.scalars().all() - if uploader_id: - for expense in expenses[:3]: - attachment = FeeAttachment( - id=uuid.uuid4(), - entity_type="special_expense", - entity_id=expense.id, - file_type="invoice", - filename=f"发票-{expense.id}.pdf", - mime_type="application/pdf", - size=40960, - storage_key=None, - url="https://example.com/invoice-demo.pdf", - uploaded_by=uploader_id, - ) - db.add(attachment) - await db.commit() diff --git a/backend/scripts/seed_overview_data.py b/backend/scripts/seed_overview_data.py deleted file mode 100644 index 205433b5..00000000 --- a/backend/scripts/seed_overview_data.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -项目概览示例数据注入脚本 - -运行方式: -docker compose run --rm backend python scripts/seed_overview_data.py -""" -import asyncio -import sys -import uuid -from datetime import date, timedelta -from pathlib import Path - -# 添加app目录到Python路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import select -from app.db.session import SessionLocal -from app.models.site import Site -from app.models.startup_feasibility import StartupFeasibility -from app.models.startup_ethics import StartupEthics -from app.models.finance_contract import FinanceContract -from app.models.kickoff_meeting import KickoffMeeting -from app.models.subject import Subject - - -async def seed_data(): - """注入示例数据""" - async with SessionLocal() as db: - # 获取第一个项目用于测试 - stmt = select(Site.study_id).limit(1) - result = await db.execute(stmt) - study_id_row = result.first() - - if not study_id_row: - print("❌ 数据库中没有项目,请先创建项目和中心") - return - - study_id = study_id_row[0] - print(f"✓ 使用项目 ID: {study_id}") - - # 获取该项目的所有中心 - stmt = select(Site).where(Site.study_id == study_id) - result = await db.execute(stmt) - sites = result.scalars().all() - - if not sites: - print("❌ 项目下没有中心,请先创建中心") - return - - print(f"✓ 找到 {len(sites)} 个中心") - - # 为每个中心设置入组目标 - targets = [80, 60, 70, 50, 40] - for idx, site in enumerate(sites[:5]): - site.enrollment_target = targets[idx] if idx < len(targets) else 50 - print(f" • {site.name}: 目标入组 {site.enrollment_target} 人") - - await db.commit() - print("✓ 入组目标已更新") - - # 为中心添加启动流程数据 - base_date = date(2024, 12, 1) - - for idx, site in enumerate(sites[:5]): - # 机构立项 - feasibility = StartupFeasibility( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - submit_date=base_date + timedelta(days=idx * 10), - accept_date=base_date + timedelta(days=idx * 10 + 5), - approved_date=base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None, - project_no=f"PROJ-{idx+1:03d}" - ) - db.add(feasibility) - - # 伦理审批 - ethics = StartupEthics( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - submit_date=base_date + timedelta(days=idx * 10 + 20), - accept_date=base_date + timedelta(days=idx * 10 + 25), - meeting_date=base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None, - approved_date=base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None, - approval_no=f"EC-{idx+1:03d}" if idx < 4 else None - ) - db.add(ethics) - - # 合同签署 (前3个中心) - if idx < 3: - contract = FinanceContract( - id=uuid.uuid4(), - study_id=study_id, - site_name=site.name, - contract_no=f"CT-{idx+1:03d}", - signed_date=base_date + timedelta(days=idx * 10 + 50), - amount=500000.0, - currency="CNY" - ) - db.add(contract) - - # 启动会 (前3个中心) - if idx < 3: - kickoff = KickoffMeeting( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - kickoff_date=base_date + timedelta(days=idx * 10 + 60), - attendees=["研究者", "CRA", "PM"] - ) - db.add(kickoff) - - await db.commit() - print("✓ 启动流程数据已添加") - - # 为前3个中心添加入组受试者 - enrollment_counts = [52, 18, 70] - start_enrollment_date = date(2025, 1, 1) - - for idx, site in enumerate(sites[:3]): - count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0 - - for i in range(count): - # 分散在6个月内入组 - enrollment_date = start_enrollment_date + timedelta(days=i * 4) - - subject = Subject( - id=uuid.uuid4(), - study_id=study_id, - site_id=site.id, - subject_no=f"S{idx+1:02d}-{i+1:03d}", - status="ENROLLED", - screening_date=enrollment_date - timedelta(days=7), - consent_date=enrollment_date - timedelta(days=3), - enrollment_date=enrollment_date - ) - db.add(subject) - - print(f" • {site.name}: 已入组 {count} 人") - - await db.commit() - print("✓ 受试者入组数据已添加") - - print("\n✅ 示例数据注入完成!") - print("请刷新项目概览页面查看效果") - - -if __name__ == "__main__": - asyncio.run(seed_data()) diff --git a/backend/tests/test_app_startup.py b/backend/tests/test_app_startup.py index f31f4bb0..1b027bb3 100644 --- a/backend/tests/test_app_startup.py +++ b/backend/tests/test_app_startup.py @@ -43,9 +43,8 @@ class _DummyEngine: @pytest.mark.asyncio -async def test_development_startup_does_not_seed_demo_fees(monkeypatch): +async def test_development_startup_does_not_seed_business_data(monkeypatch): ensure_admin_exists = AsyncMock() - seed_demo_fees = AsyncMock() create_all = AsyncMock() legacy_pk = AsyncMock() scheduler_task = _DummyTask() @@ -63,7 +62,6 @@ async def test_development_startup_does_not_seed_demo_fees(monkeypatch): monkeypatch.setattr(main.settings, "ENV", "development") monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists) - monkeypatch.setattr(main, "seed_demo_fees", seed_demo_fees, raising=False) monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler) monkeypatch.setattr(main.asyncio, "create_task", fake_create_task) monkeypatch.setattr(main, "engine", _DummyEngine()) @@ -75,4 +73,3 @@ async def test_development_startup_does_not_seed_demo_fees(monkeypatch): pass ensure_admin_exists.assert_awaited_once() - seed_demo_fees.assert_not_awaited() diff --git a/docs/audits/enterprise-ui-acceptance-checklist.md b/docs/audits/enterprise-ui-acceptance-checklist.md index 853e59c9..599d57e6 100644 --- a/docs/audits/enterprise-ui-acceptance-checklist.md +++ b/docs/audits/enterprise-ui-acceptance-checklist.md @@ -10,7 +10,7 @@ Reviewer: `Codex` - [x] Finance pages visual consistency - [x] File/admin detail visual consistency - [x] Desktop and iPad width checks -- [x] Management demo path continuity +- [x] Management path continuity Notes: - Verified unified contract classes and shared shell usage on all target pages. diff --git a/docs/guides/setup-config-api.md b/docs/guides/setup-config-api.md index f3053b06..64b1cffe 100644 --- a/docs/guides/setup-config-api.md +++ b/docs/guides/setup-config-api.md @@ -86,7 +86,7 @@ }, "published_data": null, "published_project_snapshot": { - "code": "DEMO-CTMS", + "code": "CTMS-001", "name": "演示项目", "project_full_name": "演示项目全称", "sponsor": "", diff --git a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md index c1d60a4f..7953be19 100644 --- a/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md +++ b/docs/plans/2026-03-04-ctms-enterprise-ui-implementation.md @@ -423,7 +423,7 @@ Create acceptance checklist with explicit pass/fail items. Mark all as pending i - [ ] Finance pages visual consistency - [ ] File/admin detail visual consistency - [ ] Desktop and iPad width checks -- [ ] Management demo path continuity +- [ ] Management path continuity ``` **Step 2: Run test to verify it fails** @@ -451,10 +451,10 @@ git commit -m "docs(ui): add enterprise refresh acceptance and release gates" ## Milestone Mapping -1. **M1 (Day 1-3):** Tasks 1-3 + sample page alignment. +1. **M1 (Day 1-3):** Tasks 1-3 + page alignment. 2. **M2 (Day 4-7):** Tasks 4-5 (first 6 core pages). 3. **M3 (Day 8-10):** Tasks 6-8 (remaining 4 pages + states). -4. **M4 (Day 11-14):** Task 9 + demo rehearsal + release gate. +4. **M4 (Day 11-14):** Task 9 + release rehearsal + release gate. ## Definition of Done diff --git a/docs/plans/2026-03-27-production-init-design.md b/docs/plans/2026-03-27-production-init-design.md index 9c8ed7eb..353ea488 100644 --- a/docs/plans/2026-03-27-production-init-design.md +++ b/docs/plans/2026-03-27-production-init-design.md @@ -11,7 +11,7 @@ - Production schema source of truth becomes Alembic only. - New production environments must start from an empty database. - Production initialization explicitly runs schema migrations first, then ensures a fixed administrator account exists. -- No demo users, demo projects, demo fees, or any other seed business data are created in production initialization. +- No placeholder users, placeholder projects, placeholder fees, or any other seed business data are created in production initialization. - The fixed administrator is `admin@huapont.cn` with initial password `admin123`. - The fixed administrator cannot be deleted, disabled, downgraded from `ADMIN`, or have its email changed. - The fixed administrator may change its password after first login. @@ -29,7 +29,7 @@ 4. Log in with `admin@huapont.cn / admin123`, then change the password. **Non-Goals** -- No automatic demo data import. +- No automatic business seed import. - No runtime auto-migration inside the long-lived backend container. - No second schema definition maintained in `database/init.sql`. diff --git a/docs/plans/2026-03-27-production-init-implementation.md b/docs/plans/2026-03-27-production-init-implementation.md index 5698b4af..15e116eb 100644 --- a/docs/plans/2026-03-27-production-init-implementation.md +++ b/docs/plans/2026-03-27-production-init-implementation.md @@ -2,7 +2,7 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Make production initialization use Alembic as the only schema source and seed exactly one protected administrator account with no demo data. +**Goal:** Make production initialization use Alembic as the only schema source and seed exactly one protected administrator account with no preloaded business data. **Architecture:** A dedicated initialization entrypoint will run migrations and seed the protected administrator. The runtime backend container will stop relying on production startup side effects for database structure, and the user-management API will enforce immutability rules for the protected administrator account. diff --git a/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md b/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md index a4ccd499..d3ad1abf 100644 --- a/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md +++ b/docs/plans/2026-03-30-remove-demo-study-seed-implementation.md @@ -2,9 +2,9 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Stop development initialization from auto-creating any demo study or fee data. +**Goal:** Stop development initialization from auto-creating any placeholder study or fee data. -**Architecture:** Remove the application startup hook that seeds demo fee data, while keeping schema setup and protected admin initialization intact. Add a regression test that proves the lifespan startup no longer calls the demo seed helper. +**Architecture:** Remove the application startup hook that seeds placeholder fee data, while keeping schema setup and protected admin initialization intact. Add a regression test that proves the lifespan startup no longer calls the old seed helper. **Tech Stack:** FastAPI, pytest, unittest.mock @@ -19,18 +19,18 @@ **Step 1: Write the failing test** ```python -async def test_development_startup_does_not_seed_demo_fees(): +async def test_development_startup_does_not_seed_business_data(): ... ``` **Step 2: Run test to verify it fails** Run: `pytest backend/tests/test_app_startup.py -q` -Expected: FAIL because startup still calls `seed_demo_fees`. +Expected: FAIL because startup still calls the old seed helper. **Step 3: Write minimal implementation** -Remove the `seed_demo_fees` import and startup call from `backend/app/main.py`. +Remove the obsolete seed hook from `backend/app/main.py`. **Step 4: Run test to verify it passes** diff --git a/frontend/.env.example b/frontend/.env.example index 73a9682d..b560999e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,4 +2,3 @@ VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 -VITE_USE_OVERVIEW_API=false diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index e9e0ba07..353e77d4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -3,7 +3,7 @@ import { apiGet, apiPost } from "./axios"; import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api"; export const listPendingUsers = (status: UserStatus = "PENDING"): Promise> => - apiGet("/api/v1/admin/users", { params: { status } }); + apiGet("/api/v1/admin/users/", { params: { status } }); export const approveUser = (userId: string): Promise> => apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" }); diff --git a/frontend/src/api/faqs.ts b/frontend/src/api/faqs.ts index 2b8ef8fa..e3160723 100644 --- a/frontend/src/api/faqs.ts +++ b/frontend/src/api/faqs.ts @@ -49,10 +49,10 @@ export interface FaqReply { } export const fetchFaqCategories = (params?: Record): Promise>> => - apiGet("/api/v1/faqs/categories", { params }); + apiGet("/api/v1/faqs/categories/", { params }); export const createFaqCategory = (payload: Record) => - apiPost("/api/v1/faqs/categories", payload); + apiPost("/api/v1/faqs/categories/", payload); export const updateFaqCategory = (categoryId: string, payload: Record) => apiPatch(`/api/v1/faqs/categories/${categoryId}`, payload); @@ -61,11 +61,11 @@ export const deleteFaqCategory = (categoryId: string) => apiDelete(`/api/v1/faqs/categories/${categoryId}`); export const fetchFaqItems = (params?: Record): Promise>> => - apiGet("/api/v1/faqs/items", { params }); + apiGet("/api/v1/faqs/items/", { params }); export const fetchFaqItem = (itemId: string) => apiGet(`/api/v1/faqs/items/${itemId}`); -export const createFaqItem = (payload: Record) => apiPost("/api/v1/faqs/items", payload); +export const createFaqItem = (payload: Record) => apiPost("/api/v1/faqs/items/", payload); export const updateFaqItem = (itemId: string, payload: Record) => apiPatch(`/api/v1/faqs/items/${itemId}`, payload); diff --git a/frontend/src/components/ModulePlaceholder.vue b/frontend/src/components/ModulePlaceholder.vue index 768a6ee2..4616dee9 100644 --- a/frontend/src/components/ModulePlaceholder.vue +++ b/frontend/src/components/ModulePlaceholder.vue @@ -10,11 +10,9 @@
-
- {{ listTitle }} - {{ TEXT.common.empty.listPlaceholder }} +
+
{{ emptyDescription || emptyTitle }}
-
@@ -22,7 +20,6 @@ diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts index 67d5cc1a..60a4e677 100644 --- a/frontend/src/store/auth.test.ts +++ b/frontend/src/store/auth.test.ts @@ -39,7 +39,7 @@ describe("auth store logout", () => { const session = useSessionStore(); const auth = useAuthStore(); - session.lock("token_invalid", "demo@example.com", Date.now() + 60_000); + session.lock("token_invalid", "user@example.com", Date.now() + 60_000); auth.logout(); expect(session.locked).toBe(false); diff --git a/frontend/src/store/study.test.ts b/frontend/src/store/study.test.ts index fe26b971..ab285320 100644 --- a/frontend/src/store/study.test.ts +++ b/frontend/src/store/study.test.ts @@ -46,8 +46,8 @@ describe("study store startup rehydration", () => { "ctms_current_study", JSON.stringify({ id: "deleted-study", - code: "DEMO-CTMS", - name: "示例临床试验项目", + code: "ARCHIVED-CTMS", + name: "已删除项目", }) ); fetchStudies.mockResolvedValue({ diff --git a/frontend/src/views/fees/ContractFeeDetail.vue b/frontend/src/views/fees/ContractFeeDetail.vue index 6b721e82..093a2cca 100644 --- a/frontend/src/views/fees/ContractFeeDetail.vue +++ b/frontend/src/views/fees/ContractFeeDetail.vue @@ -84,8 +84,10 @@ + - @@ -116,7 +118,6 @@ import { fetchSites } from "../../api/sites"; import { useStudyStore } from "../../store/study"; import { usePermission } from "../../utils/permission"; import { displayDate } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import StateError from "../../components/StateError.vue"; import StateLoading from "../../components/StateLoading.vue"; import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue"; @@ -307,4 +308,14 @@ onMounted(async () => { color: var(--ctms-text-placeholder); font-size: 13px; } +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/fees/ContractFees.vue b/frontend/src/views/fees/ContractFees.vue index 1e404525..f8ee862a 100644 --- a/frontend/src/views/fees/ContractFees.vue +++ b/frontend/src/views/fees/ContractFees.vue @@ -164,8 +164,10 @@ + - @@ -181,7 +183,6 @@ import { fetchSites } from "../../api/sites"; import { useStudyStore } from "../../store/study"; import { usePermission } from "../../utils/permission"; import { displayDate } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import StateLoading from "../../components/StateLoading.vue"; import StateError from "../../components/StateError.vue"; import KpiCard from "../../components/KpiCard.vue"; @@ -444,6 +445,16 @@ onMounted(async () => { font-family: var(--el-font-family); } +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/ProjectMilestones.vue b/frontend/src/views/ia/ProjectMilestones.vue index 149c6915..b3715bdd 100644 --- a/frontend/src/views/ia/ProjectMilestones.vue +++ b/frontend/src/views/ia/ProjectMilestones.vue @@ -102,17 +102,15 @@ - + + - - @@ -665,4 +663,15 @@ watch( .ctms-table :deep(.el-table__inner-wrapper::before) { display: none; } + +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/ProjectOverview.vue b/frontend/src/views/ia/ProjectOverview.vue index 40006c31..c8991d62 100644 --- a/frontend/src/views/ia/ProjectOverview.vue +++ b/frontend/src/views/ia/ProjectOverview.vue @@ -4,7 +4,6 @@
- 示例数据
更新:{{ updatedAtLabel }}
刷新
@@ -20,11 +19,12 @@
- +
+
+
中心整体进度暂未生成
+
当前项目未配置中心,或尚未形成可展示的中心进度数据。
+
+
(null); const chartMode = ref<"center" | "month">("center"); const updatedAtLabel = ref(displayDateTime(new Date())); -const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true"; - const centers = computed(() => overview.value?.centers || []); const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] => @@ -128,29 +124,35 @@ const chartEmptyText = computed(() => chartMode.value === "center" ? "暂无中心入组数据" : "暂无月度入组数据" ); +const buildOverviewFromSites = (studyId: string, siteList: any[]): ProjectOverviewViewModel => { + const centers = buildFallbackCentersFromSites(siteList); + const totalTarget = centers.reduce((sum, center) => sum + center.enrollment_target, 0); + return { + study_id: studyId, + updated_at: new Date().toISOString(), + centers, + months: [], + summary: { + total_actual: 0, + total_target: totalTarget, + }, + }; +}; + const loadOverview = async () => { const studyId = study.currentStudy?.id; if (!studyId) return; loading.value = true; + let siteList: any[] = []; try { const sitesResp = await fetchSites(studyId, { limit: 500 }); - const siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || []; + siteList = Array.isArray(sitesResp.data) ? sitesResp.data : (sitesResp.data as any).items || []; const siteActiveMap = new Map(siteList.map((s: any) => [s.id, !!s.is_active])); - - if (preferApi) { - const { data } = await fetchProjectOverview(studyId); - overview.value = adaptProjectOverview(data); - usingDemo.value = false; - updatedAtLabel.value = overview.value.updated_at - ? displayDateTime(overview.value.updated_at) - : displayDateTime(new Date()); - } else { - overview.value = adaptProjectOverview(overviewMock); - usingDemo.value = true; - updatedAtLabel.value = overview.value.updated_at - ? displayDateTime(overview.value.updated_at) - : displayDateTime(new Date()); - } + const { data } = await fetchProjectOverview(studyId); + overview.value = adaptProjectOverview(data); + updatedAtLabel.value = overview.value.updated_at + ? displayDateTime(overview.value.updated_at) + : displayDateTime(new Date()); if (overview.value && overview.value.centers.length === 0 && siteList.length > 0) { overview.value.centers = buildFallbackCentersFromSites(siteList); @@ -166,11 +168,8 @@ const loadOverview = async () => { overview.value.centers.sort((a, b) => Number(b.is_active) - Number(a.is_active)); } } catch { - overview.value = adaptProjectOverview(overviewMock); - usingDemo.value = true; - updatedAtLabel.value = overview.value.updated_at - ? displayDateTime(overview.value.updated_at) - : displayDateTime(new Date()); + overview.value = buildOverviewFromSites(studyId, siteList); + updatedAtLabel.value = displayDateTime(overview.value.updated_at); } finally { loading.value = false; } @@ -178,7 +177,6 @@ const loadOverview = async () => { const reset = () => { overview.value = null; - usingDemo.value = false; updatedAtLabel.value = displayDateTime(new Date()); }; @@ -208,11 +206,6 @@ watch( gap: 0; } -.demo-tag { - border-color: var(--ctms-border-color); - color: var(--ctms-text-secondary); -} - .updated-at { white-space: nowrap; } @@ -292,6 +285,37 @@ watch( gap: 2px; } +.overview-empty-panel { + min-height: 168px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 6px; + padding: 24px; + border-radius: 18px; + background: + linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98)); +} + +.overview-empty-content { + max-width: 520px; + text-align: center; +} + +.overview-empty-title { + font-size: 18px; + font-weight: 700; + letter-spacing: 0.01em; + color: #243a5a; +} + +.overview-empty-desc { + margin-top: 10px; + font-size: 14px; + line-height: 1.7; + color: #8a97ab; +} + .mode-switch :deep(.el-radio-button__inner) { padding: 4px 12px; } diff --git a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue index 35f1a88f..08aa2b30 100644 --- a/frontend/src/views/ia/RiskIssueMonitoringVisits.vue +++ b/frontend/src/views/ia/RiskIssueMonitoringVisits.vue @@ -98,7 +98,7 @@ @@ -824,6 +824,17 @@ watch( justify-content: flex-end; } +.issue-table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} + .issue-table :deep(.el-table__inner-wrapper::before) { display: none; } diff --git a/frontend/src/views/ia/RiskIssuePd.vue b/frontend/src/views/ia/RiskIssuePd.vue index b9c48d49..b3c655c3 100644 --- a/frontend/src/views/ia/RiskIssuePd.vue +++ b/frontend/src/views/ia/RiskIssuePd.vue @@ -51,8 +51,10 @@ + -
@@ -67,7 +69,6 @@ import { useStudyStore } from "../../store/study"; import { listStudySubjectPds } from "../../api/subjectPds"; import { fetchSites } from "../../api/sites"; import { displayEnum } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; const router = useRouter(); @@ -190,4 +191,15 @@ onMounted(load); .risk-table :deep(.el-table__inner-wrapper::before) { display: none; } + +.risk-table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/RiskIssueSae.vue b/frontend/src/views/ia/RiskIssueSae.vue index e148eb01..4dad8da7 100644 --- a/frontend/src/views/ia/RiskIssueSae.vue +++ b/frontend/src/views/ia/RiskIssueSae.vue @@ -79,8 +79,10 @@
+ - @@ -96,7 +98,6 @@ import { fetchAes } from "../../api/aes"; import { fetchSubjects } from "../../api/subjects"; import { fetchSites } from "../../api/sites"; import { displayDate, displayEnum } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; const router = useRouter(); @@ -247,4 +248,15 @@ onMounted(load); .risk-table :deep(.el-table__inner-wrapper::before) { display: none; } + +.risk-table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} diff --git a/frontend/src/views/ia/StartupFeasibilityEthics.vue b/frontend/src/views/ia/StartupFeasibilityEthics.vue index 860e034c..4edb5ee7 100644 --- a/frontend/src/views/ia/StartupFeasibilityEthics.vue +++ b/frontend/src/views/ia/StartupFeasibilityEthics.vue @@ -58,8 +58,10 @@ + - @@ -121,8 +123,10 @@ + - @@ -140,7 +144,6 @@ import { listFeasibility, listEthics, deleteFeasibility, deleteEthics } from ".. import { fetchSites } from "../../api/sites"; import type { Site } from "../../types/api"; import { displayDate } from "../../utils/display"; -import StateEmpty from "../../components/StateEmpty.vue"; import dayjs from "dayjs"; import type { StageStatus } from "./project-overview/overview.adapter"; import { TEXT } from "../../locales"; @@ -323,6 +326,16 @@ onMounted(() => { display: none; } +.table-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: #8a97ab; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; +} From ceda34fd1d8800e1ab98b0b6a3f072d2439f3705 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 31 Mar 2026 12:16:04 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E6=A0=8F=E7=A9=BA=E5=85=B3=E8=81=94=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/Register.vue | 19 ++++++++++-- frontend/src/views/admin/AuditLogs.vue | 16 +++++----- frontend/src/views/admin/Users.vue | 12 ++++---- frontend/src/views/documents/DocumentList.vue | 16 +++++----- frontend/src/views/fees/ContractFees.vue | 4 +-- frontend/src/views/fees/SpecialExpenses.vue | 6 ++-- frontend/src/views/ia/DrugShipments.vue | 6 ++-- frontend/src/views/ia/FinanceContracts.vue | 12 ++++---- frontend/src/views/ia/FinanceSpecial.vue | 12 ++++---- frontend/src/views/ia/KnowledgeNotes.vue | 4 +-- .../views/ia/RiskIssueMonitoringVisits.vue | 30 ++++++++++++++----- frontend/src/views/ia/RiskIssuePd.vue | 16 +++++----- frontend/src/views/ia/RiskIssueSae.vue | 24 +++++++-------- frontend/src/views/ia/SubjectManagement.vue | 16 +++++----- 14 files changed, 110 insertions(+), 83 deletions(-) diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue index 37cb7097..b5fcaef4 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -204,7 +204,10 @@ - 我同意 服务条款隐私政策 + 我同意 + + 和 + {{ errors.agreeTerms }} @@ -280,9 +283,9 @@ {{ TEXT.common.actions.login }}

@@ -936,6 +939,16 @@ const handleSubmit = async () => { transition: opacity 0.2s ease; } +.link-button, +.footer-link-button { + padding: 0; + border: 0; + background: transparent; + font: inherit; + line-height: inherit; + cursor: pointer; +} + .link:hover { opacity: 0.8; } diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 63bdf706..d05f074d 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -3,7 +3,7 @@
- +
- - +
+
- - +
+
- - +
+
- +
diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index 6b85373f..c07274ca 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -3,25 +3,25 @@
- +
- - +
+
- - +
+
{{ TEXT.common.actions.search }} - +
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}
diff --git a/frontend/src/views/documents/DocumentList.vue b/frontend/src/views/documents/DocumentList.vue index 0861b70d..449fbb1e 100644 --- a/frontend/src/views/documents/DocumentList.vue +++ b/frontend/src/views/documents/DocumentList.vue @@ -3,24 +3,24 @@
- +
- - +
+
- - +
+
- - +
+
{{ TEXT.common.actions.reset }} - +
diff --git a/frontend/src/views/fees/ContractFees.vue b/frontend/src/views/fees/ContractFees.vue index f8ee862a..ef36350e 100644 --- a/frontend/src/views/fees/ContractFees.vue +++ b/frontend/src/views/fees/ContractFees.vue @@ -56,7 +56,7 @@
- +