新增空闲锁屏、修复401等问题,新增文件版本管理、共享库等占位符
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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: "功能建设中",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user