移除示例项目初始化并修复前后端稳定性问题

This commit is contained in:
Cheng Zhou
2026-03-30 15:35:22 +08:00
parent a67991740e
commit 8c5d03f1cb
24 changed files with 581 additions and 102 deletions
+62 -2
View File
@@ -1,7 +1,7 @@
from typing import Annotated, AsyncGenerator, Callable, Iterable from typing import Annotated, AsyncGenerator, Callable, Iterable
import uuid import uuid
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -139,11 +139,71 @@ async def get_cra_site_scope(
def require_study_not_locked(): def require_study_not_locked():
"""检查项目是否被锁定,如果锁定则拒绝所有修改请求""" """检查项目是否被锁定,如果锁定则拒绝所有修改请求"""
from app.crud import study as study_crud from app.crud import study as study_crud
from app.crud import faq_category as faq_category_crud
from app.crud import faq_item as faq_item_crud
async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID:
raw_study_id = request.path_params.get("study_id") or request.query_params.get("study_id")
if raw_study_id:
try:
return uuid.UUID(str(raw_study_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="项目 ID 格式错误",
) from exc
try:
body = await request.json()
except Exception:
body = None
if isinstance(body, dict) and body.get("study_id"):
try:
return uuid.UUID(str(body["study_id"]))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="项目 ID 格式错误",
) from exc
raw_category_id = request.path_params.get("category_id")
if raw_category_id:
try:
category_id = uuid.UUID(str(raw_category_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="分类 ID 格式错误",
) from exc
category = await faq_category_crud.get_category(db, category_id)
if not category or not category.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
return category.study_id
raw_item_id = request.path_params.get("item_id")
if raw_item_id:
try:
item_id = uuid.UUID(str(raw_item_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="FAQ ID 格式错误",
) from exc
item = await faq_item_crud.get_item(db, item_id)
if not item or not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
return item.study_id
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="缺少项目 ID",
)
async def dependency( async def dependency(
study_id: uuid.UUID, request: Request,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
): ):
study_id = await resolve_study_id(request, db)
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException( raise HTTPException(
-2
View File
@@ -15,7 +15,6 @@ from app.core.exceptions import register_exception_handlers
from app.crud.user import ensure_admin_exists from app.crud.user import ensure_admin_exists
from app.db.base import Base from app.db.base import Base
from app.db.session import SessionLocal, engine from app.db.session import SessionLocal, engine
from app.services.fee_seed import seed_demo_fees
from app.services.visit_scheduler import run_daily_lost_visit_job from app.services.visit_scheduler import run_daily_lost_visit_job
logger = logging.getLogger("ctms.setup_config") logger = logging.getLogger("ctms.setup_config")
@@ -38,7 +37,6 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session: async with SessionLocal() as session:
await ensure_admin_exists(session) await ensure_admin_exists(session)
await seed_demo_fees(session)
yield yield
stop_event.set() stop_event.set()
await scheduler_task await scheduler_task
+78
View File
@@ -0,0 +1,78 @@
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from app import main
class _DummyTask:
def __init__(self):
self.awaited = False
def __await__(self):
async def _wait():
self.awaited = True
return _wait().__await__()
class _DummySession:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _DummyBegin:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def run_sync(self, fn):
return None
class _DummyEngine:
def begin(self):
return _DummyBegin()
@pytest.mark.asyncio
async def test_development_startup_does_not_seed_demo_fees(monkeypatch):
ensure_admin_exists = AsyncMock()
seed_demo_fees = AsyncMock()
create_all = AsyncMock()
legacy_pk = AsyncMock()
scheduler_task = _DummyTask()
async def fake_scheduler(stop_event):
return None
def fake_create_task(coroutine):
coroutine.close()
return scheduler_task
@asynccontextmanager
async def fake_session_local():
yield _DummySession()
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())
monkeypatch.setattr(main, "SessionLocal", fake_session_local)
monkeypatch.setattr(main, "_ensure_legacy_primary_keys", legacy_pk)
monkeypatch.setattr(main.Base.metadata, "create_all", create_all)
async with main.lifespan(FastAPI()):
pass
ensure_admin_exists.assert_awaited_once()
seed_demo_fees.assert_not_awaited()
+105
View File
@@ -0,0 +1,105 @@
import uuid
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.core import deps
class _Study:
def __init__(self, study_id: uuid.UUID, is_locked: bool = False):
self.id = study_id
self.is_locked = is_locked
class _Category:
def __init__(self, study_id: uuid.UUID | None):
self.study_id = study_id
class _FaqItem:
def __init__(self, study_id: uuid.UUID | None):
self.study_id = study_id
def _make_request(*, method: str = "POST", path: str = "/", query_string: bytes = b"", json_body: bytes = b"", path_params=None):
async def receive():
return {"type": "http.request", "body": json_body, "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": method,
"path": path,
"raw_path": path.encode(),
"query_string": query_string,
"headers": [(b"content-type", b"application/json")],
"path_params": path_params or {},
}
return Request(scope, receive)
@pytest.mark.asyncio
async def test_require_study_not_locked_accepts_study_id_from_request_body(monkeypatch):
study_id = uuid.uuid4()
async def fake_get(_db, requested_study_id):
return _Study(requested_study_id, is_locked=False)
monkeypatch.setattr("app.crud.study.get", fake_get)
dependency = deps.require_study_not_locked()
request = _make_request(json_body=f'{{"study_id":"{study_id}"}}'.encode())
study = await dependency(request=request, db=object())
assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_resolves_study_id_from_category_path(monkeypatch):
study_id = uuid.uuid4()
category_id = uuid.uuid4()
async def fake_get_category(_db, requested_category_id):
assert requested_category_id == category_id
return _Category(study_id)
async def fake_get_study(_db, requested_study_id):
return _Study(requested_study_id, is_locked=False)
monkeypatch.setattr("app.crud.faq_category.get_category", fake_get_category)
monkeypatch.setattr("app.crud.study.get", fake_get_study)
dependency = deps.require_study_not_locked()
request = _make_request(path=f"/api/v1/faqs/categories/{category_id}", path_params={"category_id": str(category_id)})
study = await dependency(request=request, db=object())
assert study.id == study_id
@pytest.mark.asyncio
async def test_require_study_not_locked_rejects_locked_study_resolved_from_item_path(monkeypatch):
study_id = uuid.uuid4()
item_id = uuid.uuid4()
async def fake_get_item(_db, requested_item_id):
assert requested_item_id == item_id
return _FaqItem(study_id)
async def fake_get_study(_db, requested_study_id):
return _Study(requested_study_id, is_locked=True)
monkeypatch.setattr("app.crud.faq_item.get_item", fake_get_item)
monkeypatch.setattr("app.crud.study.get", fake_get_study)
dependency = deps.require_study_not_locked()
request = _make_request(path=f"/api/v1/faqs/{item_id}", path_params={"item_id": str(item_id)})
with pytest.raises(HTTPException) as exc_info:
await dependency(request=request, db=object())
assert exc_info.value.status_code == 403
assert "项目已锁定" in exc_info.value.detail
@@ -0,0 +1,48 @@
# Remove Demo Study Seed Implementation Plan
> **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.
**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.
**Tech Stack:** FastAPI, pytest, unittest.mock
---
### Task 1: Lock the startup behavior with a failing test
**Files:**
- Create: `backend/tests/test_app_startup.py`
- Modify: `backend/app/main.py`
**Step 1: Write the failing test**
```python
async def test_development_startup_does_not_seed_demo_fees():
...
```
**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`.
**Step 3: Write minimal implementation**
Remove the `seed_demo_fees` import and startup call from `backend/app/main.py`.
**Step 4: Run test to verify it passes**
Run: `pytest backend/tests/test_app_startup.py -q`
Expected: PASS.
### Task 2: Verify no regression in admin bootstrap
**Files:**
- Test: `backend/tests/test_protected_admin.py`
**Step 1: Run targeted regression tests**
Run: `pytest backend/tests/test_app_startup.py backend/tests/test_protected_admin.py -q`
Expected: PASS.
+27 -13
View File
@@ -1,24 +1,26 @@
<template> <template>
<el-container class="layout-container"> <el-container class="layout-container">
<el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }" v-if="isAdmin || study.currentStudy"> <el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }">
<div class="aside-logo"> <div class="aside-logo">
<div class="logo-icon"></div> <div class="logo-icon"></div>
<span class="logo-text">{{ TEXT.common.appName }}</span> <span class="logo-text">{{ TEXT.common.appName }}</span>
</div> </div>
<el-menu <el-menu
router
:default-active="activeMenu" :default-active="activeMenu"
:collapse="isCollapsed" :collapse="isCollapsed"
:collapse-transition="true" :collapse-transition="true"
class="aside-menu" class="aside-menu"
@select="handleMenuSelect"
> >
<el-menu-item v-if="!isAdmin" index="/workbench"> <el-menu-item v-if="!isAdmin" index="/workbench">
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>{{ TEXT.menu.workbench }}</span> <span>{{ TEXT.menu.workbench }}</span>
</el-menu-item> </el-menu-item>
<template v-if="isAdmin"> <el-menu-item-group v-if="isAdmin" class="menu-group">
<div class="menu-divider">{{ TEXT.menu.admin }}</div> <template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
</template>
<el-menu-item index="/admin/users"> <el-menu-item index="/admin/users">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>{{ TEXT.menu.accountManagement }}</span> <span>{{ TEXT.menu.accountManagement }}</span>
@@ -31,10 +33,12 @@
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>{{ TEXT.menu.auditLogs }}</span> <span>{{ TEXT.menu.auditLogs }}</span>
</el-menu-item> </el-menu-item>
</template> </el-menu-item-group>
<template v-if="study.currentStudy"> <el-menu-item-group v-if="study.currentStudy" class="menu-group">
<div class="menu-divider">{{ TEXT.menu.currentProject }}</div> <template #title>
<span class="menu-divider">{{ TEXT.menu.currentProject }}</span>
</template>
<el-menu-item index="/project/overview"> <el-menu-item index="/project/overview">
<el-icon><House /></el-icon> <el-icon><House /></el-icon>
<span>{{ TEXT.menu.projectOverview }}</span> <span>{{ TEXT.menu.projectOverview }}</span>
@@ -102,7 +106,7 @@
<el-menu-item index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item> <el-menu-item index="/knowledge/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item> <el-menu-item index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
</template> </el-menu-item-group>
</el-menu> </el-menu>
</el-aside> </el-aside>
@@ -194,11 +198,9 @@
<el-main class="layout-main"> <el-main class="layout-main">
<div class="content-wrapper"> <div class="content-wrapper">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in"> <div class="ctms-route-shell">
<div :key="route.fullPath" class="ctms-route-shell"> <component :is="Component" />
<component :is="Component" :key="route.fullPath" /> </div>
</div>
</transition>
</router-view> </router-view>
</div> </div>
</el-main> </el-main>
@@ -392,6 +394,13 @@ const handleBreadcrumbCommand = (cmd: any) => {
} }
}; };
const handleMenuSelect = (index: string) => {
if (!index || index === route.path) {
return;
}
router.push(index);
};
watch(() => study.currentStudy, () => { watch(() => study.currentStudy, () => {
loadSites(); loadSites();
}, { immediate: true }); }, { immediate: true });
@@ -506,6 +515,7 @@ const onCommand = (cmd: string) => {
} }
.menu-divider { .menu-divider {
display: block;
padding: 8px 14px 2px; padding: 8px 14px 2px;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -514,6 +524,10 @@ const onCommand = (cmd: string) => {
letter-spacing: 1px; letter-spacing: 1px;
} }
.layout-aside :deep(.menu-group .el-menu-item-group__title) {
padding: 0 !important;
}
.layout-aside :deep(.el-menu-item) { .layout-aside :deep(.el-menu-item) {
height: 34px; height: 34px;
line-height: 34px; line-height: 34px;
+4 -1
View File
@@ -15,12 +15,15 @@ import { useStudyStore } from "./store/study";
const app = createApp(App); const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
app.use(router);
dayjs.locale("zh-cn"); dayjs.locale("zh-cn");
app.use(ElementPlus, { locale: zhCn }); app.use(ElementPlus, { locale: zhCn });
// 初始化项目上下文 // 初始化项目上下文
const studyStore = useStudyStore(); const studyStore = useStudyStore();
studyStore.loadCurrentStudy(); studyStore.loadCurrentStudy();
await studyStore.rehydrateStudyForLastUser();
app.use(router);
await router.isReady();
app.mount("#app"); app.mount("#app");
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
const fetchStudies = vi.fn();
vi.mock("../api/studies", () => ({
fetchStudies,
}));
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
clear: () => void;
};
const createStorage = (): StorageLike => {
const data = new Map<string, string>();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
data.set(key, String(value));
},
removeItem: (key) => {
data.delete(key);
},
clear: () => {
data.clear();
},
};
};
describe("study store startup rehydration", () => {
beforeEach(() => {
setActivePinia(createPinia());
fetchStudies.mockReset();
Object.defineProperty(window, "localStorage", {
value: createStorage(),
configurable: true,
});
});
it("clears a stale persisted study when the backend no longer has any studies", async () => {
window.localStorage.setItem("ctms_last_login_email", "admin@example.com");
window.localStorage.setItem(
"ctms_current_study",
JSON.stringify({
id: "deleted-study",
code: "DEMO-CTMS",
name: "示例临床试验项目",
})
);
fetchStudies.mockResolvedValue({
data: {
items: [],
},
});
const { useStudyStore } = await import("./study");
const study = useStudyStore();
study.loadCurrentStudy();
expect(study.currentStudy?.id).toBe("deleted-study");
await study.rehydrateStudyForLastUser();
expect(study.currentStudy).toBeNull();
expect(window.localStorage.getItem("ctms_current_study")).toBeNull();
});
});
+52 -26
View File
@@ -77,6 +77,47 @@ export const useStudyStore = defineStore("study", () => {
} }
}; };
const pickAvailableStudy = (
items: Study[],
userKey: string,
opts?: { preferActive?: boolean }
): Study | null => {
if (!items.length) {
clearCurrentStudy();
return null;
}
if (currentStudy.value?.id) {
const matchedCurrent = items.find((item) => item.id === currentStudy.value?.id);
if (matchedCurrent) {
setCurrentStudy(matchedCurrent);
return matchedCurrent;
}
}
const normalized = normalizeUserKey(userKey || "");
const saved = normalized ? readLastStudyMap()[normalized] : null;
if (saved?.id) {
const matchedSaved = items.find((item) => item.id === saved.id);
if (matchedSaved) {
setCurrentStudy(matchedSaved);
return matchedSaved;
}
}
const fallback = opts?.preferActive
? items.find((study) => study.status === "ACTIVE" && !study.is_locked) || items[0]
: items[0];
if (fallback) {
setCurrentStudy(fallback);
return fallback;
}
clearCurrentStudy();
return null;
};
const ensureDefaultStudy = async () => { const ensureDefaultStudy = async () => {
if (currentStudy.value) return currentStudy.value; if (currentStudy.value) return currentStudy.value;
try { try {
@@ -124,41 +165,25 @@ export const useStudyStore = defineStore("study", () => {
}; };
const restoreStudyForUser = async (userKey: string, opts?: { preferActive?: boolean }) => { const restoreStudyForUser = async (userKey: string, opts?: { preferActive?: boolean }) => {
const normalized = normalizeUserKey(userKey || ""); if (!normalizeUserKey(userKey || "")) return null;
if (!normalized) return null;
try { try {
const { data } = await fetchStudies(); const { data } = await fetchStudies();
const items = ((data as any).items || []) as Study[]; const items = ((data as any).items || []) as Study[];
if (!items.length) { return pickAvailableStudy(items, userKey, opts);
clearCurrentStudy();
return null;
}
const saved = readLastStudyMap()[normalized];
if (saved?.id) {
const matched = items.find((item) => item.id === saved.id);
if (matched) {
setCurrentStudy(matched);
return matched;
}
}
const fallback = opts?.preferActive
? items.find((study) => study.status === "ACTIVE" && !study.is_locked) || items[0]
: items[0];
if (fallback) {
setCurrentStudy(fallback);
return fallback;
}
clearCurrentStudy();
return null;
} catch { } catch {
clearCurrentStudy(); clearCurrentStudy();
return null; return null;
} }
}; };
const rehydrateStudyForLastUser = async (opts?: { preferActive?: boolean }) => {
const userKey = localStorage.getItem("ctms_last_login_email") || "";
if (!normalizeUserKey(userKey)) {
return currentStudy.value;
}
return restoreStudyForUser(userKey, opts);
};
const setCurrentStudyRole = (role: string | null) => { const setCurrentStudyRole = (role: string | null) => {
currentStudyRole.value = role; currentStudyRole.value = role;
if (role) { if (role) {
@@ -195,6 +220,7 @@ export const useStudyStore = defineStore("study", () => {
ensureDefaultStudy, ensureDefaultStudy,
ensureDefaultActiveStudy, ensureDefaultActiveStudy,
restoreStudyForUser, restoreStudyForUser,
rehydrateStudyForLastUser,
clearCurrentStudy, clearCurrentStudy,
}; };
}); });
+4 -23
View File
@@ -70,25 +70,6 @@
--el-text-color-regular: var(--ctms-text-regular); --el-text-color-regular: var(--ctms-text-regular);
} }
/* 现代滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #bfbfbf;
}
/* 全局重置 */ /* 全局重置 */
html, html,
body, body,
@@ -430,23 +411,23 @@ body {
margin-top: 0; margin-top: 0;
} }
.ctms-descriptions :deep(.el-descriptions__label) { .ctms-descriptions .el-descriptions__label {
font-weight: 500; font-weight: 500;
color: var(--ctms-text-regular); color: var(--ctms-text-regular);
background-color: #f8fafc; background-color: #f8fafc;
} }
.ctms-tabs :deep(.el-tabs__nav-wrap::after) { .ctms-tabs .el-tabs__nav-wrap::after {
height: 1px; height: 1px;
background-color: var(--ctms-border-color); background-color: var(--ctms-border-color);
} }
.ctms-tabs :deep(.el-tabs__item) { .ctms-tabs .el-tabs__item {
font-weight: 500; font-weight: 500;
color: var(--ctms-text-regular); color: var(--ctms-text-regular);
} }
.ctms-tabs :deep(.el-tabs__item.is-active) { .ctms-tabs .el-tabs__item.is-active {
color: var(--ctms-primary); color: var(--ctms-primary);
font-weight: 600; font-weight: 600;
} }
+1
View File
@@ -118,6 +118,7 @@
</div> </div>
<el-drawer <el-drawer
v-if="detailVisible"
v-model="detailVisible" v-model="detailVisible"
direction="rtl" direction="rtl"
size="560px" size="560px"
+12 -21
View File
@@ -1390,7 +1390,7 @@
</div> </div>
</div> </div>
</div> </div>
<el-dialog append-to="body" v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog"> <el-dialog v-if="publishConfirmVisible" append-to="body" v-model="publishConfirmVisible" title="发布确认" width="920px" top="8vh" class="setup-publish-dialog">
<div class="conflict-summary"> <div class="conflict-summary">
<div>发布目标立项配置草稿</div> <div>发布目标立项配置草稿</div>
<div>当前发布版本{{ setupPublishedVersionText }}</div> <div>当前发布版本{{ setupPublishedVersionText }}</div>
@@ -1451,6 +1451,7 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog <el-dialog
v-if="rollbackDialogVisible"
append-to="body" append-to="body"
v-model="rollbackDialogVisible" v-model="rollbackDialogVisible"
width="960px" width="960px"
@@ -1499,7 +1500,7 @@
:key="`head-${lane.branch}`" :key="`head-${lane.branch}`"
class="rollback-axis-lane-head-item" class="rollback-axis-lane-head-item"
:class="{ 'is-main': lane.branch === 'main' }" :class="{ 'is-main': lane.branch === 'main' }"
:style="{ left: `${lane.x}px` }" :style="getRollbackAxisLeftStyle(lane.x)"
> >
{{ formatAxisLaneLabel(lane.branch) }} {{ formatAxisLaneLabel(lane.branch) }}
</span> </span>
@@ -1566,7 +1567,7 @@
v-for="lane in rollbackAxisLanes.filter(l => l.branch === 'main')" v-for="lane in rollbackAxisLanes.filter(l => l.branch === 'main')"
:key="`${row.id}-${lane.branch}`" :key="`${row.id}-${lane.branch}`"
class="rollback-axis-lane-segment is-main-lane" class="rollback-axis-lane-segment is-main-lane"
:style="{ left: `${lane.x}px` }" :style="getRollbackAxisLeftStyle(lane.x)"
/> />
<span <span
class="rollback-axis-dot" class="rollback-axis-dot"
@@ -1575,7 +1576,7 @@
'is-current': row.is_current_published, 'is-current': row.is_current_published,
'is-merge': row.mergeFromId !== null, 'is-merge': row.mergeFromId !== null,
}" }"
:style="{ left: `${row.axisX}px` }" :style="getRollbackAxisLeftStyle(row.axisX)"
/> />
</div> </div>
<div class="rollback-axis-content"> <div class="rollback-axis-content">
@@ -1661,6 +1662,7 @@
</el-dialog> </el-dialog>
<el-drawer <el-drawer
v-if="projectMilestoneEditorVisible"
v-model="projectMilestoneEditorVisible" v-model="projectMilestoneEditorVisible"
direction="rtl" direction="rtl"
size="480px" size="480px"
@@ -1763,6 +1765,7 @@
</el-drawer> </el-drawer>
<el-drawer <el-drawer
v-if="siteMilestoneEditorVisible"
v-model="siteMilestoneEditorVisible" v-model="siteMilestoneEditorVisible"
direction="rtl" direction="rtl"
size="480px" size="480px"
@@ -1842,6 +1845,7 @@
</el-drawer> </el-drawer>
<el-drawer <el-drawer
v-if="siteEnrollmentEditorVisible"
v-model="siteEnrollmentEditorVisible" v-model="siteEnrollmentEditorVisible"
direction="rtl" direction="rtl"
size="480px" size="480px"
@@ -1911,6 +1915,7 @@
</el-drawer> </el-drawer>
<el-drawer <el-drawer
v-if="monitoringStrategyEditorVisible"
v-model="monitoringStrategyEditorVisible" v-model="monitoringStrategyEditorVisible"
direction="rtl" direction="rtl"
size="480px" size="480px"
@@ -4772,6 +4777,9 @@ const getDisplayVersionLabel = (rawVersion: number): string => {
const ROLLBACK_AXIS_LANE_GAP = 34; const ROLLBACK_AXIS_LANE_GAP = 34;
const ROLLBACK_AXIS_LANE_START = 18; const ROLLBACK_AXIS_LANE_START = 18;
const getRollbackAxisLeftStyle = (axisX?: number | null) => ({
left: `${Number.isFinite(axisX) ? axisX : ROLLBACK_AXIS_LANE_START}px`,
});
const normalizeAxisBranch = (branchName?: string | null): string => (branchName || "main").trim() || "main"; const normalizeAxisBranch = (branchName?: string | null): string => (branchName || "main").trim() || "main";
const formatAxisLaneLabel = (branchName: string): string => const formatAxisLaneLabel = (branchName: string): string =>
branchName === "main" ? "main" : branchName.replace(/^release\//, ""); branchName === "main" ? "main" : branchName.replace(/^release\//, "");
@@ -6546,23 +6554,6 @@ onBeforeUnmount(() => {
padding: 6px 0; padding: 6px 0;
} }
.rollback-axis-scroll::-webkit-scrollbar {
width: 5px;
}
.rollback-axis-scroll::-webkit-scrollbar-track {
background: transparent;
}
.rollback-axis-scroll::-webkit-scrollbar-thumb {
background: #c8d6e5;
border-radius: 10px;
}
.rollback-axis-scroll::-webkit-scrollbar-thumb:hover {
background: #a0b4cc;
}
/* SVG 覆盖层 */ /* SVG 覆盖层 */
.rollback-axis-overlay { .rollback-axis-overlay {
position: absolute; position: absolute;
+1 -1
View File
@@ -64,7 +64,7 @@
</div> </div>
</div> </div>
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false"> <el-dialog v-if="addVisible" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules"> <el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
<el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id"> <el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
<el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder"> <el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
+5 -1
View File
@@ -58,7 +58,7 @@
</el-table> </el-table>
</div> </div>
</div> </div>
<ProjectForm v-model:visible="formVisible" :project="editingProject" @saved="loadProjects" /> <ProjectForm v-if="formVisible" v-model:visible="formVisible" :project="editingProject" @saved="loadProjects" />
</div> </div>
</template> </template>
@@ -139,7 +139,11 @@ const handleDelete = async (study: Study) => {
return; return;
} }
const isDeletingCurrentStudy = studyStore.currentStudy?.id === study.id;
await deleteStudy(study.id); await deleteStudy(study.id);
if (isDeletingCurrentStudy) {
await studyStore.rehydrateStudyForLastUser({ preferActive: true });
}
ElMessage.success("项目已删除"); ElMessage.success("项目已删除");
await loadProjects(); await loadProjects();
} catch (err: any) { } catch (err: any) {
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog v-if="visibleProxy" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div> <div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div>
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item :label="TEXT.modules.adminSites.craSelect"> <el-form-item :label="TEXT.modules.adminSites.craSelect">
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog append-to=".layout-main .content-wrapper" :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false"> <el-dialog v-if="visibleProxy" append-to=".layout-main .content-wrapper" :title="site ? TEXT.modules.adminSites.editTitle : TEXT.modules.adminSites.newTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item :label="TEXT.common.fields.siteName" prop="name"> <el-form-item :label="TEXT.common.fields.siteName" prop="name">
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.siteName" /> <el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.siteName" />
+1 -1
View File
@@ -63,7 +63,7 @@
</div> </div>
</div> </div>
<SiteForm <SiteForm
v-if="projectId" v-if="projectId && formVisible"
v-model:visible="formVisible" v-model:visible="formVisible"
:study-id="projectId" :study-id="projectId"
:site="editingSite" :site="editingSite"
+2 -2
View File
@@ -85,8 +85,8 @@
</div> </div>
</div> </div>
</div> </div>
<UserForm v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" /> <UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" /> <UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
</div> </div>
</template> </template>
@@ -39,6 +39,7 @@
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<el-drawer <el-drawer
v-if="drawerVisible"
v-model="drawerVisible" v-model="drawerVisible"
direction="rtl" direction="rtl"
size="620px" size="620px"
@@ -118,6 +118,7 @@
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<el-drawer <el-drawer
v-if="editorVisible"
v-model="editorVisible" v-model="editorVisible"
direction="rtl" direction="rtl"
size="460px" size="460px"
@@ -116,6 +116,7 @@
</div> </div>
<el-drawer <el-drawer
v-if="formDialogVisible"
v-model="formDialogVisible" v-model="formDialogVisible"
direction="rtl" direction="rtl"
size="580px" size="580px"
@@ -289,7 +290,7 @@
</template> </template>
</el-drawer> </el-drawer>
<el-dialog v-model="viewDialogVisible" title="监查访视问题详情" width="680px"> <el-dialog v-if="viewDialogVisible" v-model="viewDialogVisible" title="监查访视问题详情" width="680px">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="问题编号">{{ viewIssue?.issue_no || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item label="问题编号">{{ viewIssue?.issue_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item label="问题来源">{{ viewIssue?.source || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item label="问题来源">{{ viewIssue?.source || TEXT.common.fallback }}</el-descriptions-item>
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readView = (relativePath: string) =>
readFileSync(resolve(__dirname, relativePath), "utf8");
const normalizeWhitespace = (source: string) => source.replace(/\s+/g, " ").trim();
describe("route view overlays are mounted only when visible", () => {
it("gates high-risk admin and subject overlays behind visible flags", () => {
const checks = [
{
file: "./admin/ProjectMembers.vue",
snippets: ['<el-dialog v-if="addVisible"', 'v-model="addVisible"'],
},
{
file: "./admin/Sites.vue",
snippets: ['<SiteForm', 'v-if="projectId && formVisible"'],
},
{
file: "./admin/SiteForm.vue",
snippets: ['<el-dialog v-if="visibleProxy"', 'v-model="visibleProxy"'],
},
{
file: "./admin/SiteCraBinding.vue",
snippets: ['<el-dialog v-if="visibleProxy"', 'v-model="visibleProxy"'],
},
{
file: "./subjects/SubjectDetail.vue",
snippets: [
'<el-dialog v-if="historyDialogVisible"',
'<el-dialog v-if="visitDialogVisible"',
'<el-dialog v-if="aeDialogVisible"',
'<el-dialog v-if="pdDialogVisible"',
],
},
{
file: "./ia/ProjectMilestones.vue",
snippets: ['<el-drawer v-if="editorVisible"', 'v-model="editorVisible"'],
},
{
file: "./ia/MaterialEquipment.vue",
snippets: ['<el-drawer v-if="drawerVisible"', 'v-model="drawerVisible"'],
},
{
file: "./ia/RiskIssueMonitoringVisits.vue",
snippets: [
'<el-drawer v-if="formDialogVisible"',
'v-model="formDialogVisible"',
'<el-dialog v-if="viewDialogVisible"',
'v-model="viewDialogVisible"',
],
},
{
file: "./admin/ProjectDetail.vue",
snippets: [
'<el-dialog v-if="publishConfirmVisible"',
'v-model="publishConfirmVisible"',
'v-if="rollbackDialogVisible"',
'v-model="rollbackDialogVisible"',
'<el-drawer v-if="projectMilestoneEditorVisible"',
'<el-drawer v-if="siteMilestoneEditorVisible"',
'<el-drawer v-if="siteEnrollmentEditorVisible"',
'<el-drawer v-if="monitoringStrategyEditorVisible"',
'const getRollbackAxisLeftStyle = (axisX?: number | null) => ({',
':style="getRollbackAxisLeftStyle(lane.x)"',
':style="getRollbackAxisLeftStyle(row.axisX)"',
],
},
];
for (const check of checks) {
const source = normalizeWhitespace(readView(check.file));
for (const snippet of check.snippets) {
expect(source, `${check.file} should include ${snippet}`).toContain(normalizeWhitespace(snippet));
}
}
});
});
@@ -298,7 +298,7 @@
</el-tabs> </el-tabs>
</el-card> </el-card>
<el-dialog append-to=".layout-main .content-wrapper" v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px"> <el-dialog v-if="historyDialogVisible" append-to=".layout-main .content-wrapper" v-model="historyDialogVisible" :title="TEXT.modules.subjectDetail.dialogHistory" width="520px">
<el-form :model="historyForm" label-width="90px"> <el-form :model="historyForm" label-width="90px">
<el-form-item :label="TEXT.common.fields.recordDate"> <el-form-item :label="TEXT.common.fields.recordDate">
<el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" /> <el-date-picker v-model="historyForm.record_date" type="date" value-format="YYYY-MM-DD" />
@@ -313,7 +313,7 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog append-to=".layout-main .content-wrapper" v-model="visitDialogVisible" :title="TEXT.modules.subjectDetail.dialogVisit" width="560px"> <el-dialog v-if="visitDialogVisible" append-to=".layout-main .content-wrapper" v-model="visitDialogVisible" :title="TEXT.modules.subjectDetail.dialogVisit" width="560px">
<el-form :model="visitForm" label-width="100px"> <el-form :model="visitForm" label-width="100px">
<template v-if="!visitEditingId"> <template v-if="!visitEditingId">
<el-form-item :label="TEXT.common.fields.visitCode" required> <el-form-item :label="TEXT.common.fields.visitCode" required>
@@ -344,7 +344,7 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px"> <el-dialog v-if="aeDialogVisible" append-to=".layout-main .content-wrapper" v-model="aeDialogVisible" :title="TEXT.modules.subjectDetail.dialogAe" width="560px">
<el-form :model="aeForm" label-width="100px"> <el-form :model="aeForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.title" required> <el-form-item :label="TEXT.common.fields.title" required>
<el-input v-model="aeForm.term" /> <el-input v-model="aeForm.term" />
@@ -383,7 +383,7 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog append-to=".layout-main .content-wrapper" v-model="pdDialogVisible" :title="TEXT.modules.subjectDetail.dialogPd" width="560px"> <el-dialog v-if="pdDialogVisible" append-to=".layout-main .content-wrapper" v-model="pdDialogVisible" :title="TEXT.modules.subjectDetail.dialogPd" width="560px">
<el-form :model="pdForm" label-width="100px"> <el-form :model="pdForm" label-width="100px">
<el-form-item :label="TEXT.common.fields.pdNo"> <el-form-item :label="TEXT.common.fields.pdNo">
<el-input v-model="pdForm.pd_no" disabled :placeholder="TEXT.modules.subjectDetail.pdNoAuto" /> <el-input v-model="pdForm.pd_no" disabled :placeholder="TEXT.modules.subjectDetail.pdNoAuto" />
+18 -1
View File
@@ -2,7 +2,24 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
{
name: "sanitize-legacy-css",
generateBundle(_, bundle) {
for (const asset of Object.values(bundle)) {
if (asset.type !== "asset" || !asset.fileName.endsWith(".css") || typeof asset.source !== "string") {
continue;
}
asset.source = asset.source
.replace(/\.el-button::\-moz-focus-inner\{border:0\}/g, "")
.replace(/\.el-input__inner\[type=password\]::\-ms-reveal\{display:none\}/g, "")
.replace(/filter:alpha\(opacity=0\);/g, "")
.replace(/[^{}]*::\-webkit-scrollbar[^{}]*\{[^{}]*\}/g, "");
}
},
},
],
server: { server: {
host: true, host: true,
port: 5173, port: 5173,