新增空闲锁屏、修复401等问题,新增文件版本管理、共享库等占位符

This commit is contained in:
Cheng Zhou
2026-01-13 13:59:07 +08:00
parent b3cd8e03f2
commit 0c7c03069a
21 changed files with 829 additions and 40 deletions
+81 -1
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@@ -5,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path from pathlib import Path
import uuid import uuid
from app.core.security import create_access_token, verify_password from app.core.config import settings
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import UserRole, UserStatus from app.models.user import UserRole, UserStatus
@@ -18,6 +20,21 @@ class LoginRequest(BaseModel):
password: str = Field(min_length=1) password: str = Field(min_length=1)
class ExtendResponse(BaseModel):
accessToken: str
expiresAt: datetime
class UnlockRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
class UnlockResponse(BaseModel):
accessToken: str
expiresAt: datetime
router = APIRouter() router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True) AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@@ -58,10 +75,12 @@ async def login_for_access_token(
detail="账号未审核或不可用", detail="账号未审核或不可用",
) )
session_start = datetime.now(timezone.utc)
access_token = create_access_token( access_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None, expires_minutes=None,
session_start=session_start,
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@@ -71,6 +90,67 @@ async def read_me(current_user=Depends(get_current_user)) -> UserRead:
return current_user return current_user
@router.post("/extend", response_model=ExtendResponse)
async def extend_access_token(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db_session),
) -> ExtendResponse:
payload = decode_token_allow_expired(token)
exp = payload.get("exp")
if not exp:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期")
now_ts = int(datetime.now(timezone.utc).timestamp())
if now_ts > int(exp) + settings.JWT_EXTEND_GRACE_SECONDS:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无法验证登录凭据")
db_user = await user_crud.get_by_id(db, uuid.UUID(str(user_id)))
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat")
if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else:
session_start = datetime.now(timezone.utc)
new_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.post("/unlock", response_model=UnlockResponse)
async def unlock_session(
payload: UnlockRequest,
db: AsyncSession = Depends(get_db_session),
) -> UnlockResponse:
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在")
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return UnlockResponse(accessToken=access_token, expiresAt=expires_at)
@router.patch("/me", response_model=UserRead) @router.patch("/me", response_model=UserRead)
async def update_me( async def update_me(
payload: UserSelfUpdate, payload: UserSelfUpdate,
+2
View File
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
ENV: Literal["development", "production", "test"] = "development" ENV: Literal["development", "production", "test"] = "development"
JWT_SECRET_KEY: str = "dev-secret" JWT_SECRET_KEY: str = "dev-secret"
JWT_EXPIRE_MINUTES: int = 60 JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8
@lru_cache @lru_cache
+7 -2
View File
@@ -33,12 +33,17 @@ async def get_current_user(
) from exc ) from exc
user = await user_crud.get_by_id(db, uuid.UUID(str(token_data.sub))) user = await user_crud.get_by_id(db, uuid.UUID(str(token_data.sub)))
if not user or not user.is_active: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在或已停用", detail="账号不存在",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已停用",
)
return user return user
+34 -5
View File
@@ -14,11 +14,23 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name="Bearer") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name="Bearer")
def create_access_token(*, user_id: str, role: str, expires_minutes: Optional[int] = None) -> str: def create_access_token(
expire = datetime.now(timezone.utc) + timedelta( *,
minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES user_id: str,
) role: str,
to_encode: Dict[str, Any] = {"sub": user_id, "role": role, "exp": expire} expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None,
) -> str:
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
session_start_time = session_start or now
to_encode: Dict[str, Any] = {
"sub": user_id,
"role": role,
"exp": expire,
"iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()),
}
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
@@ -34,6 +46,23 @@ def decode_token(token: str) -> Dict[str, Any]:
return payload return payload
def decode_token_allow_expired(token: str) -> Dict[str, Any]:
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[ALGORITHM],
options={"verify_exp": False},
)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证登录凭据",
headers={"WWW-Authenticate": "Bearer"},
) from exc
return payload
def hash_password(plain_password: str) -> str: def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password) return pwd_context.hash(plain_password)
+31
View File
@@ -0,0 +1,31 @@
# Auth Session Acceptance Checklist
1) Active user 60 minutes without logout
- 登录后持续操作 60 分钟,观察 token 多次 /auth/extend 且不会跳登录
2) Auto-extend near expiry
- 人工缩短 token 有效期或等待 exp < 2min
- 保持操作,确认 /auth/extend 被调用且业务不中断
3) Single-flight extend
- 同时触发 10 个 API 请求
- 仅出现 1 次 /auth/extend,其余请求自动重放成功
4) Idle lock at 30 minutes
- 30 分钟无鼠标/键盘/触摸/滚动且无任何请求
- 出现锁屏,背景虚化,原页面状态保留
5) Unlock success
- 锁屏输入正确密码,解锁成功并继续原页面
6) Unlock failure threshold
- 连续错误密码 >= 5 次
- 强制登出并跳登录页
7) Extend failure to lock
- 人为让 token 彻底过期,/auth/extend 返回 401
- 不跳登录,进入锁屏并要求密码解锁
8) 403 permission vs disabled
- 权限不足接口返回 403 时不跳登录
- /auth/extend 或 /auth/unlock 返回 403 时强制登出跳登录
+24 -1
View File
@@ -1,4 +1,27 @@
<template> <template>
<router-view /> <div class="app-shell" :class="{ 'app-locked': session.locked }">
<router-view />
<LockScreenModal v-if="session.locked" />
</div>
<!-- TODO: 预留聚合接口页面/study/{id}/overview /subjects/{id}/detail /sites/{id}/summary --> <!-- TODO: 预留聚合接口页面/study/{id}/overview /subjects/{id}/detail /sites/{id}/summary -->
</template> </template>
<script setup lang="ts">
import { useSessionStore } from "./store/session";
import { initSessionManager } from "./session/sessionManager";
import LockScreenModal from "./components/LockScreenModal.vue";
initSessionManager();
const session = useSessionStore();
</script>
<style scoped>
.app-shell {
min-height: 100vh;
}
.app-locked {
pointer-events: none;
user-select: none;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import axios from "axios";
import type { AxiosResponse } from "axios";
const authClient = axios.create({
baseURL: "/",
timeout: 15000,
});
export type ExtendResponse = {
accessToken: string;
expiresAt: string;
};
export type UnlockResponse = {
accessToken: string;
expiresAt: string;
};
export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse>> =>
authClient.post<ExtendResponse>(
"/api/v1/auth/extend",
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
export const unlockSession = (payload: { email: string; password: string }): Promise<AxiosResponse<UnlockResponse>> =>
authClient.post<UnlockResponse>("/api/v1/auth/unlock", payload);
export default authClient;
+59 -25
View File
@@ -2,51 +2,71 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } f
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import router from "../router"; import router from "../router";
import { getToken } from "../utils/auth"; import { getToken } from "../utils/auth";
import { useAuthStore } from "../store/auth";
import type { ApiError } from "../types/api"; import type { ApiError } from "../types/api";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { extendAccessToken, forceLogout, lockSession, markNetworkActive } from "../session/sessionManager";
let unauthorizedNotified = false; import { useSessionStore } from "../store/session";
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: "/", baseURL: "/",
timeout: 15000, timeout: 15000,
}); });
instance.interceptors.request.use((config: AxiosRequestConfig) => { export type ApiRequestConfig = AxiosRequestConfig & {
ignoreIdle?: boolean;
allowWhileLocked?: boolean;
_retry?: boolean;
};
class LockedError extends Error {
constructor() {
super("LOCKED");
this.name = "LockedError";
}
}
instance.interceptors.request.use((config: ApiRequestConfig) => {
const session = useSessionStore();
const reqUrl = config.url || "";
const allowWhileLocked =
config.allowWhileLocked ||
reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/unlock");
if (session.locked && !allowWhileLocked) {
return Promise.reject(new LockedError());
}
const token = getToken(); const token = getToken();
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
if (!config.ignoreIdle) {
markNetworkActive();
}
return config; return config;
}); });
instance.interceptors.response.use( instance.interceptors.response.use(
(response: AxiosResponse) => response, (response: AxiosResponse) => response,
async (error: AxiosError<ApiError>) => { async (error: AxiosError<ApiError>) => {
if (error instanceof LockedError) {
ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作");
return Promise.reject(error);
}
const status = error.response?.status; const status = error.response?.status;
const data = error.response?.data; const data = error.response?.data;
const reqUrl = error.config?.url || ""; const reqUrl = error.config?.url || "";
const isAuthEndpoint = reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/register"); const isAuthEndpoint =
reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/extend") ||
reqUrl.includes("/api/v1/auth/unlock");
if (isAuthEndpoint) { if (isAuthEndpoint) {
// 认证相关的错误由具体页面自行处理,避免重复提示 // 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error); return Promise.reject(error);
} }
// 401 统一处理:同步清理本地 token,并防止重复弹窗
const handleUnauthorized = () => {
const auth = useAuthStore();
auth.logout();
if (!unauthorizedNotified) {
unauthorizedNotified = true;
ElMessage.error(data?.message || TEXT.common.messages.sessionExpired);
setTimeout(() => {
unauthorizedNotified = false;
}, 1000);
}
router.push("/login");
};
// 如果当前项目不存在或已失效,清理项目并跳到工作台 // 如果当前项目不存在或已失效,清理项目并跳到工作台
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) { if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
try { try {
@@ -58,9 +78,23 @@ instance.interceptors.response.use(
router.push("/workbench"); router.push("/workbench");
} }
if (status === 401) { if (status === 401) {
handleUnauthorized(); const config = error.config as ApiRequestConfig;
} else if (data?.message) { if (config && !config._retry) {
ElMessage.error(data.message); const result = await extendAccessToken("response-401");
if (result.token) {
config._retry = true;
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${result.token}`;
return instance(config);
}
if (result.authFailed) {
lockSession("token_invalid");
}
}
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
forceLogout();
} else if (data?.message || (data as any)?.detail) {
ElMessage.error((data as any)?.message || (data as any)?.detail);
} else { } else {
ElMessage.error(TEXT.common.messages.requestFailed); ElMessage.error(TEXT.common.messages.requestFailed);
} }
@@ -68,12 +102,12 @@ instance.interceptors.response.use(
} }
); );
export const apiGet = <T = unknown>(url: string, config?: AxiosRequestConfig) => instance.get<T>(url, config); export const apiGet = <T = unknown>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
export const apiPost = <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => export const apiPost = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.post<T>(url, data, config); instance.post<T>(url, data, config);
export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.patch<T>(url, data, config); instance.patch<T>(url, data, config);
export const apiDelete = <T = unknown>(url: string, config?: AxiosRequestConfig) => export const apiDelete = <T = unknown>(url: string, config?: ApiRequestConfig) =>
instance.delete<T>(url, config); instance.delete<T>(url, config);
export default instance; export default instance;
+11 -2
View File
@@ -54,6 +54,10 @@
</template> </template>
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item> <el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/file-versions">
<el-icon><Files /></el-icon>
<span>{{ TEXT.menu.fileVersionManagement }}</span>
</el-menu-item>
<el-menu-item index="/startup/feasibility-ethics"> <el-menu-item index="/startup/feasibility-ethics">
<el-icon><Calendar /></el-icon> <el-icon><Calendar /></el-icon>
<span>{{ TEXT.menu.startupFeasibilityEthics }}</span> <span>{{ TEXT.menu.startupFeasibilityEthics }}</span>
@@ -77,10 +81,12 @@
<el-sub-menu index="knowledge"> <el-sub-menu index="knowledge">
<template #title> <template #title>
<el-icon><Notebook /></el-icon> <el-icon><Notebook /></el-icon>
<span>{{ TEXT.menu.knowledge }}</span> <span>{{ TEXT.menu.sharedLibrary }}</span>
</template> </template>
<el-menu-item index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item> <el-menu-item index="/knowledge/medical-consult">{{ TEXT.menu.knowledgeMedicalConsult }}</el-menu-item>
<el-menu-item index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</el-menu-item> <el-menu-item index="/knowledge/notes">{{ TEXT.menu.knowledgeNotes }}</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-sub-menu> </el-sub-menu>
</template> </template>
</el-menu> </el-menu>
@@ -151,7 +157,7 @@ import StudySelector from "./StudySelector.vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { import {
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound, Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
const auth = useAuthStore(); const auth = useAuthStore();
@@ -167,6 +173,7 @@ const activeMenu = computed(() => {
if (path.startsWith("/finance/contracts")) return "/finance/contracts"; if (path.startsWith("/finance/contracts")) return "/finance/contracts";
if (path.startsWith("/finance/special")) return "/finance/special"; if (path.startsWith("/finance/special")) return "/finance/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments"; if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/file-versions")) return "/file-versions";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics"; if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) { if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
return "/startup/meeting-auth"; return "/startup/meeting-auth";
@@ -176,6 +183,8 @@ const activeMenu = computed(() => {
if (path.startsWith("/audit")) return "/audit"; if (path.startsWith("/audit")) return "/audit";
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult"; if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
if (path.startsWith("/knowledge/notes")) return "/knowledge/notes"; if (path.startsWith("/knowledge/notes")) return "/knowledge/notes";
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
if (path.startsWith("/projects/")) return "/admin/projects"; if (path.startsWith("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects"; if (path.startsWith("/admin/projects/")) return "/admin/projects";
return path; return path;
+149
View File
@@ -0,0 +1,149 @@
<template>
<teleport to="body">
<div class="lock-screen" role="dialog" aria-modal="true">
<div class="lock-backdrop" />
<div class="lock-panel">
<h2 class="lock-title">已锁定</h2>
<p class="lock-desc">30 分钟无操作请输入密码解锁</p>
<el-input
ref="passwordInput"
v-model="password"
type="password"
placeholder="请输入登录密码"
show-password
@keyup.enter="onUnlock"
/>
<div class="lock-actions">
<el-button type="primary" :loading="loading" @click="onUnlock">解锁</el-button>
<el-button @click="onLogout">退出登录</el-button>
</div>
<p v-if="errorMessage" class="lock-error">{{ errorMessage }}</p>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { UNLOCK_MAX_ATTEMPTS, unlockWithPassword, forceLogout } from "../session/sessionManager";
const auth = useAuthStore();
const session = useSessionStore();
const password = ref("");
const loading = ref(false);
const errorMessage = ref("");
const passwordInput = ref<{ focus?: () => void } | null>(null);
const focusPassword = () => {
nextTick(() => {
passwordInput.value?.focus?.();
});
};
const onUnlock = async () => {
if (loading.value) return;
errorMessage.value = "";
const email = auth.user?.email;
if (!email) {
ElMessage.error("无法获取用户信息,请重新登录");
forceLogout();
return;
}
if (!password.value) {
errorMessage.value = "请输入密码";
focusPassword();
return;
}
loading.value = true;
try {
await unlockWithPassword(email, password.value);
password.value = "";
} catch (err: any) {
session.incrementUnlockAttempt();
const message = err?.response?.data?.detail || err?.response?.data?.message || "密码错误";
errorMessage.value = message;
if (session.unlockAttempts >= UNLOCK_MAX_ATTEMPTS) {
ElMessage.error("错误次数过多,请重新登录");
forceLogout();
}
} finally {
loading.value = false;
focusPassword();
}
};
const onLogout = () => {
forceLogout();
};
onMounted(() => {
focusPassword();
});
watch(
() => session.locked,
(value) => {
if (value) {
focusPassword();
}
}
);
</script>
<style scoped>
.lock-screen {
position: fixed;
inset: 0;
z-index: 2000;
display: grid;
place-items: center;
}
.lock-backdrop {
position: absolute;
inset: 0;
background: rgba(7, 10, 20, 0.35);
backdrop-filter: blur(10px);
}
.lock-panel {
position: relative;
z-index: 1;
width: min(420px, 92vw);
background: #ffffff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 18px 48px rgba(15, 20, 35, 0.2);
display: flex;
flex-direction: column;
gap: 16px;
}
.lock-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #0f172a;
}
.lock-desc {
margin: 0;
color: #64748b;
font-size: 14px;
}
.lock-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.lock-error {
margin: 0;
color: #d8342c;
font-size: 13px;
}
</style>
+23 -1
View File
@@ -49,6 +49,7 @@ export const TEXT = {
noPermission: "无权限执行该操作", noPermission: "无权限执行该操作",
networkError: "网络错误,请稍后重试", networkError: "网络错误,请稍后重试",
fileTooLarge: "文件大小不能超过", fileTooLarge: "文件大小不能超过",
sessionLocked: "请解锁后继续操作",
sessionExpired: "登录已过期,请重新登录", sessionExpired: "登录已过期,请重新登录",
requestFailed: "请求失败,请稍后重试", requestFailed: "请求失败,请稍后重试",
invalidStateAction: "当前状态不允许该操作", invalidStateAction: "当前状态不允许该操作",
@@ -238,14 +239,17 @@ export const TEXT = {
financeSpecials: "特殊费用", financeSpecials: "特殊费用",
drug: "药品管理", drug: "药品管理",
drugShipments: "运输/流向", drugShipments: "运输/流向",
fileVersionManagement: "文件版本管理",
startupFeasibilityEthics: "立项与伦理", startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权", startupMeetingAuth: "启动与授权",
subjects: "参与者管理", subjects: "参与者管理",
monitoring: "监查", monitoring: "监查",
audit: "稽查", audit: "稽查",
knowledge: "知识库", sharedLibrary: "共享库",
knowledgeMedicalConsult: "医学咨询", knowledgeMedicalConsult: "医学咨询",
knowledgeNotes: "注意事项", knowledgeNotes: "注意事项",
knowledgeSupportFiles: "支持性文件",
knowledgeInstructionFiles: "说明文件",
profile: "个人中心", profile: "个人中心",
logout: "退出登录", logout: "退出登录",
}, },
@@ -329,6 +333,12 @@ export const TEXT = {
detailTitle: "运输记录详情", detailTitle: "运输记录详情",
detailSubtitle: "查看运输/流向信息与附件", detailSubtitle: "查看运输/流向信息与附件",
}, },
fileVersionManagement: {
title: "文件版本管理",
subtitle: "功能建设中",
listTitle: "文件版本列表",
emptyDescription: "文件版本管理正在搭建基础能力,敬请期待。",
},
startupFeasibilityEthics: { startupFeasibilityEthics: {
title: "立项与伦理", title: "立项与伦理",
subtitle: "立项与伦理记录管理", subtitle: "立项与伦理记录管理",
@@ -444,6 +454,18 @@ export const TEXT = {
detailTitle: "注意事项详情", detailTitle: "注意事项详情",
detailSubtitle: "查看注意事项与附件", detailSubtitle: "查看注意事项与附件",
}, },
knowledgeSupportFiles: {
title: "支持性文件",
subtitle: "功能建设中",
listTitle: "支持性文件列表",
emptyDescription: "支持性文件功能正在搭建基础能力,敬请期待。",
},
knowledgeInstructionFiles: {
title: "说明文件",
subtitle: "功能建设中",
listTitle: "说明文件列表",
emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
},
monitoring: { monitoring: {
title: "监查", title: "监查",
subtitle: "功能建设中", subtitle: "功能建设中",
+22 -2
View File
@@ -20,6 +20,7 @@ import ProjectOverview from "../views/ia/ProjectOverview.vue";
import FinanceContracts from "../views/ia/FinanceContracts.vue"; import FinanceContracts from "../views/ia/FinanceContracts.vue";
import FinanceSpecial from "../views/ia/FinanceSpecial.vue"; import FinanceSpecial from "../views/ia/FinanceSpecial.vue";
import DrugShipments from "../views/ia/DrugShipments.vue"; import DrugShipments from "../views/ia/DrugShipments.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue"; import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue"; import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.vue";
import SubjectManagement from "../views/ia/SubjectManagement.vue"; import SubjectManagement from "../views/ia/SubjectManagement.vue";
@@ -27,6 +28,8 @@ import MonitoringPlaceholder from "../views/ia/MonitoringPlaceholder.vue";
import AuditPlaceholder from "../views/ia/AuditPlaceholder.vue"; import AuditPlaceholder from "../views/ia/AuditPlaceholder.vue";
import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue"; import KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.vue";
import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue"; import KnowledgeNotes from "../views/ia/KnowledgeNotes.vue";
import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import ContractForm from "../views/finance/ContractForm.vue"; import ContractForm from "../views/finance/ContractForm.vue";
import ContractDetail from "../views/finance/ContractDetail.vue"; import ContractDetail from "../views/finance/ContractDetail.vue";
import SpecialForm from "../views/finance/SpecialForm.vue"; import SpecialForm from "../views/finance/SpecialForm.vue";
@@ -161,6 +164,12 @@ const routes: RouteRecordRaw[] = [
component: ShipmentForm, component: ShipmentForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.drugShipments, requiresStudy: true },
}, },
{
path: "file-versions",
name: "FileVersionManagement",
component: FileVersionManagement,
meta: { title: TEXT.menu.fileVersionManagement, requiresStudy: true },
},
{ {
path: "startup/feasibility-ethics", path: "startup/feasibility-ethics",
name: "StartupFeasibilityEthics", name: "StartupFeasibilityEthics",
@@ -317,6 +326,18 @@ const routes: RouteRecordRaw[] = [
component: NoteForm, component: NoteForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true }, meta: { title: TEXT.common.actions.edit + TEXT.menu.knowledgeNotes, requiresStudy: true },
}, },
{
path: "knowledge/support-files",
name: "KnowledgeSupportFiles",
component: KnowledgeSupportFiles,
meta: { title: TEXT.menu.knowledgeSupportFiles, requiresStudy: true },
},
{
path: "knowledge/instruction-files",
name: "KnowledgeInstructionFiles",
component: KnowledgeInstructionFiles,
meta: { title: TEXT.menu.knowledgeInstructionFiles, requiresStudy: true },
},
], ],
}, },
{ {
@@ -383,8 +404,7 @@ router.beforeEach(async (to, _from, next) => {
try { try {
await auth.fetchMe(); await auth.fetchMe();
} catch { } catch {
// token 失效或用户不可用时立即登出,避免重复 401 // 由统一的认证处理流程处理 401/403
auth.logout();
} }
} }
const token = getToken(); const token = getToken();
+13
View File
@@ -0,0 +1,13 @@
export const parseJwtExp = (token: string): number | null => {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
if (typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
/* ignore */
}
return null;
};
+212
View File
@@ -0,0 +1,212 @@
import { ElMessage } from "element-plus";
import router from "../router";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { getToken, setToken, clearToken } from "../utils/auth";
import { extendToken, unlockSession } from "../api/authClient";
import { parseJwtExp } from "./jwt";
export const IDLE_TIMEOUT_MINUTES = 30;
export const EXTEND_EARLY_SECONDS = 120;
export const EXTEND_MIN_INTERVAL_SECONDS = 60;
export const UNLOCK_MAX_ATTEMPTS = 5;
type BroadcastMessage =
| { type: "ACTIVE"; at: number }
| { type: "NETWORK"; at: number }
| { type: "LOCK"; reason: string }
| { type: "TOKEN_UPDATED"; token: string }
| { type: "LOGOUT" };
let idleTimer: number | null = null;
let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null = null;
let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const broadcast = (message: BroadcastMessage) => {
if (channel) {
channel.postMessage(message);
}
try {
localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() }));
} catch {
/* ignore */
}
};
const handleBroadcast = (message: BroadcastMessage) => {
const session = useSessionStore();
const auth = useAuthStore();
if (message.type === "ACTIVE") {
session.recordUserActivity(message.at);
scheduleIdleCheck();
}
if (message.type === "NETWORK") {
session.recordNetworkActivity(message.at);
scheduleIdleCheck();
}
if (message.type === "LOCK") {
const reason = message.reason as "idle" | "extend_failed" | "token_invalid";
session.lock(reason || "idle");
}
if (message.type === "TOKEN_UPDATED") {
auth.setToken(message.token);
setToken(message.token);
session.unlock();
}
if (message.type === "LOGOUT") {
forceLogout();
}
};
const scheduleIdleCheck = () => {
if (idleTimer) {
window.clearTimeout(idleTimer);
}
const session = useSessionStore();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const nextCheck =
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + timeoutMs - Date.now();
if (nextCheck <= 0) {
checkIdle();
return;
}
idleTimer = window.setTimeout(checkIdle, nextCheck);
};
const checkIdle = () => {
const session = useSessionStore();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const now = Date.now();
if (now - session.lastUserActiveAt > timeoutMs && now - session.lastNetworkActiveAt > timeoutMs) {
lockSession("idle");
return;
}
scheduleIdleCheck();
};
export const initSessionManager = () => {
if (initialized) return;
initialized = true;
const events = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"];
let lastSignal = 0;
const onUserActivity = () => {
const now = Date.now();
if (now - lastSignal < 1000) return;
lastSignal = now;
markUserActive(now);
};
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
if (channel) {
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
}
window.addEventListener("storage", (event) => {
if (event.key !== "ctms_auth_broadcast" || !event.newValue) return;
try {
const message = JSON.parse(event.newValue) as BroadcastMessage;
handleBroadcast(message);
} catch {
/* ignore */
}
});
scheduleIdleCheck();
startTokenKeepAlive();
};
export const markUserActive = (at: number = Date.now()) => {
const session = useSessionStore();
session.recordUserActivity(at);
broadcast({ type: "ACTIVE", at });
scheduleIdleCheck();
};
export const markNetworkActive = (at: number = Date.now()) => {
const session = useSessionStore();
session.recordNetworkActivity(at);
broadcast({ type: "NETWORK", at });
scheduleIdleCheck();
};
export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") => {
const session = useSessionStore();
if (session.locked) return;
session.lock(reason);
broadcast({ type: "LOCK", reason });
};
export const forceLogout = () => {
const auth = useAuthStore();
const session = useSessionStore();
auth.logout();
session.unlock();
clearToken();
broadcast({ type: "LOGOUT" });
router.replace("/login");
};
const updateToken = (token: string) => {
const auth = useAuthStore();
auth.setToken(token);
setToken(token);
broadcast({ type: "TOKEN_UPDATED", token });
};
export const extendAccessToken = async (reason: "early" | "response-401") => {
const session = useSessionStore();
const token = getToken();
if (!token) return { token: null, authFailed: false };
if (reason === "early") {
const now = Date.now();
if (now - session.lastExtendAt < EXTEND_MIN_INTERVAL_SECONDS * 1000) {
return { token: null, authFailed: false };
}
}
if (extendPromise) return extendPromise;
extendPromise = (async () => {
let authFailed = false;
try {
const { data } = await extendToken(token);
session.setLastExtendAt(Date.now());
updateToken(data.accessToken);
return { token: data.accessToken, authFailed: false };
} catch (err: any) {
const status = err?.response?.status;
if (status === 401) {
lockSession("extend_failed");
authFailed = true;
} else if (status === 403) {
forceLogout();
authFailed = true;
}
return { token: null, authFailed };
} finally {
extendPromise = null;
}
})();
return extendPromise;
};
export const startTokenKeepAlive = () => {
window.setInterval(() => {
const token = getToken();
if (!token) return;
const session = useSessionStore();
if (session.locked) return;
const expAt = parseJwtExp(token);
if (!expAt) return;
const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const active = Date.now() - session.lastUserActiveAt < timeoutMs || Date.now() - session.lastNetworkActiveAt < timeoutMs;
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
void extendAccessToken("early");
}
}, 10000);
};
export const unlockWithPassword = async (email: string, password: string) => {
const session = useSessionStore();
const { data } = await unlockSession({ email, password });
updateToken(data.accessToken);
session.unlock();
markUserActive();
};
+3
View File
@@ -47,6 +47,9 @@ export const useAuthStore = defineStore("auth", () => {
login, login,
fetchMe: fetchMeAction, fetchMe: fetchMeAction,
logout, logout,
setToken: (newToken: string) => {
token.value = newToken;
},
requestReLogin: () => { requestReLogin: () => {
forceLogin.value = true; forceLogin.value = true;
}, },
+55
View File
@@ -0,0 +1,55 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export type LockReason = "idle" | "extend_failed" | "token_invalid";
export const useSessionStore = defineStore("session", () => {
const locked = ref(false);
const lockReason = ref<LockReason | null>(null);
const unlockAttempts = ref(0);
const lastUserActiveAt = ref(Date.now());
const lastNetworkActiveAt = ref(Date.now());
const lastExtendAt = ref(0);
const recordUserActivity = (ts: number = Date.now()) => {
lastUserActiveAt.value = ts;
};
const recordNetworkActivity = (ts: number = Date.now()) => {
lastNetworkActiveAt.value = ts;
};
const lock = (reason: LockReason) => {
locked.value = true;
lockReason.value = reason;
};
const unlock = () => {
locked.value = false;
lockReason.value = null;
unlockAttempts.value = 0;
};
const incrementUnlockAttempt = () => {
unlockAttempts.value += 1;
};
const setLastExtendAt = (ts: number) => {
lastExtendAt.value = ts;
};
return {
locked,
lockReason,
unlockAttempts,
lastUserActiveAt,
lastNetworkActiveAt,
lastExtendAt,
recordUserActivity,
recordNetworkActivity,
lock,
unlock,
incrementUnlockAttempt,
setLastExtendAt,
};
});
@@ -0,0 +1,13 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.fileVersionManagement.title"
:subtitle="TEXT.modules.fileVersionManagement.subtitle"
:list-title="TEXT.modules.fileVersionManagement.listTitle"
:empty-description="TEXT.modules.fileVersionManagement.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -0,0 +1,13 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.knowledgeInstructionFiles.title"
:subtitle="TEXT.modules.knowledgeInstructionFiles.subtitle"
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -0,0 +1,13 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.knowledgeSupportFiles.title"
:subtitle="TEXT.modules.knowledgeSupportFiles.subtitle"
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -1,14 +1,18 @@
export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED"; export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED";
export type StageKey = export type StageKey =
| "institution_initiation_status"
| "ethics_status" | "ethics_status"
| "contract_sign_status"
| "startup_status" | "startup_status"
| "enrollment_status" | "enrollment_status"
| "inspection_status" | "inspection_status"
| "closeout_status"; | "closeout_status";
export type StageCompletionKey = export type StageCompletionKey =
| "institution_initiation_completed_at"
| "ethics_completed_at" | "ethics_completed_at"
| "contract_sign_completed_at"
| "startup_completed_at" | "startup_completed_at"
| "enrollment_completed_at" | "enrollment_completed_at"
| "inspection_completed_at" | "inspection_completed_at"
@@ -17,14 +21,18 @@ export type StageCompletionKey =
export interface CenterOverview { export interface CenterOverview {
center_id: string; center_id: string;
center_name: string; center_name: string;
institution_initiation_status: StageStatus;
ethics_status: StageStatus; ethics_status: StageStatus;
contract_sign_status: StageStatus;
startup_status: StageStatus; startup_status: StageStatus;
enrollment_status: StageStatus; enrollment_status: StageStatus;
inspection_status: StageStatus; inspection_status: StageStatus;
closeout_status: StageStatus; closeout_status: StageStatus;
enrollment_target: number; enrollment_target: number;
enrollment_actual: number; enrollment_actual: number;
institution_initiation_completed_at?: string;
ethics_completed_at?: string; ethics_completed_at?: string;
contract_sign_completed_at?: string;
startup_completed_at?: string; startup_completed_at?: string;
enrollment_completed_at?: string; enrollment_completed_at?: string;
inspection_completed_at?: string; inspection_completed_at?: string;
@@ -59,10 +67,12 @@ export const STAGE_ORDER: Array<{
label: string; label: string;
completedKey: StageCompletionKey; completedKey: StageCompletionKey;
}> = [ }> = [
{ key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_completed_at" },
{ key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" }, { key: "ethics_status", label: "伦理审批", completedKey: "ethics_completed_at" },
{ key: "contract_sign_status", label: "合同签署", completedKey: "contract_sign_completed_at" },
{ key: "startup_status", label: "启动", completedKey: "startup_completed_at" }, { key: "startup_status", label: "启动", completedKey: "startup_completed_at" },
{ key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" }, { key: "enrollment_status", label: "入组", completedKey: "enrollment_completed_at" },
{ key: "inspection_status", label: "稽查", completedKey: "inspection_completed_at" }, { key: "inspection_status", label: "末次稽查", completedKey: "inspection_completed_at" },
{ key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" }, { key: "closeout_status", label: "关中心", completedKey: "closeout_completed_at" },
]; ];
@@ -104,14 +114,18 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
return { return {
center_id: toString((center as any).center_id || (center as any).id), center_id: toString((center as any).center_id || (center as any).id),
center_name: toString((center as any).center_name || (center as any).name), center_name: toString((center as any).center_name || (center as any).name),
institution_initiation_status: normalizeStatus((center as any).institution_initiation_status),
ethics_status: normalizeStatus((center as any).ethics_status), ethics_status: normalizeStatus((center as any).ethics_status),
contract_sign_status: normalizeStatus((center as any).contract_sign_status),
startup_status: normalizeStatus((center as any).startup_status), startup_status: normalizeStatus((center as any).startup_status),
enrollment_status: enrollmentStatus, enrollment_status: enrollmentStatus,
inspection_status: normalizeStatus((center as any).inspection_status), inspection_status: normalizeStatus((center as any).inspection_status),
closeout_status: normalizeStatus((center as any).closeout_status), closeout_status: normalizeStatus((center as any).closeout_status),
enrollment_target: toNumber((center as any).enrollment_target), enrollment_target: toNumber((center as any).enrollment_target),
enrollment_actual: hasEnrollmentStage ? enrollmentActualRaw : 0, enrollment_actual: hasEnrollmentStage ? enrollmentActualRaw : 0,
institution_initiation_completed_at: toString((center as any).institution_initiation_completed_at),
ethics_completed_at: toString((center as any).ethics_completed_at), ethics_completed_at: toString((center as any).ethics_completed_at),
contract_sign_completed_at: toString((center as any).contract_sign_completed_at),
startup_completed_at: toString((center as any).startup_completed_at), startup_completed_at: toString((center as any).startup_completed_at),
enrollment_completed_at: toString((center as any).enrollment_completed_at), enrollment_completed_at: toString((center as any).enrollment_completed_at),
inspection_completed_at: toString((center as any).inspection_completed_at), inspection_completed_at: toString((center as any).inspection_completed_at),
@@ -9,8 +9,12 @@ export const overviewMock: ProjectOverviewResponse = {
{ {
center_id: "center-001", center_id: "center-001",
center_name: "中心01", center_name: "中心01",
institution_initiation_status: "COMPLETED",
institution_initiation_completed_at: "2024-12-20",
ethics_status: "COMPLETED", ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-12", ethics_completed_at: "2025-01-12",
contract_sign_status: "COMPLETED",
contract_sign_completed_at: "2025-01-20",
startup_status: "COMPLETED", startup_status: "COMPLETED",
startup_completed_at: "2025-02-03", startup_completed_at: "2025-02-03",
enrollment_status: "IN_PROGRESS", enrollment_status: "IN_PROGRESS",
@@ -22,8 +26,11 @@ export const overviewMock: ProjectOverviewResponse = {
{ {
center_id: "center-002", center_id: "center-002",
center_name: "中心02", center_name: "中心02",
institution_initiation_status: "COMPLETED",
institution_initiation_completed_at: "2025-01-05",
ethics_status: "COMPLETED", ethics_status: "COMPLETED",
ethics_completed_at: "2025-02-08", ethics_completed_at: "2025-02-08",
contract_sign_status: "IN_PROGRESS",
startup_status: "IN_PROGRESS", startup_status: "IN_PROGRESS",
enrollment_status: "NOT_STARTED", enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED", inspection_status: "NOT_STARTED",
@@ -34,8 +41,12 @@ export const overviewMock: ProjectOverviewResponse = {
{ {
center_id: "center-003", center_id: "center-003",
center_name: "中心03", center_name: "中心03",
institution_initiation_status: "COMPLETED",
institution_initiation_completed_at: "2024-12-10",
ethics_status: "COMPLETED", ethics_status: "COMPLETED",
ethics_completed_at: "2024-12-28", ethics_completed_at: "2024-12-28",
contract_sign_status: "COMPLETED",
contract_sign_completed_at: "2025-01-05",
startup_status: "COMPLETED", startup_status: "COMPLETED",
startup_completed_at: "2025-01-20", startup_completed_at: "2025-01-20",
enrollment_status: "COMPLETED", enrollment_status: "COMPLETED",
@@ -48,8 +59,12 @@ export const overviewMock: ProjectOverviewResponse = {
{ {
center_id: "center-004", center_id: "center-004",
center_name: "中心04", center_name: "中心04",
institution_initiation_status: "COMPLETED",
institution_initiation_completed_at: "2024-12-22",
ethics_status: "COMPLETED", ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-15", ethics_completed_at: "2025-01-15",
contract_sign_status: "COMPLETED",
contract_sign_completed_at: "2025-01-25",
startup_status: "BLOCKED", startup_status: "BLOCKED",
enrollment_status: "NOT_STARTED", enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED", inspection_status: "NOT_STARTED",
@@ -60,6 +75,7 @@ export const overviewMock: ProjectOverviewResponse = {
{ {
center_id: "center-005", center_id: "center-005",
center_name: "中心05", center_name: "中心05",
institution_initiation_status: "IN_PROGRESS",
ethics_status: "IN_PROGRESS", ethics_status: "IN_PROGRESS",
startup_status: "NOT_STARTED", startup_status: "NOT_STARTED",
enrollment_status: "NOT_STARTED", enrollment_status: "NOT_STARTED",