diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 259bc906..ed3eb31b 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
@@ -5,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
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.crud import user as user_crud
from app.models.user import UserRole, UserStatus
@@ -18,6 +20,21 @@ class LoginRequest(BaseModel):
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()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@@ -58,10 +75,12 @@ async def login_for_access_token(
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,
)
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
+@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)
async def update_me(
payload: UserSelfUpdate,
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index f2e450f4..93b751f0 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
ENV: Literal["development", "production", "test"] = "development"
JWT_SECRET_KEY: str = "dev-secret"
JWT_EXPIRE_MINUTES: int = 60
+ JWT_EXTEND_GRACE_SECONDS: int = 120
+ ABSOLUTE_SESSION_MAX_HOURS: int = 8
@lru_cache
diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py
index 3ff3a673..b0b65618 100644
--- a/backend/app/core/deps.py
+++ b/backend/app/core/deps.py
@@ -33,12 +33,17 @@ async def get_current_user(
) from exc
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(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail="账号不存在或已停用",
+ detail="账号不存在",
headers={"WWW-Authenticate": "Bearer"},
)
+ if not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="账号已停用",
+ )
return user
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index a6b3fa3c..8bd788ef 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -14,11 +14,23 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
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:
- expire = datetime.now(timezone.utc) + timedelta(
- minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES
- )
- to_encode: Dict[str, Any] = {"sub": user_id, "role": role, "exp": expire}
+def create_access_token(
+ *,
+ user_id: str,
+ role: str,
+ 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)
@@ -34,6 +46,23 @@ def decode_token(token: str) -> Dict[str, Any]:
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:
return pwd_context.hash(plain_password)
diff --git a/docs/auth-session-acceptance.md b/docs/auth-session-acceptance.md
new file mode 100644
index 00000000..7ff9d776
--- /dev/null
+++ b/docs/auth-session-acceptance.md
@@ -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 时强制登出跳登录
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 8ee2521b..38ddba9b 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,4 +1,27 @@
-
+
+
+
+
+
+
+
+
diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts
new file mode 100644
index 00000000..9c10975f
--- /dev/null
+++ b/frontend/src/api/authClient.ts
@@ -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> =>
+ authClient.post(
+ "/api/v1/auth/extend",
+ {},
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+
+export const unlockSession = (payload: { email: string; password: string }): Promise> =>
+ authClient.post("/api/v1/auth/unlock", payload);
+
+export default authClient;
diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts
index 37e0b412..7d5a29da 100644
--- a/frontend/src/api/axios.ts
+++ b/frontend/src/api/axios.ts
@@ -2,51 +2,71 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } f
import { ElMessage } from "element-plus";
import router from "../router";
import { getToken } from "../utils/auth";
-import { useAuthStore } from "../store/auth";
import type { ApiError } from "../types/api";
import { useStudyStore } from "../store/study";
import { TEXT } from "../locales";
-
-let unauthorizedNotified = false;
+import { extendAccessToken, forceLogout, lockSession, markNetworkActive } from "../session/sessionManager";
+import { useSessionStore } from "../store/session";
const instance: AxiosInstance = axios.create({
baseURL: "/",
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();
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
+ if (!config.ignoreIdle) {
+ markNetworkActive();
+ }
return config;
});
instance.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
+ if (error instanceof LockedError) {
+ ElMessage.warning(TEXT.common.messages.sessionLocked || "请解锁后继续操作");
+ return Promise.reject(error);
+ }
const status = error.response?.status;
const data = error.response?.data;
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) {
// 认证相关的错误由具体页面自行处理,避免重复提示
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")) {
try {
@@ -58,9 +78,23 @@ instance.interceptors.response.use(
router.push("/workbench");
}
if (status === 401) {
- handleUnauthorized();
- } else if (data?.message) {
- ElMessage.error(data.message);
+ const config = error.config as ApiRequestConfig;
+ if (config && !config._retry) {
+ 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 {
ElMessage.error(TEXT.common.messages.requestFailed);
}
@@ -68,12 +102,12 @@ instance.interceptors.response.use(
}
);
-export const apiGet = (url: string, config?: AxiosRequestConfig) => instance.get(url, config);
-export const apiPost = (url: string, data?: unknown, config?: AxiosRequestConfig) =>
+export const apiGet = (url: string, config?: ApiRequestConfig) => instance.get(url, config);
+export const apiPost = (url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.post(url, data, config);
-export const apiPatch = (url: string, data?: unknown, config?: AxiosRequestConfig) =>
+export const apiPatch = (url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.patch(url, data, config);
-export const apiDelete = (url: string, config?: AxiosRequestConfig) =>
+export const apiDelete = (url: string, config?: ApiRequestConfig) =>
instance.delete(url, config);
export default instance;
diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue
index 7a098852..2a7162af 100644
--- a/frontend/src/components/Layout.vue
+++ b/frontend/src/components/Layout.vue
@@ -54,6 +54,10 @@
{{ TEXT.menu.drugShipments }}
+
+
+ {{ TEXT.menu.fileVersionManagement }}
+
{{ TEXT.menu.startupFeasibilityEthics }}
@@ -77,10 +81,12 @@
- {{ TEXT.menu.knowledge }}
+ {{ TEXT.menu.sharedLibrary }}
{{ TEXT.menu.knowledgeMedicalConsult }}
{{ TEXT.menu.knowledgeNotes }}
+ {{ TEXT.menu.knowledgeSupportFiles }}
+ {{ TEXT.menu.knowledgeInstructionFiles }}
@@ -151,7 +157,7 @@ import StudySelector from "./StudySelector.vue";
import { TEXT } from "../locales";
import {
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";
const auth = useAuthStore();
@@ -167,6 +173,7 @@ const activeMenu = computed(() => {
if (path.startsWith("/finance/contracts")) return "/finance/contracts";
if (path.startsWith("/finance/special")) return "/finance/special";
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/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
return "/startup/meeting-auth";
@@ -176,6 +183,8 @@ const activeMenu = computed(() => {
if (path.startsWith("/audit")) return "/audit";
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
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("/admin/projects/")) return "/admin/projects";
return path;
diff --git a/frontend/src/components/LockScreenModal.vue b/frontend/src/components/LockScreenModal.vue
new file mode 100644
index 00000000..388efe94
--- /dev/null
+++ b/frontend/src/components/LockScreenModal.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
已锁定
+
30 分钟无操作,请输入密码解锁
+
+
+ 解锁
+ 退出登录
+
+
{{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts
index 00a20ded..6234b926 100644
--- a/frontend/src/locales/zh-CN.ts
+++ b/frontend/src/locales/zh-CN.ts
@@ -49,6 +49,7 @@ export const TEXT = {
noPermission: "无权限执行该操作",
networkError: "网络错误,请稍后重试",
fileTooLarge: "文件大小不能超过",
+ sessionLocked: "请解锁后继续操作",
sessionExpired: "登录已过期,请重新登录",
requestFailed: "请求失败,请稍后重试",
invalidStateAction: "当前状态不允许该操作",
@@ -238,14 +239,17 @@ export const TEXT = {
financeSpecials: "特殊费用",
drug: "药品管理",
drugShipments: "运输/流向",
+ fileVersionManagement: "文件版本管理",
startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权",
subjects: "参与者管理",
monitoring: "监查",
audit: "稽查",
- knowledge: "知识库",
+ sharedLibrary: "共享库",
knowledgeMedicalConsult: "医学咨询",
knowledgeNotes: "注意事项",
+ knowledgeSupportFiles: "支持性文件",
+ knowledgeInstructionFiles: "说明文件",
profile: "个人中心",
logout: "退出登录",
},
@@ -329,6 +333,12 @@ export const TEXT = {
detailTitle: "运输记录详情",
detailSubtitle: "查看运输/流向信息与附件",
},
+ fileVersionManagement: {
+ title: "文件版本管理",
+ subtitle: "功能建设中",
+ listTitle: "文件版本列表",
+ emptyDescription: "文件版本管理正在搭建基础能力,敬请期待。",
+ },
startupFeasibilityEthics: {
title: "立项与伦理",
subtitle: "立项与伦理记录管理",
@@ -444,6 +454,18 @@ export const TEXT = {
detailTitle: "注意事项详情",
detailSubtitle: "查看注意事项与附件",
},
+ knowledgeSupportFiles: {
+ title: "支持性文件",
+ subtitle: "功能建设中",
+ listTitle: "支持性文件列表",
+ emptyDescription: "支持性文件功能正在搭建基础能力,敬请期待。",
+ },
+ knowledgeInstructionFiles: {
+ title: "说明文件",
+ subtitle: "功能建设中",
+ listTitle: "说明文件列表",
+ emptyDescription: "说明文件功能正在搭建基础能力,敬请期待。",
+ },
monitoring: {
title: "监查",
subtitle: "功能建设中",
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 996fc893..7568b26b 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -20,6 +20,7 @@ import ProjectOverview from "../views/ia/ProjectOverview.vue";
import FinanceContracts from "../views/ia/FinanceContracts.vue";
import FinanceSpecial from "../views/ia/FinanceSpecial.vue";
import DrugShipments from "../views/ia/DrugShipments.vue";
+import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
import StartupMeetingAuth from "../views/ia/StartupMeetingAuth.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 KnowledgeMedicalConsult from "../views/ia/KnowledgeMedicalConsult.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 ContractDetail from "../views/finance/ContractDetail.vue";
import SpecialForm from "../views/finance/SpecialForm.vue";
@@ -161,6 +164,12 @@ const routes: RouteRecordRaw[] = [
component: ShipmentForm,
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",
name: "StartupFeasibilityEthics",
@@ -317,6 +326,18 @@ const routes: RouteRecordRaw[] = [
component: NoteForm,
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 {
await auth.fetchMe();
} catch {
- // token 失效或用户不可用时立即登出,避免重复 401
- auth.logout();
+ // 由统一的认证处理流程处理 401/403
}
}
const token = getToken();
diff --git a/frontend/src/session/jwt.ts b/frontend/src/session/jwt.ts
new file mode 100644
index 00000000..7647c099
--- /dev/null
+++ b/frontend/src/session/jwt.ts
@@ -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;
+};
diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts
new file mode 100644
index 00000000..94e096c5
--- /dev/null
+++ b/frontend/src/session/sessionManager.ts
@@ -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();
+};
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
index 1d33eb9c..3cee9f27 100644
--- a/frontend/src/store/auth.ts
+++ b/frontend/src/store/auth.ts
@@ -47,6 +47,9 @@ export const useAuthStore = defineStore("auth", () => {
login,
fetchMe: fetchMeAction,
logout,
+ setToken: (newToken: string) => {
+ token.value = newToken;
+ },
requestReLogin: () => {
forceLogin.value = true;
},
diff --git a/frontend/src/store/session.ts b/frontend/src/store/session.ts
new file mode 100644
index 00000000..650c388c
--- /dev/null
+++ b/frontend/src/store/session.ts
@@ -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(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,
+ };
+});
diff --git a/frontend/src/views/ia/FileVersionManagement.vue b/frontend/src/views/ia/FileVersionManagement.vue
new file mode 100644
index 00000000..4a7ca56a
--- /dev/null
+++ b/frontend/src/views/ia/FileVersionManagement.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/frontend/src/views/ia/KnowledgeInstructionFiles.vue b/frontend/src/views/ia/KnowledgeInstructionFiles.vue
new file mode 100644
index 00000000..5f231a56
--- /dev/null
+++ b/frontend/src/views/ia/KnowledgeInstructionFiles.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/frontend/src/views/ia/KnowledgeSupportFiles.vue b/frontend/src/views/ia/KnowledgeSupportFiles.vue
new file mode 100644
index 00000000..0deff42b
--- /dev/null
+++ b/frontend/src/views/ia/KnowledgeSupportFiles.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/frontend/src/views/ia/project-overview/overview.adapter.ts b/frontend/src/views/ia/project-overview/overview.adapter.ts
index 7836e48e..1231f696 100644
--- a/frontend/src/views/ia/project-overview/overview.adapter.ts
+++ b/frontend/src/views/ia/project-overview/overview.adapter.ts
@@ -1,14 +1,18 @@
export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED";
export type StageKey =
+ | "institution_initiation_status"
| "ethics_status"
+ | "contract_sign_status"
| "startup_status"
| "enrollment_status"
| "inspection_status"
| "closeout_status";
export type StageCompletionKey =
+ | "institution_initiation_completed_at"
| "ethics_completed_at"
+ | "contract_sign_completed_at"
| "startup_completed_at"
| "enrollment_completed_at"
| "inspection_completed_at"
@@ -17,14 +21,18 @@ export type StageCompletionKey =
export interface CenterOverview {
center_id: string;
center_name: string;
+ institution_initiation_status: StageStatus;
ethics_status: StageStatus;
+ contract_sign_status: StageStatus;
startup_status: StageStatus;
enrollment_status: StageStatus;
inspection_status: StageStatus;
closeout_status: StageStatus;
enrollment_target: number;
enrollment_actual: number;
+ institution_initiation_completed_at?: string;
ethics_completed_at?: string;
+ contract_sign_completed_at?: string;
startup_completed_at?: string;
enrollment_completed_at?: string;
inspection_completed_at?: string;
@@ -59,10 +67,12 @@ export const STAGE_ORDER: Array<{
label: string;
completedKey: StageCompletionKey;
}> = [
+ { key: "institution_initiation_status", label: "机构立项", completedKey: "institution_initiation_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: "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" },
];
@@ -104,14 +114,18 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
return {
center_id: toString((center as any).center_id || (center as any).id),
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),
+ contract_sign_status: normalizeStatus((center as any).contract_sign_status),
startup_status: normalizeStatus((center as any).startup_status),
enrollment_status: enrollmentStatus,
inspection_status: normalizeStatus((center as any).inspection_status),
closeout_status: normalizeStatus((center as any).closeout_status),
enrollment_target: toNumber((center as any).enrollment_target),
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),
+ contract_sign_completed_at: toString((center as any).contract_sign_completed_at),
startup_completed_at: toString((center as any).startup_completed_at),
enrollment_completed_at: toString((center as any).enrollment_completed_at),
inspection_completed_at: toString((center as any).inspection_completed_at),
diff --git a/frontend/src/views/ia/project-overview/overview.mock.ts b/frontend/src/views/ia/project-overview/overview.mock.ts
index fb8bc9d3..41b12d62 100644
--- a/frontend/src/views/ia/project-overview/overview.mock.ts
+++ b/frontend/src/views/ia/project-overview/overview.mock.ts
@@ -9,8 +9,12 @@ export const overviewMock: ProjectOverviewResponse = {
{
center_id: "center-001",
center_name: "中心01",
+ institution_initiation_status: "COMPLETED",
+ institution_initiation_completed_at: "2024-12-20",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-12",
+ contract_sign_status: "COMPLETED",
+ contract_sign_completed_at: "2025-01-20",
startup_status: "COMPLETED",
startup_completed_at: "2025-02-03",
enrollment_status: "IN_PROGRESS",
@@ -22,8 +26,11 @@ export const overviewMock: ProjectOverviewResponse = {
{
center_id: "center-002",
center_name: "中心02",
+ institution_initiation_status: "COMPLETED",
+ institution_initiation_completed_at: "2025-01-05",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-02-08",
+ contract_sign_status: "IN_PROGRESS",
startup_status: "IN_PROGRESS",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
@@ -34,8 +41,12 @@ export const overviewMock: ProjectOverviewResponse = {
{
center_id: "center-003",
center_name: "中心03",
+ institution_initiation_status: "COMPLETED",
+ institution_initiation_completed_at: "2024-12-10",
ethics_status: "COMPLETED",
ethics_completed_at: "2024-12-28",
+ contract_sign_status: "COMPLETED",
+ contract_sign_completed_at: "2025-01-05",
startup_status: "COMPLETED",
startup_completed_at: "2025-01-20",
enrollment_status: "COMPLETED",
@@ -48,8 +59,12 @@ export const overviewMock: ProjectOverviewResponse = {
{
center_id: "center-004",
center_name: "中心04",
+ institution_initiation_status: "COMPLETED",
+ institution_initiation_completed_at: "2024-12-22",
ethics_status: "COMPLETED",
ethics_completed_at: "2025-01-15",
+ contract_sign_status: "COMPLETED",
+ contract_sign_completed_at: "2025-01-25",
startup_status: "BLOCKED",
enrollment_status: "NOT_STARTED",
inspection_status: "NOT_STARTED",
@@ -60,6 +75,7 @@ export const overviewMock: ProjectOverviewResponse = {
{
center_id: "center-005",
center_name: "中心05",
+ institution_initiation_status: "IN_PROGRESS",
ethics_status: "IN_PROGRESS",
startup_status: "NOT_STARTED",
enrollment_status: "NOT_STARTED",