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

This commit is contained in:
Cheng Zhou
2026-01-13 13:59:07 +08:00
parent b3cd8e03f2
commit 0c7c03069a
21 changed files with 829 additions and 40 deletions
+24 -1
View File
@@ -1,4 +1,27 @@
<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 -->
</template>
<script setup lang="ts">
import { useSessionStore } from "./store/session";
import { initSessionManager } from "./session/sessionManager";
import LockScreenModal from "./components/LockScreenModal.vue";
initSessionManager();
const session = useSessionStore();
</script>
<style scoped>
.app-shell {
min-height: 100vh;
}
.app-locked {
pointer-events: none;
user-select: none;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import axios from "axios";
import type { AxiosResponse } from "axios";
const authClient = axios.create({
baseURL: "/",
timeout: 15000,
});
export type ExtendResponse = {
accessToken: string;
expiresAt: string;
};
export type UnlockResponse = {
accessToken: string;
expiresAt: string;
};
export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse>> =>
authClient.post<ExtendResponse>(
"/api/v1/auth/extend",
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
export const unlockSession = (payload: { email: string; password: string }): Promise<AxiosResponse<UnlockResponse>> =>
authClient.post<UnlockResponse>("/api/v1/auth/unlock", payload);
export default authClient;
+59 -25
View File
@@ -2,51 +2,71 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } f
import { ElMessage } from "element-plus";
import 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<ApiError>) => {
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 = <T = unknown>(url: string, config?: AxiosRequestConfig) => instance.get<T>(url, config);
export const apiPost = <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
export const apiGet = <T = unknown>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
export const apiPost = <T = unknown>(url: string, data?: unknown, config?: ApiRequestConfig) =>
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);
export const apiDelete = <T = unknown>(url: string, config?: AxiosRequestConfig) =>
export const apiDelete = <T = unknown>(url: string, config?: ApiRequestConfig) =>
instance.delete<T>(url, config);
export default instance;
+11 -2
View File
@@ -54,6 +54,10 @@
</template>
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
</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-icon><Calendar /></el-icon>
<span>{{ TEXT.menu.startupFeasibilityEthics }}</span>
@@ -77,10 +81,12 @@
<el-sub-menu index="knowledge">
<template #title>
<el-icon><Notebook /></el-icon>
<span>{{ TEXT.menu.knowledge }}</span>
<span>{{ TEXT.menu.sharedLibrary }}</span>
</template>
<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/support-files">{{ TEXT.menu.knowledgeSupportFiles }}</el-menu-item>
<el-menu-item index="/knowledge/instruction-files">{{ TEXT.menu.knowledgeInstructionFiles }}</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
@@ -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;
+149
View File
@@ -0,0 +1,149 @@
<template>
<teleport to="body">
<div class="lock-screen" role="dialog" aria-modal="true">
<div class="lock-backdrop" />
<div class="lock-panel">
<h2 class="lock-title">已锁定</h2>
<p class="lock-desc">30 分钟无操作请输入密码解锁</p>
<el-input
ref="passwordInput"
v-model="password"
type="password"
placeholder="请输入登录密码"
show-password
@keyup.enter="onUnlock"
/>
<div class="lock-actions">
<el-button type="primary" :loading="loading" @click="onUnlock">解锁</el-button>
<el-button @click="onLogout">退出登录</el-button>
</div>
<p v-if="errorMessage" class="lock-error">{{ errorMessage }}</p>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { UNLOCK_MAX_ATTEMPTS, unlockWithPassword, forceLogout } from "../session/sessionManager";
const auth = useAuthStore();
const session = useSessionStore();
const password = ref("");
const loading = ref(false);
const errorMessage = ref("");
const passwordInput = ref<{ focus?: () => void } | null>(null);
const focusPassword = () => {
nextTick(() => {
passwordInput.value?.focus?.();
});
};
const onUnlock = async () => {
if (loading.value) return;
errorMessage.value = "";
const email = auth.user?.email;
if (!email) {
ElMessage.error("无法获取用户信息,请重新登录");
forceLogout();
return;
}
if (!password.value) {
errorMessage.value = "请输入密码";
focusPassword();
return;
}
loading.value = true;
try {
await unlockWithPassword(email, password.value);
password.value = "";
} catch (err: any) {
session.incrementUnlockAttempt();
const message = err?.response?.data?.detail || err?.response?.data?.message || "密码错误";
errorMessage.value = message;
if (session.unlockAttempts >= UNLOCK_MAX_ATTEMPTS) {
ElMessage.error("错误次数过多,请重新登录");
forceLogout();
}
} finally {
loading.value = false;
focusPassword();
}
};
const onLogout = () => {
forceLogout();
};
onMounted(() => {
focusPassword();
});
watch(
() => session.locked,
(value) => {
if (value) {
focusPassword();
}
}
);
</script>
<style scoped>
.lock-screen {
position: fixed;
inset: 0;
z-index: 2000;
display: grid;
place-items: center;
}
.lock-backdrop {
position: absolute;
inset: 0;
background: rgba(7, 10, 20, 0.35);
backdrop-filter: blur(10px);
}
.lock-panel {
position: relative;
z-index: 1;
width: min(420px, 92vw);
background: #ffffff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 18px 48px rgba(15, 20, 35, 0.2);
display: flex;
flex-direction: column;
gap: 16px;
}
.lock-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #0f172a;
}
.lock-desc {
margin: 0;
color: #64748b;
font-size: 14px;
}
.lock-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.lock-error {
margin: 0;
color: #d8342c;
font-size: 13px;
}
</style>
+23 -1
View File
@@ -49,6 +49,7 @@ export const TEXT = {
noPermission: "无权限执行该操作",
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: "功能建设中",
+22 -2
View File
@@ -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();
+13
View File
@@ -0,0 +1,13 @@
export const parseJwtExp = (token: string): number | null => {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
if (typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
/* ignore */
}
return null;
};
+212
View File
@@ -0,0 +1,212 @@
import { ElMessage } from "element-plus";
import router from "../router";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { getToken, setToken, clearToken } from "../utils/auth";
import { extendToken, unlockSession } from "../api/authClient";
import { parseJwtExp } from "./jwt";
export const IDLE_TIMEOUT_MINUTES = 30;
export const EXTEND_EARLY_SECONDS = 120;
export const EXTEND_MIN_INTERVAL_SECONDS = 60;
export const UNLOCK_MAX_ATTEMPTS = 5;
type BroadcastMessage =
| { type: "ACTIVE"; at: number }
| { type: "NETWORK"; at: number }
| { type: "LOCK"; reason: string }
| { type: "TOKEN_UPDATED"; token: string }
| { type: "LOGOUT" };
let idleTimer: number | null = null;
let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null = null;
let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const broadcast = (message: BroadcastMessage) => {
if (channel) {
channel.postMessage(message);
}
try {
localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() }));
} catch {
/* ignore */
}
};
const handleBroadcast = (message: BroadcastMessage) => {
const session = useSessionStore();
const auth = useAuthStore();
if (message.type === "ACTIVE") {
session.recordUserActivity(message.at);
scheduleIdleCheck();
}
if (message.type === "NETWORK") {
session.recordNetworkActivity(message.at);
scheduleIdleCheck();
}
if (message.type === "LOCK") {
const reason = message.reason as "idle" | "extend_failed" | "token_invalid";
session.lock(reason || "idle");
}
if (message.type === "TOKEN_UPDATED") {
auth.setToken(message.token);
setToken(message.token);
session.unlock();
}
if (message.type === "LOGOUT") {
forceLogout();
}
};
const scheduleIdleCheck = () => {
if (idleTimer) {
window.clearTimeout(idleTimer);
}
const session = useSessionStore();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const nextCheck =
Math.min(session.lastUserActiveAt, session.lastNetworkActiveAt) + timeoutMs - Date.now();
if (nextCheck <= 0) {
checkIdle();
return;
}
idleTimer = window.setTimeout(checkIdle, nextCheck);
};
const checkIdle = () => {
const session = useSessionStore();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const now = Date.now();
if (now - session.lastUserActiveAt > timeoutMs && now - session.lastNetworkActiveAt > timeoutMs) {
lockSession("idle");
return;
}
scheduleIdleCheck();
};
export const initSessionManager = () => {
if (initialized) return;
initialized = true;
const events = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"];
let lastSignal = 0;
const onUserActivity = () => {
const now = Date.now();
if (now - lastSignal < 1000) return;
lastSignal = now;
markUserActive(now);
};
events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true }));
if (channel) {
channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage);
}
window.addEventListener("storage", (event) => {
if (event.key !== "ctms_auth_broadcast" || !event.newValue) return;
try {
const message = JSON.parse(event.newValue) as BroadcastMessage;
handleBroadcast(message);
} catch {
/* ignore */
}
});
scheduleIdleCheck();
startTokenKeepAlive();
};
export const markUserActive = (at: number = Date.now()) => {
const session = useSessionStore();
session.recordUserActivity(at);
broadcast({ type: "ACTIVE", at });
scheduleIdleCheck();
};
export const markNetworkActive = (at: number = Date.now()) => {
const session = useSessionStore();
session.recordNetworkActivity(at);
broadcast({ type: "NETWORK", at });
scheduleIdleCheck();
};
export const lockSession = (reason: "idle" | "extend_failed" | "token_invalid") => {
const session = useSessionStore();
if (session.locked) return;
session.lock(reason);
broadcast({ type: "LOCK", reason });
};
export const forceLogout = () => {
const auth = useAuthStore();
const session = useSessionStore();
auth.logout();
session.unlock();
clearToken();
broadcast({ type: "LOGOUT" });
router.replace("/login");
};
const updateToken = (token: string) => {
const auth = useAuthStore();
auth.setToken(token);
setToken(token);
broadcast({ type: "TOKEN_UPDATED", token });
};
export const extendAccessToken = async (reason: "early" | "response-401") => {
const session = useSessionStore();
const token = getToken();
if (!token) return { token: null, authFailed: false };
if (reason === "early") {
const now = Date.now();
if (now - session.lastExtendAt < EXTEND_MIN_INTERVAL_SECONDS * 1000) {
return { token: null, authFailed: false };
}
}
if (extendPromise) return extendPromise;
extendPromise = (async () => {
let authFailed = false;
try {
const { data } = await extendToken(token);
session.setLastExtendAt(Date.now());
updateToken(data.accessToken);
return { token: data.accessToken, authFailed: false };
} catch (err: any) {
const status = err?.response?.status;
if (status === 401) {
lockSession("extend_failed");
authFailed = true;
} else if (status === 403) {
forceLogout();
authFailed = true;
}
return { token: null, authFailed };
} finally {
extendPromise = null;
}
})();
return extendPromise;
};
export const startTokenKeepAlive = () => {
window.setInterval(() => {
const token = getToken();
if (!token) return;
const session = useSessionStore();
if (session.locked) return;
const expAt = parseJwtExp(token);
if (!expAt) return;
const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const active = Date.now() - session.lastUserActiveAt < timeoutMs || Date.now() - session.lastNetworkActiveAt < timeoutMs;
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
void extendAccessToken("early");
}
}, 10000);
};
export const unlockWithPassword = async (email: string, password: string) => {
const session = useSessionStore();
const { data } = await unlockSession({ email, password });
updateToken(data.accessToken);
session.unlock();
markUserActive();
};
+3
View File
@@ -47,6 +47,9 @@ export const useAuthStore = defineStore("auth", () => {
login,
fetchMe: fetchMeAction,
logout,
setToken: (newToken: string) => {
token.value = newToken;
},
requestReLogin: () => {
forceLogin.value = true;
},
+55
View File
@@ -0,0 +1,55 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export type LockReason = "idle" | "extend_failed" | "token_invalid";
export const useSessionStore = defineStore("session", () => {
const locked = ref(false);
const lockReason = ref<LockReason | null>(null);
const unlockAttempts = ref(0);
const lastUserActiveAt = ref(Date.now());
const lastNetworkActiveAt = ref(Date.now());
const lastExtendAt = ref(0);
const recordUserActivity = (ts: number = Date.now()) => {
lastUserActiveAt.value = ts;
};
const recordNetworkActivity = (ts: number = Date.now()) => {
lastNetworkActiveAt.value = ts;
};
const lock = (reason: LockReason) => {
locked.value = true;
lockReason.value = reason;
};
const unlock = () => {
locked.value = false;
lockReason.value = null;
unlockAttempts.value = 0;
};
const incrementUnlockAttempt = () => {
unlockAttempts.value += 1;
};
const setLastExtendAt = (ts: number) => {
lastExtendAt.value = ts;
};
return {
locked,
lockReason,
unlockAttempts,
lastUserActiveAt,
lastNetworkActiveAt,
lastExtendAt,
recordUserActivity,
recordNetworkActivity,
lock,
unlock,
incrementUnlockAttempt,
setLastExtendAt,
};
});
@@ -0,0 +1,13 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.fileVersionManagement.title"
:subtitle="TEXT.modules.fileVersionManagement.subtitle"
:list-title="TEXT.modules.fileVersionManagement.listTitle"
:empty-description="TEXT.modules.fileVersionManagement.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -0,0 +1,13 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.knowledgeInstructionFiles.title"
:subtitle="TEXT.modules.knowledgeInstructionFiles.subtitle"
:list-title="TEXT.modules.knowledgeInstructionFiles.listTitle"
:empty-description="TEXT.modules.knowledgeInstructionFiles.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -0,0 +1,13 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.knowledgeSupportFiles.title"
:subtitle="TEXT.modules.knowledgeSupportFiles.subtitle"
:list-title="TEXT.modules.knowledgeSupportFiles.listTitle"
:empty-description="TEXT.modules.knowledgeSupportFiles.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -1,14 +1,18 @@
export type StageStatus = "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" | "BLOCKED";
export type 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),
@@ -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",