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.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 @@ + + + + + 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",