release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8");
describe("DesktopProjectEntry", () => {
it("offers system management access only to admins and authorized project PMs", () => {
const source = readEntryView();
expect(source).toContain("工作台总控");
expect(source).toContain("选择目标项目");
expect(source).toContain("Desktop Workbench");
expect(source).toContain('class="entry-background-grid"');
expect(source).toContain("系统管理");
expect(source).toContain("<strong>系统管理</strong>");
expect(source).toContain('class="admin-arrow"');
expect(source).toContain('loggingOut ? "正在退出" : "退出登录"');
expect(source).toContain('<AccountConnectionStatus mode="network" />');
expect(source).not.toContain("当前操作员");
expect(source).not.toContain('class="admin-desc"');
expect(source).toContain('v-if="canEnterManagement"');
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
expect(source).toContain("project-cards-grid");
expect(source).toContain("card-action-bar");
expect(source).toContain("进入项目工作空间");
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
expect(source).toContain('<span class="meta-label">我的角色</span>');
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
expect(source).not.toContain('project.role_in_study && !isAdmin');
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
expect(source).toContain("studyStore.clearCurrentStudy()");
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")");
expect(source).toContain("fetchStudies()");
expect(source).toContain("studyStore.setCurrentStudy(project)");
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
expect(source).toContain("findFirstAccessibleProjectPath");
expect(source).toContain("当前账号暂无该项目可访问模块");
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
expect(source).not.toContain("Web Workbench");
expect(source).not.toContain("PROJECT ADMIN");
});
});
File diff suppressed because it is too large Load Diff
+125 -15
View File
@@ -22,6 +22,19 @@
:closable="false"
/>
<div v-if="connectionDiagnostic" class="connection-diagnostic">
<div class="diagnostic-grid">
<span>检查时间</span>
<strong>{{ connectionDiagnostic.checkedAt }}</strong>
<span>健康检查</span>
<code>{{ connectionDiagnostic.healthUrl }}</code>
<span>耗时</span>
<strong>{{ connectionDiagnostic.durationMs }}ms</strong>
<span>HTTP</span>
<strong>{{ connectionDiagnostic.httpStatus || "-" }}</strong>
</div>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
@@ -33,10 +46,6 @@
/>
</el-form-item>
<div class="hint">
允许 HTTPS 服务地址本地开发可使用 http://localhost 或 http://127.0.0.1。
</div>
<div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
@@ -51,7 +60,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
@@ -65,13 +74,46 @@ const serverUrl = ref(currentServerUrl || "");
const urlError = ref("");
const saving = ref(false);
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null);
const connectionDiagnostic = ref<{
healthUrl: string;
checkedAt: string;
durationMs: number;
httpStatus?: number;
} | null>(null);
const canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000;
const formatDiagnosticTime = (date = new Date()) =>
date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const toConnectionError = (
message: string,
details: {
serverUrl: string;
healthUrl: string;
durationMs: number;
httpStatus?: number;
},
) => Object.assign(new Error(message), details);
const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString();
const controller = new AbortController();
const startedAt = performance.now();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const details = () => ({
serverUrl: baseUrl,
healthUrl,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
try {
const response = await fetch(healthUrl, {
method: "GET",
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
...details(),
httpStatus: response.status,
});
}
return {
...details(),
httpStatus: response.status,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态");
throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
}
if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
}
throw error;
} finally {
@@ -95,10 +144,25 @@ const checkHealth = async (baseUrl: string) => {
};
const clearSessionForServerChange = async () => {
await auth.logout();
await auth.logout({ rememberCurrentStudy: false });
studyStore.clearCurrentStudy();
};
const confirmServerChange = async (previous: string | null, next: string) => {
if (!previous || previous === next) return true;
const confirmed = await ElMessageBox.confirm(
"切换服务器会退出当前会话并清除当前项目上下文,确认后需要重新登录。",
"确认切换服务器",
{
type: "warning",
confirmButtonText: "切换并退出登录",
cancelButtonText: "继续编辑",
distinguishCancelAndClose: true,
},
).catch(() => null);
return Boolean(confirmed);
};
const save = async () => {
urlError.value = "";
connectionStatus.value = null;
@@ -110,8 +174,9 @@ const save = async () => {
saving.value = true;
try {
await checkHealth(normalized.url);
const health = await checkHealth(normalized.url);
const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalized.url))) return;
const result = setDesktopServerUrl(normalized.url);
if (!result.ok) {
urlError.value = result.reason;
@@ -125,15 +190,32 @@ const save = async () => {
title: "连接已确认",
message: result.url,
};
ElMessage.success("服务器连接已确认");
connectionDiagnostic.value = {
healthUrl: health.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: health.durationMs,
httpStatus: health.httpStatus,
};
router.replace("/login");
} catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
connectionStatus.value = {
type: "error",
title: "连接检查失败",
message,
};
connectionDiagnostic.value = {
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
};
urlError.value = message;
} finally {
saving.value = false;
@@ -147,6 +229,7 @@ const goBack = () => {
<style scoped>
.desktop-settings-page {
box-sizing: border-box;
min-height: 100vh;
display: flex;
align-items: center;
@@ -157,7 +240,9 @@ const goBack = () => {
.settings-panel {
width: min(100%, 520px);
max-height: calc(100vh - 64px);
padding: 32px;
overflow: auto;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #ffffff;
@@ -215,11 +300,36 @@ h1 {
margin-bottom: 18px;
}
.hint {
margin-top: -8px;
.connection-diagnostic {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding: 12px;
border: 1px solid #dbe6f2;
border-radius: 8px;
background: #fbfdff;
}
.diagnostic-grid {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 6px 10px;
min-width: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
font-size: 12px;
}
.diagnostic-grid strong {
min-width: 0;
color: #1e293b;
font-weight: 700;
}
.diagnostic-grid code {
min-width: 0;
overflow-wrap: anywhere;
}
.actions {
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readRestoreView = () => readFileSync(resolve(__dirname, "./DesktopSessionRestore.vue"), "utf8");
describe("DesktopSessionRestore", () => {
it("waits for network recovery without clearing the desktop session", () => {
const source = readRestoreView();
expect(source).toContain("正在恢复登录状态");
expect(source).toContain("网络恢复后将自动校验账号并回到工作台");
expect(source).toContain("auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain("window.setInterval");
expect(source).toContain('window.addEventListener("online", retryNow)');
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source).toContain("isExplicitAuthCheckFailure(error)");
expect(source).toContain("await auth.logout()");
expect(source).toContain("retryCount.value += 1");
});
});
@@ -0,0 +1,230 @@
<template>
<div class="desktop-session-restore" data-tauri-drag-region>
<section class="restore-panel">
<img class="restore-mark" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<div class="restore-copy">
<span class="restore-eyebrow">Desktop Session</span>
<h1>正在恢复登录状态</h1>
<p>{{ statusMessage }}</p>
</div>
<div class="restore-status" :class="{ checking }">
<span class="status-orbit" aria-hidden="true"></span>
<span>{{ checking ? "正在连接服务器" : "等待网络恢复" }}</span>
</div>
<div class="restore-actions">
<el-button type="primary" :loading="checking" @click="retryNow">立即重试</el-button>
<RouterLink to="/desktop/server-settings" class="server-link">服务器设置</RouterLink>
</div>
<small class="restore-note">
桌面端会保留本机登录凭据网络恢复后将自动校验账号并回到工作台
</small>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { DESKTOP_SESSION_RESTORE_PATH, isExplicitAuthCheckFailure } from "../session/authRecovery";
import { useAuthStore } from "../store/auth";
const RETRY_INTERVAL_MS = 5_000;
const FALLBACK_REDIRECT = "/desktop/project-entry";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const checking = ref(false);
const retryCount = ref(0);
let retryTimer: number | undefined;
const redirectTarget = computed(() => {
const raw = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect;
if (typeof raw !== "string") return FALLBACK_REDIRECT;
if (!raw.startsWith("/") || raw.startsWith("//") || raw.startsWith(DESKTOP_SESSION_RESTORE_PATH)) {
return FALLBACK_REDIRECT;
}
return raw;
});
const statusMessage = computed(() =>
retryCount.value === 0
? "正在确认服务器连接和账号状态。"
: "当前无法连接服务器,已保留本机登录状态并将自动重试。"
);
const recoverSession = async () => {
if (checking.value) return;
if (!auth.token) {
await router.replace("/login");
return;
}
checking.value = true;
try {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
await router.replace(redirectTarget.value);
} catch (error) {
if (isExplicitAuthCheckFailure(error)) {
await auth.logout();
await router.replace("/login");
return;
}
retryCount.value += 1;
} finally {
checking.value = false;
}
};
const retryNow = () => {
void recoverSession();
};
onMounted(() => {
void recoverSession();
retryTimer = window.setInterval(() => {
void recoverSession();
}, RETRY_INTERVAL_MS);
window.addEventListener("online", retryNow);
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, retryNow);
});
onBeforeUnmount(() => {
if (retryTimer) {
window.clearInterval(retryTimer);
}
window.removeEventListener("online", retryNow);
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, retryNow);
});
</script>
<style scoped>
.desktop-session-restore {
min-width: 1180px;
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 40px;
background:
radial-gradient(circle at 14% 16%, rgba(47, 95, 134, 0.1), transparent 30%),
radial-gradient(circle at 84% 24%, rgba(63, 143, 107, 0.1), transparent 28%),
linear-gradient(135deg, #f8fafc 0%, #eef4f8 100%);
color: #102033;
}
.restore-panel {
width: min(520px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 34px 36px;
border: 1px solid #d9e2ec;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 70px rgba(16, 32, 51, 0.12);
text-align: center;
}
.restore-mark {
width: 64px;
height: 64px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 30px rgba(21, 52, 79, 0.18);
object-fit: contain;
}
.restore-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.restore-eyebrow {
color: #3f8f6b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.restore-copy h1 {
margin: 0;
color: #102033;
font-size: 24px;
line-height: 1.25;
}
.restore-copy p,
.restore-note {
margin: 0;
color: #5d7087;
font-size: 14px;
line-height: 1.7;
}
.restore-status {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
background: #eef4f8;
color: #3f5d75;
font-size: 13px;
font-weight: 700;
}
.status-orbit {
width: 10px;
height: 10px;
border-radius: 999px;
background: #3f8f6b;
box-shadow: 0 0 0 4px rgba(63, 143, 107, 0.14);
}
.restore-status.checking .status-orbit {
animation: restore-pulse 1.2s ease-in-out infinite;
}
.restore-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.server-link {
color: #3f5d75;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.server-link:hover {
color: #15344f;
}
.restore-note {
max-width: 400px;
font-size: 12px;
}
@keyframes restore-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.72);
opacity: 0.55;
}
}
</style>
+252 -55
View File
@@ -1,8 +1,10 @@
<template>
<div class="page ctms-page-shell page--flush medical-consult-page">
<div class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page medical-consult-page--workbench">
<section class="faq-hero">
<h1><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div class="workbench-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
<div class="hero-tools">
<el-autocomplete
v-model="keyword"
@@ -19,7 +21,12 @@
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<div class="spacer" />
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
</div>
</section>
@@ -34,15 +41,6 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
</div>
<FaqList
:items="faqs"
:categories="categories"
@@ -74,7 +72,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { Collection, Plus, Search } from "@element-plus/icons-vue";
import { Plus, Search } from "@element-plus/icons-vue";
import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
@@ -104,6 +102,14 @@ const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update"));
const canDeleteFaq = computed(() => can("faq.delete"));
const activeCategoryName = computed(() => {
if (!activeCategory.value) return TEXT.common.labels.all;
return categories.value.find((item: any) => item.id === activeCategory.value)?.name || "已筛选分类";
});
const resultSummary = computed(() => {
const suffix = keyword.value.trim() ? ",已应用搜索" : "";
return `${total.value}${suffix}`;
});
const loadCategories = async () => {
try {
@@ -210,15 +216,6 @@ onMounted(async () => {
background: linear-gradient(180deg, #f0f4ff 0%, #ffffff 30%, #f8fafc 100%);
}
.page-bg-dots {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: radial-gradient(#1e40af 1px, transparent 1px);
background-size: 24px 24px;
}
.faq-hero {
position: relative;
padding: 28px 24px 36px;
@@ -242,30 +239,6 @@ onMounted(async () => {
pointer-events: none;
}
.faq-hero h1 {
margin: 0 0 24px;
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: -0.02em;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.hero-title-icon {
font-size: 28px;
color: #3b82f6;
opacity: 0.85;
}
.hero-desc {
margin: 8px 0 28px;
color: #64748b;
font-size: 15px;
font-weight: 500;
}
.hero-tools {
display: flex;
align-items: center;
@@ -329,13 +302,6 @@ onMounted(async () => {
padding: 24px 28px 0;
overflow: hidden;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 0 16px;
}
.list-title strong {
min-width: 30px;
height: 24px;
@@ -357,8 +323,226 @@ onMounted(async () => {
justify-content: flex-end;
}
.spacer {
.workbench-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
padding-left: 14px;
flex-direction: column;
gap: 2px;
text-align: left;
}
.workbench-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
width: 4px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.workbench-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.workbench-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--workbench {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 0 !important;
overflow: hidden;
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--workbench .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px !important;
border: 1px solid rgba(90, 145, 220, 0.24) !important;
background:
radial-gradient(circle at 0% 0%, rgba(42, 132, 255, 0.22) 0%, transparent 34%),
radial-gradient(circle at 78% 18%, rgba(35, 183, 217, 0.16) 0%, transparent 30%),
linear-gradient(135deg, #ecf7ff 0%, #edf3ff 46%, #f8fcff 100%) !important;
box-shadow:
0 10px 24px rgba(30, 73, 128, 0.08),
0 1px 0 rgba(255, 255, 255, 0.86) inset !important;
text-align: left;
border-radius: 0 !important;
}
.medical-consult-page--workbench .faq-hero::before {
display: none;
}
.medical-consult-page--workbench .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--workbench .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--workbench .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 8px 20px rgba(48, 92, 150, 0.08),
0 0 0 1px rgba(93, 146, 214, 0.24) inset;
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--workbench .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--workbench .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--workbench .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
background: linear-gradient(135deg, #2f7be8 0%, #2560bd 100%);
border: none;
box-shadow: 0 10px 18px rgba(47, 123, 232, 0.24);
font-size: 13px;
font-weight: 800;
transition: all 0.2s;
}
.medical-consult-page--workbench .new-consult-btn:hover,
.medical-consult-page--workbench .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--workbench .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
height: 0;
min-height: 0;
margin-top: 0;
border: 1px solid rgba(130, 158, 190, 0.22);
border-radius: 0;
background: #ffffff;
overflow: hidden;
}
.medical-consult-page--workbench .faq-sidebar-col,
.medical-consult-page--workbench .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--workbench .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.13) 0%, transparent 34%),
linear-gradient(180deg, #e9f4ff 0%, #f6faff 44%, #eaf1fa 100%);
overflow: hidden;
}
.medical-consult-page--workbench .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--workbench .faq-main {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
flex-direction: column;
border: 0 !important;
padding: 0;
overflow: hidden;
background: #ffffff;
}
.medical-consult-page--workbench .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--workbench :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--workbench :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--workbench :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--workbench :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
font-size: 13px;
line-height: 36px;
}
.medical-consult-page--workbench :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--workbench .workbench-toolbar-meta span) {
color: #e5edf7;
}
@media (max-width: 1080px) {
@@ -376,5 +560,18 @@ onMounted(async () => {
.faq-main {
padding: 18px 16px 0;
}
.medical-consult-page--workbench .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--workbench .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--workbench .faq-main {
padding: 0;
}
}
</style>
-2
View File
@@ -422,8 +422,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@600;800&display=swap");
/* ═══════════════════
根容器
═══════════════════ */
+42 -24
View File
@@ -8,7 +8,7 @@ describe("Login protocol agreement", () => {
it("requires protocol agreement before calling login", () => {
const source = readLoginView();
const protocolGuardIndex = source.indexOf("!form.agreeProtocol");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password");
expect(source).toContain('v-model="form.agreeProtocol"');
expect(source).toContain("我已阅读并同意");
@@ -18,21 +18,25 @@ describe("Login protocol agreement", () => {
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
});
it("does not access browser password credentials on the login page", () => {
it("supports remembered passwords through runtime credential helpers only", () => {
const source = readLoginView();
expect(source).toContain('autocomplete="username"');
expect(source).toContain('name="username"');
expect(source).toContain('name="ctms-login-password"');
expect(source).toContain('autocomplete="new-password"');
expect(source).not.toContain('v-model="form.rememberPassword"');
expect(source).not.toContain("记住密码");
expect(source).not.toContain("tryLoadBrowserCredential");
expect(source).not.toContain("tryStoreBrowserCredential");
expect(source).toContain('name="password"');
expect(source).toContain('autocomplete="current-password"');
expect(source).toContain('v-model="form.rememberPassword"');
expect(source).toContain("记住密码");
expect(source).toContain("getSavedLoginCredential");
expect(source).toContain("saveLoginCredential");
expect(source).toContain("clearLoginCredential");
expect(source).not.toContain("navigator.credentials");
expect(source).not.toContain("PasswordCredential");
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"');
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
const localStorageSetItem = "localStorage" + ".setItem";
const savedPasswordKey = "ctms_saved_" + "password";
expect(source).not.toContain(`${localStorageSetItem}("${savedPasswordKey}"`);
expect(source).not.toContain(`${localStorageSetItem}('${savedPasswordKey}'`);
expect(source).not.toContain("sessionStorage.setItem");
});
it("opens a CTMS-specific protocol dialog from the protocol text", () => {
@@ -55,25 +59,28 @@ describe("Login protocol agreement", () => {
expect(source).toContain("confirmProtocol");
});
it("avoids browser password caching", () => {
it("does not write remembered passwords to browser storage", () => {
const source = readLoginView();
expect(source).toContain('name="ctms-login-password"');
expect(source).toContain('autocomplete="new-password"');
expect(source).not.toContain('autocomplete="current-password"');
expect(source).not.toMatch(/localStorage\.setItem\([^)]*password/i);
expect(source).not.toMatch(/sessionStorage\.setItem\([^)]*password/i);
});
it("restores the current user's last project before routing after login", () => {
it("sends logins to the entry chooser without restoring a project in the login view", () => {
const source = readLoginView();
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email");
const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey");
const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")');
const entryPathIndex = source.indexOf('const entryPath = isDesktopLogin ? "/desktop/project-entry" : "/workbench";');
const loginCallIndex = source.indexOf("auth.login(form.email, form.password, { restoreStudy: false })");
const clearIndex = source.indexOf("studyStore.clearCurrentStudy()");
const entryRouteIndex = source.indexOf("router.push(entryPath)");
expect(userKeyIndex).toBeGreaterThan(loginCallIndex);
expect(restoreIndex).toBeGreaterThan(loginCallIndex);
expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex);
expect(source).toContain("preferActive: !!auth.user?.is_admin");
expect(source).toContain("const isDesktopLogin = showDesktopServerSettings");
expect(entryPathIndex).toBeGreaterThan(-1);
expect(loginCallIndex).toBeGreaterThan(-1);
expect(entryPathIndex).toBeLessThan(loginCallIndex);
expect(clearIndex).toBeGreaterThan(loginCallIndex);
expect(entryRouteIndex).toBeGreaterThan(clearIndex);
expect(source).not.toContain("studyStore.restoreStudyForUser(userKey");
expect(source).not.toContain('router.push("/project/overview")');
});
it("shows persistent logout reason notices on the login card", () => {
@@ -96,10 +103,21 @@ describe("Login protocol agreement", () => {
expect(source).toContain("max-width: calc(100vw - 40px);");
});
it("keeps the login shell inside the viewport without a visible page scrollbar", () => {
const source = readLoginView();
expect(source).toContain("height: 100dvh;");
expect(source).toContain('document.body.classList.add("is-login-page")');
expect(source).toContain("scrollbar-width: none;");
expect(source).toContain(".login-wrapper::-webkit-scrollbar");
expect(source).toContain("overflow-y: auto;");
expect(source).toContain("@media (min-width: 961px) and (max-height: 820px)");
});
it("falls back to full email input when no server-configured domains exist", () => {
const source = readLoginView();
expect(source).toContain('fetchEmailDomains()');
expect(source).toContain("fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
expect(source).toContain("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)");
expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)");
+248 -42
View File
@@ -11,13 +11,7 @@
<div class="brand-content-wrapper">
<!-- 顶部系统微标 -->
<div class="brand-mini-logo">
<div class="mini-logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<img class="mini-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<span class="mini-logo-text">华邦制药 · CTMS</span>
</div>
@@ -84,13 +78,7 @@
<div class="login-card-container">
<!-- 顶部 Logo 组合 -->
<div class="login-brand-header">
<div class="brand-logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<img class="brand-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<div class="brand-divider"></div>
<span class="brand-system-text">CTMS</span>
</div>
@@ -176,11 +164,14 @@
<el-input
id="password" v-model="form.password" type="password"
placeholder="请输入密码" show-password size="large"
name="ctms-login-password" autocomplete="new-password" class="login-input">
name="password" autocomplete="current-password" class="login-input">
</el-input>
</el-form-item>
<div class="login-options">
<el-checkbox v-model="form.rememberPassword" class="remember-password-checkbox">
<span class="remember-password-text">记住密码</span>
</el-checkbox>
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text">
我已阅读并同意
@@ -256,7 +247,14 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
import {
clearLoginCredential,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDesktopServerUrl,
getSavedLoginCredential,
isTauriRuntime,
saveLoginCredential,
} from "../runtime";
import {
consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED,
@@ -270,7 +268,7 @@ const router = useRouter();
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
const formRef = ref<FormInstance>();
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", rememberPassword: false, agreeProtocol: false });
const configuredEmailDomains = ref<string[]>([]);
const rules: FormRules<typeof form> = {
@@ -291,6 +289,7 @@ const desktopServerUrl = ref(getDesktopServerUrl());
const refreshDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
void loadRememberedCredential(true);
};
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
@@ -337,7 +336,7 @@ const applyEmailValue = (email: string) => {
const loadEmailDomains = async () => {
try {
const { data } = await fetchEmailDomains();
const { data } = await fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true });
const domains = Array.from(new Set(
(Array.isArray(data.items) ? data.items : [])
.map(item => typeof item === "string" ? normalizeDomain(item) : "")
@@ -373,7 +372,46 @@ const handleAccountPaste = (event: ClipboardEvent) => {
applyEmailValue(pasted);
};
const loadRememberedCredential = async (clearWhenMissing = false) => {
try {
const credential = await getSavedLoginCredential();
if (credential) {
applyEmailValue(credential.email);
form.password = credential.password;
form.rememberPassword = true;
return;
}
} catch {
/* ignore credential loading failures */
}
if (clearWhenMissing) {
form.password = "";
form.rememberPassword = false;
}
};
const syncRememberedCredential = async () => {
try {
if (form.rememberPassword) {
const saved = await saveLoginCredential(form.email, form.password);
if (!saved) {
ElMessage.warning(
showDesktopServerSettings
? "记住密码保存失败,请确认系统凭据库可用。"
: "当前浏览器不支持安全保存密码,请使用浏览器密码管理器。",
);
}
return;
}
await clearLoginCredential();
} catch {
ElMessage.warning("记住密码状态同步失败,本次登录不受影响。");
}
};
onMounted(async () => {
document.documentElement.classList.add("is-login-page");
document.body.classList.add("is-login-page");
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
await loadEmailDomains();
const reason = consumeLogoutReason();
@@ -386,9 +424,12 @@ onMounted(async () => {
}
applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
await loadRememberedCredential();
});
onUnmounted(() => {
document.documentElement.classList.remove("is-login-page");
document.body.classList.remove("is-login-page");
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
});
@@ -407,15 +448,13 @@ const onSubmit = async () => {
loginError.value = null; // 清除上次错误
loading.value = true;
try {
await auth.login(form.email, form.password);
const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
if (studyStore.currentStudy) {
router.push("/project/overview");
} else {
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
}
const isDesktopLogin = showDesktopServerSettings;
const entryPath = isDesktopLogin ? "/desktop/project-entry" : "/workbench";
await auth.login(form.email, form.password, { restoreStudy: false });
await syncRememberedCredential();
studyStore.clearCurrentStudy();
await router.push(entryPath);
} catch (error: any) {
const status = error?.response?.status;
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
@@ -440,26 +479,44 @@ const onSubmit = async () => {
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/* ═══════════════════════
根容器
═══════════════════════ */
.login-wrapper {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: stretch;
justify-content: stretch;
overflow: hidden;
scrollbar-width: none;
background: #ffffff;
font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", sans-serif;
}
.login-wrapper::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
:global(html.is-login-page),
:global(body.is-login-page) {
height: 100%;
overflow: hidden;
}
.login-split-container {
display: flex;
width: 100vw;
min-height: 100vh;
min-width: 0;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ═══════════════════════
@@ -467,6 +524,9 @@ const onSubmit = async () => {
═══════════════════════ */
.login-left-brand {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
padding: 60px 80px;
position: relative;
@@ -551,14 +611,12 @@ const onSubmit = async () => {
}
.mini-logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 55px;
height: 55px;
background: #2563eb;
background: #ffffff;
border-radius: 14px;
color: white;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.18);
object-fit: contain;
}
.mini-logo-text {
@@ -715,12 +773,16 @@ const onSubmit = async () => {
═══════════════════════ */
.login-right-form {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
background: #ffffff;
padding: 60px 80px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 上方浏览器兼容建议 */
@@ -763,14 +825,12 @@ const onSubmit = async () => {
}
.brand-logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: #2563eb;
background: #ffffff;
border-radius: 9px;
color: white;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.16);
object-fit: contain;
}
.brand-divider {
@@ -848,6 +908,7 @@ const onSubmit = async () => {
}
.desktop-server-action {
white-space: nowrap;
color: #2563eb;
font-weight: 700;
text-decoration: none;
@@ -884,7 +945,7 @@ const onSubmit = async () => {
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
.ln-icon svg { width: 100%; height: 100%; }
.ln-body { display: flex; flex-direction: column; gap: 2px; }
.ln-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ln-body strong { font-size: 12px; font-weight: 700; }
.ln-body span { font-size: 11px; opacity: 0.9; }
@@ -1060,25 +1121,37 @@ const onSubmit = async () => {
/* 协议区 */
.login-options {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 24px;
}
.remember-password-checkbox,
.protocol-checkbox {
align-items: center;
}
.remember-password-checkbox :deep(.el-checkbox__input .el-checkbox__inner),
.protocol-checkbox :deep(.el-checkbox__input .el-checkbox__inner) {
border-color: #cbd5e1;
border-radius: 4px;
}
.remember-password-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner),
.protocol-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background: #2563eb;
border-color: #2563eb;
}
.remember-password-checkbox :deep(.el-checkbox__label),
.protocol-checkbox :deep(.el-checkbox__label) {
padding-left: 8px;
white-space: normal;
line-height: 1.6;
}
.remember-password-text,
.protocol-text {
color: #64748b;
font-size: 12px;
@@ -1326,6 +1399,28 @@ const onSubmit = async () => {
}
}
@media (max-width: 1280px) {
.login-left-brand {
padding: 48px 52px;
}
.login-right-form {
padding: 48px 56px;
}
.brand-main-title {
font-size: 34px;
}
.feature-card {
padding: 14px 16px;
}
.login-card-container {
width: min(440px, 100%);
}
}
@media (max-width: 480px) {
.login-brand-header {
margin-bottom: 28px;
@@ -1337,4 +1432,115 @@ const onSubmit = async () => {
margin-top: 32px;
}
}
/* 统一收口:小屏允许触摸滚动,但不显示页面右侧滚动条。 */
@media (max-width: 960px) {
.login-wrapper {
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
overflow-y: auto;
}
.login-split-container {
height: auto;
min-height: 100vh;
min-height: 100dvh;
overflow: visible;
}
.login-right-form {
width: 100%;
height: auto;
min-height: 100vh;
min-height: 100dvh;
padding: clamp(20px, 5vh, 40px) clamp(16px, 6vw, 56px) 24px;
overflow: visible;
}
.login-card-container {
width: min(440px, 100%);
max-width: 100%;
}
}
/* 中等高度桌面窗口:压缩纵向间距,确保双栏始终锁在视口内。 */
@media (min-width: 961px) and (max-height: 820px) {
.login-left-brand,
.login-right-form {
padding-top: clamp(24px, 3.5vh, 44px);
padding-bottom: clamp(24px, 3.5vh, 44px);
}
.brand-slogan-block {
margin-top: 20px;
}
.brand-main-title {
margin-bottom: 12px;
font-size: clamp(30px, 5vh, 36px);
}
.brand-features-grid {
gap: 10px;
margin: 20px 0;
}
.feature-card {
padding: 12px 16px;
}
.login-brand-header {
margin-bottom: 24px;
}
.login-tabs {
margin-bottom: 22px;
}
.login-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.login-options {
margin-bottom: 18px;
}
.login-right-footer {
margin-top: 20px;
padding-top: 14px;
gap: 5px;
}
}
@media (max-width: 480px) and (max-height: 700px) {
.login-right-form {
padding-top: 16px;
padding-bottom: 16px;
}
.login-brand-header {
margin-bottom: 18px;
}
.login-tabs {
margin-bottom: 18px;
}
.login-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.login-options {
gap: 6px;
margin-bottom: 18px;
}
.login-right-footer {
margin-top: 20px;
padding-top: 14px;
}
}
</style>
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./OfficePreviewWorkspace.vue"), "utf8");
const hostSource = readFileSync(resolve(__dirname, "../components/OnlyOfficeViewer.vue"), "utf8");
describe("OfficePreviewWorkspace contract", () => {
it("uses a full-page viewer without dialogs, cards, downloads, or browser iframe fallback", () => {
expect(source).toContain("OnlyOfficeViewer");
expect(source).not.toContain("el-dialog");
expect(source).not.toContain("downloadDocumentVersion");
expect(source).not.toContain("downloadAttachment");
expect(source).not.toContain("<iframe");
expect(source).toContain('route.name === "OfficeCollaborationRevisionPreview"');
expect(source).toContain("fetchCollaborationRevisionOnlyOfficeConfig");
});
it("destroys signed configuration on deactivation, server changes, and unmount", () => {
expect(source).toContain("onDeactivated");
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source.match(/destroyViewer\(\)/g)?.length).toBeGreaterThanOrEqual(3);
expect(hostSource).toContain('frameRef.value.src = "about:blank"');
});
it("closes a desktop transient preview task before falling back to browser history", () => {
expect(source).toContain("inject(workspaceTaskControllerKey, null)");
expect(source).toContain("workspaceTaskController?.closeTransientTask(route.path)");
expect(source).toContain('workspaceTaskController ? "关闭预览并返回" : "返回"');
expect(source).toContain("router.back()");
});
});
@@ -0,0 +1,271 @@
<template>
<section class="office-preview-page">
<header class="office-preview-page__header">
<button
class="office-preview-page__back"
type="button"
:aria-label="backActionLabel"
:title="backActionLabel"
@click="goBack"
></button>
<div class="office-preview-page__title" :title="displayFileName">{{ displayFileName }}</div>
<div class="office-preview-page__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadConfig">重试</el-button>
</header>
<main class="office-preview-page__content">
<OnlyOfficeViewer
v-if="previewConfig && !errorMessage"
:key="viewerKey"
:config="previewConfig.config"
@ready="handleDocumentReady"
@warning="handleWarning"
@error="handleViewerError"
/>
<div v-else class="office-preview-page__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<div class="office-preview-page__error-title">无法预览此文件</div>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadConfig">重新加载</el-button>
</template>
</div>
</main>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Loading } from "@element-plus/icons-vue";
import OnlyOfficeViewer from "../components/OnlyOfficeViewer.vue";
import {
fetchAttachmentOnlyOfficeConfig,
fetchCollaborationRevisionOnlyOfficeConfig,
fetchVersionOnlyOfficeConfig,
} from "../api/onlyoffice";
import { useStudyStore } from "../store/study";
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
import { getApiErrorMessage } from "../utils/apiErrorMessage";
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
type PreviewStatus = "loading" | "ready" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const study = useStudyStore();
const previewConfig = ref<OnlyOfficePreviewConfig | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const warningMessage = ref("");
const status = ref<PreviewStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
let mounted = false;
const resourceType = computed<OnlyOfficeResourceType>(() => {
if (route.name === "OfficeAttachmentPreview") return "attachment";
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
return "version";
});
const resourceId = computed(() => String(route.params.id || ""));
const collaborationFileId = computed(() => String(route.params.fileId || ""));
const displayFileName = computed(() => previewConfig.value?.file_name || String(route.meta.title || "Office 文件预览"));
const viewerKey = computed(() => `${resourceType.value}:${resourceId.value}:${viewerSequence.value}`);
const backActionLabel = computed(() => workspaceTaskController ? "关闭预览并返回" : "返回");
const statusLabel = computed(() => {
if (status.value === "ready") return "只读";
if (status.value === "warning") return warningMessage.value || "预览警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "Office 在线预览尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "Office 预览服务暂时不可用";
if (code === "ONLYOFFICE_FORMAT_UNSUPPORTED" || statusCode === 415) return "该文件格式不支持 Office 在线预览";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有预览此文件的权限";
if (statusCode === 404) return "文件不存在或已被删除";
return getApiErrorMessage(error, "Office 预览配置加载失败");
};
const destroyViewer = () => {
requestSequence.value += 1;
previewConfig.value = null;
viewerSequence.value += 1;
};
const loadConfig = async () => {
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
previewConfig.value = null;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const response = resourceType.value === "attachment"
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
: resourceType.value === "collaboration_revision"
? await fetchCollaborationRevisionOnlyOfficeConfig(
study.currentStudy?.id || "",
collaborationFileId.value,
resourceId.value,
)
: await fetchVersionOnlyOfficeConfig(resourceId.value);
if (sequence !== requestSequence.value) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) {
throw new Error("Office 预览宿主页配置不合法");
}
previewConfig.value = response.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
} catch (error) {
if (sequence !== requestSequence.value) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (sequence === requestSequence.value) loading.value = false;
}
};
const handleDocumentReady = () => {
status.value = "ready";
};
const viewerDetailMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
const code = detail.errorCode ?? detail.warningCode ?? detail.code;
return code === undefined || code === null ? "" : `错误代码:${String(code)}`;
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = viewerDetailMessage(detail) || "预览服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = viewerDetailMessage(detail) || "文件转换失败,请稍后重试";
status.value = "error";
previewConfig.value = null;
};
const handleServerChange = () => {
destroyViewer();
loading.value = false;
status.value = "error";
errorMessage.value = "服务器地址已切换,请重新加载预览";
};
const goBack = () => {
if (workspaceTaskController?.closeTransientTask(route.path)) return;
router.back();
};
onMounted(() => {
mounted = true;
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadConfig();
});
onActivated(() => {
if (mounted && !previewConfig.value && !loading.value && !errorMessage.value) void loadConfig();
});
onDeactivated(() => {
destroyViewer();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
destroyViewer();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.office-preview-page {
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.office-preview-page__header {
z-index: 1;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 0 14px 0 8px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.office-preview-page__back {
width: 32px;
height: 32px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 30px/1 system-ui, sans-serif;
cursor: pointer;
}
.office-preview-page__back:hover { background: #edf2f7; }
.office-preview-page__title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.office-preview-page__status {
font-size: 12px;
color: #738398;
white-space: nowrap;
}
.office-preview-page__status.is-ready { color: #25845b; }
.office-preview-page__status.is-warning,
.office-preview-page__status.is-error { color: #b65c29; }
.office-preview-page__content {
min-height: 0;
overflow: hidden;
}
.office-preview-page__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
height: 100%;
color: #718096;
text-align: center;
}
.office-preview-page__state p { margin: 0; }
.office-preview-page__error-title { color: #2f4054; font-size: 17px; font-weight: 600; }
</style>
+102 -275
View File
@@ -1,114 +1,73 @@
<template>
<div class="page">
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
</aside>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div>
</aside>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<section class="form-section form-section--desktop">
<div class="section-heading">
<span class="section-kicker">Desktop</span>
<h4>客户端与通知</h4>
</div>
<el-form-item v-if="isDesktop" label="系统通知">
<div class="desktop-setting-stack">
<div class="desktop-setting-row">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
</div>
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
</div>
</el-form-item>
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
<div class="desktop-setting-row">
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
</div>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata-panel">
<dl class="client-metadata-list">
<template v-for="row in clientMetadataRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" @click="copyClientMetadata">复制</el-button>
</div>
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
</el-form>
</main>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</el-form>
</main>
</div>
</template>
@@ -118,25 +77,9 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth";
import {
clientRuntime,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
type NotificationPermissionState,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
const emit = defineEmits<{
"close-request": [];
@@ -144,31 +87,6 @@ const emit = defineEmits<{
saved: [];
}>();
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const clientMetadataRows = [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopUpdateChecking = ref(false);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -192,7 +110,6 @@ const hasUnsavedChanges = computed(
form.clinical_department !== savedProfile.value.clinical_department ||
Boolean(form.current_password || form.password || form.confirmPassword)
);
const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
@@ -247,72 +164,6 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined;
};
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const notificationPermissionText = computed(() => {
if (!isDesktop) return "不可用";
if (notificationPermission.value === "granted") return "系统已允许";
if (notificationPermission.value === "denied") return "系统已拒绝";
if (notificationPermission.value === "prompt") return "等待授权";
return "不可用";
});
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (!isDesktop || desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
notificationPermission.value = permission;
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(enable);
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const copyClientMetadata = async () => {
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
@@ -345,7 +196,7 @@ const onSubmit = async () => {
};
const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar,
@@ -368,27 +219,32 @@ const selectAndUploadAvatar = async () => {
onMounted(() => {
loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
</script>
<style scoped>
.page {
background: #fff;
}
.profile-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 620px;
height: min(720px, calc(100vh - 64px));
min-height: 0;
overflow: hidden;
background: #fff;
border-radius: 8px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
isolation: isolate;
}
.profile-aside {
min-height: 0;
padding: 48px 32px;
overflow: hidden;
border-right: 1px solid #e5ebf2;
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.avatar-panel {
@@ -442,8 +298,13 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
}
.profile-main {
min-height: 0;
padding: 42px 48px 36px;
overflow: hidden;
overflow-wrap: anywhere;
background: #fff;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
.profile-header {
@@ -455,10 +316,29 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
}
.dialog-close {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-top: -4px;
color: #718096;
padding: 0;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #f8fafc;
color: #475569;
}
.dialog-close:hover,
.dialog-close:focus-visible {
border-color: #b8c4d3;
background: #eef2f7;
color: #172033;
}
.dialog-close :deep(.el-icon) {
font-size: 18px;
}
.title {
@@ -483,59 +363,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2;
}
.form-section--desktop {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.desktop-setting-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.desktop-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.desktop-setting-hint {
color: #7f92ad;
font-size: 12px;
}
.client-metadata-panel {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.client-metadata-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
flex: 1;
min-width: 0;
margin: 0;
color: #40566f;
font-size: 12px;
}
.client-metadata-list dt {
color: #7f92ad;
font-weight: 700;
}
.client-metadata-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./ProjectNotifications.vue"), "utf8");
describe("project notification center contract", () => {
it("keeps read state separate from actionable business state", () => {
expect(source).toContain("已读不代表业务已完成");
expect(source).toContain("requires_action");
expect(source).not.toContain("include_resolved");
expect(source).toContain("markAllGeneralNotificationsRead");
expect(source).toContain('category.startsWith("VISIT_WINDOW_")');
expect(source).toContain('return "访视窗口"');
});
});
+739
View File
@@ -0,0 +1,739 @@
<template>
<section class="notification-page" aria-label="提醒中心已读不代表业务已完成">
<div class="notification-page__canvas">
<header class="notification-page__hero">
<div class="notification-page__header">
<div class="notification-page__intro">
<div class="notification-page__title-row">
<span class="notification-page__title-icon" aria-hidden="true">
<el-icon><Bell /></el-icon>
</span>
<h1>提醒中心</h1>
</div>
</div>
<div class="notification-page__summary" aria-label="提醒概览">
<div
class="notification-summary-item notification-summary-item--unread"
>
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><BellFilled /></el-icon>
</span>
<span class="notification-summary-item__label">未读</span>
<strong>{{ feed.unread_count }}</strong>
</div>
<div class="notification-summary-item">
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><Tickets /></el-icon>
</span>
<span class="notification-summary-item__label">当前</span>
<strong>{{ feed.total_count }}</strong>
</div>
</div>
<div class="notification-page__actions">
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">
刷新
</el-button>
<el-button
type="primary"
:icon="CircleCheck"
:disabled="feed.unread_count === 0"
:loading="markingAllRead"
@click="markAllRead"
>
全部标为已读
</el-button>
</div>
</div>
</header>
<section class="notification-content" aria-label="提醒列表">
<div class="notification-toolbar">
<div class="notification-toolbar__main">
<div class="notification-toolbar__title">
<strong>提醒列表</strong>
</div>
<el-radio-group v-model="filter" aria-label="提醒筛选" @change="resetAndLoad">
<el-radio-button value="active">当前提醒</el-radio-button>
<el-radio-button value="unread">仅未读</el-radio-button>
<el-radio-button value="action">仅待处理</el-radio-button>
</el-radio-group>
</div>
</div>
<div v-loading="loading" class="notification-list" aria-live="polite">
<button
v-for="item in feed.items"
:key="item.id"
type="button"
class="notification-row"
:class="{ 'is-read': item.read_at, 'is-resolved': item.resolved_at }"
@click="openNotification(item)"
>
<span class="notification-row__mark" :class="`is-${priorityTone(item.priority)}`"></span>
<span class="notification-row__main">
<span class="notification-row__heading">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">{{ categoryLabel(item.category) }}</el-tag>
<el-tag
v-if="item.requires_action && !item.resolved_at"
size="small"
type="warning"
effect="light"
>
待处理
</el-tag>
<el-tag v-else-if="item.resolved_at" size="small" type="info" effect="plain">
已结束
</el-tag>
</span>
<span class="notification-row__message">{{ item.message }}</span>
<span v-if="item.due_at" class="notification-row__due">
截止{{ formatDateTime(item.due_at) }}
</span>
</span>
<span class="notification-row__meta">
<span>{{ formatDateTime(item.created_at) }}</span>
<small :class="{ 'is-unread': !item.read_at }">
<span v-if="!item.read_at" aria-hidden="true"></span>
{{ item.read_at ? "已读" : "未读" }}
</small>
</span>
</button>
<el-empty
v-if="!loading && feed.items.length === 0"
class="notification-empty"
:image-size="88"
>
<template #description>
<div class="notification-empty__copy">
<strong>{{ emptyState }}</strong>
</div>
</template>
<el-button
v-if="filter !== 'active'"
text
type="primary"
@click="showAllNotifications"
>
查看当前提醒
</el-button>
</el-empty>
</div>
<div v-if="feed.total_count > pageSize" class="notification-pagination">
<el-pagination
v-model:current-page="page"
background
layout="prev, pager, next"
:page-size="pageSize"
:total="feed.total_count"
@current-change="loadNotifications"
/>
</div>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, BellFilled, CircleCheck, Refresh, Tickets } from "@element-plus/icons-vue";
import {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
type GeneralNotificationQuery,
} from "../api/notifications";
import {
notifyProjectNotificationsChanged,
PROJECT_NOTIFICATIONS_CHANGED_EVENT,
} from "../composables/useProjectNotifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
type NotificationFilter = "active" | "unread" | "action";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const markingAllRead = ref(false);
const filter = ref<NotificationFilter>("active");
const page = ref(1);
const pageSize = 20;
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
let requestId = 0;
const emptyState = computed(() => {
if (filter.value === "unread") return "没有未读提醒";
if (filter.value === "action") return "没有待处理事项";
return "当前暂无提醒";
});
const queryForFilter = (): GeneralNotificationQuery => {
const query: GeneralNotificationQuery = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
};
if (filter.value === "unread") query.unread_only = true;
if (filter.value === "action") query.requires_action = true;
return query;
};
const loadNotifications = async () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId) {
feed.value = { unread_count: 0, total_count: 0, items: [] };
return;
}
loading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, queryForFilter());
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
// 保留当前列表,网络恢复或窗口重新聚焦时会再次同步。
} finally {
if (currentRequest === requestId) loading.value = false;
}
};
const resetAndLoad = () => {
page.value = 1;
void loadNotifications();
};
const showAllNotifications = () => {
filter.value = "active";
resetAndLoad();
};
const markAllRead = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || markingAllRead.value) return;
markingAllRead.value = true;
try {
await markAllGeneralNotificationsRead(studyId);
notifyProjectNotificationsChanged();
await loadNotifications();
ElMessage.success("已将当前项目提醒全部标为已读");
} finally {
markingAllRead.value = false;
}
};
const openNotification = async (item: GeneralNotificationItem) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!item.read_at && !item.resolved_at) {
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => null);
if (response) notifyProjectNotificationsChanged();
}
if (item.action_path?.startsWith("/")) {
await router.push(item.action_path);
return;
}
await loadNotifications();
};
const priorityTone = (priority: GeneralNotificationItem["priority"]) => {
if (priority === "URGENT") return "danger";
if (priority === "HIGH") return "warning";
return "info";
};
const categoryLabel = (category: string) => {
if (category.startsWith("RISK_") || category.includes("MONITORING")) return "风险时效";
if (category.startsWith("DOCUMENT_")) return "文件回执";
if (category.startsWith("MILESTONE_")) return "项目里程碑";
if (category.startsWith("VISIT_WINDOW_")) return "访视窗口";
if (category.startsWith("COLLABORATION_")) return "在线协作";
return "业务通知";
};
const formatDateTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const handleNotificationChange = () => { void loadNotifications(); };
watch(() => study.currentStudy?.id, resetAndLoad);
onMounted(() => {
void loadNotifications();
window.addEventListener("focus", handleNotificationChange);
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
onBeforeUnmount(() => {
window.removeEventListener("focus", handleNotificationChange);
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
</script>
<style scoped>
.notification-page {
--notification-accent: var(--ctms-primary, #3f5d75);
--notification-accent-soft: rgba(63, 93, 117, 0.1);
min-height: 100%;
background: var(--ctms-bg-base, #f9fafb);
color: var(--ctms-text-main, #172033);
}
.notification-page__canvas {
width: min(100%, 1560px);
min-height: 100%;
margin: 0 auto;
display: grid;
grid-template-rows: auto minmax(320px, 1fr);
gap: 0;
align-content: stretch;
padding: clamp(10px, 1.4vw, 18px);
}
.notification-page__hero {
padding: 6px 4px 12px;
border-bottom: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-page__header {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 18px;
}
.notification-page__intro {
min-width: 0;
}
.notification-page__title-row {
display: flex;
align-items: center;
gap: 10px;
}
.notification-page__title-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(63, 93, 117, 0.12);
border-radius: 9px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 4px 12px rgba(38, 72, 98, 0.06);
color: var(--notification-accent);
font-size: 17px;
}
.notification-page__header h1 {
margin: 0;
color: var(--ctms-text-main, #172033);
font-size: 22px;
font-weight: 750;
letter-spacing: -0.02em;
line-height: 1.2;
}
.notification-page__actions {
display: flex;
flex: 0 0 auto;
gap: 10px;
}
.notification-page__summary {
display: flex;
align-items: center;
justify-content: start;
}
.notification-summary-item {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 18px;
border-right: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-summary-item:first-child {
padding-left: 0;
}
.notification-summary-item:last-child {
padding-right: 0;
border-right: 0;
}
.notification-summary-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: var(--ctms-text-secondary, #64748b);
font-size: 15px;
}
.notification-summary-item--unread .notification-summary-item__icon {
color: var(--notification-accent);
}
.notification-summary-item__label {
min-width: 0;
color: var(--ctms-text-regular, #334155);
font-size: 12px;
font-weight: 650;
}
.notification-summary-item strong {
min-width: 1ch;
margin-left: 4px;
color: var(--ctms-text-main, #172033);
font-size: 19px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.notification-content {
display: flex;
height: 100%;
min-height: 320px;
flex-direction: column;
background: transparent;
}
.notification-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: transparent;
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-toolbar__main {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.notification-toolbar__title {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.notification-toolbar__title strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
}
.notification-toolbar :deep(.el-radio-button__inner) {
min-height: 28px;
padding: 5px 12px;
font-weight: 550;
box-shadow: none;
}
.notification-list {
position: relative;
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
padding: 0;
}
.notification-row {
width: 100%;
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
gap: 12px;
align-items: flex-start;
padding: 12px 10px;
border: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, box-shadow 160ms ease;
}
.notification-row:hover {
background: var(--ctms-neutral-100, #f5f7fa);
box-shadow: inset 3px 0 0 rgba(63, 93, 117, 0.24);
}
.notification-row:focus-visible {
outline: 2px solid var(--notification-accent);
outline-offset: -2px;
}
.notification-row.is-read {
opacity: 0.76;
}
.notification-row.is-resolved {
opacity: 0.58;
}
.notification-row__mark {
width: 8px;
height: 8px;
margin-top: 7px;
border-radius: 999px;
background: var(--notification-accent);
box-shadow: 0 0 0 4px var(--notification-accent-soft);
}
.notification-row__mark.is-danger {
background: var(--ctms-danger, #c24b4b);
box-shadow: 0 0 0 4px rgba(194, 75, 75, 0.1);
}
.notification-row__mark.is-warning {
background: var(--ctms-warning, #c58b2a);
box-shadow: 0 0 0 4px rgba(197, 139, 42, 0.11);
}
.notification-row__main,
.notification-row__meta {
display: flex;
flex-direction: column;
}
.notification-row__main { gap: 6px; }
.notification-row__heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.notification-row__heading strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
font-weight: 650;
}
.notification-row__message,
.notification-row__due,
.notification-row__meta {
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-row__message {
line-height: 1.6;
}
.notification-row__due {
color: #a16207;
font-weight: 550;
}
.notification-row__meta {
align-items: flex-end;
gap: 6px;
white-space: nowrap;
}
.notification-row__meta small {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
background: var(--ctms-neutral-100, #f5f7fa);
color: var(--ctms-text-secondary, #64748b);
font-size: 10px;
}
.notification-row__meta small.is-unread {
background: var(--notification-accent-soft);
color: var(--notification-accent);
font-weight: 650;
}
.notification-row__meta small > span {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.notification-empty {
display: flex;
min-height: 240px;
flex: 1;
align-items: center;
justify-content: center;
padding: 22px 16px 28px;
}
.notification-empty :deep(.el-empty__image) {
opacity: 0.72;
filter: saturate(0.55);
}
.notification-empty :deep(.el-empty__description) {
margin-top: 14px;
}
.notification-empty__copy { text-align: center; }
.notification-empty__copy strong {
color: var(--ctms-text-regular, #334155);
font-size: 14px;
font-weight: 650;
}
.notification-empty :deep(.el-empty__bottom) {
margin-top: 8px;
}
.notification-pagination {
display: flex;
justify-content: flex-end;
padding: 14px 20px 18px;
border-top: 1px solid var(--el-border-color-lighter);
}
:global([data-ctms-theme="dark"]) .notification-page {
--notification-accent-soft: rgba(143, 183, 212, 0.14);
background: var(--ctms-bg-base);
}
:global([data-ctms-theme="dark"]) .notification-page__title-icon {
border-color: rgba(143, 183, 212, 0.16);
background: rgba(17, 24, 39, 0.72);
}
:global(.desktop-workbench) .notification-page__canvas {
padding: 10px;
}
:global(.desktop-workbench) .notification-page__hero {
padding: 4px 2px 10px;
}
@media (max-width: 1100px) {
.notification-page__header {
grid-template-columns: 1fr auto;
}
.notification-page__summary {
grid-column: 1 / -1;
grid-row: 2;
}
}
@media (max-width: 767px) {
.notification-page__canvas {
grid-template-rows: auto minmax(320px, 1fr);
padding: 8px;
}
.notification-page__hero {
padding: 4px 2px 10px;
}
.notification-toolbar,
.notification-toolbar__main {
flex-direction: column;
align-items: stretch;
}
.notification-page__header {
grid-template-columns: 1fr;
gap: 10px;
}
.notification-page__actions {
width: 100%;
}
.notification-page__actions :deep(.el-button) {
flex: 1;
}
.notification-page__summary {
grid-column: 1;
grid-row: auto;
}
.notification-toolbar {
gap: 8px;
padding: 9px 10px;
}
.notification-toolbar__main {
gap: 12px;
}
.notification-toolbar :deep(.el-radio-group) {
display: flex;
width: 100%;
}
.notification-toolbar :deep(.el-radio-button) {
flex: 1;
}
.notification-toolbar :deep(.el-radio-button__inner) {
width: 100%;
padding-right: 8px;
padding-left: 8px;
}
.notification-row {
grid-template-columns: 8px minmax(0, 1fr);
padding: 16px 8px;
}
.notification-row__meta {
grid-column: 2;
align-items: flex-start;
flex-direction: row;
align-items: center;
}
}
@media (max-width: 520px) {
.notification-page__title-icon {
width: 34px;
height: 34px;
}
.notification-page__header h1 {
font-size: 21px;
}
.notification-summary-item {
min-height: 30px;
padding: 0 12px;
}
}
</style>
+9 -27
View File
@@ -8,13 +8,7 @@
<div class="brand-content-wrapper">
<!-- 顶部系统微标 -->
<div class="brand-mini-logo">
<div class="mini-logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<img class="mini-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<span class="mini-logo-text">华邦制药 · CTMS</span>
</div>
@@ -77,13 +71,7 @@
<div class="reg-card-container">
<!-- 顶部 Logo -->
<div class="reg-brand-header">
<div class="brand-logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<img class="brand-logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
<div class="brand-divider"></div>
<span class="brand-system-text">新用户注册</span>
</div>
@@ -380,7 +368,7 @@ import {
sendRegisterEmailCode,
verifyRegisterEmailCode,
} from "../api/auth";
import type { ApiError, RegisterRequest } from "../types/api";
import type { ApiError } from "../types/api";
import { TEXT, requiredMessage } from "../locales";
import { privacyPolicySections, serviceTermsSections } from "../content/authProtocol";
@@ -750,8 +738,6 @@ onUnmounted(() => {
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/* ═══════════════════════
根容器
═══════════════════════ */
@@ -823,14 +809,12 @@ onUnmounted(() => {
}
.mini-logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 55px;
height: 55px;
background: #2563eb;
background: #ffffff;
border-radius: 14px;
color: white;
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.18);
object-fit: contain;
}
.mini-logo-text {
@@ -991,14 +975,12 @@ onUnmounted(() => {
}
.brand-logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: #2563eb;
background: #ffffff;
border-radius: 9px;
color: white;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.16);
object-fit: contain;
}
.brand-divider {
-371
View File
@@ -1,371 +0,0 @@
<template>
<div class="study-home-container unified-shell" v-if="study.currentStudy">
<!-- 概览头部 -->
<div class="home-header">
<div class="header-content">
<h1 class="welcome-title">{{ TEXT.modules.projectOverview.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.projectOverview.subtitle }}</p>
<p class="study-info">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
<span class="study-code">{{ study.currentStudy.code }}</span>
</p>
</div>
<div class="header-meta">
<el-tag effect="plain" class="role-tag">{{ TEXT.common.labels.role }}: {{ roleLabel }}</el-tag>
</div>
</div>
<!-- 数据指标网格 -->
<div class="stats-grid">
<div class="stats-item">
<KpiCard
:title="TEXT.modules.projectOverview.kpiProgress"
:value="progress?.milestones_done ?? 0"
:unit="` / ${progress?.milestones_total ?? 0}`"
:subtext="milestoneCompletionRate"
:loading="loading.progress"
:icon="List"
/>
</div>
<div class="stats-item">
<KpiCard
:title="TEXT.modules.projectOverview.kpiOverdueAes"
:value="overdueAes"
:unit="TEXT.common.units.case"
:subtext="TEXT.modules.projectOverview.kpiOverdueAesHint"
:loading="loading.overdueAes"
:icon="Warning"
/>
</div>
<div v-if="canReadFinanceContracts" class="stats-item">
<KpiCard
:title="TEXT.modules.projectOverview.kpiFinanceTotal"
:value="formatAmount(financeSummary?.total_amount ?? 0)"
:subtext="`${TEXT.modules.projectOverview.kpiFinancePaid}:¥${formatAmount(financeSummary?.paid_amount ?? 0)}`"
:loading="loading.finance"
:icon="Money"
/>
</div>
</div>
<!-- 通知/警告 -->
<el-alert
v-if="overdueAes > 0"
type="warning"
:title="TEXT.modules.projectOverview.alertWarning"
show-icon
:closable="false"
class="home-alert"
/>
<el-alert
v-else
type="success"
:title="TEXT.modules.projectOverview.alertOk"
show-icon
:closable="false"
class="home-alert"
/>
<!-- 通知 -->
<el-card class="notifications-card unified-shell">
<div class="notifications-header">
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
</div>
<el-skeleton v-if="loading.notifications" :rows="3" animated />
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
<div v-else class="notification-list">
<div class="notification-item" :class="{ 'is-unread': !item.read_at }" v-for="item in notifications" :key="item.id">
<div class="notification-main">
<div class="notification-title">
<span class="notification-doc">{{ item.document_title }}</span>
<span class="notification-version">V{{ item.version_no }}</span>
</div>
<div class="notification-meta">
<span class="notification-time">{{ formatDate(item.effective_at || item.created_at) }}</span>
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
</div>
</div>
<el-button link type="primary" @click="openDocument(item)">
{{ TEXT.common.actions.view }}
</el-button>
</div>
</div>
</el-card>
<!-- 快捷模块 -->
<div class="quick-nav-section">
<QuickActions />
</div>
</div>
<div v-else class="empty-state-container">
<StateEmpty :description="TEXT.common.empty.selectProject" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
import { listNotifications } from "../api/notifications";
import { markDesktopNotificationRead } from "../api/desktopNotifications";
import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue";
import { getProjectRole, isSystemAdmin } from "../utils/roles";
import { isApiPermissionAllowed } from "../utils/apiPermissionValue";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { TEXT } from "../locales";
import type { NotificationItem } from "../types/notifications";
import { useRouter } from "vue-router";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const progress = ref<any>(null);
const financeSummary = ref<any>(null);
const overdueAes = ref<number>(0);
const notifications = ref<NotificationItem[]>([]);
const loading = ref({
progress: false,
finance: false,
overdueAes: false,
notifications: false,
});
const milestoneCompletionRate = computed(() => {
const total = progress.value?.milestones_total ?? 0;
if (!total) return TEXT.modules.projectOverview.noMilestones;
const rate = (progress.value?.milestones_done ?? 0) / total;
return TEXT.modules.projectOverview.progressLabel.replace("{rate}", `${(rate * 100).toFixed(0)}%`);
});
const projectRole = computed(() => {
if (isSystemAdmin(auth.user)) return "ADMIN";
return getProjectRole(study.currentStudy, study.currentStudyRole);
});
const roleLabel = computed(() => displayRoleLabel(projectRole.value));
const canReadFinanceContracts = computed(() => {
if (isSystemAdmin(auth.user)) return true;
const role = projectRole.value;
if (!role) return false;
return isApiPermissionAllowed(study.currentPermissions?.[role]?.["fees_contracts:read"]);
});
const formatAmount = (val: number) => {
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2 }).format(val);
};
const loadData = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value.progress = true;
loading.value.finance = canReadFinanceContracts.value;
loading.value.overdueAes = true;
loading.value.notifications = true;
try {
const [pRes, fRes, aesRes, nRes] = await Promise.allSettled([
fetchProgress(studyId),
canReadFinanceContracts.value ? fetchFinanceSummary(studyId) : Promise.resolve(null),
fetchOverdueAesCount(studyId),
listNotifications(studyId, { limit: 5 }),
]);
if (pRes.status === "fulfilled") progress.value = pRes.value.data;
if (fRes.status === "fulfilled" && fRes.value) financeSummary.value = fRes.value.data;
if (aesRes.status === "fulfilled") overdueAes.value = aesRes.value.data?.total ?? 0;
if (nRes.status === "fulfilled") notifications.value = nRes.value.data || [];
} finally {
loading.value.progress = false;
loading.value.finance = false;
loading.value.overdueAes = false;
loading.value.notifications = false;
}
};
const resetData = () => {
progress.value = null;
financeSummary.value = null;
overdueAes.value = 0;
notifications.value = [];
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const openDocument = async (item: NotificationItem) => {
await markDesktopNotificationRead(item.id).catch(() => {});
item.read_at = new Date().toISOString();
router.push(`/documents/${item.document_id}`);
};
onMounted(() => {
loadRoleTemplates();
loadData();
});
watch(
() => study.currentStudy?.id,
() => {
resetData();
loadData();
}
);
</script>
<style scoped>
.study-home-container {
display: flex;
flex-direction: column;
gap: 24px;
}
.home-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 4px;
}
.welcome-title {
font-size: 26px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0;
}
.page-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
margin: 6px 0 0;
}
.study-info {
margin: 6px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.study-divider {
opacity: 0.3;
}
.role-tag {
background-color: #ffffff;
border-color: var(--ctms-border-color);
color: var(--ctms-text-regular);
font-weight: 500;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.home-alert {
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius);
}
.notifications-card {
border-radius: 16px;
}
.notifications-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
}
.notifications-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.notifications-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.notification-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.notification-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-radius: 12px;
background: var(--ctms-bg-muted);
}
.notification-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.notification-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--ctms-text-main);
}
.notification-version {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.notification-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.notification-summary {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quick-nav-section {
margin-top: 8px;
}
.empty-state-container {
padding-top: 100px;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEntryView = () => readFileSync(resolve(__dirname, "./WebWorkbenchEntry.vue"), "utf8");
describe("WebWorkbenchEntry", () => {
it("keeps the web login entry flow aligned with desktop without importing desktop runtime APIs", () => {
const source = readEntryView();
expect(source).toContain("Web Workspace");
expect(source).toContain("选择项目或进入管理界面");
expect(source).toContain("工作台操作规则");
expect(source).toContain("登录后进入工作台");
expect(source).toContain("换项目先回工作台");
expect(source).toContain("项目内不直接切换项目");
expect(source).toContain("项目工作区");
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
expect(source).toContain('<span class="meta-label">我的角色</span>');
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
expect(source).not.toContain('project.role_in_study && !isAdmin');
expect(source).toContain("fetchStudies()");
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
expect(source).toContain('v-if="canEnterManagement"');
expect(source).toContain('<strong>系统管理</strong>');
expect(source).toContain('class="admin-arrow"');
expect(source).toContain('loggingOut ? "正在退出" : "退出登录"');
expect(source).toContain('<AccountConnectionStatus mode="network" />');
expect(source).not.toContain("当前操作员");
expect(source).not.toContain('class="admin-desc"');
expect(source).toContain('const managementActionLabel = computed(() => canEnterManagement.value ? "进入系统管理" : "");');
expect(source).toContain("studyStore.clearCurrentStudy()");
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
expect(source).toContain("projects.value.find((project) => project.role_in_study === \"PM\")");
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
expect(source).toContain("studyStore.setCurrentStudy(project)");
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
expect(source).toContain("findFirstAccessibleProjectPath");
expect(source).toContain("当前账号暂无该项目可访问模块");
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
expect(source).not.toContain("isTauriRuntime");
expect(source).not.toContain("@tauri-apps");
expect(source).not.toContain("data-tauri-drag-region");
expect(source).not.toContain("Desktop Workbench");
});
});
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import ApiPermissions from "@/views/admin/ApiPermissions.vue";
import { createPinia, setActivePinia } from "pinia";
vi.mock("vue-router", async () => {
const actual = await vi.importActual<typeof import("vue-router")>("vue-router");
return {
...actual,
useRoute: () => ({ params: { id: "study-1" } }),
};
});
vi.mock("@/api/projectPermissions", () => ({
fetchApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
updateApiEndpointPermissions: vi.fn().mockResolvedValue({ data: {} }),
fetchPermissionMetrics: vi.fn().mockResolvedValue({ data: {} }),
fetchCacheStats: vi.fn().mockResolvedValue({ data: {} }),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
fetchPermissionHealth: vi.fn().mockResolvedValue({ data: {} }),
resetPermissionMetrics: vi.fn().mockResolvedValue({ data: undefined }),
}));
describe("ApiPermissions.vue", () => {
beforeEach(() => {
Object.defineProperty(window, "localStorage", {
value: {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
configurable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value: window.localStorage,
configurable: true,
});
setActivePinia(createPinia());
});
it("renders permission management page", () => {
const wrapper = mount(ApiPermissions, {
global: {
stubs: {
ApiEndpointPermissions: true,
PermissionMonitoring: true,
},
},
});
expect(wrapper.find(".permission-page").exists()).toBe(true);
expect(wrapper.find(".permission-title h2").text()).toBe("权限管理");
});
it("renders tabs", () => {
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
expect(source).toContain('label="角色权限"');
expect(source).not.toContain('label="权限监控"');
expect(source).not.toContain("<PermissionMonitoring");
});
it("has save button disabled when not dirty", () => {
const source = readFileSync(resolve(__dirname, "./ApiPermissions.vue"), "utf8");
expect(source).toContain(':disabled="!dirty"');
});
});
-229
View File
@@ -1,229 +0,0 @@
<template>
<div class="permission-page">
<div class="permission-shell unified-shell" v-loading="loading">
<!-- 顶部操作栏 -->
<div class="permission-header unified-action-bar">
<div class="permission-title">
<el-icon><Key /></el-icon>
<h2>权限管理</h2>
</div>
<div class="permission-project-meta">
<span class="meta-item">
<span class="meta-label">项目编号</span>
<strong>{{ project?.code || "-" }}</strong>
</span>
<span class="meta-separator" />
<span class="meta-item">
<span class="meta-label">项目名称</span>
<strong>{{ project?.name || "-" }}</strong>
</span>
</div>
<div class="permission-actions">
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
<el-icon><Check /></el-icon>
保存
</el-button>
</div>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab">
<!-- 接口级权限 -->
<el-tab-pane label="角色权限" name="api">
<PermissionTemplateSelector
:study-id="studyId"
:current-permissions="currentPermissionsForTemplate"
@applied="onTemplateApplied"
/>
<el-divider />
<ApiEndpointPermissions
:project="project"
:matrix="apiMatrix"
@update="onApiMatrixUpdate"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Key, Check } from "@element-plus/icons-vue";
import type {
ApiEndpointPermissionsResponse,
} from "@/types/api";
import {
fetchApiEndpointPermissions,
updateApiEndpointPermissions,
} from "@/api/projectPermissions";
import { useStudyStore } from "@/store/study";
import ApiEndpointPermissions from "@/components/ApiEndpointPermissions.vue";
import PermissionTemplateSelector from "@/components/PermissionTemplateSelector.vue";
const route = useRoute();
const studyStore = useStudyStore();
const activeTab = ref<"api">("api");
const loading = ref(false);
const saving = ref(false);
const project = computed(() => studyStore.currentStudy);
const studyId = computed(() => {
const id = route.params.id;
return typeof id === "string" ? id : (id as string[])[0];
});
// 权限数据
const apiMatrix = ref<ApiEndpointPermissionsResponse | null>(null);
// 脏值检测
const dirty = ref(false);
const loadPermissionData = async () => {
if (!studyId.value) return;
loading.value = true;
try {
const apiRes = await fetchApiEndpointPermissions(studyId.value);
apiMatrix.value = apiRes.data;
dirty.value = false;
} catch (error) {
ElMessage.error("加载权限数据失败");
console.error(error);
} finally {
loading.value = false;
}
};
const onApiMatrixUpdate = (newMatrix: ApiEndpointPermissionsResponse) => {
apiMatrix.value = newMatrix;
dirty.value = true;
};
// 将当前 apiMatrix 转换为模板所需的 {role: {endpoint_key: bool}} 格式
const currentPermissionsForTemplate = computed(() => {
if (!apiMatrix.value) return undefined;
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(apiMatrix.value)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
});
const onTemplateApplied = async (permissions: Record<string, Record<string, { allowed: boolean }>>) => {
// 模板应用后刷新权限矩阵
await loadPermissionData();
ElMessage.success("权限已更新");
};
const flattenMatrix = (matrix: ApiEndpointPermissionsResponse): Record<string, Record<string, boolean>> => {
const result: Record<string, Record<string, boolean>> = {};
for (const [role, endpoints] of Object.entries(matrix)) {
result[role] = {};
for (const [key, val] of Object.entries(endpoints)) {
result[role][key] = typeof val === "boolean" ? val : val.allowed;
}
}
return result;
};
const save = async () => {
if (!studyId.value || !dirty.value) return;
saving.value = true;
try {
if (apiMatrix.value) {
const res = await updateApiEndpointPermissions(studyId.value, flattenMatrix(apiMatrix.value));
apiMatrix.value = res.data;
}
dirty.value = false;
ElMessage.success("权限已保存");
await loadPermissionData();
} catch (error) {
ElMessage.error("保存权限失败");
console.error(error);
} finally {
saving.value = false;
}
};
onMounted(() => {
loadPermissionData();
});
</script>
<style scoped lang="scss">
.permission-page {
padding: 20px;
}
.permission-shell {
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.permission-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid #ebeef5;
gap: 20px;
}
.permission-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
h2 {
margin: 0;
font-size: 18px;
}
}
.permission-project-meta {
display: flex;
align-items: center;
gap: 20px;
flex: 1;
font-size: 14px;
color: #606266;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
}
.meta-label {
color: #909399;
}
.meta-separator {
width: 1px;
height: 20px;
background: #dcdfe6;
}
.permission-actions {
display: flex;
gap: 10px;
}
:deep(.el-tabs) {
padding: 20px;
}
</style>
+58 -11
View File
@@ -8,13 +8,21 @@ describe("audit logs access", () => {
it("keeps the audit summary header compact", () => {
const source = readAuditLogsView();
expect(source).toContain("padding: 10px 16px;");
expect(source).toContain("padding: 9px 12px;");
expect(source).toContain("width: 32px;");
expect(source).toContain("height: 32px;");
expect(source).toContain(".stat-icon svg { width: 18px; height: 18px; }");
expect(source).toContain(".stat-value { font-size: 20px;");
expect(source).toContain(".stat-label { font-size: 11px;");
expect(source).toContain('class="audit-overview"');
expect(source).toContain("min-height: 48px;");
expect(source).toContain("width: 30px;");
expect(source).toContain("height: 30px;");
expect(source).toContain(".stat-icon svg { width: 17px; height: 17px; }");
expect(source).toContain(".stat-value { font-size: 18px;");
expect(source).toContain(".stat-label { font-size: 10px;");
expect(source).toContain("content-wrapper:has(.audit-logs-page)");
expect(source).toContain(".table-section { padding: 0 !important; }");
expect(source).toContain("position: sticky;");
expect(source).toContain('class="filter-item-form project-filter-group"');
expect(source).toContain('class="project-export-button"');
expect(source).toContain('@click="confirmExport"');
expect(source).not.toContain('class="audit-export-btn"');
expect(source).not.toContain("handleExportCommand");
});
it("loads audit logs from the selected project context", () => {
@@ -67,6 +75,30 @@ describe("audit logs access", () => {
expect(source).not.toContain("limit: 2000");
});
it("keeps the filter bar focused without request-source or IP-location inputs", () => {
const source = readAuditLogsView();
expect(source).not.toContain("filters.clientType");
expect(source).not.toContain("filters.ipKeyword");
expect(source).not.toContain("TEXT.modules.adminAuditLogs.filterSource");
expect(source).not.toContain("TEXT.modules.adminAuditLogs.filterIpLocation");
expect(source).not.toContain("TEXT.modules.adminAuditLogs.columns.source");
expect(source).not.toContain("formatClientIpLine(scope.row)");
expect(source).not.toContain("isIpSearchToken");
});
it("includes request source context in audit exports", () => {
const columns = readFileSync(resolve(__dirname, "../../audit/export/auditExportColumns.ts"), "utf8");
const formatter = readFileSync(resolve(__dirname, "../../audit/export/auditExportFormatter.ts"), "utf8");
expect(columns).toContain('key: "ipLocation"');
expect(columns).toContain('key: "clientSource"');
expect(columns).toContain('key: "userAgent"');
expect(formatter).toContain("ip: event.clientIp || \"\"");
expect(formatter).toContain("clientSource: event.clientSourceLabel || \"\"");
expect(formatter).toContain("userAgent: event.userAgent || \"\"");
});
it("keeps the audit table compact by removing the duplicate content column", () => {
const source = readAuditLogsView();
@@ -84,13 +116,16 @@ describe("audit logs access", () => {
expect(source).not.toContain("getInitials(scope.row.actorName)");
});
it("lets target and detail columns absorb remaining table width", () => {
it("allocates audit columns by typical content length", () => {
const source = readAuditLogsView();
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">');
expect(source).toContain('prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128"');
expect(source).toContain('prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155"');
expect(source).toContain('prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170"');
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">');
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.target" width="240"');
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.diff" width="360"');
expect(source).toContain('width="72" align="center" class-name="result-column"');
expect(source).toContain("white-space: nowrap;");
});
it("parses diff lines by the last field separator before the arrow", () => {
@@ -109,6 +144,18 @@ describe("audit logs access", () => {
expect(source).not.toContain("meta-card-full");
});
it("keeps the audit detail focused on business changes instead of technical request context", () => {
const source = readAuditLogsView();
expect(source).not.toContain('<span class="meta-label">请求来源</span>');
expect(source).not.toContain('<span class="meta-label">来源 IP</span>');
expect(source).not.toContain('<span class="meta-label">IP 位置</span>');
expect(source).not.toContain('<span class="meta-label">客户端</span>');
expect(source).not.toContain("访问上下文");
expect(source).not.toContain("formatClientMeta");
expect(source).not.toContain("formatBuildMeta");
});
it("groups setup diff detail rows by business item path", () => {
const source = readAuditLogsView();
+210 -93
View File
@@ -1,8 +1,13 @@
<template>
<div class="page page--flush">
<div class="page page--flush audit-logs-page">
<div class="main-content-flat unified-shell">
<!-- 统计概览 -->
<div class="stats-row">
<section class="audit-overview" aria-labelledby="audit-overview-title">
<div class="overview-heading">
<h2 id="audit-overview-title">审计概览</h2>
<span class="overview-status"><i></i>日志汇总</span>
</div>
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
@@ -35,19 +40,34 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ operatorCount }}</span>
<span class="stat-label">操作人数</span>
<span class="stat-value">{{ uniqueIpCount }}</span>
<span class="stat-label">来源 IP</span>
</div>
</div>
</div>
</div>
</section>
<!-- 筛选栏 -->
<div class="audit-toolbar unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item-form">
<div class="filter-heading">
<el-icon><Filter /></el-icon>
<span>筛选</span>
</div>
<div class="filter-item-form project-filter-group">
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
</el-select>
<el-tooltip content="导出当前项目日志" placement="top">
<el-button
v-if="canProjectExport"
:icon="Download"
class="project-export-button"
:loading="exportLoading"
aria-label="导出当前项目日志"
@click="confirmExport"
/>
</el-tooltip>
</div>
<div class="filter-item-form">
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
@@ -78,42 +98,32 @@
@change="onLocalFilterChange"
/>
</div>
<div class="filter-spacer"></div>
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
<el-button plain class="audit-export-btn" :loading="exportLoading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置</el-button>
</el-form>
</div>
<!-- 日志表格 -->
<div class="unified-section table-section">
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="150">
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="128">
<template #default="scope">
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
</template>
</el-table-column>
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="120">
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="155">
<template #default="scope">
<div class="actor-cell">
<span class="actor-name">{{ scope.row.actorName }}</span>
<span v-if="scope.row.actorAccount" class="actor-account">{{ scope.row.actorAccount }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="145">
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="170">
<template #default="scope">
<span class="event-tag">{{ scope.row.eventLabel }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" width="280">
<template #default="scope">
<div class="target-cell">
<span v-if="scope.row.targetTypeLabel" class="target-text">
@@ -135,7 +145,7 @@
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="72" align="center" class-name="result-column">
<template #default="scope">
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
{{ scope.row.resultLabel }}
@@ -211,7 +221,6 @@
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
</div>
<!-- 变更明细 -->
<div class="detail-section">
<div class="detail-section-title">
@@ -256,7 +265,7 @@
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import { Download, Filter, RefreshLeft } from "@element-plus/icons-vue";
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
import { fetchStudies } from "../../api/studies";
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
@@ -265,7 +274,6 @@ import { listMembers } from "../../api/members";
import { auditDict, normalizeAuditEvent } from "../../audit";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
@@ -302,7 +310,13 @@ const filters = ref({
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
const uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
const hasActiveFilters = computed(() => Boolean(
filters.value.eventType ||
filters.value.operatorId ||
filters.value.result ||
filters.value.range?.length
));
const formatDiffLine = (line: any): string => {
if (typeof line === 'string') return line;
@@ -470,6 +484,13 @@ const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
return allItems;
};
const buildAuditServerParams = () => {
return {
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
};
};
const loadLogs = async () => {
if (!selectedStudyId.value) return;
loading.value = true;
@@ -477,10 +498,7 @@ const loadLogs = async () => {
if (!permissionMatrix.value) {
await loadPermissionMatrix();
}
const allItems = await fetchAuditLogPages({
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
});
const allItems = await fetchAuditLogPages(buildAuditServerParams());
enrichLogs(allItems);
refreshPagedLogs();
} catch (e: any) {
@@ -498,15 +516,13 @@ const enrichLogs = (items: any[]) => {
allLogs.value = items
.map((log) => normalizeAuditEvent(log, userMap))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
if (users.value.length === 0) {
const byActor = new Map<string, string>();
allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorId);
}
});
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
}
const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)]));
allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorAccount || log.actorId);
}
});
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
};
const filterLogs = (items: any[]) =>
@@ -540,6 +556,14 @@ const onLocalFilterChange = () => {
refreshPagedLogs();
};
const resetFilters = () => {
filters.value.eventType = "";
filters.value.operatorId = "";
filters.value.result = "";
filters.value.range = [];
onServerFilterChange();
};
const onProjectChange = async () => {
permissionMatrix.value = null;
users.value = [];
@@ -582,24 +606,12 @@ const fetchAllForExport = async () => {
if (!selectedStudyId.value) return [];
exportLoading.value = true;
try {
const items = await fetchAuditLogPages({
action: filters.value.eventType || undefined,
operator_id: filters.value.operatorId || undefined,
});
const items = await fetchAuditLogPages(buildAuditServerParams());
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
}, {});
const filtered = items.filter((log: any) => {
if (filters.value.range?.length === 2) {
const ts = new Date(log.created_at);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
return filterLogs(items.map((log: any) => normalizeAuditEvent(log, userMap)));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
return [];
@@ -608,10 +620,6 @@ const fetchAllForExport = async () => {
}
};
const handleExportCommand = (command: string) => {
if (command === "project") confirmExport();
};
const confirmExport = async () => {
const currentStudy = selectedStudy.value;
if (!currentStudy) return;
@@ -647,33 +655,77 @@ onMounted(async () => {
</script>
<style scoped>
:global(.web-layout-container .content-wrapper:has(.audit-logs-page)) {
padding: 0;
}
/* 统计卡片 */
.audit-overview {
padding: 0;
border-bottom: 1px solid var(--unified-shell-divider);
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
}
.overview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
padding: 6px 16px;
}
.overview-heading h2 {
margin: 0;
color: var(--ctms-text-main);
font-size: 14px;
font-weight: 700;
}
.overview-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border: 1px solid rgba(63, 143, 107, 0.18);
border-radius: 999px;
background: rgba(63, 143, 107, 0.07);
color: var(--ctms-success);
font-size: 10px;
white-space: nowrap;
}
.overview-status i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
gap: 0;
border-top: 1px solid var(--unified-shell-divider);
background: var(--ctms-bg-card);
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
min-height: 48px;
padding: 6px 14px;
background: transparent;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
.stat-card + .stat-card {
border-left: 1px solid var(--unified-shell-divider);
}
.stat-icon {
width: 32px;
height: 32px;
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
@@ -681,19 +733,27 @@ onMounted(async () => {
flex-shrink: 0;
}
.stat-icon svg { width: 18px; height: 18px; }
.stat-icon svg { width: 17px; height: 17px; }
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
.stat-body { display: flex; flex-direction: column; }
.stat-value { font-size: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 11px; color: var(--ctms-text-secondary); margin-top: 2px; }
.stat-value { font-size: 18px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 10px; color: var(--ctms-text-secondary); margin-top: 2px; }
/* 筛选栏 */
.audit-toolbar {
position: sticky;
top: 0;
z-index: 8;
border-bottom: 1px solid var(--unified-shell-divider);
background: color-mix(in srgb, var(--ctms-bg-card) 95%, transparent);
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
padding-top: 8px;
padding-bottom: 8px;
}
.filter-form {
@@ -701,7 +761,7 @@ onMounted(async () => {
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-wrap: wrap;
}
.filter-item-form {
@@ -710,29 +770,65 @@ onMounted(async () => {
flex-shrink: 0;
}
.project-filter-group {
display: inline-flex;
align-items: center;
gap: 0;
}
.filter-select-project :deep(.el-select__wrapper) {
border-radius: 8px 0 0 8px;
}
.project-export-button {
width: 32px;
min-width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
margin-left: -1px;
border-radius: 0 8px 8px 0;
color: var(--ctms-primary);
border-color: color-mix(in srgb, var(--ctms-primary) 24%, var(--ctms-border-color));
background: color-mix(in srgb, var(--ctms-primary) 7%, var(--ctms-bg-card));
}
.filter-heading {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--ctms-text-secondary);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.filter-form :deep(.el-input__inner),
.filter-form :deep(.el-select__placeholder),
.filter-form :deep(.el-select__selected-item),
.filter-form :deep(.el-range-input),
.filter-form :deep(.el-button) {
font-size: 12px;
}
.filter-form :deep(.el-select__wrapper),
.filter-form :deep(.el-input__wrapper),
.filter-form :deep(.el-date-editor) {
min-height: 32px;
box-shadow: 0 0 0 1px var(--ctms-border-color) inset;
}
.reset-filter-button {
color: var(--ctms-text-secondary);
}
.filter-select-comp { width: 132px; }
.filter-select-project { width: 180px; }
.filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; }
.audit-export-dropdown { flex-shrink: 0; }
.audit-export-btn {
border-radius: 8px !important;
font-weight: 600;
height: 32px;
color: var(--ctms-primary) !important;
border-color: #c0cdd7 !important;
background: #f0f6ff !important;
}
.audit-export-btn:hover {
color: var(--ctms-primary-hover) !important;
border-color: #9bb1c2 !important;
background: #e2edfa !important;
}
/* 表格 */
.table-section { padding: 0; }
.table-section { padding: 0 !important; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.audit-table :deep(.el-table__cell) {
padding: 8px 0;
@@ -748,17 +844,29 @@ onMounted(async () => {
}
.actor-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.actor-name {
.actor-name,
.actor-account {
display: block;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actor-name {
font-size: 13px;
}
.actor-account {
font-size: 11px;
color: var(--ctms-text-secondary);
}
.event-tag {
display: inline-flex;
padding: 2px 8px;
@@ -807,6 +915,12 @@ onMounted(async () => {
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.audit-table :deep(.result-column .cell) {
padding-right: 4px;
padding-left: 4px;
}
.result--success { background: #e6f4ed; color: #166534; }
@@ -1057,6 +1171,9 @@ onMounted(async () => {
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-wrap: wrap; }
.stat-card:nth-child(3) { border-left: 0; }
.stat-card:nth-child(n + 3) { border-top: 1px solid var(--unified-shell-divider); }
.filter-heading { width: 100%; }
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
}
</style>
File diff suppressed because it is too large Load Diff
@@ -13,15 +13,15 @@ describe("permission management custom roles", () => {
const listTabEnd = source.indexOf("<!-- 标签页:生效管理 -->", listTabStart);
const listTabSource = source.slice(listTabStart, listTabEnd);
expect(source).toContain('title="角色管理"');
expect(source).toContain('label="角色列表"');
expect(source).toContain('title="角色与权限"');
expect(source).toContain('label="角色定义"');
expect(listTabSource).not.toContain('placeholder="角色类型"');
expect(listTabSource).not.toContain("typeFilter");
expect(listTabSource).not.toContain("loadTemplates");
expect(listTabSource).not.toContain('<el-option label="预设角色" value="ROLE" />');
expect(listTabSource).not.toContain('<el-option label="自定义角色" value="CUSTOM" />');
expect(listTabSource).not.toContain('<el-option label="场景角色" value="SCENARIO" />');
expect(source).toContain("新增角色");
expect(source).toContain("创建角色");
expect(source).toContain('label="角色名称"');
expect(source).toContain("roleDisplayName(row)");
expect(source).not.toContain('title="权限模板管理"');
@@ -31,6 +31,17 @@ describe("permission management custom roles", () => {
expect(source).toContain("const res = await fetchPermissionTemplates();");
});
it("supports efficient role permission editing with change summaries and module actions", () => {
const source = readSource();
expect(source).toContain("roleEditorChangeCount");
expect(source).toContain("新增授权 {{ roleEditorGrantedCount }} 项");
expect(source).toContain("取消授权 {{ roleEditorRevokedCount }} 项");
expect(source).toContain("setRoleEditorPermissions(roleEditorModuleOps(sections), true)");
expect(source).toContain("setRoleEditorPermissions(roleEditorModuleOps(sections), false)");
expect(source).toContain(':disabled="!canEditSelectedRole || !roleEditorDirtyGuard.isDirty.value"');
});
it("keeps active roles configurable for permissions and members without inline role creation", () => {
const source = readSource();
@@ -45,7 +56,8 @@ describe("permission management custom roles", () => {
expect(source).toContain("const roleDisplayName = (row: PermissionTemplate) => row.name;");
expect(source).toContain("await loadPermissionData();");
expect(source).toContain('Object.keys(assignableRoleLabels.value)[0] || ""');
expect(source).toContain('role === "ADMIN"');
expect(source).toContain("const canAssignProjectRole");
expect(source).toContain("(ROLE_RANK[role] ?? 0) < ROLE_RANK.PM");
expect(source).not.toContain("const ALL_ROLES = [");
expect(source).not.toContain("const ROLE_LABELS");
});
@@ -133,7 +145,9 @@ describe("permission management custom roles", () => {
expect(source).toContain("{{ role.label }}");
expect(source).toContain("{{ role.desc }}");
expect(source).toContain("保存 {{ roleLabel(editingRole) }} 权限");
expect(source).toContain("await updateMember(selectedStudyId.value, memberId, { role_in_study: role });");
expect(source).toContain("await updateMember(selectedStudyId.value, member.id, { role_in_study: role });");
expect(source).toContain("const confirmMemberRoleChange = async");
expect(source).toContain('"调整项目角色"');
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
});
@@ -142,6 +156,8 @@ describe("permission management custom roles", () => {
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:read")');
expect(source).toContain("!u.is_admin && !ids.has(u.id)");
expect(source).toContain("请选择可添加的项目成员");
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
@@ -364,6 +380,15 @@ describe("permission management custom roles", () => {
const source = readSource();
expect(source).toContain("systemPermissionsByModule");
expect(source).toContain('class="system-stats" aria-label="系统级权限概览"');
expect(source).toContain('class="system-permissions-card"');
expect(source).toContain('class="perm-body perm-body--system"');
expect(source).toMatch(/\.system-perm-container \{[\s\S]*?padding: 0;[\s\S]*?gap: 8px;/);
expect(source).toMatch(/\.system-perm-row \{[\s\S]*?min-height: 36px;[\s\S]*?padding: 4px 12px;/);
expect(source).toContain(".system-module-block + .system-module-block");
expect(source).toContain(".system-perm-row:last-child");
expect(source).not.toContain("system-perm-row--alt");
expect(source).not.toContain("system-stat-divider");
expect(source).not.toContain("<h2 class=\"perm-title\">系统级权限</h2>");
expect(source).not.toContain("只读展示,所有系统管理操作仅限 ADMIN 角色执行");
});
@@ -371,10 +396,21 @@ describe("permission management custom roles", () => {
it("removes the vertical gap between permission headers and body content", () => {
const source = readSource();
expect(source).toContain("padding: 0 0 20px;");
expect(source).toContain("padding: 0 0 8px;");
expect(source).not.toContain("padding: 20px 0 20px;");
});
it("uses compact, legible typography for the selected project", () => {
const source = readSource();
expect(source).toContain(".project-selector :deep(.el-select__selected-item)");
expect(source).toContain('popper-class="project-selector-dropdown"');
expect(source).toContain(".project-selector-dropdown .el-select-dropdown__item");
expect(source).toContain("color: #193b5a !important;");
expect(source).toContain("font-size: 13px;");
expect(source).toContain("font-variant-numeric: tabular-nums;");
});
it("limits project PM member management to subordinate project roles", () => {
const source = readSource();
@@ -386,4 +422,17 @@ describe("permission management custom roles", () => {
expect(source).toContain('addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";');
expect(source).not.toContain('addMemberForm.role_in_study = activeRolesInStudy.value.includes("PM") ? "PM"');
});
it("separates role configuration from matrix comparison and supports member filtering", () => {
const source = readSource();
expect(source).toContain('v-model="permissionView"');
expect(source).toContain('value="roles">角色配置');
expect(source).toContain('value="matrix">权限对比');
expect(source).toContain('v-if="permissionView === \'roles\'"');
expect(source).toContain("const filteredMemberRows = computed");
expect(source).toContain("const memberRoleSummary = computed");
expect(source).toContain('placeholder="搜索姓名或邮箱"');
expect(source).toContain(">账号已停用</el-tag>");
});
});
File diff suppressed because it is too large Load Diff
+2 -205
View File
@@ -1833,77 +1833,6 @@
</template>
</el-drawer>
<el-drawer
v-if="siteEnrollmentEditorVisible"
v-model="siteEnrollmentEditorVisible"
direction="rtl"
size="480px"
:close-on-click-modal="true"
:before-close="siteEnrollmentEditorDirtyGuard.beforeClose"
:show-close="false"
class="setup-milestone-editor-drawer"
>
<template #header>
<div class="sme-header">
<div class="sme-header-title">编辑中心入组计划</div>
<div class="sme-header-subtitle">配置中心入组目标与时间范围</div>
</div>
</template>
<el-form label-position="top" class="sme-form">
<!-- 中心选择 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-blue"></span>中心选择</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="中心">
<el-select v-model="siteEnrollmentEditorForm.siteId" filterable clearable placeholder="选择中心" class="w-full">
<el-option
v-for="site in siteSelectOptions"
:key="site.id"
:label="site.label"
:value="site.id"
:disabled="isSiteEnrollmentSiteTaken(site.id)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划例数">
<el-input-number v-model="siteEnrollmentEditorForm.target" :min="0" controls-position="right" class="w-full" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 时间计划 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-amber"></span>时间计划</div>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="启动日期">
<el-date-picker v-model="siteEnrollmentEditorForm.startDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="完成日期">
<el-date-picker v-model="siteEnrollmentEditorForm.endDate" type="date" value-format="YYYY-MM-DD" class="w-full" placeholder="选择日期" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 备注 -->
<div class="sme-group">
<div class="sme-group-title"><span class="sme-dot sme-dot-gray"></span>备注</div>
<el-input v-model="siteEnrollmentEditorForm.note" type="textarea" :rows="3" placeholder="请输入备注信息" />
</div>
</el-form>
<template #footer>
<div class="sme-footer">
<el-button @click="siteEnrollmentEditorVisible = false">取消</el-button>
<el-button type="primary" @click="saveSiteEnrollmentEditor">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -1920,7 +1849,6 @@ import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import type { Site, Study } from "../../types/api";
import type {
CenterConfirmDraft,
ProjectPublishSnapshot,
ProjectMilestoneDraft,
SetupConfigDraft,
@@ -1954,7 +1882,7 @@ import {
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type SetupStepKey =
| "project-info"
@@ -2117,8 +2045,6 @@ const projectMilestoneEditorVisible = ref(false);
const projectMilestoneEditingIndex = ref<number>(-1);
const siteMilestoneEditorVisible = ref(false);
const siteMilestoneEditingIndex = ref<number>(-1);
const siteEnrollmentEditorVisible = ref(false);
const siteEnrollmentEditingIndex = ref<number>(-1);
type IndexedEditorController = {
visible: Ref<boolean>;
@@ -2132,10 +2058,6 @@ const siteMilestoneEditor: IndexedEditorController = {
visible: siteMilestoneEditorVisible,
index: siteMilestoneEditingIndex,
};
const siteEnrollmentEditor: IndexedEditorController = {
visible: siteEnrollmentEditorVisible,
index: siteEnrollmentEditingIndex,
};
const projectMilestoneEditorForm = reactive<ProjectMilestoneDraft>({
id: "",
@@ -2156,19 +2078,8 @@ const siteMilestoneEditorForm = reactive<SiteMilestoneDraft>({
remark: "",
status: "未开始",
});
const siteEnrollmentEditorForm = reactive<SiteEnrollmentPlanDraft>({
id: "",
siteId: "",
siteName: "",
target: 0,
startDate: "",
endDate: "",
note: "",
stageBreakdown: "",
});
const projectMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => projectMilestoneEditorForm);
const siteMilestoneEditorDirtyGuard = useDrawerDirtyGuard(() => siteMilestoneEditorForm);
const siteEnrollmentEditorDirtyGuard = useDrawerDirtyGuard(() => siteEnrollmentEditorForm);
type EnrollmentCycle = "month" | "quarter";
@@ -2494,7 +2405,6 @@ const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps
const setupPublishedVersionText = computed(() => {
return setupPublishedVersion.value || "v0";
});
const hasProjectPendingChanges = computed(() => hasFormUnsavedChanges.value);
const draftSyncStatus = computed<DraftSyncStatus>(() => {
const formDirty = Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value);
if (formDirty || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)) {
@@ -2623,7 +2533,6 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot =>
status: form.value.status,
visit_schedule: normalizeVisitSchedule(form.value.visit_schedule),
});
const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot());
const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value));
const hasSetupDiffFromServer = (): boolean =>
Boolean(setupServerSnapshot.value && serializeSetupForCompare() !== setupServerSnapshot.value);
@@ -2654,9 +2563,6 @@ const reconcileDraftSyncStateBeforePublish = () => {
clearLocalProjectDraft();
}
};
const hasUnsavedChanges = computed(
() => hasFormUnsavedChanges.value || setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value)
);
const hasSetupDraftUnsavedChanges = computed(() => setupDirtySinceLastPersist.value || Boolean(autoSaveTimer.value));
const hasLeaveGuardChanges = computed(
() => hasFormUnsavedChanges.value || hasSetupDraftUnsavedChanges.value || draftSyncStatus.value !== "SYNCED"
@@ -3088,7 +2994,6 @@ const toDateOnly = (value: string | null | undefined): Date | null => {
}
return date;
};
const pad2 = (value: number): string => String(value).padStart(2, "0");
const getQuarter = (month: number): number => Math.floor((month - 1) / 3) + 1;
const toYearMonthIndex = (year: number, month: number): number => year * 12 + (month - 1);
const buildYearMonthKey = (year: number, month: number): string => `${year}-${String(month).padStart(2, "0")}`;
@@ -3324,20 +3229,6 @@ const centerConfiguredSiteCount = computed(() => {
const centerPlannedCount = computed(() => {
return Array.from(centerEnrollmentTargetMap.value.values()).reduce((sum, target) => sum + target, 0);
});
const centerPendingCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
return Math.max(totalTarget - centerPlannedCount.value, 0);
});
const centerOverflowCount = computed(() => {
const totalTarget = normalizeEnrollmentTarget(currentSetupDraft.value.enrollmentPlan.totalTarget);
return Math.max(centerPlannedCount.value - totalTarget, 0);
});
const centerUnplannedSiteCount = computed(() => {
const activeSites = siteOptions.value.filter((site) => site.is_active !== false);
if (!activeSites.length) return 0;
const plannedSiteIds = centerEnrollmentTargetMap.value;
return activeSites.filter((site) => !plannedSiteIds.has(site.id)).length;
});
const getSiteEnrollmentTargetsByCycle = (row: SiteEnrollmentPlanDraft): Record<EnrollmentCycle, Record<string, number>> => {
const parsed = parseSiteEnrollmentStageBreakdown(row.stageBreakdown);
return {
@@ -3601,14 +3492,6 @@ const getSiteOptionLabel = (site: Partial<Site> | null | undefined): string => {
if (siteId) return `中心-${siteId.slice(0, 8)}`;
return "未命名中心";
};
const siteSelectOptions = computed(() =>
siteOptions.value
.map((site) => ({
id: normalizeSiteId(String(site.id || "")),
label: getSiteOptionLabel(site),
}))
.filter((site) => Boolean(site.id))
);
const getSiteName = (siteId: string): string => {
const normalizedSiteId = normalizeSiteId(siteId);
const matched = siteOptions.value.find((s) => normalizeSiteId(String(s.id || "")) === normalizedSiteId);
@@ -3760,18 +3643,6 @@ const createSelectedSiteEnrollmentPlan = () => {
stageBreakdown: "",
});
};
const getSiteEnrollmentEditorIndex = () => getEditingIndex(siteEnrollmentEditor, setupDraft.siteEnrollmentPlans.length);
const isSiteEnrollmentDuplicate = (siteId: string, excludeIndex: number): boolean => {
const normalizedSiteId = normalizeSiteId(siteId);
if (!normalizedSiteId) return false;
return setupDraft.siteEnrollmentPlans.some(
(row, rowIndex) => rowIndex !== excludeIndex && normalizeSiteId(row.siteId) === normalizedSiteId
);
};
const isSiteEnrollmentSiteTaken = (siteId: string): boolean => {
const editingIndex = getSiteEnrollmentEditorIndex();
return isSiteEnrollmentDuplicate(siteId, editingIndex);
};
watch(
[siteOptions, () => currentSetupDraft.value.siteEnrollmentPlans.map((row) => normalizeSiteId(row.siteId)).join("|")],
syncSelectedSiteEnrollmentPlanSiteId,
@@ -5345,7 +5216,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
await saveFile({
await saveFileWithFeedback({
suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob,
@@ -5841,80 +5712,6 @@ const removeSiteEnrollment = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.siteEnrollmentPlans.splice(index, 1);
};
const openSiteEnrollmentEditor = (index: number) => {
const opened = openIndexedEditor(setupDraft.siteEnrollmentPlans, index, siteEnrollmentEditor, (row) => {
siteEnrollmentEditorForm.id = row.id || "";
siteEnrollmentEditorForm.siteId = row.siteId || "";
siteEnrollmentEditorForm.siteName = row.siteName || "";
siteEnrollmentEditorForm.target = row.target ?? 0;
siteEnrollmentEditorForm.startDate = row.startDate || "";
siteEnrollmentEditorForm.endDate = row.endDate || "";
siteEnrollmentEditorForm.note = row.note || "";
siteEnrollmentEditorForm.stageBreakdown = row.stageBreakdown || "";
});
if (opened) siteEnrollmentEditorDirtyGuard.syncBaseline();
};
const saveSiteEnrollmentEditor = () => {
if (!canMutateDraft()) return;
const index = getEditingIndex(siteEnrollmentEditor, setupDraft.siteEnrollmentPlans.length);
if (index < 0) return;
if (!siteEnrollmentEditorForm.siteId) {
ElMessage.warning("请选择中心");
return;
}
if (isSiteEnrollmentDuplicate(siteEnrollmentEditorForm.siteId, index)) {
ElMessage.warning("该中心已配置入组计划,请勿重复配置");
return;
}
if (!siteEnrollmentEditorForm.startDate || !siteEnrollmentEditorForm.endDate) {
ElMessage.warning("请填写启动日期和完成日期");
return;
}
if (siteEnrollmentEditorForm.startDate > siteEnrollmentEditorForm.endDate) {
ElMessage.warning("完成日期不能早于启动日期");
return;
}
const { start: planStart, end: planEnd } = getProjectPlanWindow();
const startDate = normalizePlanDate(siteEnrollmentEditorForm.startDate);
const endDate = normalizePlanDate(siteEnrollmentEditorForm.endDate);
if (planStart && startDate < planStart) {
ElMessage.warning("中心启动日期不能早于项目计划开始日期");
return;
}
if (planEnd && startDate > planEnd) {
ElMessage.warning("中心启动日期不能晚于项目计划结束日期");
return;
}
if (planStart && endDate < planStart) {
ElMessage.warning("中心完成日期不能早于项目计划开始日期");
return;
}
if (planEnd && endDate > planEnd) {
ElMessage.warning("中心完成日期不能晚于项目计划结束日期");
return;
}
setupDraft.siteEnrollmentPlans[index] = {
id: siteEnrollmentEditorForm.id || makeId(),
siteId: siteEnrollmentEditorForm.siteId,
siteName: getSiteName(siteEnrollmentEditorForm.siteId),
target: Math.max(0, Number(siteEnrollmentEditorForm.target || 0)),
startDate: siteEnrollmentEditorForm.startDate,
endDate: siteEnrollmentEditorForm.endDate,
note: (siteEnrollmentEditorForm.note || "").trim(),
stageBreakdown: siteEnrollmentEditorForm.stageBreakdown || "",
};
closeIndexedEditor(siteEnrollmentEditor, "中心入组计划已更新");
};
const addCenterConfirm = () => {
if (!canMutateDraft()) return;
setupDraft.centerConfirm.push({ id: makeId(), siteId: "", siteName: "", confirmer: "", confirmStatus: "待确认", confirmDate: "", note: "" });
};
const removeCenterConfirm = (index: number) => {
if (!canMutateDraft()) return;
setupDraft.centerConfirm.splice(index, 1);
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasLeaveGuardChanges.value) return;
event.preventDefault();
+1 -64
View File
@@ -1,29 +1,12 @@
<template>
<el-dialog
append-to=".layout-main .content-wrapper"
append-to="body"
:title="project ? TEXT.modules.adminProjects.editTitle : TEXT.modules.adminProjects.newTitle"
width="560px"
v-model="visibleProxy"
:close-on-click-modal="false"
class="project-form-dialog"
>
<div class="form-header">
<div class="form-icon" :class="project ? 'icon--edit' : 'icon--new'">
<svg v-if="!project" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<div class="form-header-text">
<span class="form-header-title">{{ project ? '编辑项目信息' : '创建新项目' }}</span>
<span class="form-header-desc">{{ project ? form.name || '修改项目基本信息' : '填写以下信息创建临床试验项目' }}</span>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="project-form-body">
<el-form-item :label="TEXT.common.fields.projectName" prop="name">
<el-input v-model="form.name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.projectName">
@@ -198,52 +181,6 @@ const onSubmit = async () => {
</script>
<style scoped>
.form-header {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 18px;
margin-bottom: 18px;
border-bottom: 1px solid var(--ctms-border-color);
}
.form-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.form-icon.icon--new {
background: linear-gradient(135deg, #e8edf3, #d5dce5);
color: var(--ctms-primary);
}
.form-icon.icon--edit {
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
color: #fff;
}
.form-header-text {
display: flex;
flex-direction: column;
}
.form-header-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.form-header-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
.project-form-body :deep(.el-form-item) {
margin-bottom: 16px;
}
-419
View File
@@ -1,419 +0,0 @@
<template>
<div class="page">
<div class="main-content-flat unified-shell">
<div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div>
<el-button v-if="canAddMember" type="primary" @click="openAdd">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button>
</div>
<div class="unified-section member-table-section">
<el-table :data="memberRows" v-loading="loading" stripe class="member-table">
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
<template #default="scope">
<el-tag>{{ roleLabel(scope.row.role_in_study) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" width="100">
<template #default="scope">
<el-tooltip
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
:content="TEXT.modules.adminProjectMembers.disabledGlobalHint"
placement="top"
>
<el-tag type="danger">{{ TEXT.modules.adminProjectMembers.disabledGlobal }}</el-tag>
</el-tooltip>
<el-tag v-else :type="scope.row.is_active ? 'success' : 'danger'">
{{ scope.row.is_active ? TEXT.common.actions.enable : TEXT.modules.adminProjectMembers.disabled }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
<template #default="scope">
<el-select
v-model="scope.row.role_in_study"
size="small"
style="width: 120px"
@change="(val: string) => updateRole(scope.row.id, val)"
:disabled="!canEditMember(scope.row) || !scope.row.is_active || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
>
<el-option
v-for="role in roleOptions"
:key="role.value"
:label="role.label"
:value="role.value"
:disabled="!canAssignRole(role.value)"
/>
</el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
:disabled="!canEditMember(scope.row) || scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleActive(scope.row)"
>
{{ scope.row.is_active ? TEXT.common.actions.disable : TEXT.common.actions.enable }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-dialog v-if="addVisible" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminProjectMembers.newTitle" width="520px" v-model="addVisible" :close-on-click-modal="false">
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
<el-form-item :label="TEXT.modules.adminProjectMembers.user" prop="user_id">
<el-select v-model="newMember.user_id" filterable :placeholder="TEXT.modules.adminProjectMembers.userPlaceholder">
<el-option
v-for="user in availableUsers"
:key="user.id"
:label="user.full_name || TEXT.common.fallback"
:value="user.id"
:disabled="!user.is_active"
/>
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.adminProjectMembers.projectRole" prop="role_in_study">
<el-select v-model="newMember.role_in_study" :placeholder="TEXT.modules.adminProjectMembers.rolePlaceholder">
<el-option
v-for="role in roleOptions"
:key="role.value"
:label="role.label"
:value="role.value"
:disabled="!canAssignRole(role.value)"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="adding" @click="submitAdd">{{ TEXT.common.actions.save }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { addMember, listMemberCandidates, listMembers, removeMember, updateMember } from "../../api/members";
import { fetchStudyDetail } from "../../api/studies";
import type { Study, StudyMember, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { usePermission } from "../../utils/permission";
import { displayDateTime } from "../../utils/display";
import { TEXT, requiredMessage } from "../../locales";
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const auth = useAuthStore();
const permission = usePermission();
const { roleLabel, roleOptionsFor, loadRoleTemplates } = useRoleTemplateMeta();
const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]);
const users = ref<UserInfo[]>([]);
const loading = ref(false);
const addVisible = ref(false);
const adding = ref(false);
const addFormRef = ref<FormInstance>();
const newMember = reactive({
user_id: "",
role_in_study: "PM",
});
const canListMembers = computed(() => permission.can("project.members.list"));
const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));
const canCreateMember = computed(() => permission.can("project.members.create"));
const canUpdateMember = computed(() => permission.can("project.members.update"));
const canDeleteMember = computed(() => permission.can("project.members.delete"));
const canAddMember = computed(() => canCreateMember.value && canListMemberCandidates.value);
const projectRole = computed(() => project.value?.role_in_study || "");
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
const roleRank: Record<string, number> = {
ADMIN: 100,
PM: 80,
PV: 50,
QA: 60,
CRA: 40,
CTA: 40,
};
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
const canMutateProjectMember = (row: StudyMember) => {
if (row.user?.is_admin) return false;
if (auth.user?.is_admin) return true;
if (row.user_id === auth.user?.id) return false;
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
};
const canEditMember = (row: StudyMember) => canUpdateMember.value && canMutateProjectMember(row);
const canDeleteProjectMember = (row: StudyMember) => canDeleteMember.value && canMutateProjectMember(row);
const addRules = reactive<FormRules>({
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
role_in_study: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.projectRole), trigger: "change" }],
});
const loadProject = async () => {
if (!projectId.value) return;
try {
const { data } = await fetchStudyDetail(projectId.value);
project.value = data;
} catch {
/* ignore */
}
};
const loadMembers = async () => {
if (!canListMembers.value || !projectId.value) return;
loading.value = true;
try {
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
members.value = Array.isArray(data) ? data : data.items || [];
if (!canListMemberCandidates.value) syncUsersFromMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
} finally {
loading.value = false;
}
};
const syncUsersFromMembers = () => {
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
};
const loadUsers = async () => {
if (!canListMemberCandidates.value || !projectId.value) {
syncUsersFromMembers();
return;
}
try {
const { data } = await listMemberCandidates(projectId.value, { limit: 500 });
users.value = data || [];
} catch {
users.value = [];
}
};
const memberRows = computed(() =>
members.value.map((m) => {
const user = users.value.find((u) => u.id === m.user_id) || m.user;
const effectiveStatus = user && user.is_active === false ? "DISABLED_GLOBAL" : m.is_active ? "ACTIVE" : "DISABLED";
return {
...m,
full_name: user?.full_name || user?.username || m.user_id,
effectiveStatus,
};
})
);
const openAdd = () => {
if (!canAddMember.value) return;
newMember.user_id = "";
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
addVisible.value = true;
};
const submitAdd = async () => {
if (!projectId.value) return;
if (!canAddMember.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.create",
target: { projectId: projectId.value },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return;
}
if (!canAssignRole(newMember.role_in_study)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
await addFormRef.value?.validate();
adding.value = true;
try {
await addMember(projectId.value, newMember);
ElMessage.success(TEXT.modules.adminProjectMembers.addSuccess);
addVisible.value = false;
await Promise.all([loadMembers(), loadUsers()]);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.addFailed);
} finally {
adding.value = false;
}
};
const updateRole = async (memberId: string, role: string) => {
if (!projectId.value) return;
const row = members.value.find((member) => member.id === memberId);
if (!row || !canEditMember(row) || !canAssignRole(role)) {
ElMessage.warning(TEXT.common.messages.noPermission);
loadMembers();
return;
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.update",
target: { projectId: projectId.value, memberId },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return;
}
try {
await updateMember(projectId.value, memberId, { role_in_study: role });
ElMessage.success(TEXT.modules.adminProjectMembers.roleUpdated);
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.updateFailed);
loadMembers();
}
};
const toggleActive = async (row: StudyMember) => {
if (!projectId.value) return;
if (!canEditMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.update",
target: { projectId: projectId.value, memberId: row.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return;
}
if (row.is_active) {
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.disableConfirm, TEXT.modules.adminProjectMembers.disableTitle, {
type: "warning",
confirmButtonText: TEXT.common.actions.confirm,
}).catch(() => null);
if (!ok) return;
try {
await updateMember(projectId.value, row.id, { is_active: false });
ElMessage.success(TEXT.modules.adminProjectMembers.disableSuccess);
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
}
} else {
try {
await updateMember(projectId.value, row.id, { is_active: true });
ElMessage.success(TEXT.modules.adminProjectMembers.enableSuccess);
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.actionFailed);
}
}
};
const onDelete = async (row: StudyMember) => {
if (!projectId.value) return;
if (!canDeleteProjectMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.delete",
target: { projectId: projectId.value, memberId: row.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return;
}
const ok = await ElMessageBox.confirm(TEXT.modules.adminProjectMembers.removeConfirm, TEXT.modules.adminProjectMembers.removeTitle, {
type: "warning",
confirmButtonText: TEXT.common.actions.confirm,
cancelButtonText: TEXT.common.actions.cancel,
}).catch(() => null);
if (!ok) return;
try {
await removeMember(projectId.value, row.id);
members.value = members.value.filter((m) => m.id !== row.id);
await loadUsers();
ElMessage.success(TEXT.modules.adminProjectMembers.removeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const availableUsers = computed(() => {
const memberUserIds = new Set(members.value.map((m) => m.user_id));
return users.value.filter((u) => !memberUserIds.has(u.id));
});
onMounted(async () => {
await Promise.all([loadRoleTemplates(), loadProject(), loadMembers()]);
loadUsers();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.main-content-flat {
width: 100%;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
}
.main-content-flat :deep(.el-card__body) {
padding: 0;
}
.actions-only-bar {
display: flex;
justify-content: flex-end;
align-items: center;
}
.member-table-section {
padding: 0;
}
.member-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.hint {
color: #999;
margin: 0 8px;
font-size: 12px;
}
</style>
<style>
.el-table .action-column .cell {
padding-left: 8px;
padding-right: 8px;
}
.action-column .el-select {
margin-right: 8px;
}
</style>
+39 -15
View File
@@ -16,11 +16,12 @@ describe("project management access", () => {
const layout = readLayout();
const router = readRouter();
expect(layout).toContain('v-if="auth.user"');
expect(layout).toContain('v-if="showAdminNavigation"');
expect(layout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user)");
expect(layout).toContain('index="/admin/projects"');
expect(router).toContain('name: "AdminProjects"');
expect(router).toContain("meta: { title: TEXT.menu.projectManagement }");
expect(projects).toContain('v-if="isAdmin" type="primary"');
expect(projects).toContain('v-if="isAdmin" content="新增项目"');
expect(projects).toContain('v-if="isAdmin && !scope.row.is_locked"');
expect(projects).toContain('v-if="isAdmin" :content="TEXT.common.actions.delete"');
});
@@ -69,24 +70,47 @@ describe("project management access", () => {
const source = readProjects();
expect(source).toContain('class="project-table" style="width: 100%" table-layout="fixed"');
expect(source).toContain(':label="TEXT.common.fields.projectName"');
expect(source).toContain(`:label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip`);
expect(source).toContain(':label="TEXT.common.fields.status" align="center"');
expect(source).toContain('label="锁定状态" align="center"');
expect(source).toContain(':label="TEXT.common.labels.actions" align="center"');
expect(source).not.toContain('projectColumnWidth');
expect(source).not.toContain('fixed="right"');
expect(source).toContain(':label="TEXT.common.fields.projectName" min-width="260"');
expect(source).toContain(`:label="TEXT.common.fields.protocolNo || '方案号'" min-width="220" show-overflow-tooltip`);
expect(source).toContain('v-if="!isAdmin" label="我的角色" align="center" width="120"');
expect(source).toContain(':label="TEXT.common.fields.status" align="center" width="150"');
expect(source).toContain('label="锁定状态" align="center" width="150"');
expect(source).toContain(':label="TEXT.common.labels.actions" align="center" width="300" fixed="right"');
expect(source).toContain("min-width: 252px;");
expect(source).toContain("min-width: 1060px;");
});
it("reserves enough width for the densest project action set", () => {
const source = readProjects();
expect(source).toContain('width="300" fixed="right"');
expect(source).toContain("min-width: 252px;");
expect(source).toContain("min-width: 1060px;");
});
it("keeps the project summary header compact", () => {
const source = readProjects();
expect(source).toContain("padding: 10px 16px;");
expect(source).toContain("padding: 9px 12px;");
expect(source).toContain("width: 32px;");
expect(source).toContain("height: 32px;");
expect(source).toContain("font-size: 20px;");
expect(source).toContain("font-size: 11px;");
expect(source).toContain('class="project-overview"');
expect(source).toContain('class="create-project-button"');
expect(source).toContain("min-height: 48px;");
expect(source).toContain("width: 30px;");
expect(source).toContain("height: 30px;");
expect(source).toContain("font-size: 18px;");
expect(source).toContain("font-size: 10px;");
expect(source).toContain("content-wrapper:has(.projects-page)");
});
it("mounts the project form in a container shared by web and desktop layouts", () => {
const form = readFileSync(resolve(__dirname, "./ProjectForm.vue"), "utf8");
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
expect(form).toContain('append-to="body"');
expect(form).not.toContain('append-to=".layout-main .content-wrapper"');
expect(form).not.toContain('class="form-header"');
expect(form).not.toContain("填写以下信息创建临床试验项目");
expect(desktopStyles).toContain("body.is-desktop-runtime .project-form-dialog");
expect(desktopStyles).toContain("body.is-desktop-runtime .project-form-dialog .el-dialog__body");
});
it("renders project names as plain text and moves setup configuration to a gear action", () => {
+110 -49
View File
@@ -1,8 +1,17 @@
<template>
<div class="page page--flush">
<div class="page page--flush projects-page">
<div class="main-content-flat unified-shell">
<!-- 统计卡片 -->
<div class="stats-row">
<!-- 项目概览 -->
<section class="project-overview" aria-labelledby="project-overview-title">
<div class="overview-heading">
<div class="overview-title-wrap">
<h2 id="project-overview-title">项目概览</h2>
</div>
<el-tooltip v-if="isAdmin" content="新增项目" placement="left">
<el-button type="primary" :icon="Plus" circle class="create-project-button" aria-label="新增项目" @click="openCreate" />
</el-tooltip>
</div>
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@@ -51,20 +60,13 @@
<span class="stat-label">已锁定</span>
</div>
</div>
</div>
<!-- 操作栏 -->
<div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div>
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjects.projectLabel }}
</el-button>
</div>
</div>
</section>
<!-- 项目表格 -->
<div class="unified-section project-table-section">
<el-table :data="projects" v-loading="loading" class="project-table" style="width: 100%" table-layout="fixed">
<el-table-column :label="TEXT.common.fields.projectName">
<el-table-column :label="TEXT.common.fields.projectName" min-width="260">
<template #default="scope">
<div class="project-cell">
<div class="project-icon" :class="'icon--' + (scope.row.status || 'draft').toLowerCase()">
@@ -80,17 +82,17 @@
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.protocolNo || '方案号'" show-overflow-tooltip>
<el-table-column :label="TEXT.common.fields.protocolNo || '方案号'" min-width="220" show-overflow-tooltip>
<template #default="scope">
<span class="text-muted">{{ scope.row.protocol_no || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="!isAdmin" label="我的角色" align="center">
<el-table-column v-if="!isAdmin" label="我的角色" align="center" width="120">
<template #default="scope">
<span class="role-badge">{{ roleLabel(scope.row.role_in_study) || '-' }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.status" align="center">
<el-table-column :label="TEXT.common.fields.status" align="center" width="150">
<template #default="scope">
<span class="status-badge" :class="'badge--' + (scope.row.status || 'draft').toLowerCase()">
<span class="badge-dot"></span>
@@ -98,7 +100,7 @@
</span>
</template>
</el-table-column>
<el-table-column label="锁定状态" align="center">
<el-table-column label="锁定状态" align="center" width="150">
<template #default="scope">
<span v-if="scope.row.is_locked" class="lock-indicator locked">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
@@ -116,7 +118,7 @@
</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center">
<el-table-column :label="TEXT.common.labels.actions" align="center" width="300" fixed="right">
<template #default="scope">
<div class="action-row">
<el-tooltip v-if="canProject(scope.row, 'project_members', 'read')" :content="TEXT.modules.adminProjects.members" placement="top">
@@ -372,31 +374,70 @@ onMounted(() => {
</script>
<style scoped>
:global(.web-layout-container .content-wrapper:has(.projects-page)) {
padding: 0;
}
.project-overview {
padding: 0;
border-bottom: 1px solid var(--unified-shell-divider);
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
}
.overview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
padding: 6px 16px;
}
.overview-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.overview-title-wrap h2 {
margin: 0;
color: var(--ctms-text-main);
font-size: 14px;
font-weight: 700;
}
.create-project-button {
width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding: 10px 16px;
gap: 0;
border-top: 1px solid var(--unified-shell-divider);
background: var(--ctms-bg-card);
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
min-height: 48px;
padding: 6px 14px;
background: transparent;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
.stat-card + .stat-card {
border-left: 1px solid var(--unified-shell-divider);
}
.stat-icon {
width: 32px;
height: 32px;
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
@@ -405,8 +446,8 @@ onMounted(() => {
}
.stat-icon svg {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
}
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
@@ -420,28 +461,18 @@ onMounted(() => {
}
.stat-value {
font-size: 20px;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: var(--ctms-text-main);
}
.stat-label {
font-size: 11px;
font-size: 10px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
.actions-only-bar {
display: flex;
justify-content: flex-end;
align-items: center;
}
.filter-spacer {
flex: 1;
}
/* 项目单元格 */
.project-cell {
display: flex;
@@ -541,16 +572,22 @@ onMounted(() => {
.action-row {
display: inline-flex;
align-items: center;
gap: 4px;
justify-content: center;
gap: 4px;
min-width: 252px;
max-width: 100%;
padding: 1px 3px;
border-radius: 8px;
background: rgba(248, 250, 252, 0.72);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
white-space: nowrap;
}
.action-btn {
width: 28px;
height: 28px;
min-height: 28px;
font-size: 16px;
width: 26px;
height: 26px;
min-height: 26px;
font-size: 15px;
padding: 0;
margin: 0;
border-radius: 6px;
@@ -573,16 +610,40 @@ onMounted(() => {
/* 表格 */
.project-table-section {
padding: 0;
padding: 0 !important;
overflow: hidden;
}
.project-table {
min-width: 1060px;
}
.project-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.project-table :deep(th.el-table__cell) {
padding-top: 7px;
padding-bottom: 7px;
}
.project-table :deep(td.el-table__cell) {
padding-top: 6px;
padding-bottom: 6px;
}
@media (max-width: 768px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.stat-card:nth-child(3) {
border-left: 0;
}
.stat-card:nth-child(n + 3) {
border-top: 1px solid var(--unified-shell-divider);
}
}
</style>
-115
View File
@@ -1,115 +0,0 @@
<template>
<el-dialog v-if="visibleProxy" append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminSites.craBindTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<div class="tip">{{ TEXT.modules.adminSites.sitePrefix }}{{ site?.name }}</div>
<el-form label-width="120px">
<el-form-item :label="TEXT.modules.adminSites.craSelect">
<el-select v-model="selectedCras" multiple filterable :placeholder="TEXT.modules.adminSites.craSelectPlaceholder" style="width: 100%">
<el-option v-for="user in craUsers" :key="user.id" :label="user.username" :value="user.id" />
</el-select>
<div class="hint">{{ TEXT.modules.adminSites.craHint }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="onSave">{{ TEXT.modules.adminSites.craSave }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { updateSite } from "../../api/sites";
import type { Site, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
import { TEXT } from "../../locales";
const props = defineProps<{
visible: boolean;
studyId: string;
site: Site | null;
craUsers: UserInfo[];
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "saved"): void;
}>();
const auth = useAuthStore();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
});
const selectedCras = ref<string[]>([]);
const saving = ref(false);
const loadSelected = () => {
if (!props.site) {
selectedCras.value = [];
return;
}
const raw = props.site.contact || "";
const tokens = raw
.split(",")
.map((i) => i.trim())
.filter(Boolean);
selectedCras.value = tokens
.map((token) => {
const byId = props.craUsers.find((u) => u.id === token);
if (byId) return byId.id;
const byName = props.craUsers.find((u) => u.username === token);
return byName?.id;
})
.filter((v): v is string => !!v);
};
watch(
() => props.visible,
(val) => {
if (val) {
loadSelected();
}
}
);
const onSave = async () => {
if (!props.site) return;
const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "site.cra.bind",
target: { siteId: props.site.id, studyId: props.studyId },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || TEXT.common.messages.noPermission);
return;
}
saving.value = true;
try {
const names = selectedCras.value
.map((id) => props.craUsers.find((u) => u.id === id)?.username || id)
.filter(Boolean);
await updateSite(props.studyId, props.site.id, { contact: names.join(",") });
ElMessage.success(TEXT.modules.adminSites.craSaveSuccess);
emit("saved");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
</script>
<style scoped>
.tip {
margin-bottom: 8px;
}
.hint {
color: #888;
font-size: 12px;
margin-top: 4px;
}
</style>
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSystemMonitoringPage = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
describe("SystemMonitoringPage desktop shell", () => {
it("reuses the web monitoring component and fits it within the desktop workspace", () => {
const source = readSystemMonitoringPage();
expect(source).toContain('<PermissionMonitoring :is-admin="true" />');
expect(source).toContain('import { isTauriRuntime } from "@/runtime";');
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain("system-monitoring-page--desktop");
expect(source).toContain("height: calc(100dvh - 52px);");
expect(source).toContain("height: 100%;");
expect(source).toContain("content-wrapper:has(.system-monitoring-page)");
expect(source).toContain("height: calc(100dvh - 52px);");
expect(source).toContain("overflow: hidden;");
expect(source).toContain(".overview-hero-card");
expect(source).toContain(":deep(.overview-hero-card > .metric-strip)");
expect(source).not.toContain(".system-monitoring-page--desktop :deep(.metric-strip)");
expect(source).toContain(".access-logs .audit-filters");
});
});
@@ -1,22 +1,78 @@
<template>
<div class="system-monitoring-page">
<div class="system-monitoring-page" :class="{ 'system-monitoring-page--desktop': isDesktop }">
<PermissionMonitoring :is-admin="true" />
</div>
</template>
<script setup lang="ts">
import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
import { isTauriRuntime } from "@/runtime";
const isDesktop = isTauriRuntime();
</script>
<style scoped>
.system-monitoring-page {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
height: calc(100dvh - 48px);
height: calc(100vh - 52px);
height: calc(100dvh - 52px);
min-height: 0;
margin: -6px -8px;
margin: 0;
overflow: hidden;
background: #f5f7fa;
}
:global(.web-layout-container .content-wrapper:has(.system-monitoring-page)) {
height: calc(100vh - 52px);
height: calc(100dvh - 52px);
min-height: 0;
padding: 0;
overflow: hidden;
}
/*
* DesktopLayout already reserves space for its title toolbar and workspace
* tabs. Keep the shared monitoring view inside that remaining area instead
* of sizing it from the browser viewport as the web shell does.
*/
.system-monitoring-page--desktop {
height: 100%;
margin: 0;
}
@media (max-width: 1450px) {
/*
* At the minimum desktop window width, the sidebar leaves the monitoring
* surface with roughly 900px. These rules preserve the web information
* hierarchy while giving the shared cards room to wrap cleanly.
*/
.system-monitoring-page--desktop :deep(.overview-hero-card) {
flex-wrap: wrap;
}
.system-monitoring-page--desktop :deep(.overview-title-group) {
flex: 1 1 112px;
}
.system-monitoring-page--desktop :deep(.overview-hero-actions) {
flex: 0 1 auto;
gap: 8px;
}
.system-monitoring-page--desktop :deep(.overview-hero-card > .metric-strip) {
flex-basis: 100%;
order: 3;
padding-top: 6px;
border-top: 1px solid #edf1f6;
}
.system-monitoring-page--desktop :deep(.access-logs .audit-filters) {
flex-wrap: wrap;
}
.system-monitoring-page--desktop :deep(.access-logs .filter-keyword) {
max-width: none;
}
}
</style>
+1 -75
View File
@@ -1,28 +1,12 @@
<template>
<el-dialog
append-to=".layout-main .content-wrapper"
append-to="body"
:title="user ? TEXT.modules.adminUsers.editTitle : TEXT.modules.adminUsers.newTitle"
width="540px"
v-model="visibleProxy"
:close-on-click-modal="false"
class="user-form-dialog"
>
<div class="form-header">
<div class="form-avatar" :class="user ? 'avatar--edit' : 'avatar--new'">
<svg v-if="!user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
<span v-else class="avatar-initials">{{ getInitials(form.full_name) }}</span>
</div>
<div class="form-header-text">
<span class="form-header-title">{{ user ? form.full_name || '编辑用户' : '创建新用户' }}</span>
<span class="form-header-desc">{{ user ? form.email : '填写以下信息创建系统账号' }}</span>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" autocomplete="off" class="user-form-body">
<el-form-item :label="TEXT.common.fields.email" prop="email">
<el-input v-model="form.email" :disabled="!!user" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.email">
@@ -129,13 +113,6 @@ const form = reactive({
is_active: true,
});
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
};
const rules = reactive<FormRules>({
email: [
{ required: true, message: requiredMessage(TEXT.common.fields.email), trigger: "blur" },
@@ -232,57 +209,6 @@ const onSubmit = async () => {
</script>
<style scoped>
.form-header {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 18px;
margin-bottom: 18px;
border-bottom: 1px solid var(--ctms-border-color);
}
.form-avatar {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.form-avatar.avatar--new {
background: linear-gradient(135deg, #e8edf3, #d5dce5);
color: var(--ctms-primary);
}
.form-avatar.avatar--edit {
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
color: #fff;
}
.avatar-initials {
font-size: 16px;
font-weight: 700;
}
.form-header-text {
display: flex;
flex-direction: column;
}
.form-header-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.form-header-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
.user-form-body :deep(.el-form-item) {
margin-bottom: 16px;
}
@@ -0,0 +1,352 @@
<template>
<el-drawer
v-model="visibleProxy"
title="登录记录"
direction="rtl"
size="min(760px, 96vw)"
destroy-on-close
>
<div class="login-activity-drawer">
<div v-if="user" class="drawer-user-summary">
<div class="user-meta-info">
<span class="user-fullname">{{ user.full_name }}</span>
<span class="user-email-text">{{ user.email }}</span>
</div>
<span class="status-badge" :class="user.login_status === 'ONLINE' ? 'is-online' : 'is-offline'">
<span class="status-badge-dot"></span>
<span>{{ user.login_status === 'ONLINE' ? `在线 (${user.active_session_count || 1})` : '离线' }}</span>
</span>
</div>
<div class="activity-view-toolbar">
<div>
<strong>{{ activityViewMode === 'source' ? '最近登录来源' : '全部登录会话' }}</strong>
<span v-if="activityViewMode === 'all'">展示最近 100 条原始登录会话</span>
</div>
<el-segmented v-model="activityViewMode" :options="activityViewOptions" size="small" />
</div>
<el-table v-loading="loading" :data="displayActivities" class="login-activity-table" empty-text="暂无登录记录">
<el-table-column label="状态" width="102">
<template #default="scope">
<el-tooltip :content="activityStatusDescription(scope.row)" placement="top">
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
<span class="status-badge-dot"></span>
<span>{{ activityStatusLabel(scope.row) }}</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="客户端" min-width="150">
<template #default="scope">
<div class="client-cell">
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
累计 {{ scope.row.grouped_count }} 次会话
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="登录 IP(位置)" min-width="190">
<template #default="scope">
<div class="source-cell">
<strong>{{ scope.row.login_ip || 'IP 未记录' }}</strong>
<span>{{ scope.row.ip_location || '--' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="登录 / 最近活动" min-width="190">
<template #default="scope">
<div class="time-cell">
<span>登录 {{ displayDateTime(scope.row.login_at) }}</span>
<span>活动 {{ displayDateTime(scope.row.last_seen_at) }}</span>
<span v-if="scope.row.ended_at">退出 {{ displayDateTime(scope.row.ended_at) }}</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { fetchUserLoginActivities } from "../../api/users";
import type { UserInfo, UserLoginActivity } from "../../types/api";
import { displayDateTime } from "../../utils/display";
import {
groupLoginActivitiesBySource,
type DisplayLoginActivity,
} from "./loginActivitySources";
const props = defineProps<{
modelValue: boolean;
user: UserInfo | null;
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const activities = ref<UserLoginActivity[]>([]);
const loading = ref(false);
const activityViewMode = ref<"source" | "all">("source");
const activityViewOptions = [
{ label: "最近来源", value: "source" },
{ label: "全部会话", value: "all" },
];
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
const resolvedActivityStatus = (item: UserLoginActivity) => {
if (item.activity_status) return item.activity_status;
if (item.ended_at) return "ENDED";
return Date.now() - new Date(item.last_seen_at).getTime() <= 5 * 60 * 1000 ? "ONLINE" : "OFFLINE";
};
const activityStatusLabel = (item: UserLoginActivity) => {
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "已退出";
return status === "ONLINE" ? "在线" : "已离线";
};
const activityStatusClass = (item: UserLoginActivity) => {
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "is-ended";
return status === "ONLINE" ? "is-online" : "is-offline";
};
const activityStatusDescription = (item: UserLoginActivity) => {
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "服务端已收到明确的退出请求";
if (status === "ONLINE") return "最近心跳仍在服务端在线判定时间内";
return "未明确退出,但最近心跳已超过服务端在线判定时间";
};
const clientDescription = (item: UserLoginActivity) =>
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
const sourceActivities = computed<DisplayLoginActivity[]>(() =>
groupLoginActivitiesBySource(activities.value, resolvedActivityStatus),
);
const displayActivities = computed<DisplayLoginActivity[]>(() =>
activityViewMode.value === "source"
? sourceActivities.value
: activities.value.map((item) => ({ ...item, grouped_count: 1 })),
);
const loadActivities = async () => {
if (!props.user) return;
loading.value = true;
try {
const { data } = await fetchUserLoginActivities(props.user.id);
activities.value = data;
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || "登录记录加载失败");
} finally {
loading.value = false;
}
};
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) {
activityViewMode.value = "source";
void loadActivities();
}
},
{ immediate: true },
);
</script>
<style scoped>
.login-activity-drawer {
display: flex;
flex-direction: column;
gap: 18px;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.drawer-user-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 14px 18px;
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.03);
}
.user-meta-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-fullname {
color: #0f172a;
font-size: 15px;
font-weight: 700;
}
.user-email-text {
color: #64748b;
font-size: 12.5px;
}
/* 状态徽标 */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.status-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-badge.is-online {
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
}
.status-badge.is-online .status-badge-dot {
background-color: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
}
.status-badge.is-offline {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
}
.status-badge.is-offline .status-badge-dot {
background-color: #64748b;
}
.status-badge.is-ended {
background: #f8fafc;
color: #94a3b8;
border: 1px solid #e2e8f0;
}
.status-badge.is-ended .status-badge-dot {
background-color: #cbd5e1;
}
.status-badge.compact {
padding: 2.5px 8px;
font-size: 11px;
}
.status-badge.compact .status-badge-dot {
width: 5px;
height: 5px;
}
.login-activity-table {
width: 100%;
border: 1px solid #f1f5f9;
border-radius: 8px;
overflow: hidden;
}
.activity-view-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 2px;
}
.activity-view-toolbar > div {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.activity-view-toolbar strong {
color: #334155;
font-size: 13px;
}
.activity-view-toolbar span {
color: #64748b;
font-size: 11.5px;
line-height: 1.45;
}
.login-activity-table :deep(.el-table__header-wrapper) th {
background-color: #f8fafc !important;
color: #475569 !important;
font-size: 12px;
font-weight: 600;
height: 38px;
border-bottom: 1px solid #e2e8f0;
}
.login-activity-table :deep(.el-table__row) td {
border-bottom: 1px solid #f1f5f9;
padding: 10px 0;
}
.client-cell,
.source-cell,
.time-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.client-cell strong {
color: #334155;
font-size: 12.5px;
font-weight: 600;
}
.client-cell span,
.source-cell span,
.time-cell span {
color: #64748b;
font-size: 11.5px;
}
.client-cell .el-tag {
align-self: flex-start;
}
.source-cell strong {
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
font-weight: 600;
}
.time-cell span {
line-height: 1.4;
}
@media (max-width: 680px) {
.activity-view-toolbar {
align-items: stretch;
flex-direction: column;
}
.activity-view-toolbar :deep(.el-segmented) {
align-self: flex-start;
}
}
</style>
@@ -1,6 +1,6 @@
<template>
<el-dialog
append-to=".layout-main .content-wrapper"
append-to="body"
:title="TEXT.modules.adminUsers.resetTitle"
width="540px"
v-model="visibleProxy"
+390 -80
View File
@@ -1,52 +1,77 @@
<template>
<div class="page page--flush">
<div class="page page--flush users-page">
<div class="main-content-flat unified-shell">
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<!-- 独立状态概览 -->
<section class="account-overview" aria-labelledby="account-overview-title">
<div class="overview-heading">
<div>
<h2 id="account-overview-title">账号概览</h2>
</div>
<div class="stat-body">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">总用户</span>
<span class="overview-live"><i></i>状态汇总</span>
</div>
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ allUsers.length }}</span>
<span class="stat-label">总用户</span>
</div>
</div>
<div class="stat-card stat-card--active">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ activeCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
</div>
</div>
<div class="stat-card stat-card--disabled">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ disabledCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
</div>
</div>
<div class="stat-card stat-card--online">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M5 12a7 7 0 0 1 14 0"/>
<path d="M8 12a4 4 0 0 1 8 0"/>
<circle cx="12" cy="16" r="1"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ onlineCount }}</span>
<span class="stat-label">当前在线</span>
</div>
</div>
</div>
<div class="stat-card stat-card--active">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ activeCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.ACTIVE }}</span>
</div>
</div>
<div class="stat-card stat-card--disabled">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="10"/>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ disabledCount }}</span>
<span class="stat-label">{{ TEXT.enums.userStatus.DISABLED }}</span>
</div>
</div>
</div>
</section>
<!-- 筛选栏 -->
<div class="filter-container unified-action-bar">
<div class="filter-form">
<div class="filter-item-form">
<div class="filter-heading">
<el-icon><Filter /></el-icon>
<span>筛选</span>
</div>
<div class="filter-item-form filter-item-form--search">
<el-input
v-model="searchKeyword"
:placeholder="TEXT.common.placeholders.keyword"
@@ -57,11 +82,19 @@
/>
</div>
<div class="filter-item-form">
<el-select v-model="statusFilter" :placeholder="TEXT.common.fields.status" style="width: 140px" clearable class="filter-select-comp" @change="applyFilters">
<el-select v-model="statusFilter" placeholder="账号状态" clearable class="filter-select-comp" @change="applyFilters">
<el-option :label="TEXT.enums.userStatus.ACTIVE" value="ACTIVE" />
<el-option :label="TEXT.enums.userStatus.DISABLED" value="DISABLED" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="loginStatusFilter" placeholder="登录状态" clearable class="filter-select-comp" @change="applyFilters">
<el-option label="在线" value="ONLINE" />
<el-option label="离线" value="OFFLINE" />
</el-select>
</div>
<el-button v-if="hasActiveFilters" text :icon="RefreshLeft" class="reset-filter-button" @click="resetFilters">重置筛选</el-button>
<span class="filter-result" aria-live="polite">{{ hasActiveFilters ? `筛选结果 ${total}` : `${total}` }}</span>
<div class="filter-spacer"></div>
<el-button type="primary" :icon="Plus" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminUsers.userLabel }}</el-button>
</div>
@@ -70,7 +103,7 @@
<!-- 用户表格 -->
<div class="unified-section user-table-section">
<el-table :data="users" v-loading="loading" class="user-table" style="width: 100%" table-layout="fixed">
<el-table-column :label="TEXT.common.fields.name" width="360">
<el-table-column :label="TEXT.common.fields.name" min-width="200">
<template #default="scope">
<div class="user-cell">
<div class="user-info">
@@ -80,21 +113,42 @@
</div>
</template>
</el-table-column>
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.status">
<el-table-column prop="clinical_department" :label="TEXT.modules.adminUsers.clinicalDepartmentLabel" min-width="120" class-name="department-column" show-overflow-tooltip>
<template #default="scope">
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
{{ statusLabel(scope.row.status) }}
<span class="department-text">{{ scope.row.clinical_department }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" :label="TEXT.modules.adminUsers.createdAt" show-overflow-tooltip>
<el-table-column :label="TEXT.common.fields.status" min-width="100" class-name="account-status-column">
<template #default="scope">
<span class="text-muted">{{ displayDateTime(scope.row.created_at) }}</span>
<div class="account-status-cell">
<span class="status-dot" :class="'dot--' + (scope.row.status || '').toLowerCase()"></span>
<span>{{ statusLabel(scope.row.status) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center">
<el-table-column label="登录状态" min-width="140">
<template #default="scope">
<div class="login-status-cell">
<span class="status-dot" :class="'dot--' + (scope.row.login_status || 'OFFLINE').toLowerCase()"></span>
<span>{{ loginStatusLabel(scope.row) }}</span>
<small>{{ scope.row.last_client_type === 'desktop' ? '桌面端' : scope.row.last_client_type === 'web' ? '网页端' : '暂无会话' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="最近登录" min-width="150">
<template #default="scope">
<div class="login-time-cell">
<span>{{ scope.row.last_login_at ? displayDateTime(scope.row.last_login_at) : '从未登录' }}</span>
<small v-if="scope.row.last_seen_at">活动 {{ displayDateTime(scope.row.last_seen_at) }}</small>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" align="center" width="176" class-name="actions-column">
<template #default="scope">
<div class="action-row">
<el-tooltip content="登录记录" placement="top">
<el-button link type="info" :icon="Clock" class="action-btn" aria-label="查看登录记录" @click="openLoginActivities(scope.row)" />
</el-tooltip>
<el-tooltip :content="TEXT.common.actions.edit" placement="top">
<el-button link type="primary" :icon="Edit" class="action-btn" @click="openEdit(scope.row)" />
</el-tooltip>
@@ -141,19 +195,21 @@
</div>
<UserForm v-if="formVisible" v-model:visible="formVisible" :user="editingUser" :admin-count="activeAdminCount" @saved="loadUsers" />
<UserResetPassword v-if="resetVisible" v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
<UserLoginActivitiesDrawer v-if="loginActivitiesVisible" v-model="loginActivitiesVisible" :user="loginActivitiesUser" />
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, Key, Lock, Unlock, Search, Plus } from "@element-plus/icons-vue";
import { Edit, Delete, Key, Lock, Unlock, Search, Plus, Clock, RefreshLeft, Filter } from "@element-plus/icons-vue";
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
import { fetchStudies } from "../../api/studies";
import { listMembers } from "../../api/members";
import type { UserInfo } from "../../types/api";
import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue";
import UserLoginActivitiesDrawer from "./UserLoginActivitiesDrawer.vue";
import { useAuthStore } from "../../store/auth";
import { displayDateTime } from "../../utils/display";
import { TEXT } from "../../locales";
@@ -169,13 +225,18 @@ const formVisible = ref(false);
const resetVisible = ref(false);
const searchKeyword = ref("");
const statusFilter = ref("");
const loginStatusFilter = ref("");
const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
const loginActivitiesUser = ref<UserInfo | null>(null);
const loginActivitiesVisible = ref(false);
const auth = useAuthStore();
let keywordSearchTimer: ReturnType<typeof setTimeout> | null = null;
const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTIVE').length);
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
const onlineCount = computed(() => allUsers.value.filter(u => u.login_status === 'ONLINE').length);
const hasActiveFilters = computed(() => Boolean(searchKeyword.value.trim() || statusFilter.value || loginStatusFilter.value));
const statusLabel = (status: string) => {
switch (status) {
@@ -185,6 +246,12 @@ const statusLabel = (status: string) => {
}
};
const loginStatusLabel = (row: UserInfo) => {
if (row.status !== "ACTIVE") return "账号不可用";
if (row.login_status === "ONLINE") return row.active_session_count && row.active_session_count > 1 ? `${row.active_session_count} 个会话` : "在线";
return "离线";
};
const isLastAdmin = (row: UserInfo) =>
row.is_admin && row.status === "ACTIVE" && activeAdminCount.value <= 1;
@@ -197,6 +264,7 @@ const loadUsers = async () => {
limit: pageSize.value,
keyword: searchKeyword.value || undefined,
status: statusFilter.value || undefined,
login_status: loginStatusFilter.value || undefined,
}),
fetchUsers({ skip: 0, limit: 10000 }),
]);
@@ -219,6 +287,13 @@ const applyFilters = () => {
loadUsers();
};
const resetFilters = () => {
searchKeyword.value = "";
statusFilter.value = "";
loginStatusFilter.value = "";
applyFilters();
};
const clearKeywordSearchTimer = () => {
if (!keywordSearchTimer) return;
clearTimeout(keywordSearchTimer);
@@ -236,7 +311,7 @@ const scheduleKeywordSearch = () => {
watch(searchKeyword, () => {
scheduleKeywordSearch();
});
}, { flush: "sync" });
const onPageSizeChange = (size: number) => {
pageSize.value = size;
@@ -284,6 +359,11 @@ const openReset = (row: UserInfo) => {
resetVisible.value = true;
};
const openLoginActivities = (row: UserInfo) => {
loginActivitiesUser.value = row;
loginActivitiesVisible.value = true;
};
const onDelete = async (row: UserInfo) => {
const { action } = await ElMessageBox.prompt(
TEXT.modules.adminUsers.deleteConfirm,
@@ -350,32 +430,97 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.page,
.main-content-flat,
.user-table-section {
min-width: 0;
box-sizing: border-box;
}
.main-content-flat {
width: 100%;
max-width: 100%;
}
:global(.web-layout-container .content-wrapper:has(.users-page)) {
padding: 0;
}
.account-overview {
padding: 0;
border-bottom: 1px solid var(--unified-shell-divider);
background: linear-gradient(180deg, var(--ctms-bg-card) 0%, color-mix(in srgb, var(--ctms-neutral-100) 55%, var(--ctms-bg-card)) 100%);
}
.overview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 0;
padding: 8px 16px 6px;
}
.overview-heading h2 {
margin: 0;
color: var(--ctms-text-main);
font-size: 14px;
font-weight: 700;
line-height: 1.3;
}
.overview-live {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border: 1px solid rgba(34, 166, 99, 0.18);
border-radius: 999px;
background: rgba(34, 166, 99, 0.07);
color: #188454;
font-size: 11px;
white-space: nowrap;
}
.overview-live i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.12);
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
padding: 10px 16px;
border-bottom: 1px solid var(--unified-shell-divider);
grid-template-columns: repeat(4, 1fr);
gap: 0;
overflow: hidden;
border-top: 1px solid var(--unified-shell-divider);
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 0;
background: var(--ctms-bg-card);
box-shadow: none;
}
.stat-card {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
min-height: 48px;
padding: 6px 14px;
background: transparent;
width: 100%;
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
.stat-card + .stat-card {
border-left: 1px solid var(--unified-shell-divider);
}
.stat-icon {
width: 32px;
height: 32px;
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
@@ -384,8 +529,8 @@ onBeforeUnmount(() => {
}
.stat-icon svg {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
}
.stat-card--total .stat-icon {
@@ -403,43 +548,100 @@ onBeforeUnmount(() => {
color: var(--ctms-danger);
}
.stat-card--online .stat-icon {
background: #e8f6ef;
color: #1f9d63;
}
.stat-body {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 20px;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
color: var(--ctms-text-main);
}
.stat-label {
font-size: 11px;
font-size: 10px;
color: var(--ctms-text-secondary);
margin-top: 2px;
}
/* 筛选栏 */
.filter-container {
position: sticky;
top: 0;
z-index: 8;
background: color-mix(in srgb, var(--ctms-bg-card) 94%, transparent);
box-shadow: 0 5px 14px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
}
.filter-form {
display: flex;
width: 100%;
gap: 10px;
align-items: center;
min-width: 0;
flex-wrap: wrap;
}
.filter-heading {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--ctms-text-secondary);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
min-width: 0;
}
.filter-item-form--search {
flex: 0 1 320px;
}
.filter-input-comp {
width: 220px;
width: 100%;
max-width: 100%;
}
.filter-form :deep(.el-input__inner),
.filter-form :deep(.el-select__placeholder),
.filter-form :deep(.el-select__selected-item) {
font-size: 12px;
}
.filter-form :deep(.el-button) {
font-size: 12px;
}
.filter-select-comp {
width: 140px;
}
.reset-filter-button {
color: var(--ctms-text-secondary);
}
.filter-result {
color: var(--ctms-text-secondary);
font-size: 11px;
white-space: nowrap;
}
.filter-spacer {
flex: 1;
min-width: 0;
}
/* 用户单元格 */
@@ -456,7 +658,7 @@ onBeforeUnmount(() => {
.user-name {
font-weight: 600;
font-size: 13px;
font-size: 12px;
color: var(--ctms-text-main);
white-space: nowrap;
overflow: hidden;
@@ -464,13 +666,26 @@ onBeforeUnmount(() => {
}
.user-email {
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.department-text,
.account-status-cell {
color: var(--ctms-text-main);
font-size: 12px;
line-height: 1.25;
}
.account-status-cell {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
/* 状态点 */
.status-dot {
display: inline-block;
@@ -491,6 +706,43 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
}
.status-dot.dot--online {
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.15);
}
.status-dot.dot--offline {
background: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.15);
}
.login-status-cell,
.login-time-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
column-gap: 6px;
color: var(--ctms-text-main);
font-size: 11px;
}
.login-status-cell small,
.login-time-cell small {
grid-column: 2;
margin-top: 0;
color: var(--ctms-text-secondary);
font-size: 10px;
}
.login-time-cell {
display: grid;
grid-template-columns: 1fr;
}
.login-time-cell small {
grid-column: 1;
}
.text-muted {
color: var(--ctms-text-secondary);
font-size: 12px;
@@ -502,7 +754,7 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
gap: 4px;
padding: 2px;
padding: 1px;
border-radius: 8px;
background: rgba(248, 250, 252, 0.72);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
@@ -510,9 +762,9 @@ onBeforeUnmount(() => {
}
.action-btn {
width: 28px;
height: 28px;
min-height: 28px;
width: 26px;
height: 26px;
min-height: 26px;
padding: 0;
margin: 0;
border-radius: 7px;
@@ -526,6 +778,13 @@ onBeforeUnmount(() => {
margin-left: 0;
}
.user-table :deep(td.actions-column .cell) {
padding-right: 6px;
padding-left: 6px;
overflow: visible;
text-overflow: clip;
}
.action-row :deep(.el-button .el-icon) {
width: 15px;
height: 15px;
@@ -545,21 +804,31 @@ onBeforeUnmount(() => {
/* 表格区域 */
.user-table-section {
padding: 0;
padding: 0 !important;
max-width: 100%;
overflow-x: hidden;
}
.user-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.user-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.user-table :deep(th.el-table__cell) {
padding-top: 10px;
padding-bottom: 10px;
padding-top: 7px;
padding-bottom: 7px;
}
.user-table :deep(td.el-table__cell) {
padding-top: 10px;
padding-bottom: 10px;
padding-top: 5px;
padding-bottom: 5px;
}
.user-table :deep(.cell) {
line-height: 1.25;
}
.pagination-wrap {
@@ -567,11 +836,52 @@ onBeforeUnmount(() => {
display: flex;
justify-content: flex-end;
padding: 8px 16px;
max-width: 100%;
overflow: hidden;
}
.pagination-wrap :deep(.el-pagination) {
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
row-gap: 6px;
}
@media (max-width: 768px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.stat-card:nth-child(3) {
border-left: 0;
}
.stat-card:nth-child(n + 3) {
border-top: 1px solid var(--unified-shell-divider);
}
.filter-heading {
width: 100%;
}
.filter-spacer {
display: none;
}
.filter-item-form--search {
flex: 1 1 100%;
}
.filter-select-comp {
width: 100%;
}
.filter-item-form:not(.filter-item-form--search) {
flex: 1 1 calc(50% - 5px);
}
.filter-form > .el-button--primary {
width: 100%;
}
}
</style>
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("admin user login status", () => {
it("shows server-provided session state with login source details and grouped history", () => {
const users = readSource("./Users.vue");
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
expect(users).toContain("当前在线");
expect(users).toContain("login_status");
expect(users).toContain("last_login_at");
expect(users).toContain("openLoginActivities");
expect(users).toContain("UserLoginActivitiesDrawer");
expect(drawer).toContain("登录记录");
expect(drawer).toContain("登录 IP(位置)");
expect(drawer).toContain("scope.row.login_ip");
expect(drawer).toContain("scope.row.ip_location");
expect(drawer).toContain("activity_status");
expect(drawer).toContain("resolvedActivityStatus");
expect(drawer).toContain("最近来源");
expect(drawer).toContain("全部会话");
expect(drawer).toContain("groupLoginActivitiesBySource");
expect(drawer).toContain("grouped_count");
expect(drawer).toContain("scope.row.ended_at");
expect(drawer).not.toContain("const isRecent");
expect(drawer).not.toContain("access_token");
const usersApi = readSource("../../api/users.ts");
expect(usersApi).toContain("limit = 100");
});
it("offers responsive account and login-status filters with clear reset feedback", () => {
const users = readSource("./Users.vue");
expect(users).toContain('placeholder="账号状态"');
expect(users).toContain('placeholder="登录状态"');
expect(users).toContain('login_status: loginStatusFilter.value || undefined');
expect(users).toContain('class="account-overview"');
expect(users).toContain("position: sticky");
expect(users).not.toContain("filterByStat");
expect(users).not.toContain("账号与登录会话状态");
expect(users).toContain("min-height: 48px");
expect(users).toContain('class-name="actions-column"');
expect(users).toContain("text-overflow: clip");
expect(users).toContain(".department-text,\n.account-status-cell");
expect(users).toContain("padding: 0 !important;");
expect(users).toContain("border-radius: 0;");
expect(users).toContain("content-wrapper:has(.users-page)");
expect(users).toContain("resetFilters");
expect(users).toContain("筛选结果");
});
it("mounts account edit and emergency-reset dialogs to a container shared by web and desktop layouts", () => {
const userForm = readSource("./UserForm.vue");
const resetPassword = readSource("./UserResetPassword.vue");
expect(userForm).toContain('append-to="body"');
expect(resetPassword).toContain('append-to="body"');
expect(userForm).not.toContain('append-to=".layout-main .content-wrapper"');
expect(resetPassword).not.toContain('append-to=".layout-main .content-wrapper"');
expect(userForm).not.toContain('class="form-header"');
expect(userForm).not.toContain("填写以下信息创建系统账号");
});
it("uses the desktop dialog treatment without an additional outer card for account actions", () => {
const desktopStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
expect(desktopStyles).toContain("body.is-desktop-runtime .user-form-dialog");
expect(desktopStyles).toContain("body.is-desktop-runtime .reset-password-dialog");
expect(desktopStyles).toContain("background: transparent !important;");
expect(desktopStyles).toContain("box-shadow: none !important;");
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__body");
expect(desktopStyles).toContain(".reset-password-dialog .el-dialog__body");
expect(desktopStyles).toContain("border-radius: 8px !important;");
expect(desktopStyles).toContain("padding: 0 !important;");
expect(desktopStyles).toContain(".user-form-dialog .el-dialog__headerbtn");
expect(desktopStyles).toContain("top: 5px;");
expect(desktopStyles).toContain("right: 7px;");
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { UserLoginActivity } from "../../types/api";
import { activitySourceKey, groupLoginActivitiesBySource } from "./loginActivitySources";
const activity = (overrides: Partial<UserLoginActivity>): UserLoginActivity => ({
id: "session-1",
client_type: "web",
client_platform: "macos",
client_version: "0.1.0",
login_ip: "192.168.97.1",
login_at: "2026-07-16T02:23:04Z",
last_seen_at: "2026-07-16T03:08:32Z",
activity_status: "ENDED",
...overrides,
});
describe("login activity sources", () => {
it("uses only client type and a recorded IP as the source identity", () => {
expect(activitySourceKey(activity({ client_platform: "windows", client_source: "installer" }))).toBe(
"web|192.168.97.1",
);
expect(activitySourceKey(activity({ id: "legacy", login_ip: null }))).toBe("session:legacy");
});
it("merges online and historical sessions from the same client and IP", () => {
const rows = groupLoginActivitiesBySource(
[
activity({
id: "current",
login_at: "2026-07-16T07:22:42Z",
last_seen_at: "2026-07-16T07:53:00Z",
activity_status: "ONLINE",
ended_at: null,
}),
activity({ id: "history", ended_at: "2026-07-16T03:08:32Z" }),
activity({
id: "desktop",
client_type: "desktop",
login_at: "2026-07-15T08:42:02Z",
last_seen_at: "2026-07-16T07:53:42Z",
activity_status: "ONLINE",
ended_at: null,
}),
],
(item) => item.activity_status!,
);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ id: "desktop", grouped_count: 1, activity_status: "ONLINE" });
expect(rows[1]).toMatchObject({
id: "current",
grouped_count: 2,
login_at: "2026-07-16T02:23:04Z",
last_seen_at: "2026-07-16T07:53:00Z",
activity_status: "ONLINE",
ended_at: null,
});
});
it("does not merge sessions whose IP was not recorded", () => {
const rows = groupLoginActivitiesBySource(
[activity({ id: "legacy-1", login_ip: null }), activity({ id: "legacy-2", login_ip: null })],
(item) => item.activity_status!,
);
expect(rows).toHaveLength(2);
});
});
@@ -0,0 +1,56 @@
import type { UserLoginActivity } from "../../types/api";
export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
type ActivityStatus = NonNullable<UserLoginActivity["activity_status"]>;
const activityTime = (value: string) => new Date(value).getTime();
export const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.client_type, item.login_ip].join("|");
};
export const groupLoginActivitiesBySource = (
activities: UserLoginActivity[],
resolveStatus: (item: UserLoginActivity) => ActivityStatus,
): DisplayLoginActivity[] => {
const grouped = new Map<
string,
{ row: DisplayLoginActivity; firstLoginAt: string; hasOnlineSession: boolean }
>();
activities.forEach((item) => {
const key = activitySourceKey(item);
const status = resolveStatus(item);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, {
row: { ...item, activity_status: status, grouped_count: 1 },
firstLoginAt: item.login_at,
hasOnlineSession: status === "ONLINE",
});
return;
}
existing.row.grouped_count += 1;
existing.hasOnlineSession ||= status === "ONLINE";
if (activityTime(item.login_at) < activityTime(existing.firstLoginAt)) {
existing.firstLoginAt = item.login_at;
}
if (activityTime(item.last_seen_at) > activityTime(existing.row.last_seen_at)) {
const groupedCount = existing.row.grouped_count;
existing.row = { ...item, activity_status: status, grouped_count: groupedCount };
}
});
return Array.from(grouped.values())
.map(({ row, firstLoginAt, hasOnlineSession }) => ({
...row,
login_at: firstLoginAt,
activity_status: hasOnlineSession ? "ONLINE" : row.activity_status,
ended_at: hasOnlineSession ? null : row.ended_at,
end_reason: hasOnlineSession ? null : row.end_reason,
}))
.sort((left, right) => activityTime(right.last_seen_at) - activityTime(left.last_seen_at));
};
@@ -57,11 +57,35 @@ describe("DocumentDetail permissions", () => {
expect(source).toContain("@click=\"previewVersion(row)\"");
expect(source).toContain("TEXT.common.labels.preview");
expect(source).toContain('return "image"');
expect(source).toContain('return "pdf"');
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("TEXT.common.messages.previewNotSupported");
expect(source).toContain("URL.createObjectURL(blob)");
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
expect(source).toContain("<PdfViewer");
expect(source).toContain("detectFilePreviewKind");
expect(source).toContain("/office-preview/version/${version.id}");
expect(source).not.toContain("<iframe");
});
it("keeps all four version actions visible and reports blob download errors", () => {
const source = readSource();
expect(source).toContain('columns.actions" width="220"');
expect(source).toContain("getApiErrorMessage");
expect(source).toContain("await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)");
expect(source).toContain("ElMessage.error(TEXT.common.messages.openFailed)");
});
it("preserves upload filenames and selects a web save destination before downloading", () => {
const source = readSource();
expect(source).toContain("version.original_filename?.trim()");
expect(source).toContain("uuidStorageName.test(name)");
expect(source).toContain("detail.title?.trim()");
expect(source).toContain("await prepareSaveFile(suggestedName)");
expect(source.indexOf("await prepareSaveFile(suggestedName)")).toBeLessThan(source.indexOf("const response = await downloadDocumentVersion(version.id)", source.indexOf("const downloadVersion")));
expect(source).toContain("getContentDispositionFilename");
expect(source).not.toContain("const getFilename =");
});
it("prefers backend creator display data instead of exposing creator ids", () => {
@@ -93,6 +117,12 @@ describe("DocumentDetail breadcrumbs", () => {
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName: displaySite(detail.site_id)");
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined");
expect(source.indexOf("syncBreadcrumbContext();", source.indexOf("Object.assign(detail, data);") + 1)).toBeGreaterThan(
source.indexOf("Object.assign(detail, data);"),
);
expect(source).toContain("onActivated(() => {");
expect(source).toContain("if (detail.id) syncBreadcrumbContext();");
});
});
+60 -40
View File
@@ -114,7 +114,7 @@
</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="160" fixed="right">
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="220" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button link type="primary" size="small" @click="previewVersion(row)" v-if="canReadDocument">
@@ -172,14 +172,14 @@
</div>
</template>
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" :width="previewType === 'pdf' ? 'min(86vw, 1440px)' : 'min(1180px, 94vw)'" :top="previewType === 'pdf' ? '8vh' : '3vh'" :close-on-click-modal="false" :show-close="previewType !== 'pdf'" :class="['pdf-preview-dialog', { 'pdf-preview-dialog--flush': previewType === 'pdf' }]">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<div v-loading="previewLoading" class="preview-body">
<img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" class="preview-frame" />
<PdfViewer v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" :filename="previewTitle" show-close @close="previewVisible = false" />
</div>
</template>
</el-dialog>
@@ -350,8 +350,8 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { computed, defineAsyncComponent, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
import {
@@ -378,10 +378,16 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { getContentDispositionFilename } from "../../utils/contentDisposition";
import { prepareSaveFile } from "../../runtime";
import { detectFilePreviewKind, ONLYOFFICE_FILE_EXTENSIONS } from "../../utils/officePreview";
const route = useRoute();
const router = useRouter();
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
const auth = useAuthStore();
const study = useStudyStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
@@ -478,6 +484,7 @@ const syncBreadcrumbContext = () => {
study.setViewContext({
siteName: displaySite(detail.site_id),
pageTitle: detail.title || TEXT.modules.fileVersionManagement.title,
objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined,
});
};
@@ -524,9 +531,9 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
}));
const triggerFileInput = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
accept: [...ONLYOFFICE_FILE_EXTENSIONS, "pdf", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
@@ -664,6 +671,7 @@ const loadDetail = async () => {
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
syncBreadcrumbContext();
if (!users.value.length) await loadUsers();
if (!members.value.length) await loadMembers();
if (!sites.value.length) await loadSites();
@@ -752,27 +760,21 @@ const submitUpload = async () => {
});
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const getVersionFileName = (version: DocumentVersion) => {
if (version.original_filename?.trim()) return version.original_filename.trim();
const uri = version.file_uri || "";
const name = uri.split(/[\\/]/).filter(Boolean).pop() || "";
return name.toLowerCase();
const uuidStorageName = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(\.[^.]*)?$/i;
if (uuidStorageName.test(name)) {
const extension = name.match(/(\.[^.]*)$/)?.[1] || "";
const title = detail.title?.trim() || `document-${version.version_no || version.id}`;
return extension && title.toLowerCase().endsWith(extension.toLowerCase()) ? title : `${title}${extension}`;
}
return name;
};
const detectPreviewType = (version: DocumentVersion) => {
const mime = (version.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getVersionFileName(version);
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
return detectFilePreviewKind(getVersionFileName(version), version.mime_type);
};
const clearPreviewObjectUrl = () => {
@@ -784,8 +786,13 @@ const clearPreviewObjectUrl = () => {
const previewVersion = async (version: DocumentVersion) => {
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
clearPreviewObjectUrl();
previewType.value = detectPreviewType(version);
previewTitle.value = `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
const detectedType = detectPreviewType(version);
if (detectedType === "office") {
await router.push(`/office-preview/version/${version.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewUrl.value = "";
previewError.value = "";
previewVisible.value = true;
@@ -801,34 +808,49 @@ const previewVersion = async (version: DocumentVersion) => {
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch (e: any) {
previewError.value = e?.response?.data?.message || TEXT.common.messages.previewNotSupported;
previewError.value = await getApiErrorMessage(e, TEXT.common.messages.previewNotSupported);
} finally {
previewLoading.value = false;
}
};
const downloadVersion = async (version: DocumentVersion) => {
const suggestedName = getVersionFileName(version) || `document-${version.version_no || version.id}.bin`;
try {
const destination = await prepareSaveFile(suggestedName);
if (!destination) {
ElMessage.info("已取消保存文件");
return;
}
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const filename = getContentDispositionFilename(response.headers?.["content-disposition"]) || suggestedName;
const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }, {}, destination);
} catch (e: any) { ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)); }
};
const openVersion = async (version: DocumentVersion) => {
let response;
try {
response = await downloadDocumentVersion(version.id);
} catch (e: any) {
ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed));
return;
}
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({
const filename = getContentDispositionFilename(response.headers?.["content-disposition"])
|| getVersionFileName(version)
|| `document-${version.version_no || version.id}.bin`;
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
} catch {
ElMessage.error(TEXT.common.messages.openFailed);
}
};
@@ -904,6 +926,10 @@ onMounted(async () => {
await loadDetail();
});
onActivated(() => {
if (detail.id) syncBreadcrumbContext();
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
@@ -1118,12 +1144,6 @@ onBeforeUnmount(() => {
min-height: 120px;
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
@@ -48,4 +48,55 @@ describe("DocumentList permissions", () => {
expect(source).toContain("const displayValue = displayDateTime(value)");
expect(source).not.toContain('replace("T", " ").replace("Z", "")');
});
it("keeps desktop document browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="document-workbench"');
expect(source).toContain('class="document-preview-pane"');
expect(source).toContain("selectedDocument");
expect(source).toContain('@row-click="handleDocumentRowClick"');
expect(source).toContain('@row-dblclick="openDocumentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedDocument"');
expect(source).toContain("selectDocument(row);");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).not.toContain('@row-click="handleRowClick"');
});
it("stretches the desktop document workbench flush to the desktop content edges", () => {
const source = readSource();
expect(source).toContain(":global(.desktop-content:has(> .document-page--desktop))");
expect(source).toContain("padding: 0;");
expect(source).toContain(".document-page--desktop {");
expect(source).toContain("width: 100%;");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 100%;");
expect(source).toContain("margin: 0;");
expect(source).toContain("overflow-x: hidden;");
expect(source).toContain("border-radius: 0 !important;");
expect(source).toContain(".document-page--desktop .main-content-flat");
expect(source).toContain(".document-page--desktop .document-table-section");
expect(source).toContain(".document-page--desktop .document-workbench.is-desktop");
expect(source).toContain(".document-page--desktop .document-table-pane");
expect(source).toContain("flex: 1 1 auto;");
expect(source).toContain("min-height: 0;");
});
it("routes desktop document actions through context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
expect(source).toContain("documentContextMenu");
expect(source).toContain('class="document-context-menu"');
expect(source).not.toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedDocument\"");
expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
expect(source).toContain("@click=\"deleteSelectedDocument\"");
expect(source).toContain(':disabled="isInactiveSite(selectedDocument.site_id)"');
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
expect(source).toContain('"row-selected"');
});
});
+425 -69
View File
@@ -1,5 +1,5 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="page ctms-page-shell page--flush" :class="{ 'document-page--desktop': isDesktop }" @click="closeDocumentContextMenu">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" :model="filters" class="filter-form">
@@ -29,72 +29,132 @@
</el-form>
</div>
<section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom">
<el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleRowClick"
:row-class-name="documentRowClass"
class="ctms-table"
table-layout="fixed"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-secondary datetime-stack">
<span>{{ splitDateTime(row.updated_at).date }}</span>
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
<div class="document-workbench" :class="{ 'is-desktop': isDesktop }">
<div class="document-table-pane" :tabindex="isDesktop ? 0 : undefined" @keydown.enter.prevent="openSelectedDocument">
<el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleDocumentRowClick"
@row-dblclick="openDocumentDetail"
@row-contextmenu="openDocumentContextMenu"
:row-class-name="documentRowClass"
class="ctms-table"
highlight-current-row
table-layout="fixed"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-secondary datetime-stack">
<span>{{ splitDateTime(row.updated_at).date }}</span>
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="document-preview-pane">
<template v-if="selectedDocument">
<div class="preview-head">
<div>
<div class="preview-kicker">当前文档</div>
<div class="preview-title">{{ selectedDocument.title || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedDocument">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.modules.fileVersionManagement.columns.scope }}</dt>
<dd>{{ displayEnum(TEXT.enums.scopeType, selectedDocument.scope_type) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.site }}</dt>
<dd>{{ displaySite(selectedDocument.site_id) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.docType }}</dt>
<dd>{{ displayText(selectedDocument.doc_type, TEXT.enums.documentType) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.currentVersion }}</dt>
<dd>{{ selectedDocument.current_effective_version?.version_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
</dl>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table>
<div v-else class="preview-empty">
<span>选择一行查看文档摘要</span>
</div>
</aside>
</div>
</section>
</div>
<div
v-if="isDesktop && documentContextMenu.visible && selectedDocument"
class="document-context-menu"
:style="{ left: `${documentContextMenu.x}px`, top: `${documentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedDocument">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="openSelectedDocumentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="deleteSelectedDocument"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<el-drawer
v-if="editorVisible"
v-model="editorVisible"
@@ -171,7 +231,6 @@ import { Plus } from "@element-plus/icons-vue";
import { fetchDocuments, createDocument, deleteDocument, updateDocument } from "../../api/documents";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import type { DocumentSummary } from "../../types/documents";
@@ -179,11 +238,11 @@ import type { Site } from "../../types/api";
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const auth = useAuthStore();
const { can } = usePermission();
const loading = ref(false);
const items = ref<DocumentSummary[]>([]);
@@ -192,6 +251,9 @@ const editorVisible = ref(false);
const saving = ref(false);
const editingDocumentId = ref("");
const editorFormRef = ref<FormInstance>();
const isDesktop = isTauriRuntime();
const selectedDocumentId = ref("");
const documentContextMenu = ref({ visible: false, x: 0, y: 0 });
let desktopRefreshCleanup: (() => void) | undefined;
const trialId = computed(() => (route.params.trialId as string) || "");
@@ -220,15 +282,18 @@ const siteActiveMap = computed(() => {
});
return map;
});
const isAdmin = computed(() => {
return !!auth.user?.is_admin;
});
const canCreate = computed(() => can("documents.create"));
const canUpdate = computed(() => can("documents.update"));
const canDelete = computed(() => can("documents.delete"));
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
[
row?.id ? "clickable-row" : "",
isInactiveSite(row?.site_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedDocumentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const filteredItems = computed(() =>
items.value.filter((item) => {
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
@@ -240,6 +305,9 @@ const filteredItems = computed(() =>
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const selectedDocument = computed(() =>
sortedItems.value.find((item) => item.id === selectedDocumentId.value) || null
);
const editorForm = reactive({
doc_no: "",
@@ -402,11 +470,60 @@ const submitEditor = async () => {
const goDetail = (id: string) => router.push(`/documents/${id}`);
const handleRowClick = (row: DocumentSummary) => {
const selectDocument = (row: DocumentSummary) => {
if (!row?.id) return;
selectedDocumentId.value = row.id;
};
const handleDocumentRowClick = (row: DocumentSummary) => {
if (!row?.id) return;
if (isDesktop) {
selectDocument(row);
return;
}
goDetail(row.id);
};
const openDocumentDetail = (row?: DocumentSummary | null) => {
const target = row?.id ? row : selectedDocument.value;
if (target?.id) goDetail(target.id);
};
const openSelectedDocument = () => {
closeDocumentContextMenu();
openDocumentDetail(selectedDocument.value);
};
const openSelectedDocumentEditor = () => {
const target = selectedDocument.value;
closeDocumentContextMenu();
if (target) openEdit(target);
};
const deleteSelectedDocument = () => {
const target = selectedDocument.value;
closeDocumentContextMenu();
if (target) {
void confirmDelete(target);
}
};
const closeDocumentContextMenu = () => {
if (!documentContextMenu.value.visible) return;
documentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openDocumentContextMenu = (row: DocumentSummary, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectDocument(row);
documentContextMenu.value = {
visible: true,
x: Math.min(event.clientX, window.innerWidth - 184),
y: Math.min(event.clientY, window.innerHeight - 132),
};
};
const confirmDelete = async (row: DocumentSummary) => {
if (!canDelete.value) {
ElMessage.warning("权限不足");
@@ -457,6 +574,22 @@ watch(
}
}
);
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedDocumentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedDocumentId.value)) {
selectedDocumentId.value = rows[0].id;
}
},
{ immediate: true }
);
</script>
<style scoped>
@@ -466,6 +599,31 @@ watch(
gap: 0;
}
:global(.desktop-content:has(> .document-page--desktop)) {
overflow-x: hidden;
padding: 0;
}
.document-page--desktop {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
.document-page--desktop .main-content-flat {
display: flex;
width: auto;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
background: #ffffff;
box-shadow: none !important;
overflow-x: hidden;
}
.filter-form {
display: flex;
width: 100%;
@@ -551,6 +709,162 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02);
}
.document-page--desktop .document-table-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
box-shadow: none !important;
}
.document-workbench {
min-width: 0;
min-height: 0;
}
.document-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
min-height: min(570px, calc(100vh - 178px));
}
.document-page--desktop .document-workbench.is-desktop {
height: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.document-table-pane {
min-width: 0;
outline: none;
}
.document-page--desktop .document-table-pane {
min-height: 0;
overflow: hidden;
background: #ffffff;
}
.document-workbench.is-desktop .document-table-pane {
border-right: 1px solid #e3e9f1;
}
.document-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.document-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.document-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.document-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.document-context-menu button.danger {
color: #b42318;
}
.document-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.ctms-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -583,6 +897,10 @@ watch(
transition: background 0.1s ease;
}
.ctms-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.ctms-table :deep(.font-semibold) {
font-weight: 600;
color: #0a0a0a;
@@ -628,6 +946,44 @@ watch(
.ctms-table :deep(.cell-actions .el-button + .el-button) {
margin-left: 0;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-table-section) {
background: #172033;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .document-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .document-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .document-page--desktop .ctms-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style>
<style>
@@ -35,4 +35,19 @@ describe("ContractFees.vue", () => {
expect(source).not.toContain('router.push("/fees/contracts/new")');
expect(source).not.toContain("`/fees/contracts/${contractId.value}`");
});
it("uses a desktop-only density class for the fee workspace", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain(":class=\"{ 'fee-contracts-page--desktop': isDesktop }\"");
expect(source).toContain("contract-shell");
expect(source).toContain(".fee-contracts-page--desktop .contract-shell");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain(".fee-contracts-page--desktop :deep(.kpi-card)");
expect(source).toContain("min-height: 104px;");
expect(source).toContain(".fee-contracts-page--desktop .table-empty");
expect(source).toContain("min-height: 132px;");
expect(source).not.toContain("empty-create-btn");
});
});
+75 -3
View File
@@ -1,6 +1,6 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="overview">
<div class="page ctms-page-shell page--flush" :class="{ 'fee-contracts-page--desktop': isDesktop }">
<div class="overview fee-overview">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
@@ -53,7 +53,7 @@
<StateLoading v-else-if="loading" :rows="6" />
<div class="main-content-flat unified-shell" v-else>
<div class="main-content-flat unified-shell contract-shell" v-else>
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item">
@@ -208,9 +208,11 @@ import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
@@ -522,6 +524,76 @@ onMounted(async () => {
letter-spacing: 0.02em;
}
.fee-contracts-page--desktop {
height: 100%;
min-height: 0;
overflow: hidden;
gap: 8px;
}
.fee-contracts-page--desktop .fee-overview {
flex: 0 0 auto;
}
.fee-contracts-page--desktop .contract-shell {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
}
.fee-contracts-page--desktop .contract-table-section {
flex: 1 1 0;
min-height: 0;
background: var(--ctms-bg-card);
}
.fee-contracts-page--desktop .table-empty {
min-height: 132px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-card) {
min-height: 104px;
padding: 12px 16px;
border-radius: 8px;
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-card:hover) {
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-badge) {
margin-bottom: 8px;
padding: 3px 9px;
border-radius: 5px;
}
.fee-contracts-page--desktop :deep(.kpi-title) {
overflow: hidden;
font-size: 15px;
line-height: 1.25;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.fee-contracts-page--desktop :deep(.kpi-footer) {
padding-top: 8px;
}
.fee-contracts-page--desktop :deep(.kpi-value) {
font-size: 30px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-unit) {
font-size: 13px;
}
</style>
<style>
@@ -85,4 +85,37 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true');
});
it("keeps desktop shipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="shipment-workbench"');
expect(source).toContain('class="shipment-preview-pane"');
expect(source).toContain('ref="shipmentTablePaneRef"');
expect(source).toContain("selectedShipment");
expect(source).toContain('@row-click="handleShipmentRowClick"');
expect(source).toContain('@row-dblclick="openShipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedShipment"');
expect(source).toContain("selectShipment(row);");
expect(source).toContain("shipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("router.push(`/drug/shipments/${row.id}`);");
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop shipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
expect(source).toContain("shipmentContextMenu");
expect(source).toContain('class="shipment-context-menu"');
expect(source).toContain('class="preview-actions"');
expect(source).toContain('@click="openSelectedShipment"');
expect(source).toContain('@click="openSelectedShipmentEditor"');
expect(source).toContain('@click="removeSelectedShipment"');
expect(source).toContain(':disabled="isInactiveSite(selectedShipment.center_id)"');
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
expect(source).toContain('"row-selected"');
});
});
+469 -69
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
@@ -47,85 +47,178 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="sortedItems"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="shipmentTablePaneRef"
class="shipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedShipment"
>
<el-table
v-loading="loading"
:data="sortedItems"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
highlight-current-row
:row-class-name="shipmentRowClass"
@row-click="handleShipmentRowClick"
@row-dblclick="openShipmentDetail"
@row-contextmenu="openShipmentContextMenu"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span>{{ TEXT.modules.drugShipments.empty }}</span>
</div>
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="shipment-preview-pane">
<template v-if="selectedShipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前发运</div>
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.common.fields.direction }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
<dt>{{ TEXT.common.fields.status }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
<dt>{{ TEXT.common.fields.shipDate }}</dt>
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
<dt>{{ TEXT.common.fields.quantity }}</dt>
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.batchNo }}</dt>
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.carrier }}</dt>
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.remark }}</dt>
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button
v-if="canUpdate"
size="small"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
type="danger"
plain
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span>{{ TEXT.modules.drugShipments.empty }}</span>
<div v-else class="preview-empty">
<span>选择一行查看发运摘要</span>
</div>
</template>
</el-table>
</aside>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<div
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
class="shipment-context-menu"
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedShipment">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer
v-if="drawerVisible"
@@ -290,6 +383,7 @@ import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { isTauriRuntime } from "../../runtime";
type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
@@ -322,6 +416,10 @@ const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
const isDesktop = isTauriRuntime();
const selectedShipmentId = ref("");
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const filters = reactive({
center_id: study.currentSite?.id || "",
direction: "" as "" | ShipmentDirection,
@@ -363,6 +461,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const selectedShipment = computed(() =>
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
);
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
@@ -564,12 +665,69 @@ const handleSearch = () => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const onRowClick = (row: ShipmentRow) => {
[
row?.id ? "clickable-row" : "",
isInactiveSite(row?.center_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const selectShipment = (row: ShipmentRow) => {
if (!row?.id) return;
selectedShipmentId.value = row.id;
shipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleShipmentRowClick = (row: ShipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectShipment(row);
return;
}
router.push(`/drug/shipments/${row.id}`);
};
const openShipmentDetail = (row?: ShipmentRow | null) => {
const target = row?.id ? row : selectedShipment.value;
if (target?.id) router.push(`/drug/shipments/${target.id}`);
};
const openSelectedShipment = () => {
closeShipmentContextMenu();
openShipmentDetail(selectedShipment.value);
};
const openSelectedShipmentEditor = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedShipment = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) {
void remove(target);
}
};
const closeShipmentContextMenu = () => {
if (!shipmentContextMenu.value.visible) return;
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectShipment(row);
shipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
};
};
const statusType = (status: string) => {
switch (status) {
case "PENDING": return "info";
@@ -633,6 +791,22 @@ watch(
}
);
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedShipmentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
selectedShipmentId.value = rows[0].id;
}
},
{ immediate: true }
);
onMounted(async () => {
await loadSites();
await load();
@@ -648,12 +822,26 @@ onMounted(async () => {
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */
.create-btn {
height: 34px;
@@ -733,6 +921,167 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.shipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.shipment-workbench {
min-width: 0;
min-height: 0;
}
.shipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.shipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.shipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.shipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.shipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.shipment-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.shipment-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.shipment-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.shipment-context-menu button.danger {
color: #b42318;
}
.shipment-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.shipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -771,6 +1120,10 @@ onMounted(async () => {
transition: background 0.1s ease;
}
.shipment-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.cell-nowrap {
white-space: nowrap;
}
@@ -1035,6 +1388,15 @@ onMounted(async () => {
gap: 12px;
align-items: stretch;
}
.shipment-workbench.is-desktop {
grid-template-columns: minmax(0, 1fr);
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 0;
}
.shipment-preview-pane {
display: none;
}
}
@media (max-width: 640px) {
@@ -1042,6 +1404,44 @@ onMounted(async () => {
width: 100%;
}
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style>
<style>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEtmfSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
describe("EtmfPlaceholder copy", () => {
it("does not show redundant directory count or select-node helper copy", () => {
const source = readEtmfSource();
expect(source).not.toContain("个目录节点");
expect(source).not.toContain("selectNodeHint");
});
});
describe("EtmfPlaceholder desktop layout", () => {
it("keeps archive status in the document header instead of a standalone side column", () => {
const source = readEtmfSource();
expect(source).toContain('class="document-status-summary"');
expect(source).toContain('class="status-summary-list"');
expect(source).not.toContain('class="etmf-node-detail"');
expect(source).toContain("grid-template-columns: 280px minmax(0, 1fr);");
expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);");
});
});
+380 -246
View File
@@ -5,43 +5,41 @@
<div class="etmf-toolbar">
<div class="etmf-toolbar-main">
<div class="etmf-filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable size="small" class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="etmf-filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable size="small" class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
</div>
<!-- 内联状态摘要徽章 -->
<div class="etmf-status-inline">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-badge" :class="`etmf-status-badge--${card.key}`">
<span class="status-badge-value">{{ card.value }}</span>
<span class="status-badge-label">{{ card.label }}</span>
</div>
</div>
<div class="etmf-toolbar-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }}
</el-button>
<el-button v-if="canCreate && selectedNode" type="primary" @click="openDocumentDialog">
<el-button size="small" :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate && selectedNode" size="small" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</div>
<div class="etmf-status-strip">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-card" :class="`etmf-status-card--${card.key}`">
<div class="status-card-value">{{ card.value }}</div>
<div class="status-card-label">{{ card.label }}</div>
</div>
</div>
</div>
<section class="unified-section section--flush etmf-workspace">
@@ -49,7 +47,6 @@
<div class="panel-heading panel-heading--compact">
<div>
<div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div>
<div class="panel-subtitle">{{ totalNodeCount }} 个目录节点</div>
</div>
</div>
<div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree">
@@ -86,69 +83,30 @@
<main class="etmf-document-panel">
<div class="panel-heading">
<div>
<div class="panel-heading-left">
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<div class="panel-subtitle">
{{ selectedNode ? `${selectedNode.code} · ${scopeLabel(selectedNode.scope_type)}` : TEXT.modules.etmf.selectNodeHint }}
</div>
<span v-if="selectedNode" class="panel-subtitle">{{ selectedNode.code }} · {{ scopeLabel(selectedNode.scope_type) }}</span>
</div>
<div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 生效版本</span>
<el-tag effect="plain" :type="statusType(selectedNode.status)">
<span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
</div>
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="120">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="150">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div class="empty-icon">
<el-icon><DocumentAdd /></el-icon>
</div>
<div class="empty-title">{{ selectedNode ? TEXT.modules.etmf.emptyDocuments : TEXT.modules.etmf.selectNodeHint }}</div>
<div class="empty-desc">{{ selectedNode ? "当前目录下还没有归档文件。" : "选择目录后可查看文件、版本与中心范围。" }}</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
<aside class="etmf-node-detail">
<div class="panel-title">{{ TEXT.modules.etmf.detailTitle }}</div>
<template v-if="selectedNode">
<dl class="node-meta">
<div>
<dt>{{ TEXT.modules.etmf.fields.code }}</dt>
<dd>{{ selectedNode.code }}</dd>
</div>
<section v-if="selectedNode" class="document-status-summary">
<div class="status-summary-head">
<span class="status-summary-label">{{ TEXT.modules.etmf.detailTitle }}</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
<span class="status-summary-note" :class="`status-summary-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</span>
<dl class="status-summary-list">
<div>
<dt>{{ TEXT.modules.etmf.fields.scope }}</dt>
<dd>{{ scopeLabel(selectedNode.scope_type) }}</dd>
@@ -166,18 +124,44 @@
<dd>{{ selectedNode.effective_document_count }}</dd>
</div>
</dl>
<div class="node-status-note" :class="`node-status-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</div>
</template>
<div v-else class="etmf-empty-block etmf-empty-block--detail">
<div class="empty-icon">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="empty-title">等待选择目录</div>
<div class="empty-desc">{{ TEXT.modules.etmf.selectNodeHint }}</div>
</div>
</aside>
</section>
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip min-width="1" />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" min-width="1">
<template #default="{ row }">
<el-tag effect="plain" type="info" size="small">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="1" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" min-width="1">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="1">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div v-if="selectedNode" class="empty-title">{{ TEXT.modules.etmf.emptyDocuments }}</div>
<div v-if="selectedNode" class="empty-desc">当前目录下还没有归档文件</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
</section>
</div>
@@ -411,12 +395,6 @@ const selectNode = async (node: EtmfTreeNode) => {
await loadNodeDocuments();
};
const resetFilters = () => {
filters.siteId = "";
filters.status = "";
loadNodeDocuments();
};
const openNodeDialog = () => {
if (!canCreate.value) {
ElMessage.warning("权限不足");
@@ -522,151 +500,233 @@ onMounted(load);
</script>
<style scoped>
.etmf-action-bar {
display: grid;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid var(--ctms-border-light);
/* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
/* .page 本身占满路由容器给它的全部高度 */
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* unified-shellmain-content-flat)是直接子元素,flex:1 让它撑满 .page */
.page :deep(.main-content-flat) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ==================== 顶部单行紧凑工具栏 ==================== */
.etmf-action-bar {
padding: 8px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
flex-shrink: 0;
}
.etmf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 10px;
width: 100%;
min-width: 0;
}
.etmf-toolbar-main,
.etmf-toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.etmf-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.etmf-filter-item {
width: 240px;
min-width: 180px;
width: 180px;
min-width: 140px;
}
.etmf-filter-item :deep(.el-select) {
width: 100%;
}
.etmf-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
/* 内联状态徽章条 */
.etmf-status-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
}
.etmf-status-card {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
.etmf-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 10px;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.07);
white-space: nowrap;
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
.status-card-value {
font-size: 22px;
/* 左侧彩色竖线 */
.etmf-status-badge::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 8px 0 0 8px;
background: #c8d8ef;
}
.etmf-status-badge:hover {
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
}
.status-badge-value {
font-size: 17px;
font-weight: 900;
color: #1e2a3a;
line-height: 1;
font-weight: 750;
color: #172033;
letter-spacing: -0.03em;
min-width: 1ch;
text-align: right;
}
.status-card-label {
font-size: 12px;
color: #68758a;
.status-badge-label {
font-size: 11px;
font-weight: 600;
color: #8a9ab0;
letter-spacing: 0.01em;
line-height: 1.2;
}
.etmf-status-card--missing .status-card-value {
color: #c24141;
}
/* 目录 — 蓝色 */
.etmf-status-badge--nodes::before { background: #4f7ecf; }
.etmf-status-badge--nodes .status-badge-value { color: #1e3a6e; }
.etmf-status-card--uploaded .status-card-value {
color: #b7791f;
/* 缺失 — 红色 */
.etmf-status-badge--missing {
background: linear-gradient(90deg, #fff8f8 0%, #fff 50%);
border-color: rgba(224, 82, 82, 0.2);
}
.etmf-status-badge--missing::before { background: #e05252; }
.etmf-status-badge--missing .status-badge-value { color: #c03434; }
.etmf-status-badge--missing .status-badge-label { color: #c06060; }
.etmf-status-card--effective .status-card-value {
color: #24764b;
/* 已上传 — 琥珀色 */
.etmf-status-badge--uploaded {
background: linear-gradient(90deg, #fffbf2 0%, #fff 50%);
border-color: rgba(212, 146, 10, 0.2);
}
.etmf-status-badge--uploaded::before { background: #d4920a; }
.etmf-status-badge--uploaded .status-badge-value { color: #a06b06; }
.etmf-status-badge--uploaded .status-badge-label { color: #b08030; }
/* 已生效 — 绿色 */
.etmf-status-badge--effective {
background: linear-gradient(90deg, #f4fdf8 0%, #fff 50%);
border-color: rgba(45, 172, 110, 0.2);
}
.etmf-status-badge--effective::before { background: #2dac6e; }
.etmf-status-badge--effective .status-badge-value { color: #1a7a4e; }
.etmf-status-badge--effective .status-badge-label { color: #4a9a70; }
/* 旧状态卡片已替换为内联徽章,保留空占位避免其他引用报错 */
.etmf-workspace {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);
min-height: calc(100vh - 260px);
grid-template-columns: 280px minmax(0, 1fr);
min-height: 0;
flex: 1;
border-top: 0;
background: #fff;
overflow: hidden;
}
.etmf-tree-panel,
.etmf-document-panel,
.etmf-node-detail {
.etmf-document-panel {
min-width: 0;
padding: 16px;
min-height: 0;
padding: 12px 14px;
overflow-y: auto;
}
.etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light);
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%);
border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
}
.etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff;
overflow-x: hidden;
}
.panel-heading {
min-height: 48px;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
}
.panel-heading--compact {
min-height: 38px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
}
.panel-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.01em;
}
.panel-subtitle {
margin-top: 4px;
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
}
.etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff;
--el-tree-node-hover-bg-color: rgba(79, 126, 207, 0.07);
background: transparent;
padding-top: 2px;
}
.etmf-tree :deep(.el-tree-node__content) {
height: 34px;
border-radius: 7px;
margin: 2px 0;
height: 30px;
border-radius: 6px;
margin: 1px 0;
transition: background 0.15s ease;
}
.etmf-tree :deep(.is-current > .el-tree-node__content) {
background: #eaf2ff;
background: linear-gradient(90deg, rgba(79, 126, 207, 0.12) 0%, rgba(79, 126, 207, 0.04) 100%);
box-shadow: inset 3px 0 0 #4f7ecf;
}
@@ -674,16 +734,21 @@ onMounted(load);
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr) 28px max-content;
grid-template-columns: max-content minmax(0, 1fr) 22px max-content;
align-items: center;
gap: 8px;
padding-right: 8px;
gap: 6px;
padding-right: 6px;
}
.tree-node-code {
font-size: 12px;
font-weight: 700;
color: #315f9f;
font-size: 10px;
font-weight: 800;
color: #4f7ecf;
background: rgba(79, 126, 207, 0.1);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.tree-node-name {
@@ -691,159 +756,228 @@ onMounted(load);
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 12px;
}
.tree-node-count {
height: 20px;
min-width: 22px;
height: 18px;
min-width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #edf2f7;
background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174;
font-size: 12px;
font-weight: 650;
font-size: 10px;
font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.document-panel-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
gap: 6px;
font-size: 11px;
font-weight: 500;
color: #69778d;
flex-shrink: 0;
}
.document-status-summary {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
padding: 8px 12px;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 8px;
background: linear-gradient(135deg, #f9fbff 0%, #f3f7fd 100%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.status-summary-head {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-summary-label {
color: var(--ctms-text-main);
font-size: 12px;
font-weight: 700;
}
.status-summary-note {
font-size: 11px;
color: var(--ctms-text-secondary);
line-height: 1.4;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-summary-note--effective { color: #1a5c3a; }
.status-summary-note--missing { color: #7a2020; }
.status-summary-note--uploaded { color: #7a4e0a; }
.status-summary-list {
display: flex;
gap: 6px;
margin: 0;
flex-shrink: 0;
}
.status-summary-list div {
display: flex;
align-items: baseline;
gap: 3px;
min-width: 0;
padding: 3px 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(79, 126, 207, 0.1);
}
.status-summary-list dt {
color: var(--ctms-text-secondary);
font-size: 10px;
font-weight: 600;
order: 2;
}
.status-summary-list dd {
min-width: 0;
margin: 0;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 800;
order: 1;
}
.etmf-document-table {
width: 100%;
}
.node-meta {
display: grid;
gap: 12px;
margin: 16px 0;
/* 文档表格行悬停效果 */
.etmf-document-table :deep(.el-table__body tr:hover > td) {
background: rgba(79, 126, 207, 0.04) !important;
cursor: pointer;
}
.node-meta div {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 10px;
/* 表格头部紧凑化 */
.etmf-document-table :deep(th.el-table__cell) {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
color: #526174;
background: #f8fafc;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.node-meta dt {
color: var(--ctms-text-secondary);
}
.node-meta dd {
min-width: 0;
margin: 0;
font-weight: 600;
color: var(--ctms-text-main);
overflow-wrap: anywhere;
}
.node-status-note {
border-left: 3px solid #8aa0bd;
background: #f6f8fb;
padding: 10px 12px;
.etmf-document-table :deep(td.el-table__cell) {
padding: 8px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
}
.node-status-note--effective {
border-left-color: #2f8f5b;
/* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
.etmf-document-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.node-status-note--missing {
border-left-color: #c84646;
/* 隐藏横向滚动条 */
.etmf-document-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.node-status-note--uploaded {
border-left-color: #c58b20;
.etmf-document-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.etmf-empty-block {
min-height: 168px;
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 22px;
padding: 20px;
text-align: center;
color: #7b8797;
}
.etmf-empty-block--tree {
min-height: 280px;
border: 1px dashed #d8e0eb;
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
min-height: 200px;
border-radius: 10px;
background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
}
.etmf-empty-block--documents {
min-height: 420px;
}
.etmf-empty-block--detail {
min-height: 280px;
min-height: 300px;
}
.empty-icon {
width: 44px;
height: 44px;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #eef3f9;
color: #6b7c91;
font-size: 22px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
font-size: 18px;
box-shadow: 0 3px 8px rgba(79, 126, 207, 0.15);
}
.empty-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: #2f3a4c;
color: #2a3550;
letter-spacing: -0.01em;
}
.empty-desc {
max-width: 320px;
font-size: 13px;
line-height: 1.55;
max-width: 280px;
font-size: 12px;
line-height: 1.5;
color: #7b8797;
}
@media (max-width: 1180px) {
@media (max-width: 900px) {
.etmf-toolbar {
align-items: stretch;
flex-direction: column;
flex-wrap: wrap;
}
.etmf-toolbar-actions {
justify-content: flex-start;
.etmf-status-inline {
display: none;
}
.etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-columns: 240px minmax(0, 1fr);
}
.etmf-node-detail {
grid-column: 1 / -1;
border-top: 1px solid var(--ctms-border-light);
.status-summary-list {
display: none;
}
.document-status-summary {
flex-wrap: wrap;
}
}
@media (max-width: 760px) {
.etmf-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 700px) {
.etmf-toolbar {
flex-direction: column;
align-items: stretch;
}
.etmf-toolbar-main {
flex-direction: column;
align-items: stretch;
flex-wrap: wrap;
}
.etmf-filter-item {
@@ -1,41 +0,0 @@
<template>
<div class="redirecting ctms-page-shell page--flush">
<section class="unified-action-bar bar--flush">
<div class="redirecting-title">{{ TEXT.menu.fileVersionManagement }}</div>
</section>
<section class="unified-shell">
<section class="unified-section section--flush">
<el-empty :description="`${TEXT.common.loading}...`" />
</section>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
onMounted(() => {
const currentStudy = study.currentStudy?.id;
if (currentStudy) {
router.replace(`/trial/${currentStudy}/documents`);
}
});
</script>
<style scoped>
.redirecting {
min-height: 180px;
}
.redirecting-title {
font-size: 15px;
font-weight: 700;
color: var(--ctms-text-main);
}
</style>
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment desktop list workflow", () => {
it("keeps desktop equipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="equipment-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="equipment-preview-pane"');
expect(source).toContain('ref="equipmentTablePaneRef"');
expect(source).toContain("selectedEquipment");
expect(source).toContain('@row-click="handleEquipmentRowClick"');
expect(source).toContain('@row-dblclick="openEquipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedEquipment"');
expect(source).toContain("selectEquipment(row);");
expect(source).toContain("equipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain('router.push({ name: "MaterialEquipmentDetail"');
expect(source).toContain('"row-selected"');
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop equipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openEquipmentContextMenu"');
expect(source).toContain("equipmentContextMenu");
expect(source).toContain('class="equipment-context-menu"');
expect(source).toContain('@click="openSelectedEquipment"');
expect(source).toContain('@click="openSelectedEquipmentEditor"');
expect(source).toContain('@click="removeSelectedEquipment"');
expect(source).toContain('@click="copySelectedEquipmentName"');
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
});
it("aligns the desktop preview pane with the drug shipment preview layout", () => {
const source = readSource();
expect(source).toContain("grid-template-columns: minmax(0, 1fr) 316px;");
expect(source).toContain(".equipment-preview-pane");
expect(source).toContain("background: #f8fafc;");
expect(source).toContain("grid-template-columns: 78px minmax(0, 1fr);");
expect(source).toContain("gap: 10px 12px;");
expect(source).toContain("font-weight: 650;");
expect(source).not.toContain("float: left;");
expect(source).not.toContain("border-radius: 10px;");
expect(source).not.toContain("box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);");
});
});
+483 -82
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'equipment-page--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
@@ -24,59 +24,115 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
<div class="equipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="equipmentTablePaneRef"
class="equipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedEquipment"
>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="handleEquipmentRowClick"
@row-dblclick="openEquipmentDetail"
@row-contextmenu="openEquipmentContextMenu"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>暂无设备数据</span>
</div>
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="equipment-preview-pane">
<template v-if="selectedEquipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前设备</div>
<div class="preview-title">{{ selectedEquipment.name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedEquipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>规格型号</dt>
<dd>{{ selectedEquipment.specModel || TEXT.common.fallback }}</dd>
<dt>单位</dt>
<dd>{{ selectedEquipment.unit || TEXT.common.fallback }}</dd>
<dt>品牌</dt>
<dd>{{ selectedEquipment.brand || TEXT.common.fallback }}</dd>
<dt>产地</dt>
<dd>{{ selectedEquipment.origin || TEXT.common.fallback }}</dd>
<dt>校准</dt>
<dd>{{ selectedEquipment.needCalibration ? "需要校准" : "无需校准" }}</dd>
<dt>周期</dt>
<dd>{{ selectedEquipment.needCalibration ? `${selectedEquipment.calibrationCycleDays || TEXT.common.fallback}` : TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button v-if="canUpdate" size="small" @click="openSelectedEquipmentEditor">编辑</el-button>
<el-button v-if="canDelete" size="small" type="danger" plain @click="removeSelectedEquipment">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>暂无设备数据</span>
<div v-else class="preview-empty">
<span>选择一行查看详情摘要</span>
</div>
</template>
</el-table>
</aside>
</div>
</div>
<div
v-if="isDesktop && equipmentContextMenu.visible && selectedEquipment"
class="equipment-context-menu"
:style="{ left: `${equipmentContextMenu.x}px`, top: `${equipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedEquipment">打开详情</button>
<button v-if="canUpdate" type="button" @click="openSelectedEquipmentEditor">编辑</button>
<button type="button" @click="copySelectedEquipmentName">复制设备名称</button>
<button v-if="canDelete" type="button" class="danger" @click="removeSelectedEquipment">删除</button>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -174,7 +230,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -192,6 +248,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
interface EquipmentRow {
id: string;
@@ -209,12 +266,16 @@ type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const isDesktop = isTauriRuntime();
const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]);
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const editingId = ref("");
const selectedEquipmentId = ref("");
const equipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const equipmentTablePaneRef = ref<HTMLElement | null>(null);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
@@ -239,6 +300,7 @@ const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.c
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const selectedEquipment = computed(() => rows.value.find((item) => item.id === selectedEquipmentId.value) || null);
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -326,11 +388,76 @@ const openEdit = (row: EquipmentRow) => {
drawerVisible.value = true;
};
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : "");
const equipmentRowClass = ({ row }: { row: EquipmentRow }) =>
[
row?.id ? "clickable-row" : "",
isDesktop && row?.id === selectedEquipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const onRowClick = (row: EquipmentRow) => {
const selectEquipment = (row: EquipmentRow) => {
if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } });
closeEquipmentContextMenu();
selectedEquipmentId.value = row.id;
equipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleEquipmentRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectEquipment(row);
return;
}
openEquipmentDetail(row);
};
const openEquipmentDetail = (row?: EquipmentRow | null) => {
const target = row?.id ? row : selectedEquipment.value;
if (target?.id) router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: target.id } });
};
const openSelectedEquipment = () => {
closeEquipmentContextMenu();
openEquipmentDetail(selectedEquipment.value);
};
const openSelectedEquipmentEditor = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedEquipment = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) {
void removeRow(target);
}
};
const openEquipmentContextMenu = (row: EquipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectEquipment(row);
equipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 172)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 142)),
};
};
const closeEquipmentContextMenu = () => {
if (!equipmentContextMenu.value.visible) return;
equipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedEquipmentName = async () => {
const name = selectedEquipment.value?.name;
closeEquipmentContextMenu();
if (!name || !navigator.clipboard) return;
await navigator.clipboard.writeText(name);
ElMessage.success("设备名称已复制");
};
const saveForm = async () => {
@@ -405,10 +532,25 @@ watch(
filters.name = "";
loadRows();
drawerVisible.value = false;
selectedEquipmentId.value = "";
},
{ immediate: true }
);
watch(
() => rows.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
if (!rows.value.length) {
selectedEquipmentId.value = "";
return;
}
if (!rows.value.some((item) => item.id === selectedEquipmentId.value)) {
selectedEquipmentId.value = rows.value[0].id;
}
},
);
watch(
() => form.needCalibration,
(need) => {
@@ -416,6 +558,14 @@ watch(
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
onMounted(() => {
if (isDesktop) document.addEventListener("click", closeEquipmentContextMenu);
});
onBeforeUnmount(() => {
if (isDesktop) document.removeEventListener("click", closeEquipmentContextMenu);
});
</script>
<style scoped>
@@ -427,20 +577,35 @@ watch(
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.equipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.equipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
gap: 12px;
background: linear-gradient(135deg, #f8faff 0%, #f2f6ff 100%);
}
.toolbar-filters {
@@ -466,16 +631,30 @@ watch(
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
font-weight: 700;
color: #7a8ca8;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.04em;
}
.filter-input {
width: 200px;
}
.filter-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(79, 126, 207, 0.2);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input :deep(.el-input__wrapper:hover),
.filter-input :deep(.el-input__wrapper.is-focus) {
border-color: rgba(79, 126, 207, 0.5);
box-shadow: 0 0 0 3px rgba(79, 126, 207, 0.1);
}
.filter-actions {
display: flex;
gap: 8px;
@@ -484,21 +663,35 @@ watch(
}
.filter-btn {
border-radius: 6px;
border-radius: 8px;
}
.filter-summary {
display: inline-flex;
align-items: center;
padding: 2px 10px;
background: rgba(79, 126, 207, 0.08);
border: 1px solid rgba(79, 126, 207, 0.15);
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
font-weight: 700;
color: #4f7ecf;
white-space: nowrap;
letter-spacing: 0.01em;
}
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
padding: 0 18px;
font-weight: 600;
box-shadow: 0 2px 6px rgba(64, 128, 220, 0.25);
transition: box-shadow 0.2s, transform 0.15s;
}
.create-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 128, 220, 0.35);
}
/* ==================== Table Card ==================== */
@@ -511,6 +704,57 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.equipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.equipment-workbench {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.equipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.equipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.equipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.equipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.equipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.equipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -543,6 +787,10 @@ watch(
transition: background 0.1s ease;
}
.equipment-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap {
white-space: nowrap;
@@ -573,21 +821,125 @@ watch(
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
padding: 2px 10px;
font-size: 11px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
line-height: 1.7;
letter-spacing: 0.02em;
}
.status-pill--yes {
background: #dcfce7;
color: #16a34a;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.15);
}
.status-pill--no {
background: #f5f5f5;
color: #737373;
background: #f1f5f9;
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.15);
}
/* ==================== Desktop Preview ==================== */
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.equipment-context-menu {
position: fixed;
z-index: 3000;
min-width: 152px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.equipment-context-menu button {
display: block;
width: 100%;
min-height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.equipment-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.equipment-context-menu button.danger {
color: #dc2626;
}
/* ==================== Empty State ==================== */
@@ -597,30 +949,33 @@ watch(
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 12px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
box-shadow: 0 4px 12px rgba(79, 126, 207, 0.15);
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
font-weight: 600;
color: #8aa0bd;
}
/* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 20px 24px 8px;
padding: 20px 24px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
}
:deep(.equipment-editor-drawer > .el-drawer__body) {
@@ -634,10 +989,10 @@ watch(
.editor-title {
font-size: 18px;
font-weight: 700;
font-weight: 800;
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
letter-spacing: -0.02em;
}
.equipment-form {
@@ -746,6 +1101,15 @@ watch(
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.equipment-workbench.is-desktop {
grid-template-columns: 1fr;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 0;
}
.equipment-preview-pane {
display: none;
}
.field-grid--2,
.field-grid--3 {
grid-template-columns: 1fr;
@@ -769,4 +1133,41 @@ watch(
width: 100%;
}
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .table-card) {
background: #172033;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .equipment-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button:hover:not(:disabled)) {
background: #243247;
}
:global([data-ctms-theme="dark"] .equipment-page--desktop .equipment-context-menu button.danger) {
color: #fca5a5;
}
</style>
+45 -41
View File
@@ -1,16 +1,9 @@
<template>
<div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="unified-shell ctms-table-card">
<section class="unified-action-bar bar--flush">
<section class="unified-action-bar milestone-action-bar">
<div class="card-header ctms-page-header-row">
<div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="card-subtitle">
{{ TEXT.modules.projectMilestones.sourceLabel }}{{ dataSourceLabel }}
<span class="dot-sep">·</span>
{{ TEXT.common.labels.updatedAt }}{{ updatedAtLabel }}
</div>
</div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="header-actions">
<el-tag size="small" effect="plain">
{{ TEXT.modules.projectMilestones.statTotal }}{{ rows.length }}
@@ -200,7 +193,6 @@ import { useStudyStore } from "../../store/study";
import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones";
import { TEXT } from "../../locales";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayDateTime } from "../../utils/display";
import { usePermission } from "../../utils/permission";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
@@ -237,8 +229,6 @@ const study = useStudyStore();
const { can } = usePermission();
const rows = ref<MilestoneRow[]>([]);
const loading = ref(false);
const dataSourceLabel = ref<string>(TEXT.modules.projectMilestones.sourcePublished);
const updatedAtLabel = ref<string>(TEXT.common.fallback);
const editorVisible = ref(false);
const editingRowId = ref("");
const editorForm = reactive<MilestoneLocalEdit>({
@@ -309,15 +299,8 @@ const loadMilestones = async () => {
const { data } = (await listProjectMilestones(studyId)) as any;
const list = Array.isArray(data) ? data : data?.items || [];
rows.value = list.map(normalizeRow);
const updatedAt = list
.map((item: any) => String(item?.updated_at || ""))
.filter(Boolean)
.sort()
.pop();
updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback;
} catch (error: any) {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
@@ -452,7 +435,6 @@ watch(
() => study.currentStudy?.id,
() => {
rows.value = [];
updatedAtLabel.value = TEXT.common.fallback;
loadMilestones();
}
);
@@ -462,6 +444,32 @@ watch(
.page {
display: flex;
flex-direction: column;
/* 撑满父容器(desktop-route-shell 已经是 height: 100%*/
height: 100%;
min-height: 0;
}
/* 让白色卡片壳撑满 .page 的剩余高度,并去掉圆角 */
.page > .unified-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
}
/* 让表格 section 撑满卡片壳的剩余高度 */
.page > .unified-shell > .unified-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* el-table 本身填满 section */
.page > .unified-shell > .unified-section :deep(.el-table),
.page > .unified-shell > .unified-section :deep(.el-table__inner-wrapper) {
height: 100%;
}
.ctms-page-content-grid {
@@ -477,20 +485,15 @@ watch(
flex-wrap: wrap;
}
.milestone-action-bar {
min-height: 56px;
box-sizing: border-box;
}
.card-title {
font-size: 15px;
font-weight: 700;
color: #0f2345;
}
.card-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.dot-sep {
margin: 0 8px;
color: var(--unified-title-color);
}
.header-actions {
@@ -520,11 +523,11 @@ watch(
border-radius: 50%;
border: 3px solid #2f84c6;
box-sizing: border-box;
background: #ffffff;
background: var(--ctms-bg-card);
}
.plan-time-label {
color: #4b5563;
color: var(--ctms-text-secondary);
font-size: 11px;
}
@@ -546,10 +549,10 @@ watch(
}
.time-editor-group {
border: 1px solid #e8eef6;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
padding: 14px 16px 10px;
background: #fbfcfe;
background: var(--ctms-bg-muted);
}
.time-editor-group + .time-editor-group {
@@ -566,7 +569,7 @@ watch(
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
color: #1a3560;
color: var(--ctms-text-main);
display: flex;
align-items: center;
gap: 8px;
@@ -600,14 +603,14 @@ watch(
.duration-label {
font-size: 13px;
color: #6b7280;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.duration-value {
font-size: 14px;
font-weight: 700;
color: #1a3560;
color: var(--ctms-text-main);
}
.time-editor-footer {
@@ -628,7 +631,7 @@ watch(
.time-editor-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
color: var(--ctms-text-regular);
padding-bottom: 4px;
}
@@ -655,7 +658,7 @@ watch(
.status-detail {
padding-left: 14px;
color: #6b7280;
color: var(--ctms-text-secondary);
font-size: 11px;
}
@@ -684,9 +687,10 @@ watch(
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
+663 -59
View File
@@ -1,72 +1,145 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="page ctms-page-shell page--flush" :class="{ 'project-overview--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-body">
<div class="overview-container">
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
<section v-if="isDesktop" class="desktop-attention-section">
<div class="desktop-attention-board">
<div class="attention-card attention-card--stages">
<div class="attention-title">阶段状态</div>
<div class="stage-status-grid">
<div v-for="item in stageStatusSummary" :key="item.key" class="stage-status-item">
<span class="legend-dot" :class="item.dotClass"></span>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</div>
</div>
</div>
</div>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div class="attention-card">
<div class="attention-title">当前推进</div>
<div v-if="activeStageItems.length" class="attention-list">
<div v-for="item in activeStageItems" :key="item.key" class="attention-list-row">
<strong>{{ item.centerName }}</strong>
<span>{{ item.stageLabel }}</span>
</div>
</div>
<div v-else class="desktop-empty-note">暂无进行中阶段</div>
</div>
<div class="attention-card">
<div class="attention-title">需关注</div>
<div class="attention-list">
<div v-for="item in attentionItems" :key="item" class="attention-list-row attention-list-row--plain">
<span>{{ item }}</span>
</div>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
<div class="overview-workbench">
<section class="overview-card overview-card--progress">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<span v-if="!isDesktop" class="overview-live-clock">数据 {{ overviewClockText }}</span>
<span v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
</div>
</div>
</div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<EnrollmentBarChart
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card overview-card--enrollment">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
</div>
</div>
<el-radio-group v-if="!isDesktop" v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<StateLoading v-if="isDesktop && loading" :rows="4" />
<div v-else-if="isDesktop" class="enrollment-snapshot">
<div class="enrollment-meter">
<div class="meter-head">
<span>达成率</span>
<strong>{{ enrollmentCompletionLabel }}</strong>
</div>
<div class="meter-track">
<span class="meter-fill" :style="{ width: enrollmentCompletionWidth }"></span>
</div>
<div class="meter-foot">
<span>已入组 {{ overview?.summary.total_actual || 0 }}</span>
<span>目标 {{ overview?.summary.total_target || 0 }}</span>
</div>
</div>
<div class="enrollment-center-list">
<div v-for="row in enrollmentCenterRows" :key="row.key" class="enrollment-center-row">
<div class="enrollment-center-meta">
<span>{{ row.label }}</span>
<strong>{{ row.actual }} / {{ row.target }}</strong>
</div>
<div class="mini-progress-track">
<span class="mini-progress-fill" :style="{ width: row.percentWidth }"></span>
</div>
</div>
<div v-if="enrollmentCenterRows.length === 0" class="desktop-empty-note">
暂无中心入组数据
</div>
</div>
</div>
<EnrollmentBarChart
v-else
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -74,24 +147,145 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites";
import { isTauriRuntime } from "../../runtime";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
import { STAGE_ORDER, adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel, type StageStatus } from "./project-overview/overview.adapter";
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const overviewClockNow = ref(new Date());
let overviewClockTimer: number | undefined;
const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return 0;
return Math.min(100, Math.round((summary.total_actual / summary.total_target) * 100));
});
const enrollmentCompletionLabel = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return "未设目标";
return `${enrollmentCompletionRate.value}%`;
});
const enrollmentCompletionWidth = computed(() => `${enrollmentCompletionRate.value}%`);
const overviewUpdatedAtLabel = computed(() => {
if (!overview.value?.updated_at) return "-";
const date = new Date(overview.value.updated_at);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const overviewClockText = computed(() => {
const date = overviewClockNow.value;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusDotClassMap: Record<StageStatus, string> = {
COMPLETED: "completed",
IN_PROGRESS: "active",
NOT_STARTED: "pending",
BLOCKED: "blocked",
};
const stageStatusCounts = computed<Record<StageStatus, number>>(() => {
const counts: Record<StageStatus, number> = {
COMPLETED: 0,
IN_PROGRESS: 0,
NOT_STARTED: 0,
BLOCKED: 0,
};
centers.value.forEach((center) => {
STAGE_ORDER.forEach((stage) => {
counts[center[stage.key] || "NOT_STARTED"] += 1;
});
});
return counts;
});
const stageStatusSummary = computed(() =>
(["COMPLETED", "IN_PROGRESS", "NOT_STARTED", "BLOCKED"] as StageStatus[]).map((status) => ({
key: status,
label: statusLabelMap[status],
count: stageStatusCounts.value[status],
dotClass: statusDotClassMap[status],
}))
);
const activeStageItems = computed(() =>
centers.value
.flatMap((center, centerIndex) =>
STAGE_ORDER
.filter((stage) => center[stage.key] === "IN_PROGRESS")
.map((stage) => ({
key: `${center.center_id || centerIndex}-${stage.key}`,
centerName: center.center_name || TEXT.common.fallback,
stageLabel: stage.label,
}))
)
.slice(0, 4)
);
const attentionItems = computed(() => {
const items: string[] = [];
const targetlessCenters = centers.value.filter((center) => !center.enrollment_target).length;
const inactiveCenters = centers.value.filter((center) => center.is_active === false).length;
if (stageStatusCounts.value.BLOCKED > 0) {
items.push(`${stageStatusCounts.value.BLOCKED} 个阶段阻塞/延期`);
}
if (targetlessCenters > 0) {
items.push(`${targetlessCenters} 个中心未设置入组目标`);
}
if (overview.value && overview.value.months.length === 0) {
items.push("暂无月度入组趋势数据");
}
if (inactiveCenters > 0) {
items.push(`${inactiveCenters} 个中心已停用`);
}
if (items.length === 0) {
items.push("暂无需要额外关注的总览风险");
}
return items.slice(0, 4);
});
const enrollmentCenterRows = computed(() =>
centers.value.slice(0, 4).map((center, index) => {
const target = center.enrollment_target || 0;
const actual = center.enrollment_actual || 0;
const percent = target > 0 ? Math.min(100, Math.round((actual / target) * 100)) : 0;
return {
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual,
target,
percentWidth: `${percent}%`,
};
})
);
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
siteList.map((site: any) => ({
@@ -188,9 +382,17 @@ const reset = () => {
};
onMounted(() => {
overviewClockNow.value = new Date();
overviewClockTimer = window.setInterval(() => {
overviewClockNow.value = new Date();
}, 1000);
loadOverview();
});
onBeforeUnmount(() => {
if (overviewClockTimer) window.clearInterval(overviewClockTimer);
});
watch(
() => study.currentStudy?.id,
() => {
@@ -218,6 +420,12 @@ watch(
gap: 10px;
}
.overview-workbench {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header-right {
display: flex;
align-items: center;
@@ -230,6 +438,14 @@ watch(
border-radius: 8px;
}
.overview-live-clock,
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.overview-card {
background: var(--ctms-bg-card);
border: 1px solid var(--ctms-border-color);
@@ -389,6 +605,385 @@ watch(
border-radius: 8px;
}
.project-overview--desktop {
min-height: 0;
}
.project-overview--desktop .page-body {
min-height: 0;
gap: 8px;
}
.project-overview--desktop .overview-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
gap: 10px;
}
.project-overview--desktop .overview-workbench {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);
align-items: stretch;
overflow: hidden;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.project-overview--desktop .overview-card {
min-width: 0;
border: 0;
border-radius: 0;
padding: 12px 14px;
background: transparent;
box-shadow: none;
}
.project-overview--desktop .overview-card:hover {
box-shadow: none;
}
.project-overview--desktop .overview-card--enrollment {
border-left: 1px solid #d9e2ec;
}
.project-overview--desktop .card-header {
margin-bottom: 8px;
}
.project-overview--desktop .card-header-left {
gap: 8px;
}
.project-overview--desktop .card-icon {
width: 24px;
height: 24px;
border-radius: 5px;
}
.project-overview--desktop .card-title {
font-size: 13px;
}
.project-overview--desktop .card-subtitle,
.project-overview--desktop .progress-legend {
font-size: 11px;
}
.project-overview--desktop .progress-legend {
gap: 8px;
}
.project-overview--desktop .legend-dot {
width: 8px;
height: 8px;
}
.enrollment-snapshot {
display: flex;
flex-direction: column;
gap: 14px;
}
.enrollment-meter {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
}
.meter-head,
.meter-foot,
.enrollment-center-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.meter-head span,
.meter-foot,
.enrollment-center-meta span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.meter-head strong {
color: #15344f;
font-size: 18px;
line-height: 1;
}
.meter-track,
.mini-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #e8eef5;
}
.meter-fill,
.mini-progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: #3f5d75;
}
.enrollment-center-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-center-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.enrollment-center-meta strong {
color: #1f2f45;
font-size: 12px;
}
.project-overview--desktop .progress-list {
max-height: min(360px, calc(100vh - 300px));
overflow: auto;
padding-right: 2px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.project-overview--desktop .progress-list::-webkit-scrollbar {
width: 8px;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-track {
background: transparent;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-thumb {
border: 2px solid #ffffff;
border-radius: 999px;
background: #c8d4e1;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: minmax(126px, 156px) minmax(0, 1fr);
gap: 8px;
padding: 6px;
border-radius: 5px;
}
.project-overview--desktop :deep(.center-name) {
font-size: 13px;
}
.project-overview--desktop :deep(.center-enrollment) {
font-size: 11px;
}
.project-overview--desktop :deep(.center-timeline) {
padding: 4px 0 0;
}
.project-overview--desktop :deep(.timeline-segment) {
gap: 6px;
}
.project-overview--desktop :deep(.stage-node) {
min-width: 52px;
gap: 4px;
}
.project-overview--desktop :deep(.stage-dot) {
width: 13px;
height: 13px;
}
.project-overview--desktop :deep(.stage-in_progress .stage-dot) {
width: 16px;
height: 16px;
}
.project-overview--desktop :deep(.stage-label) {
max-width: 62px;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.project-overview--desktop :deep(.stage-in_progress .stage-label) {
padding: 1px 6px;
}
.project-overview--desktop :deep(.stage-connector) {
min-width: 30px;
}
.project-overview--desktop :deep(.chart-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
.project-overview--desktop :deep(.chart-plot) {
border-radius: 6px;
background: #f8fafc;
}
.project-overview--desktop :deep(.chart-empty-shell) {
min-height: 112px;
border-radius: 6px;
padding: 14px;
}
.project-overview--desktop .overview-empty-panel {
min-height: 104px;
padding: 12px;
border-radius: 6px;
}
.desktop-attention-board {
display: grid;
grid-template-columns: minmax(220px, 0.75fr) minmax(260px, 1fr) minmax(260px, 1fr);
gap: 10px;
}
.desktop-attention-section {
display: flex;
flex-direction: column;
}
.attention-card {
min-width: 0;
min-height: 126px;
padding: 12px 14px;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.attention-title {
margin-bottom: 10px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.stage-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.stage-status-item,
.attention-list-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 8px;
border-radius: 6px;
background: #f8fafc;
}
.stage-status-item span:not(.legend-dot),
.attention-list-row span {
min-width: 0;
overflow: hidden;
color: #53677f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-status-item strong {
margin-left: auto;
color: #15253a;
font-size: 13px;
}
.attention-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attention-list-row strong {
min-width: 0;
overflow: hidden;
color: #15253a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attention-list-row--plain {
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.attention-list-row--plain span {
white-space: normal;
}
.desktop-empty-note {
min-height: 32px;
display: flex;
align-items: center;
color: #7b8da3;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-workbench),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-card) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-card--enrollment) {
border-left-color: #26364a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {
background: #111a2a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-title),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-center-meta strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .meter-head strong) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .progress-list::-webkit-scrollbar-thumb) {
border-color: #172033;
background: #3b4b60;
}
@media (max-width: 1240px) {
.project-overview--desktop .overview-workbench,
.desktop-attention-board {
grid-template-columns: 1fr;
}
.project-overview--desktop .overview-card--enrollment {
border-top: 1px solid #d9e2ec;
border-left: 0;
}
}
@media (max-width: 768px) {
.overview-container {
padding: 8px;
@@ -406,5 +1001,14 @@ watch(
.card-header-right {
justify-content: flex-start;
}
.project-overview--desktop .overview-container {
grid-template-columns: 1fr;
padding-top: 8px;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: 1fr;
}
}
</style>
@@ -466,7 +466,7 @@ import {
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime";
import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
@@ -978,7 +978,14 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback(
{ suggestedName: filename, mimeType: contentType, data: blob },
{
kind: "export",
title: "导出文件",
completedDetail: "导出文件已保存",
},
);
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -1010,7 +1017,7 @@ const importIssueFile = async (file: File) => {
};
const selectImportFile = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",
-13
View File
@@ -1,13 +0,0 @@
<template>
<ModulePlaceholder
:title="TEXT.modules.riskIssues.title"
:subtitle="TEXT.modules.riskIssues.subtitle"
:list-title="TEXT.modules.riskIssues.listTitle"
:empty-description="TEXT.modules.riskIssues.emptyDescription"
/>
</template>
<script setup lang="ts">
import ModulePlaceholder from "../../components/ModulePlaceholder.vue";
import { TEXT } from "../../locales";
</script>
@@ -5,15 +5,17 @@ import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => {
it("hides create, edit, and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:update")');
expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canUpdateSubject"');
expect(source).toContain('v-if="canDeleteSubject"');
});
});
@@ -24,6 +26,8 @@ describe("SubjectManagement drawer editor", () => {
expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible");
expect(source).toContain("editingSubjectId");
expect(source).toContain(':subject-id="editingSubjectId || undefined"');
expect(source).not.toContain('router.push("/subjects/new")');
});
});
@@ -32,10 +36,49 @@ describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\"");
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="subject-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="subject-preview-pane"');
expect(source).toContain('ref="subjectTablePaneRef"');
expect(source).toContain("selectedSubject");
expect(source).toContain("@row-click=\"handleSubjectRowClick\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@keydown.enter.prevent=\"openSelectedSubject\"");
expect(source).toContain("selectSubject(row);");
expect(source).toContain("subjectTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain("subject-preview-pane");
expect(source).toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedSubjectEditor\"");
expect(source).toContain("@click=\"removeSelectedSubject\"");
expect(source).not.toContain('<el-button size="small" @click="copySelectedSubjectNo">复制编号</el-button>');
expect(source).toContain("subject-context-menu");
expect(source).toContain('v-if="!isDesktop && canDeleteSubject"');
expect(source).toContain('"row-selected"');
});
it("does not render unused table selection controls", () => {
const source = readSubjectManagementSource();
expect(source).not.toContain('type="selection"');
expect(source).not.toContain("@selection-change");
expect(source).not.toContain("selectedRows");
expect(source).not.toContain("selection-count");
});
it("keeps desktop footer controls at the bottom of the workbench", () => {
const source = readSubjectManagementSource();
expect(source).toContain("subject-page--desktop");
expect(source).toContain(".subject-page--desktop .table-card");
expect(source).toContain(".subject-workbench.is-desktop");
expect(source).toContain(".subject-table-pane");
expect(source).toContain(".pagination-wrap");
expect(source).toContain(".preview-actions");
expect(source.match(/margin-top: auto;/g)?.length).toBeGreaterThanOrEqual(2);
});
});
+132 -38
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'subject-page--desktop': isDesktop }">
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
@@ -24,7 +24,6 @@
</div>
</div>
<div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} </span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -32,8 +31,13 @@
</div>
</div>
<div class="subject-workbench">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
<div class="subject-workbench" :class="{ 'is-desktop': isDesktop }">
<div
class="subject-table-pane"
ref="subjectTablePaneRef"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedSubject"
>
<el-table
:data="pagedItems"
v-loading="loading"
@@ -41,13 +45,11 @@
class="subject-table"
:row-class-name="subjectRowClass"
highlight-current-row
@selection-change="onSelectionChange"
@row-click="selectSubject"
@row-click="handleSubjectRowClick"
@row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu"
table-layout="fixed"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
@@ -73,7 +75,7 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<el-table-column v-if="!isDesktop && canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
@@ -104,7 +106,7 @@
</div>
</div>
<aside class="subject-preview-pane">
<aside v-if="isDesktop" class="subject-preview-pane">
<template v-if="selectedSubject">
<div class="preview-head">
<div>
@@ -130,6 +132,26 @@
{{ badge.label }}
</span>
</div>
<div class="preview-actions">
<el-button
v-if="canUpdateSubject"
size="small"
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="openSelectedSubjectEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteSubject"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="removeSelectedSubject"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<div class="empty-icon">
@@ -142,7 +164,7 @@
</div>
<div
v-if="contextMenu.visible"
v-if="isDesktop && contextMenu.visible && selectedSubject"
class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
@@ -154,13 +176,17 @@
type="button"
class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)"
@click="removeSelectedSubject"
>
删除
</button>
</div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
:subject-id="editingSubjectId || undefined"
@success="handleSubjectEditorSuccess"
/>
</div>
</template>
@@ -179,15 +205,18 @@ import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const editingSubjectId = ref("");
const subjectTablePaneRef = ref<HTMLElement | null>(null);
const items = ref<any[]>([]);
const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
@@ -215,6 +244,7 @@ const canUseApiPermission = (operationKey: string) => {
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
};
const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
@@ -251,14 +281,26 @@ const load = async () => {
}
};
const goNew = () => { subjectDrawerVisible.value = true; };
const goNew = () => {
editingSubjectId.value = "";
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => {
contextMenu.value.visible = false;
closeSubjectContextMenu();
if (!row?.id) return;
selectedSubjectId.value = row.id;
subjectTablePaneRef.value?.focus({ preventScroll: true });
};
const handleSubjectRowClick = (row: any) => {
if (!row?.id) return;
if (isDesktop) {
selectSubject(row);
return;
}
goDetail(row.id);
};
const openSubjectDetail = (row: any) => {
if (!row?.id) return;
@@ -268,21 +310,19 @@ const openSelectedSubject = () => {
contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
};
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
if (!row?.id) return;
selectedSubjectId.value = row.id;
selectSubject(row);
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 152)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 122)),
};
};
const closeSubjectContextMenu = () => {
contextMenu.value.visible = false;
if (!contextMenu.value.visible) return;
contextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false;
@@ -291,6 +331,28 @@ const copySelectedSubjectNo = async () => {
await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制");
};
const openSelectedSubjectEditor = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (!target) return;
if (!canUpdateSubject.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(target.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingSubjectId.value = target.id;
subjectDrawerVisible.value = true;
};
const removeSelectedSubject = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (target) {
void remove(target);
}
};
const remove = async (row: any) => {
contextMenu.value.visible = false;
const studyId = study.currentStudy?.id;
@@ -349,6 +411,7 @@ watch(() => filteredItems.value.length, (total) => {
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => {
if (!isDesktop) return;
if (!rows.length) {
selectedSubjectId.value = "";
return;
@@ -359,7 +422,7 @@ watch(pagedItems, (rows) => {
});
onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites();
load();
@@ -369,7 +432,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.();
});
</script>
@@ -383,25 +446,53 @@ onBeforeUnmount(() => {
background: #ffffff;
}
.subject-page--desktop {
height: 100%;
min-height: 0;
}
.table-card {
background: #ffffff;
overflow: hidden;
}
.subject-page--desktop .table-card {
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
}
.subject-workbench {
min-width: 0;
min-height: 0;
}
.subject-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px;
flex: 1;
min-height: 0;
}
.subject-table-pane {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #edf1f7;
min-height: 0;
outline: none;
}
.subject-workbench.is-desktop .subject-table-pane {
border-right: 1px solid #edf1f7;
}
.subject-preview-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
padding: 16px;
background: #fbfcff;
}
@@ -431,13 +522,6 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item {
display: flex;
flex-direction: column;
@@ -473,6 +557,8 @@ onBeforeUnmount(() => {
/* ==================== Table ==================== */
.subject-table {
flex: 1;
min-height: 0;
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
@@ -546,7 +632,7 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px;
margin: 16px 0 0;
margin: 0;
}
.preview-list dt {
@@ -566,10 +652,17 @@ onBeforeUnmount(() => {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
flex: 1;
min-height: 360px;
display: flex;
flex-direction: column;
@@ -651,6 +744,7 @@ onBeforeUnmount(() => {
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: auto;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
@@ -680,8 +774,8 @@ onBeforeUnmount(() => {
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; }
.subject-workbench.is-desktop { grid-template-columns: 1fr; }
.subject-workbench.is-desktop .subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
@@ -1,5 +1,5 @@
<template>
<div class="enrollment-chart">
<div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body">
@@ -9,7 +9,7 @@
</div>
<div v-else class="chart-body">
<div class="chart-scroll">
<div class="chart-plot">
<div class="chart-plot" :style="chartPlotStyle">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
@@ -105,23 +105,40 @@ const props = withDefaults(
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
compact?: boolean;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
compact: false,
}
);
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 260;
const axisPadding = {
top: 28,
right: 32,
bottom: 48,
left: 56,
};
const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = computed(() => ({
top: compact.value ? 24 : 28,
right: compact.value ? 24 : 32,
bottom: compact.value ? 42 : 48,
left: compact.value ? 46 : 56,
}));
const chartWidth = computed(() => {
if (!compact.value) return baseChartWidth.value;
const itemWidth = showTarget.value ? 72 : 64;
return Math.max(
baseChartWidth.value,
props.items.length * itemWidth + axisPadding.value.left + axisPadding.value.right,
);
});
const chartPlotStyle = computed(() => ({
"--chart-min-width": `${chartWidth.value}px`,
"--chart-aspect": `${chartWidth.value} / ${chartHeight.value}`,
}));
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
@@ -146,36 +163,37 @@ const yTicks = computed(() => {
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 20;
const valueGap = 10;
const barRadius = 6;
const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = computed(() => axisPadding.value.left);
const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = computed(() => axisPadding.value.top);
const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = computed(() => (compact.value ? 5 : 6));
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(52, bandWidth.value * 0.5));
const barWidth = computed(() => Math.min(compact.value ? 44 : 52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2;
const barCenter = (index: number) => axisLeft.value + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
return barTop(anchor) - valueGap.value;
};
const truncateLabel = (label: string) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
const limit = compact.value ? 7 : 8;
if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
@@ -228,7 +246,8 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative;
aspect-ratio: 960 / 260;
width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px;
}
@@ -297,6 +316,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
font-weight: 500;
}
.enrollment-chart--compact .chart-plot {
width: max(100%, var(--chart-min-width));
min-height: 0;
border-radius: 6px;
}
.enrollment-chart--compact .chart-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.enrollment-chart--compact .tick-label {
font-size: 10px;
}
.enrollment-chart--compact .bar-value {
font-size: 11px;
}
.enrollment-chart--compact .bar-value-actual {
font-size: 12px;
}
.enrollment-chart--compact .bar-label {
font-size: 10px;
}
@media (max-width: 768px) {
.bar-label {
font-size: 11px;
@@ -0,0 +1,332 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
describe("CollaborationLibrary UI contract", () => {
it("opens the create dialog directly from the primary sidebar action", () => {
expect(source).toContain(':icon="Plus" @click="openCreateDialog">新建</el-button>');
expect(source).toContain(':icon="Upload" :loading="importing"');
expect(source).not.toContain("新建协作文件⌄");
});
it("provides compact document-library views and file type filtering", () => {
expect(source).toContain("全部文件");
expect(source).toContain("最近更新");
expect(source).toContain("与我共享");
expect(source).toContain('v-model="fileTypeFilter"');
expect(source).toContain("currentViewTitle");
});
it("uses folder ellipsis actions on web and a context menu on desktop", () => {
expect(source).toContain('const isDesktop = isTauriRuntime();');
expect(source).toContain(":trigger=\"isDesktop ? 'contextmenu' : 'click'\"");
expect(source).toContain('v-if="!isDesktop && canManage');
expect(source).toContain('class="folder-more"');
expect(source).toContain('command="rename"');
expect(source).toContain('command="delete"');
expect(source).not.toContain('class="folder-actions"');
});
it("renames folders inline and keeps destructive deletion confirmed", () => {
expect(source).toContain(':data-folder-rename="folder.id"');
expect(source).toContain('@keydown.enter.stop.prevent="submitRenameFolder(folder)"');
expect(source).toContain('@keydown.esc.stop.prevent="cancelRenameFolder"');
expect(source).toContain('确认删除空文件夹“${folder.name}”');
});
it("renders action dialogs as a single desktop surface", () => {
expect(source.match(/class="collaboration-action-dialog(?:\s|\")/g)?.length).toBe(8);
expect(source).toContain("body.is-desktop-runtime .collaboration-action-dialog.el-dialog");
expect(source).toContain("--el-dialog-padding-primary: 0;");
expect(source).toContain("padding: 0 !important;");
});
it("offers the complete file action menu with explicit permission gates", () => {
for (const command of ["permissions", "rename", "copy", "move", "info", "history", "download", "trash"]) {
expect(source).toContain(`command: "${command}"`);
}
expect(source).toContain("item.can_export && canCreate.value");
expect(source).toContain("if (item.can_export) actions.push");
expect(source).toContain('label: "访问与权限"');
expect(source).not.toContain('command: "members"');
expect(source).not.toContain('command: "share"');
expect(source).not.toContain('command="manage"');
});
it("keeps rename and move as separate focused operations", () => {
expect(source).toContain('v-model="renameDialogVisible" title="重命名"');
expect(source).toContain('v-model="moveDialogVisible" title="移动到"');
expect(source).toContain("submitRenameFile");
expect(source).toContain("submitMoveFile");
expect(source).toContain("const targetFolderId = moveFolderId.value || null;");
expect(source).toContain("{ folder_id: targetFolderId }");
});
it("shows file metadata and downloads the current revision without a save picker", () => {
expect(source).toContain('v-model="infoDialogVisible" title="文件信息"');
expect(source).toContain("current_revision_file_size");
expect(source).toContain("current_revision_no");
expect(source).not.toContain('<el-table-column label="修订"');
expect(source).not.toContain('<el-descriptions-item label="当前版本"');
expect(source).not.toContain("R${");
expect(source).toContain("downloadCollaborationFile");
expect(source).toContain("downloadFileWithFeedback(");
expect(source).not.toContain("prepareSaveFile");
});
it("refreshes history immediately after restoring a revision", () => {
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
expect(source).toContain(':loading="restoringRevisionId === row.id"');
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
expect(source).toContain("current_revision_id: restoredForDisplay.id");
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
expect(source).toContain("const [revisionResponse] = await Promise.all([");
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
});
it("loads fresh history on open and follows delayed ONLYOFFICE session callbacks", () => {
expect(source).toContain('@closed="closeHistory"');
expect(source).toContain("const historyRefreshTimers: number[] = []");
expect(source).toContain("scheduleHistoryRefresh();");
expect(source).toContain("refreshHistory({ silent: true })");
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
expect(source).toContain("historyFile.value = fileResponse.data");
expect(source).toContain("requestSequence !== historyRequestSequence");
});
it("presents version history as a date-grouped file timeline", () => {
expect(source).toContain('class="collaboration-action-dialog history-dialog"');
expect(source).toContain('width="840px"');
expect(source).toContain('modal-class="history-dialog-overlay"');
expect(source).toContain("history-file-badge");
expect(source).toContain("创建者:{{ historyFile.owner_name");
expect(source).toContain('v-model="historyOnlyDescribed"');
expect(source).toContain('v-model="historySourceFilter"');
expect(source).toContain('v-for="group in historyRevisionGroups"');
expect(source).toContain("revision.created_by_name");
expect(source).toContain(":src=\"row.created_by_avatar_url || undefined\"");
expect(source).toContain("当前版本");
expect(source).toContain("版本名称");
expect(source).not.toContain("<strong>R{{ row.revision_no }}</strong>");
expect(source).toContain("仅显示已命名版本");
expect(source).toContain("未命名");
expect(source).toContain(">命名</el-button>");
expect(source).toContain(">预览</el-button>");
expect(source).toContain(">另存为</el-button>");
expect(source).toContain("nameRevision(row)");
expect(source).toContain("previewRevision(row)");
expect(source).toContain("saveRevisionAs(row)");
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
expect(source).not.toContain("historyDialogVisible.value = false");
expect(source).toContain("copyCollaborationRevision");
expect(source).toContain("updateCollaborationRevision");
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
expect(source).toContain("<CollaborationSaveAsDialog");
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
expect(source).toContain("submitRevisionSaveAs");
expect(source).toContain("folder_id: payload.folderId");
expect(source).toContain('`${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`');
expect(source).toContain("${pad(date.getMinutes())}");
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
expect(source).toContain('command="delete"');
expect(source).toContain("删除版本");
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
expect(source).toContain("deleteCollaborationRevision");
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
expect(source).toContain("height: min(760px, 82vh)");
expect(source).toContain("min-height: 48px");
expect(source).toContain("body.is-desktop-runtime .history-dialog.el-dialog");
expect(source).toContain("margin: 0 auto !important;");
expect(source).not.toContain('<el-table :data="revisions"');
});
it("supports inviting several members inside unified access management", () => {
expect(source).toContain('v-model="accessDialogVisible"');
expect(source).toContain('<strong>{{ accessPanelTitle }}</strong>');
expect(source).toContain("openAccessManagement");
expect(source).toContain('@click="openMemberInvite"');
expect(source).toContain('class="access-section__manage-button"');
expect(source).toContain("<span>管理协作者</span>");
expect(source).toContain('class="access-member-identity"');
expect(source).toContain('class="access-member-role"');
expect(source).toContain('class="access-member-owner">所有者</span>');
expect(source).not.toContain('<el-table :data="members"');
expect(source).toContain('v-model="memberInviteDialogVisible"');
expect(source).toContain('title="管理协作者"');
expect(source).not.toContain('title="添加协作者"');
expect(source).toContain('placeholder="搜索姓名、账号或角色"');
expect(source).not.toContain('member-picker-group--recent');
expect(source).not.toContain('member-picker-recent-empty');
expect(source).not.toContain('最近选择的账号将显示在这里');
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
expect(source).toContain("selectedMemberCandidates.length");
expect(source).toContain('aria-label="文件授权角色"');
expect(source).toContain('label="可编辑" value="EDITOR"');
expect(source).toContain('label="可管理" value="MANAGER"');
expect(source).toContain('class="member-picker-selection__footer"');
expect(source).toContain("Promise.allSettled(selectedUserIds.map");
expect(source).toContain("个账号,${failed.length} 个账号授权失败");
expect(source).toContain("closeAccessManagement");
expect(source).not.toContain("memberDialogVisible");
expect(source).not.toContain("shareDialogVisible");
});
it("opens permission settings as a returnable view in the same dialog", () => {
expect(source).toContain('const accessPanel = ref<"overview" | "permissions">("overview")');
expect(source).toContain('@click="openPermissionSettings"');
expect(source).toContain('aria-label="返回访问与权限"');
expect(source).toContain('@click="handleAccessPanelBack"');
expect(source).toContain("const handleAccessPanelBack = () =>");
expect(source).toContain('v-else-if="accessPanel === \'permissions\'" class="access-section access-section--common"');
expect(source).not.toContain("permissionDialogVisible");
});
it("implements edit requests, sheet structure control, and contact ownership transfer", () => {
expect(source).toContain("允许申请编辑权限");
expect(source).toContain("handleEditRequestPermissionChange");
expect(source).toContain("待处理申请");
expect(source).toContain("resolveCollaborationEditRequest");
expect(source).toContain("notifyProjectNotificationsChanged");
expect(source).toContain("route.query.editRequestFile");
expect(source).toContain("openEditRequestNotificationTarget");
expect(source).toContain('class="access-section__pending-count"');
expect(source).toContain('ref="editRequestSectionRef"');
expect(source).toContain("editRequestSectionRef.value?.focus");
const accountAuthorizationIndex = source.indexOf('class="access-section access-section--members"');
const pendingRequestIndex = source.indexOf('class="edit-request-list"');
const permissionSettingsIndex = source.indexOf("v-else-if=\"accessPanel === 'permissions'\"");
expect(pendingRequestIndex).toBeGreaterThan(accountAuthorizationIndex);
expect(pendingRequestIndex).toBeLessThan(permissionSettingsIndex);
expect(source).toContain("允许所有协作者添加、删除工作表");
expect(source).toContain("handleSheetStructurePermissionChange");
expect(source).toContain("转让文档所有权");
expect(source).toContain('v-model="transferDialogVisible"');
expect(source).toContain('title="请选择新的所有者"');
expect(source).toContain('class="ownership-transfer-layout"');
expect(source).toContain('role="listbox" aria-label="可转让联系人"');
expect(source).toContain('v-for="candidate in transferCandidates"');
expect(source).toContain('{{ candidate.role_in_study }}');
expect(source).toContain('{{ selectedTransferCandidate.role_in_study }}');
expect(source).toContain('currentOwnerCandidate?.role_in_study');
expect(source).toContain('class="collaboration-role-tag"');
expect(source).toContain('--el-tag-bg-color: color-mix(in srgb, var(--ctms-primary) 13%, var(--ctms-bg-muted))');
expect(source).toContain('v-if="selectedTransferCandidate"');
expect(source).toContain('description="暂未选择联系人"');
expect(source).toContain(':disabled="!selectedTransferCandidate"');
expect(source).toContain('>确定</el-button>');
expect(source).not.toContain('accessPanel === "transfer"');
expect(source).toContain("transferCollaborationOwnership");
});
it("saves permission switches silently while retaining failure feedback", () => {
expect(source).not.toContain('ElMessage.success("内容操作权限已更新")');
expect(source).not.toContain('ElMessage.success("编辑权限申请设置已更新")');
expect(source).not.toContain('ElMessage.success("工作表结构权限已更新")');
expect(source).toContain('getApiErrorMessage(error, "内容操作权限保存失败")');
expect(source).toContain('getApiErrorMessage(error, "编辑权限申请设置保存失败")');
expect(source).toContain('getApiErrorMessage(error, "工作表结构权限保存失败")');
});
it("configures real immutable link sharing with mutable access settings", () => {
expect(source).toContain('v-model="accessDialogVisible"');
expect(source).toContain("const openAccessManagement = async");
expect(source).toContain("accessFile.value.id");
expect(source).toContain('v-model="shareForm.enabled"');
expect(source).toContain('class="access-setting-status"');
expect(source).toContain('class="share-settings-list"');
expect(source).toContain('class="share-setting-row share-setting-row--password"');
expect(source).toContain('class="share-link-panel"');
expect(source).toContain('class="access-setting-navigation"');
expect(source).toContain("accessPermissionSummary");
expect(source).toContain('v-model="shareForm.access_mode"');
expect(source).toContain('v-model="commonExportEnabled"');
expect(source).toContain('@change="handleCommonExportChange"');
expect(source).toContain("{ allow_export: next }");
expect(source).not.toContain("shareForm.allow_export");
expect(source).toContain('v-model="shareForm.expiry_policy"');
for (const policy of ["ONE_DAY", "SEVEN_DAYS", "THIRTY_DAYS", "PERMANENT"]) {
expect(source).toContain(`value="${policy}"`);
}
expect(source).toContain('v-model="shareForm.password_enabled"');
expect(source).toContain("handleCommonExportChange");
expect(source).toContain("promptSharePassword");
expect(source).toContain('label="可查看" value="VIEW"');
expect(source).toContain('label="可编辑" value="EDIT"');
expect(source).not.toContain("获得链接的人可编辑");
expect(source).not.toContain("获得链接的人可查看");
expect(source).toContain("允许编辑者和匿名访问者下载、打印、另存和复制");
expect(source).not.toContain("系统内账号登录后按文件角色访问");
expect(source).not.toContain("无需 CTMS 账号,通过链接访问文件");
expect(source).not.toContain("同时约束账号授权和匿名访问");
expect(source).toContain('@change="persistShareSettings()"');
expect(source).not.toContain("保存链接设置");
expect(source).toContain("fetchCollaborationShareLink");
expect(source).toContain("updateCollaborationShareLink");
expect(source).not.toContain("regenerateCollaborationShareLink");
expect(source).not.toContain("重新生成链接");
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
expect(source).not.toContain("匿名访问已关闭,之前复制的访问链接无法继续使用");
expect(source).not.toContain("share-link-disabled");
expect(source).not.toContain("修改后自动保存");
expect(source).not.toContain("通过链接邀请外部人员查看或协作编辑");
expect(source).not.toContain("邀请项目内成员并分配协作权限");
expect(source).toContain("copyShareUrl");
expect(source).not.toContain("链接分享暂未开放");
});
it("uses an edge-to-edge compact table with a single row action menu", () => {
expect(source).toContain('size="small"');
expect(source).toContain('label="操作" width="72"');
expect(source).toContain('popper-class="collaboration-row-actions-popper"');
expect(source).toContain('@command="handleFileAction($event, row)"');
expect(source).toContain(".collaboration-library__main { display: flex; min-width: 0; min-height: 0;");
expect(source).toContain("height: 100%;");
expect(source).toContain(".collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto;");
expect(source).not.toContain('label="操作" width="300"');
});
it("opens the same action definitions from a desktop row context menu", () => {
expect(source).toContain('@row-contextmenu="openFileContextMenu"');
expect(source).toContain('v-if="isDesktop"');
expect(source).toContain('ref="fileContextMenuRef"');
expect(source).toContain("fileActions(fileContextMenuRow)");
expect(source).toContain("handleFileContextAction");
expect(source).toContain("event.preventDefault();");
});
it("keeps the fixed operation column background consistent with the table", () => {
expect(source).toContain(".el-table__header-wrapper .el-table-fixed-column--right");
expect(source).toContain("background: var(--unified-table-header-bg) !important;");
expect(source).not.toContain("background: var(--el-table-header-bg-color) !important;");
expect(source).toContain(".el-table__body tr:hover > .el-table-fixed-column--right");
});
it("uses shared theme tokens across the full collaboration canvas", () => {
expect(source).toContain("background: var(--ctms-bg-card);");
expect(source).toContain("background: var(--ctms-bg-base);");
expect(source).toContain("border-right: 1px solid var(--ctms-border-color);");
expect(source).toContain(".folder-item.active { color: var(--ctms-primary); background: var(--ctms-primary-light);");
expect(source).toContain(".collaboration-table-wrap { min-height: 0; flex: 1; overflow: auto; background: var(--ctms-bg-card);");
expect(source).not.toContain("background: #f8fafc;");
});
it("shows collaborators from the file-list response as a compact avatar stack", () => {
expect(source).toContain('label="协作者" width="150"');
expect(source).toContain("row.collaborators");
expect(source).toContain("collaborator-stack");
expect(source).toContain("collaboratorTone(collaborator.user_id)");
});
it("refreshes again after returning from an editor so delayed save callbacks update the list", () => {
expect(source).toContain("const scheduleRevisionRefresh");
expect(source).toContain("[2_500, 12_000]");
expect(source).toContain("onActivated(() =>");
expect(source).toContain("onDeactivated(clearRevisionRefreshTimers)");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationShareWorkspace.vue"), "utf8");
describe("CollaborationShareWorkspace security contract", () => {
it("reads the opaque link from the URL fragment and keeps grants in memory", () => {
expect(source).toContain("window.location.hash.slice(1)");
expect(source).toContain("const accessToken = ref");
expect(source).not.toContain("localStorage");
expect(source).not.toContain("sessionStorage");
expect(source).not.toContain("route.query");
});
it("requires the link password before initializing ONLYOFFICE", () => {
expect(source).toContain("verifyPublicCollaborationSharePassword");
expect(source).toContain("fetchPublicCollaborationEditorConfig");
expect(source).toContain('v-else-if="needsPassword"');
expect(source).toContain(':allow-save-as="false"');
expect(source).toContain(':allow-download="editorConfig.can_download"');
expect(source).toContain(':allow-clipboard="Boolean(metadata?.allow_export)"');
expect(source).toContain("response.data.host_path !== ONLYOFFICE_HOST_PATH");
});
it("downloads export-enabled shares to a user-selected local destination", () => {
expect(source).toContain('@download="handleDownload"');
expect(source).toContain("CollaborationDownloadDialog");
expect(source).toContain('@confirm="confirmDownload"');
expect(source).toContain("downloadDialogVisible.value = true");
expect(source).toContain("const destinationPromise = prepareSaveFile(suggestedName)");
expect(source).toContain('kind: "download"');
});
it("shows view or edit access and destroys sensitive state on unmount", () => {
expect(source).toContain('metadata.access_mode === "edit"');
expect(source).toContain("destroyEditor();");
expect(source).toContain('accessToken.value = ""');
expect(source).toContain('password.value = ""');
});
});
@@ -0,0 +1,368 @@
<template>
<section class="share-workspace">
<header class="share-workspace__header">
<div class="share-workspace__brand">CTMS 在线协作</div>
<div class="share-workspace__title" :title="metadata?.file_name">{{ metadata?.file_name || "共享文件" }}</div>
<span v-if="metadata" class="share-workspace__access" :class="`is-${metadata.access_mode}`">
{{ metadata.access_mode === "edit" ? "可编辑" : "只读查看" }}
</span>
<span v-if="metadata?.expires_at" class="share-workspace__expiry">有效期至 {{ displayDateTime(metadata.expires_at) }}</span>
<span v-else-if="metadata" class="share-workspace__expiry">永久有效</span>
<span class="share-workspace__status" :class="`is-${status}`">{{ statusLabel }}</span>
</header>
<main class="share-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(metadata?.allow_export)"
:allow-save-as="false"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 共享文件:${metadata?.file_name || ''}`"
@ready="status = 'ready'"
@warning="handleWarning"
@error="handleViewerError"
@state-change="handleStateChange"
@download="handleDownload"
@download-error="handleDownloadError"
/>
<div v-else class="share-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="30"><Loading /></el-icon>
<div v-else-if="needsPassword" class="share-password-card">
<div class="share-password-card__icon"><el-icon><Lock /></el-icon></div>
<h1>此链接受密码保护</h1>
<p>{{ metadata?.file_name }}</p>
<el-input
v-model="password"
type="password"
show-password
maxlength="64"
autocomplete="current-password"
placeholder="请输入链接密码"
autofocus
@keyup.enter="submitPassword"
/>
<el-button type="primary" :loading="verifying" @click="submitPassword">打开文件</el-button>
<span v-if="passwordError" class="share-password-card__error">{{ passwordError }}</span>
</div>
<div v-else-if="errorMessage" class="share-error-card">
<h1>无法打开共享文件</h1>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadShare">重新加载</el-button>
</div>
</div>
</main>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Loading, Lock } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import {
fetchPublicCollaborationEditorConfig,
fetchPublicCollaborationShareMetadata,
verifyPublicCollaborationSharePassword,
} from "../../api/collaboration";
import { isTauriRuntime, ONLYOFFICE_HOST_PATH, prepareSaveFile } from "../../runtime";
import { displayDateTime } from "../../utils/display";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import type {
CollaborationEditorConfig,
CollaborationPublicShareMetadata,
} from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type ShareStatus = "loading" | "locked" | "ready" | "saving" | "saved" | "warning" | "error";
const metadata = ref<CollaborationPublicShareMetadata | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const loading = ref(false);
const verifying = ref(false);
const needsPassword = ref(false);
const password = ref("");
const passwordError = ref("");
const errorMessage = ref("");
const status = ref<ShareStatus>("loading");
const warningMessage = ref("");
const accessToken = ref("");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const shareToken = () => {
try {
return decodeURIComponent(window.location.hash.slice(1).trim());
} catch {
return "";
}
};
const clientId = (() => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
})();
const viewerKey = computed(() => `${clientId}:${viewerSequence.value}`);
const statusLabel = computed(() => {
if (downloading.value) return "正在下载";
if (status.value === "locked") return "等待密码";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
if (statusCode === 404) return "链接不存在、已关闭或共享文件已被删除";
if (statusCode === 410) return "共享链接已过期,请联系文件所有者重新分享";
if (statusCode === 429) return "密码尝试次数过多,请稍后再试";
if (statusCode === 503) return "在线协作服务暂时不可用";
return getApiErrorMessage(error, "共享文件加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
};
const loadEditor = async (sequence: number) => {
const response = await fetchPublicCollaborationEditorConfig(shareToken(), {
access_token: accessToken.value || undefined,
client_id: clientId,
display_name: "链接访客",
});
if (requestSequence.value !== sequence) return;
if (response.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
editorConfig.value = response.data;
viewerSequence.value += 1;
};
const loadShare = async () => {
const token = shareToken();
if (!token) {
errorMessage.value = "共享链接缺少访问凭证";
status.value = "error";
return;
}
destroyEditor();
const sequence = requestSequence.value;
loading.value = true;
errorMessage.value = "";
passwordError.value = "";
needsPassword.value = false;
status.value = "loading";
try {
const response = await fetchPublicCollaborationShareMetadata(token);
if (requestSequence.value !== sequence) return;
metadata.value = response.data;
needsPassword.value = response.data.requires_password && !accessToken.value;
if (needsPassword.value) {
status.value = "locked";
} else {
await loadEditor(sequence);
}
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const submitPassword = async () => {
if (!password.value || verifying.value) {
if (!password.value) passwordError.value = "请输入链接密码";
return;
}
verifying.value = true;
passwordError.value = "";
try {
const response = await verifyPublicCollaborationSharePassword(shareToken(), password.value);
accessToken.value = response.data.access_token;
password.value = "";
needsPassword.value = false;
status.value = "loading";
loading.value = true;
const sequence = requestSequence.value;
await loadEditor(sequence);
} catch (error) {
const statusCode = Number((error as any)?.response?.status || 0);
passwordError.value = statusCode === 401 ? "链接密码不正确" : await errorForResponse(error);
status.value = statusCode === 401 ? "locked" : "error";
} finally {
verifying.value = false;
loading.value = false;
}
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
};
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleViewerError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "共享文件加载或转换失败";
editorConfig.value = null;
status.value = "error";
};
const handleStateChange = (changed: boolean) => {
status.value = changed ? "saving" : "saved";
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const suggestedName = suggestedDownloadName(payload);
// Web
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载共享文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "共享文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "共享文件下载失败");
};
onMounted(loadShare);
onBeforeUnmount(() => {
destroyEditor();
accessToken.value = "";
password.value = "";
});
</script>
<style scoped>
.share-workspace {
display: grid;
grid-template-rows: 48px minmax(0, 1fr);
width: 100vw;
height: 100vh;
height: 100dvh;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #273b50;
background: #f3f5f8;
}
.share-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: auto minmax(120px, 1fr) auto auto auto;
align-items: center;
gap: 12px;
padding: 0 18px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.share-workspace__brand { color: #3d617d; font-size: 14px; font-weight: 700; white-space: nowrap; }
.share-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.share-workspace__access { padding: 3px 9px; border-radius: 11px; color: #52667a; background: #eef2f6; font-size: 12px; white-space: nowrap; }
.share-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.share-workspace__expiry,
.share-workspace__status { color: #738398; font-size: 12px; white-space: nowrap; }
.share-workspace__status.is-ready,
.share-workspace__status.is-saved { color: #25845b; }
.share-workspace__status.is-saving { color: #99651e; }
.share-workspace__status.is-warning,
.share-workspace__status.is-error { color: #b65c29; }
.share-workspace__content { min-width: 0; min-height: 0; overflow: hidden; }
.share-workspace__state { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; padding: 24px; }
.share-password-card,
.share-error-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; gap: 14px; padding: 30px; border: 1px solid #dbe3ec; border-radius: 14px; background: #fff; box-shadow: 0 16px 42px rgba(40, 58, 78, 0.12); text-align: center; }
.share-password-card__icon { display: grid; width: 46px; height: 46px; place-items: center; align-self: center; border-radius: 50%; color: #3f6f91; background: #eaf2f8; font-size: 22px; }
.share-password-card h1,
.share-error-card h1 { margin: 0; color: #26384c; font-size: 19px; }
.share-password-card p,
.share-error-card p { margin: 0 0 4px; color: #75859a; font-size: 13px; }
.share-password-card__error { color: #d14f4f; font-size: 12px; text-align: left; }
@media (max-width: 720px) {
.share-workspace__header { grid-template-columns: minmax(0, 1fr) auto auto; padding: 0 10px; }
.share-workspace__brand,
.share-workspace__expiry { display: none; }
}
</style>
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./CollaborationWorkspace.vue"), "utf8");
const globalStyles = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
describe("CollaborationWorkspace Save As contract", () => {
it("separates signed Save As and download capabilities", () => {
expect(source).toContain(':allow-save-as="editorConfig.can_save_as"');
expect(source).toContain(':allow-download="editorConfig.can_download"');
expect(source).toContain(':allow-clipboard="Boolean(editorConfig.can_download)"');
expect(source).toContain('@save-as="handleSaveAs"');
expect(source).toContain('@download="handleDownload"');
expect(source).toContain("!editorConfig.value?.can_save_as");
expect(source).toContain("!editorConfig.value?.can_download");
});
it("reuses the workspace picker and imports Save As bytes as a new file", () => {
expect(source).toContain("CollaborationSaveAsDialog");
expect(source).toContain('@confirm="submitSaveAs"');
expect(source).toContain("const form = new FormData()");
expect(source).toContain('form.append("folder_id", selection.folderId)');
expect(source).toContain("importCollaborationFile(studyId.value, form)");
});
it("asks for a local destination from a user-confirmed web dialog and records the completed download", () => {
expect(source).toContain("CollaborationDownloadDialog");
expect(source).toContain('@confirm="confirmDownload"');
expect(source).toContain("downloadDialogVisible.value = true");
expect(source).toContain("const destinationPromise = prepareSaveFile(suggestedName)");
expect(source).toContain("saveFileWithFeedback(");
expect(source).toContain('kind: "download"');
expect(source).toContain("recordCollaborationDownload(studyId.value, fileId.value, extension)");
});
it("submits ONLYOFFICE edit-right requests without granting edit locally", () => {
expect(source).toContain('@request-edit-rights="requestEditRights"');
expect(source).toContain("createCollaborationEditRequest(studyId.value, fileId.value)");
expect(source).toContain('edit_request_status: "PENDING"');
expect(source).toContain("编辑权限申请处理中");
});
it("keeps the plain edit-request action visually distinct from solid primary buttons", () => {
expect(source).toContain('type="primary"');
expect(source).toContain("plain");
expect(source).toContain("申请编辑权限");
expect(globalStyles).toContain(".el-button--primary:not(.is-plain):not(.is-link):not(.is-text)");
expect(globalStyles).not.toMatch(/\.el-button--primary\s*\{[^}]*background-color:/);
});
it("provides an accessible web fullscreen control without duplicating native desktop controls", () => {
expect(source).toContain('v-if="showWorkspaceFullscreenControl"');
expect(source).toContain("computed(() => webFullscreenAvailable.value && !isTauriRuntime())");
expect(source).toContain('class="collaboration-workspace__fullscreen"');
expect(source).toContain(':aria-label="webFullscreenLabel"');
expect(source).toContain(':aria-pressed="webFullscreenActive"');
expect(source).toContain("toggleWebFullscreen");
expect(source).toContain("listenWebFullscreenChange(syncWebFullscreenState)");
expect(source).toContain("await exitWorkspaceFullscreen()");
});
it("reserves the macOS traffic-light safe area only outside native fullscreen", () => {
expect(source).toContain("'is-macos-desktop': isMacDesktop");
expect(source).toContain('isTauriRuntime() && getRuntimePlatform() === "macos"');
expect(source).toContain(".collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header");
expect(source).toContain("padding-left: 72px;");
});
it("keeps the shared web and desktop document bar compact", () => {
expect(source).toContain("grid-template-rows: 36px minmax(0, 1fr);");
expect(source).toContain("grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;");
expect(source).toContain("width: 28px;");
expect(source).toContain("height: 28px;");
});
it("reduces the fullscreen CTMS header to an opaque auto-hiding control", () => {
expect(source).toContain("'is-fullscreen': webFullscreenActive");
expect(source).toContain("'is-fullscreen-toolbar-visible': fullscreenToolbarVisible");
expect(source).toContain('class="collaboration-workspace__fullscreen-hotzone"');
expect(source).toContain('@pointerenter="showFullscreenToolbar"');
expect(source).toContain("fullscreenToolbarHideTimer = window.setTimeout");
expect(source).toContain("}, 900);");
expect(source).toContain('const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);');
expect(source).toContain(".collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }");
expect(source).toContain("width: 88px;");
expect(source).toContain("transform: translateY(calc(-100% - 12px));");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen)");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible)");
expect(source).toContain("background: #fff;");
expect(source).toContain("opacity: 1;");
expect(source).toContain(".collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }");
});
});
@@ -0,0 +1,617 @@
<template>
<section
class="collaboration-workspace"
:class="{
'is-fullscreen': webFullscreenActive,
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
'is-macos-desktop': isMacDesktop,
}"
>
<div
v-if="webFullscreenActive"
class="collaboration-workspace__fullscreen-hotzone"
aria-hidden="true"
@pointerenter="showFullscreenToolbar"
@pointerleave="scheduleFullscreenToolbarHide"
/>
<header
class="collaboration-workspace__header"
@pointerenter="cancelFullscreenToolbarHide"
@pointerleave="scheduleFullscreenToolbarHide"
@focusin="showFullscreenToolbar"
@focusout="scheduleFullscreenToolbarHide"
>
<button class="collaboration-workspace__back" type="button" title="关闭协作文件并返回" @click="goBack"></button>
<div class="collaboration-workspace__title" :title="fileName">{{ fileName }}</div>
<div class="collaboration-workspace__access" :class="`is-${accessMode}`">
{{ accessMode === "edit" ? "协作编辑" : "只读查看" }}
</div>
<div class="collaboration-workspace__status" :class="`is-${status}`">{{ statusLabel }}</div>
<el-button
v-if="accessMode === 'view' && file?.edit_request_status === 'PENDING'"
size="small"
disabled
>编辑权限申请处理中</el-button>
<el-button
v-else-if="accessMode === 'view' && (file?.can_request_edit || editorConfig?.can_request_edit)"
size="small"
type="primary"
plain
:loading="requestingEdit"
@click="requestEditRights"
>申请编辑权限</el-button>
<el-button v-if="errorMessage" link type="primary" :loading="loading" @click="loadWorkspace">重试</el-button>
<el-tooltip v-if="showWorkspaceFullscreenControl" :content="webFullscreenLabel" placement="bottom">
<button
type="button"
class="collaboration-workspace__fullscreen"
:aria-label="webFullscreenLabel"
:aria-pressed="webFullscreenActive"
@click="handleWebFullscreenToggle"
>
<el-icon><ScaleToOriginal v-if="webFullscreenActive" /><FullScreen v-else /></el-icon>
</button>
</el-tooltip>
</header>
<main class="collaboration-workspace__content">
<OnlyOfficeViewer
v-if="editorConfig && !errorMessage"
:key="viewerKey"
:config="editorConfig.config"
:allow-clipboard="Boolean(editorConfig.can_download)"
:allow-save-as="editorConfig.can_save_as"
:allow-download="editorConfig.can_download"
:frame-title="`ONLYOFFICE 在线协作:${fileName}`"
@ready="handleReady"
@warning="handleWarning"
@error="handleError"
@state-change="handleStateChange"
@save-as="handleSaveAs"
@save-as-error="handleSaveAsError"
@download="handleDownload"
@download-error="handleDownloadError"
@request-edit-rights="requestEditRights"
/>
<div v-else class="collaboration-workspace__state">
<el-icon v-if="loading" class="is-loading" :size="28"><Loading /></el-icon>
<template v-else-if="errorMessage">
<strong>无法打开协作文件</strong>
<p>{{ errorMessage }}</p>
<el-button type="primary" @click="loadWorkspace">重新加载</el-button>
</template>
</div>
</main>
<CollaborationSaveAsDialog
v-model="saveAsDialogVisible"
:study-id="studyId"
:initial-title="saveAsTitle"
:initial-folder-id="file?.folder_id || null"
:saving="savingCopy"
:can-create-folder="canCreateWorkspaceFile"
@confirm="submitSaveAs"
/>
<CollaborationDownloadDialog
v-model="downloadDialogVisible"
:file-name="downloadTitle"
:saving="downloading"
@confirm="confirmDownload"
/>
</section>
</template>
<script setup lang="ts">
import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { FullScreen, Loading, ScaleToOriginal } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import OnlyOfficeViewer from "../../components/OnlyOfficeViewer.vue";
import CollaborationDownloadDialog from "../../components/collaboration/CollaborationDownloadDialog.vue";
import CollaborationSaveAsDialog from "../../components/collaboration/CollaborationSaveAsDialog.vue";
import { workspaceTaskControllerKey } from "../../components/layout/workspaceTaskController";
import {
fetchCollaborationEditorConfig,
fetchCollaborationFile,
createCollaborationEditRequest,
importCollaborationFile,
recordCollaborationDownload,
} from "../../api/collaboration";
import { useStudyStore } from "../../store/study";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
getRuntimePlatform,
isWebFullscreenActive,
isWebFullscreenAvailable,
isTauriRuntime,
listenWebFullscreenChange,
ONLYOFFICE_HOST_PATH,
prepareSaveFile,
refreshWebFullscreenState,
toggleWebFullscreen,
} from "../../runtime";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { usePermission } from "../../utils/permission";
import type { CollaborationEditorConfig, CollaborationFile } from "../../types/collaboration";
import type { OnlyOfficeSaveAsPayload } from "../../types/onlyoffice";
type WorkspaceStatus = "loading" | "ready" | "saving" | "saved" | "warning" | "error";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
const { can } = usePermission();
const file = ref<CollaborationFile | null>(null);
const editorConfig = ref<CollaborationEditorConfig | null>(null);
const errorMessage = ref("");
const warningMessage = ref("");
const loading = ref(false);
const status = ref<WorkspaceStatus>("loading");
const requestSequence = ref(0);
const viewerSequence = ref(0);
const savingCopy = ref(false);
const requestingEdit = ref(false);
const downloading = ref(false);
const downloadDialogVisible = ref(false);
const downloadTitle = ref("");
const pendingDownload = ref<OnlyOfficeSaveAsPayload | null>(null);
const saveAsDialogVisible = ref(false);
const saveAsTitle = ref("");
const pendingSaveAs = ref<OnlyOfficeSaveAsPayload | null>(null);
const webFullscreenAvailable = ref(false);
const webFullscreenActive = ref(false);
const fullscreenToolbarVisible = ref(false);
const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
let mounted = false;
let webFullscreenUnlisten: (() => void) | null = null;
let fullscreenToolbarHideTimer: number | undefined;
watch(downloadDialogVisible, (visible) => {
if (!visible && !downloading.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
});
const studyId = computed(() => study.currentStudy?.id || "");
const fileId = computed(() => String(route.params.fileId || ""));
const fileName = computed(() => editorConfig.value?.file_name || file.value?.title || "在线协作");
const accessMode = computed(() => editorConfig.value?.access_mode || (file.value?.can_edit ? "edit" : "view"));
const viewerKey = computed(() => `${fileId.value}:${viewerSequence.value}`);
const canCreateWorkspaceFile = computed(() =>
["read", "create", "edit", "manage", "export", "delete"].every((action) =>
can(`collaboration.${action}`),
),
);
const statusLabel = computed(() => {
if (savingCopy.value) return "正在另存为";
if (downloading.value) return "正在下载";
if (status.value === "ready") return "已连接";
if (status.value === "saving") return "正在自动保存";
if (status.value === "saved") return "更改已同步";
if (status.value === "warning") return warningMessage.value || "协作服务警告";
if (status.value === "error") return "加载失败";
return "正在加载";
});
const showWorkspaceFullscreenControl = computed(() => webFullscreenAvailable.value && !isTauriRuntime());
const webFullscreenLabel = computed(() => webFullscreenActive.value ? "退出全屏" : "进入全屏");
const cancelFullscreenToolbarHide = () => {
if (fullscreenToolbarHideTimer !== undefined) window.clearTimeout(fullscreenToolbarHideTimer);
fullscreenToolbarHideTimer = undefined;
};
const showFullscreenToolbar = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = true;
};
const scheduleFullscreenToolbarHide = () => {
if (!webFullscreenActive.value) return;
cancelFullscreenToolbarHide();
fullscreenToolbarHideTimer = window.setTimeout(() => {
fullscreenToolbarVisible.value = false;
fullscreenToolbarHideTimer = undefined;
}, 900);
};
const syncWebFullscreenState = () => {
webFullscreenAvailable.value = isWebFullscreenAvailable();
const active = isWebFullscreenActive();
if (active !== webFullscreenActive.value) {
cancelFullscreenToolbarHide();
fullscreenToolbarVisible.value = false;
}
webFullscreenActive.value = active;
};
const handleWebFullscreenToggle = async () => {
try {
await toggleWebFullscreen();
} catch (error) {
const message = isTauriRuntime()
? "无法切换桌面全屏,请使用“显示 > 进入全屏”重试"
: typeof error === "object" && error !== null && "name" in error && error.name === "NotAllowedError"
? "浏览器未允许进入全屏,请再次点击或检查站点权限"
: "无法切换全屏,请使用浏览器菜单重试";
ElMessage.warning(message);
} finally {
syncWebFullscreenState();
}
};
const exitWorkspaceFullscreen = async () => {
try {
if (!await refreshWebFullscreenState()) return;
await toggleWebFullscreen();
} catch {
// 退
} finally {
syncWebFullscreenState();
}
};
const errorForResponse = async (error: unknown) => {
const statusCode = Number((error as any)?.response?.status || 0);
const code = String((error as any)?.response?.data?.code || "");
if (code === "ONLYOFFICE_DISABLED") return "在线协作服务尚未启用";
if (code === "ONLYOFFICE_UNAVAILABLE") return "在线协作服务暂时不可用";
if (statusCode === 401) return "登录状态已失效,请重新登录";
if (statusCode === 403) return "您没有查看此协作文件的权限";
if (statusCode === 404) return "协作文件不存在或已被移入回收站";
return getApiErrorMessage(error, "在线协作编辑器加载失败");
};
const destroyEditor = () => {
requestSequence.value += 1;
editorConfig.value = null;
viewerSequence.value += 1;
savingCopy.value = false;
downloading.value = false;
downloadDialogVisible.value = false;
downloadTitle.value = "";
pendingDownload.value = null;
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
};
const loadWorkspace = async () => {
if (!studyId.value || !fileId.value) return;
const sequence = requestSequence.value + 1;
requestSequence.value = sequence;
destroyEditor();
requestSequence.value = sequence;
errorMessage.value = "";
warningMessage.value = "";
loading.value = true;
status.value = "loading";
try {
const [fileResponse, configResponse] = await Promise.all([
fetchCollaborationFile(studyId.value, fileId.value),
fetchCollaborationEditorConfig(studyId.value, fileId.value),
]);
if (requestSequence.value !== sequence) return;
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
file.value = fileResponse.data;
editorConfig.value = configResponse.data;
viewerSequence.value += 1;
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
} catch (error) {
if (requestSequence.value !== sequence) return;
errorMessage.value = await errorForResponse(error);
status.value = "error";
} finally {
if (requestSequence.value === sequence) loading.value = false;
}
};
const eventMessage = (detail: Record<string, unknown>) => {
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
const value = detail[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
const code = detail.errorCode ?? detail.warningCode ?? detail.code;
return code === undefined ? "" : `错误代码:${String(code)}`;
};
const handleReady = () => { status.value = "ready"; };
const handleStateChange = (changed: boolean) => { status.value = changed ? "saving" : "saved"; };
const handleWarning = (detail: Record<string, unknown>) => {
warningMessage.value = eventMessage(detail) || "协作服务返回警告";
status.value = "warning";
};
const handleError = (detail: Record<string, unknown>) => {
errorMessage.value = eventMessage(detail) || "协作文件加载或转换失败";
status.value = "error";
editorConfig.value = null;
};
const requestEditRights = async () => {
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
requestingEdit.value = true;
try {
await createCollaborationEditRequest(studyId.value, fileId.value);
if (file.value) {
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
}
if (editorConfig.value) editorConfig.value = { ...editorConfig.value, can_request_edit: false };
ElMessage.success("编辑权限申请已提交");
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "编辑权限申请失败"));
} finally {
requestingEdit.value = false;
}
};
const handleSaveAs = (payload: OnlyOfficeSaveAsPayload) => {
if (savingCopy.value || !editorConfig.value?.can_save_as) return;
const extension = payload.fileType.toLowerCase();
saveAsTitle.value = payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
pendingSaveAs.value = payload;
saveAsDialogVisible.value = true;
};
const submitSaveAs = async (selection: { title: string; folderId: string | null }) => {
const payload = pendingSaveAs.value;
if (!payload || savingCopy.value || !editorConfig.value?.can_save_as) return;
savingCopy.value = true;
try {
const extension = payload.fileType.toLowerCase();
const targetTitle = selection.title.toLowerCase().endsWith(`.${extension}`)
? selection.title
: `${selection.title}.${extension}`;
const form = new FormData();
form.append("file", new File([payload.data], targetTitle, {
type: payload.mimeType || "application/octet-stream",
lastModified: Date.now(),
}));
if (selection.folderId) form.append("folder_id", selection.folderId);
const { data } = await importCollaborationFile(studyId.value, form);
saveAsDialogVisible.value = false;
pendingSaveAs.value = null;
ElMessage.success(`已在工作区创建“${data.title}`);
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件另存为失败"));
} finally {
savingCopy.value = false;
}
};
const handleSaveAsError = (message: string) => {
ElMessage.error(message || "协作文件另存为失败");
};
const suggestedDownloadName = (payload: OnlyOfficeSaveAsPayload) => {
const extension = payload.fileType.toLowerCase();
return payload.title.toLowerCase().endsWith(`.${extension}`)
? payload.title
: `${payload.title}.${extension}`;
};
const savePendingDownload = async () => {
const payload = pendingDownload.value;
if (!payload || downloading.value || !editorConfig.value?.can_download) return;
const extension = payload.fileType.toLowerCase();
const suggestedName = suggestedDownloadName(payload);
// Web
const destinationPromise = prepareSaveFile(suggestedName);
downloading.value = true;
try {
const destination = await destinationPromise;
if (!destination) {
if (!downloadDialogVisible.value) {
pendingDownload.value = null;
downloadTitle.value = "";
}
return;
}
const result = await saveFileWithFeedback(
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
{
kind: "download",
title: "下载协作文件",
pendingDetail: "请选择本机保存位置",
completedDetail: "文件已下载到指定位置",
},
destination,
);
if (result === "saved") {
downloadDialogVisible.value = false;
pendingDownload.value = null;
downloadTitle.value = "";
try {
await recordCollaborationDownload(studyId.value, fileId.value, extension);
} catch {
ElMessage.warning("文件已下载,但下载审计记录失败");
}
}
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "协作文件下载失败"));
} finally {
downloading.value = false;
}
};
const confirmDownload = () => {
void savePendingDownload();
};
const handleDownload = (payload: OnlyOfficeSaveAsPayload) => {
if (downloading.value || pendingDownload.value || !editorConfig.value?.can_download) return;
pendingDownload.value = payload;
downloadTitle.value = suggestedDownloadName(payload);
if (isTauriRuntime()) {
void savePendingDownload();
return;
}
downloadDialogVisible.value = true;
};
const handleDownloadError = (message: string) => {
ElMessage.error(message || "协作文件下载失败");
};
const handleServerChange = () => {
destroyEditor();
loading.value = false;
errorMessage.value = "服务器地址已切换,请重新加载协作文件";
status.value = "error";
};
const goBack = async () => {
await exitWorkspaceFullscreen();
if (workspaceTaskController?.closeTransientTask(route.path)) return;
await router.push("/knowledge/collaboration");
};
onMounted(() => {
mounted = true;
syncWebFullscreenState();
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
void loadWorkspace();
});
onActivated(() => {
syncWebFullscreenState();
if (mounted && !editorConfig.value && !loading.value && !errorMessage.value) void loadWorkspace();
});
onDeactivated(() => {
void exitWorkspaceFullscreen();
destroyEditor();
loading.value = false;
errorMessage.value = "";
});
onBeforeUnmount(() => {
cancelFullscreenToolbarHide();
webFullscreenUnlisten?.();
webFullscreenUnlisten = null;
void exitWorkspaceFullscreen();
destroyEditor();
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
study.setViewContext(null);
});
</script>
<style scoped>
.collaboration-workspace {
position: relative;
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: #26384c;
background: #f3f5f8;
}
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
.collaboration-workspace__fullscreen-hotzone {
position: absolute;
z-index: 20;
top: 0;
left: 0;
width: 88px;
height: 14px;
}
.collaboration-workspace__fullscreen-hotzone::after {
position: absolute;
top: 3px;
left: 32px;
width: 24px;
height: 3px;
border-radius: 999px;
background: rgba(38, 56, 76, 0.34);
content: "";
}
.collaboration-workspace__header {
z-index: 1;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto auto auto auto;
align-items: center;
gap: 8px;
padding: 0 12px 0 6px;
border-bottom: 1px solid #dce3eb;
background: #fff;
}
.collaboration-workspace.is-macos-desktop:not(.is-fullscreen) .collaboration-workspace__header {
padding-left: 72px;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header {
position: absolute;
z-index: 21;
top: 6px;
left: 6px;
display: flex;
width: auto;
height: 34px;
gap: 2px;
padding: 0 4px;
border: 1px solid #dce3eb;
border-radius: 11px;
background: #fff;
opacity: 0;
pointer-events: none;
transform: translateY(calc(-100% - 12px));
box-shadow: 0 8px 24px rgba(20, 32, 51, 0.16);
transition: opacity 150ms ease, transform 180ms ease;
}
.collaboration-workspace.is-fullscreen .collaboration-workspace__header > :not(.collaboration-workspace__back):not(.collaboration-workspace__fullscreen) {
display: none;
}
.collaboration-workspace.is-fullscreen.is-fullscreen-toolbar-visible .collaboration-workspace__header,
.collaboration-workspace.is-fullscreen .collaboration-workspace__header:has(:focus-visible) {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.collaboration-workspace__back {
width: 28px;
height: 28px;
padding: 0 0 3px;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
font: 26px/1 system-ui, sans-serif;
cursor: pointer;
}
.collaboration-workspace__back:hover { background: #edf2f7; }
.collaboration-workspace__title { overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.collaboration-workspace__access,
.collaboration-workspace__status { font-size: 12px; white-space: nowrap; }
.collaboration-workspace__access { padding: 3px 8px; border-radius: 10px; color: #52667a; background: #eef2f6; }
.collaboration-workspace__access.is-edit { color: #237451; background: #e7f5ee; }
.collaboration-workspace__status { color: #738398; }
.collaboration-workspace__status.is-saving { color: #99651e; }
.collaboration-workspace__fullscreen {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 7px;
color: #516579;
background: transparent;
cursor: pointer;
font-size: 16px;
}
.collaboration-workspace__fullscreen:hover { color: #237451; background: #edf5f1; }
.collaboration-workspace__fullscreen:focus-visible { outline: 2px solid rgba(35, 116, 81, 0.3); outline-offset: 1px; }
.collaboration-workspace__status.is-saved,
.collaboration-workspace__status.is-ready { color: #25845b; }
.collaboration-workspace__status.is-warning,
.collaboration-workspace__status.is-error { color: #b65c29; }
.collaboration-workspace__content { min-height: 0; overflow: hidden; }
.collaboration-workspace.is-fullscreen .collaboration-workspace__content { grid-row: 1; }
.collaboration-workspace__state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 100%; height: 100%; color: #718096; text-align: center; }
.collaboration-workspace__state strong { color: #2f4054; font-size: 17px; }
.collaboration-workspace__state p { margin: 0; }
</style>
-192
View File
@@ -1,192 +0,0 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.startupMeetingAuth.trainingEditTitle : TEXT.modules.startupMeetingAuth.trainingNewTitle }}</div>
<div class="ctms-section-actions">
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-form :model="form" label-width="120px">
<el-form-item :label="TEXT.common.fields.name" required>
<el-input v-model="form.name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.labels.role">
<el-input v-model="form.role" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.labels.role" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.site">
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.trained">
<el-switch v-model="form.trained" :disabled="isReadOnly" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.trainedDate">
<el-date-picker v-model="form.trained_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.authorized">
<el-switch v-model="form.authorized" :disabled="isReadOnly" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.authorizedDate">
<el-date-picker v-model="form.authorized_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<div class="ctms-section-header">
<div class="ctms-section-title">{{ TEXT.common.labels.attachments }}</div>
</div>
<AttachmentList
ref="attachmentPanelRef"
:study-id="studyId"
entity-type="training_authorization"
:entity-id="recordId || ''"
:readonly="isReadOnly"
:mode="'upload'"
/>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study";
import { createTrainingAuthorization, getTrainingAuthorization, updateTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const saving = ref(false);
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const sites = ref<any[]>([]);
const recordId = computed(() => route.params.recordId as string | undefined);
const isEdit = computed(() => !!recordId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
name: "",
role: "",
site_name: "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
});
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const canCreateAuth = computed(() => can("startup.auth.create"));
const canUpdateAuth = computed(() => can("startup.auth.update"));
const canSaveAuth = computed(() => (isEdit.value ? canUpdateAuth.value : canCreateAuth.value));
const isInactiveSite = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const isReadOnly = computed(() => !canSaveAuth.value || isInactiveSite.value);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!isEdit.value || !studyId.value || !recordId.value) return;
try {
const { data } = await getTrainingAuthorization(studyId.value, recordId.value);
Object.assign(form, {
name: data.name || "",
role: data.role || "",
site_name: data.site_name || "",
trained: !!data.trained,
authorized: !!data.authorized,
trained_date: data.trained_date || "",
authorized_date: data.authorized_date || "",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!studyId.value) return;
if (!canSaveAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.site_name && siteActiveMap.value[form.site_name] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true;
try {
const payload = {
name: form.name,
role: form.role || null,
site_name: form.site_name || null,
trained: form.trained,
authorized: form.authorized,
trained_date: form.trained_date || null,
authorized_date: form.authorized_date || null,
remark: form.remark || null,
};
let savedId = recordId.value || "";
if (isEdit.value && recordId.value) {
await updateTrainingAuthorization(studyId.value, recordId.value, payload);
} else {
const { data } = await createTrainingAuthorization(studyId.value, payload);
savedId = data.id;
}
await attachmentPanelRef.value?.uploadPending(savedId);
ElMessage.success(isEdit.value ? TEXT.common.messages.saveSuccess : TEXT.common.messages.createSuccess);
router.push(`/startup/training/${savedId}`);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/startup/meeting-auth");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
</style>
@@ -16,7 +16,7 @@
</div>
</template>
<el-form ref="formRef" :model="form" label-position="top" class="visit-editor-form">
<el-form :model="form" label-position="top" class="visit-editor-form">
<div class="form-group">
<div class="form-group-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
@@ -99,7 +99,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance } from "element-plus";
import { ElMessage } from "element-plus";
import { createVisit, updateVisit } from "../../api/visits";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
@@ -121,8 +121,6 @@ const emit = defineEmits<{
}>();
const saving = ref(false);
const formRef = ref<FormInstance>();
const form = reactive({
visit_code: "",