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
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:
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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
/* ═══════════════════
|
||||
根容器
|
||||
═══════════════════ */
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 "访视窗口"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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();");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);");
|
||||
});
|
||||
});
|
||||
@@ -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-shell(main-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);");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "导入监查访视问题",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user