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

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
@@ -0,0 +1,48 @@
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("account connection status", () => {
it("keeps the compact indicator and detailed account diagnostics in both shells", () => {
const component = readSource("./AccountConnectionStatus.vue");
const webLayout = readSource("./WebLayout.vue");
const desktopLayout = readSource("./DesktopLayout.vue");
expect(component).toContain('mode === \'indicator\'');
expect(component).toContain("当前账号与连接状态");
expect(component).toContain("API 延迟");
expect(component).toContain("会话状态");
expect(component).toContain("useStudyStore");
expect(component).toContain("getProjectRole(study.currentStudy, study.currentStudyRole)");
expect(component).toContain('isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"');
expect(component).toContain("projectRole.value ? displayRoleLabel(projectRole.value) : \"未选择项目\"");
expect(component).toContain("`${study.currentStudy.name} · ${roleLabel.value}`");
expect(component).not.toContain('auth.user?.is_admin ? "系统管理员" : "普通账号"');
expect(component).toContain("mode === 'network'");
expect(component).toContain("当前 IP");
expect(component).toContain("clientIpLabel");
expect(component).toContain("连接安全检查");
expect(component).toContain("runSecurityCheck");
expect(component).toContain("evaluateConnectionSecurity");
expect(component).toContain("const securityChecked = ref(true)");
expect(component).toContain("const securityCheckExpanded = ref(true)");
expect(component).toContain("const nowTs = ref(Date.now())");
expect(component).toContain("const tokenExpiryAt = computed");
expect(component).toContain("const credentialDeadlineAt = computed");
expect(component).toContain("return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000");
expect(component).toContain("sessionDeadlineAt: credentialDeadlineAt.value");
expect(component).toContain("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)");
expect(component).toContain("sessionClockTimer !== undefined");
expect(component).toContain("window.clearInterval(sessionClockTimer)");
expect(component).not.toContain("连接状态仅表示会话与后端通信");
expect(component).not.toContain("connection-details-note");
expect(component).not.toContain("ip_address");
expect(component).not.toContain("access_token");
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
expect(webLayout).toContain('<AccountConnectionStatus mode="details" />');
expect(desktopLayout).toContain('<AccountConnectionStatus mode="indicator" />');
expect(desktopLayout).toContain('<AccountConnectionStatus mode="details" />');
});
});
@@ -0,0 +1,533 @@
<template>
<span
v-if="mode === 'indicator'"
class="account-connection-indicator"
:class="`is-${status}`"
:title="`账号会话:${statusLabel}`"
>
<i class="account-connection-dot"></i>
<span>{{ statusLabel }}</span>
</span>
<section v-else-if="mode === 'network'" class="network-overview" aria-label="当前网络状态">
<div class="network-overview-head">
<span>网络状态</span>
<span class="connection-state-badge" :class="`is-${status}`">
<i class="account-connection-dot"></i>
{{ statusLabel }}
</span>
</div>
<dl class="network-overview-grid">
<div class="network-ip-row">
<dt>当前 IP</dt>
<dd :title="clientIpLabel">{{ clientIpLabel }}</dd>
</div>
<div>
<dt>API 延迟</dt>
<dd>{{ latencyLabel }}</dd>
</div>
<div>
<dt>最近通信</dt>
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
<button class="security-check-trigger" type="button" :disabled="securityChecking" @click="runSecurityCheck">
<span>连接安全检查</span>
<strong :class="`is-${securityResult.level}`">
{{ securityChecking ? "检查中" : securityChecked ? securityResult.label : "立即检查" }}
</strong>
</button>
<ul v-if="securityCheckExpanded" class="security-check-list" aria-live="polite">
<li v-for="item in securityResult.checks" :key="item.key">
<i :class="`is-${item.level}`"></i>
<span>{{ item.label }}</span>
<small>{{ item.detail }}</small>
</li>
</ul>
</section>
<div v-else class="account-connection-details" aria-label="当前账号与连接状态" @click.stop>
<div class="connection-details-head">
<span class="connection-details-title">当前账号</span>
<span class="connection-state-badge" :class="`is-${status}`">
<i class="account-connection-dot"></i>
{{ statusLabel }}
</span>
</div>
<strong class="connection-account-name">{{ userDisplayName }}</strong>
<span class="connection-account-email">{{ auth.user?.email || "未提供邮箱" }}</span>
<dl class="connection-details-grid">
<div>
<dt>{{ roleFieldLabel }}</dt>
<dd :title="roleContextTitle">{{ roleLabel }}</dd>
</div>
<div>
<dt>客户端</dt>
<dd>{{ clientLabel }}</dd>
</div>
<div>
<dt>运行环境</dt>
<dd>{{ environmentLabel }}</dd>
</div>
<div>
<dt>会话状态</dt>
<dd>{{ sessionRemainingLabel }}</dd>
</div>
<div>
<dt>API 延迟</dt>
<dd>{{ latencyLabel }}</dd>
</div>
<div>
<dt>最近通信</dt>
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { useStudyStore } from "../store/study";
import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime";
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
import { parseJwtExp } from "../session/jwt";
import { useConnectionStatus } from "../composables/useConnectionStatus";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { evaluateConnectionSecurity } from "../utils/connectionSecurity";
import { getProjectRole, isSystemAdmin } from "../utils/roles";
withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
mode: "indicator",
});
const auth = useAuthStore();
const session = useSessionStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const appMetadata = getAppMetadata();
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const {
status,
statusLabel,
latencyMs,
clientIp,
lastSuccessAt,
lastCheckedAt,
checkConnection,
startConnectionMonitor,
} = useConnectionStatus();
let stopConnectionMonitor: (() => void) | undefined;
const nowTs = ref(Date.now());
let sessionClockTimer: number | undefined;
const securityChecking = ref(false);
const securityChecked = ref(true);
const securityCheckExpanded = ref(true);
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
);
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const roleFieldLabel = computed(() => (isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"));
const roleLabel = computed(() => {
if (isSystemAdmin(auth.user)) return "系统管理员";
return projectRole.value ? displayRoleLabel(projectRole.value) : "未选择项目";
});
const roleContextTitle = computed(() => {
if (isSystemAdmin(auth.user)) return roleLabel.value;
if (!study.currentStudy) return "选择项目后显示该项目内的具体角色";
return `${study.currentStudy.name} · ${roleLabel.value}`;
});
const clientLabel = computed(() => {
if (!isDesktop) return "网页端";
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
return `桌面端 · ${platform}`;
});
const environmentLabel = computed(() =>
import.meta.env.VITE_RUNTIME_ENV === "production" ? "生产环境" : "开发环境",
);
const effectiveNow = computed(() => Math.max(lastCheckedAt.value || 0, nowTs.value));
const tokenExpiryAt = computed(() => (auth.token ? parseJwtExp(auth.token) : null));
const sessionDeadlineAt = computed(() => {
if (!auth.token) return null;
if (isDesktop) return tokenExpiryAt.value;
return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
});
const credentialDeadlineAt = computed(() => {
if (!tokenExpiryAt.value) return sessionDeadlineAt.value;
if (!sessionDeadlineAt.value) return tokenExpiryAt.value;
return Math.min(tokenExpiryAt.value, sessionDeadlineAt.value);
});
const sessionRemainingLabel = computed(() => {
if (!auth.token) return "未登录";
if (!sessionDeadlineAt.value) return "有效";
const remainingMs = sessionDeadlineAt.value - effectiveNow.value;
if (remainingMs <= 0) return "即将失效";
const calculatedMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
const totalMinutes = isDesktop
? calculatedMinutes
: Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes);
if (totalMinutes < 60) return `剩余 ${totalMinutes} 分钟`;
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) return `剩余 ${totalHours} 小时`;
return `剩余 ${Math.floor(totalHours / 24)} 天`;
});
const latencyLabel = computed(() => (latencyMs.value === null ? "--" : `${latencyMs.value} ms`));
const clientIpLabel = computed(() => clientIp.value || (status.value === "checking" ? "检测中" : "--"));
const lastCommunicationLabel = computed(() => {
if (!lastSuccessAt.value) return status.value === "checking" ? "检测中" : "暂无成功记录";
return new Date(lastSuccessAt.value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const securityResult = computed(() => evaluateConnectionSecurity({
apiBaseUrl: clientRuntime.apiBaseUrl(),
pageOrigin: window.location.origin,
status: status.value,
hasToken: Boolean(auth.token),
sessionDeadlineAt: credentialDeadlineAt.value,
now: effectiveNow.value,
clientIp: clientIp.value,
latencyMs: latencyMs.value,
sessionRemainingLabel: sessionRemainingLabel.value,
}));
const runSecurityCheck = async () => {
securityCheckExpanded.value = true;
securityChecking.value = true;
try {
await checkConnection();
nowTs.value = Date.now();
securityChecked.value = true;
} finally {
securityChecking.value = false;
}
};
onMounted(() => {
if (!isSystemAdmin(auth.user)) {
void loadRoleTemplates().catch(() => {});
}
stopConnectionMonitor = startConnectionMonitor();
nowTs.value = Date.now();
sessionClockTimer = window.setInterval(() => {
nowTs.value = Date.now();
}, 1_000);
});
onBeforeUnmount(() => {
stopConnectionMonitor?.();
if (sessionClockTimer !== undefined) window.clearInterval(sessionClockTimer);
});
</script>
<style scoped>
.account-connection-indicator,
.connection-state-badge {
display: inline-flex;
align-items: center;
gap: 5px;
color: #64748b;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.network-overview {
box-sizing: border-box;
width: 100%;
margin-top: 14px;
padding: 14px 16px;
border: 1px solid rgba(203, 213, 225, 0.82);
border-radius: 12px;
background: rgba(255, 255, 255, 0.66);
color: #334155;
}
.network-overview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #334155;
font-size: 12.5px;
font-weight: 750;
}
.network-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
margin: 12px 0 0;
padding-top: 11px;
border-top: 1px solid rgba(226, 232, 240, 0.9);
}
.network-overview-grid div,
.network-overview-grid dt,
.network-overview-grid dd {
min-width: 0;
margin: 0;
}
.security-check-trigger {
appearance: none;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 12px;
padding: 10px 0 0;
border: 0;
border-top: 1px solid rgba(226, 232, 240, 0.9);
background: transparent;
color: #475569;
font: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.security-check-trigger:disabled {
cursor: wait;
opacity: 0.72;
}
.security-check-trigger strong {
font-size: 10.5px;
font-weight: 750;
}
.security-check-trigger .is-safe {
color: #15803d;
}
.security-check-trigger .is-warning {
color: #b45309;
}
.security-check-trigger .is-danger {
color: #dc2626;
}
.security-check-list {
display: grid;
gap: 7px;
margin: 10px 0 0;
padding: 10px 0 0;
border-top: 1px dashed rgba(203, 213, 225, 0.9);
list-style: none;
}
.security-check-list li {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
min-width: 0;
}
.security-check-list i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
}
.security-check-list i.is-safe {
background: #22a663;
}
.security-check-list i.is-warning {
background: #d99b21;
}
.security-check-list i.is-danger {
background: #e05252;
}
.security-check-list span,
.security-check-list small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.security-check-list span {
color: #475569;
font-size: 10.5px;
font-weight: 650;
}
.security-check-list small {
color: #94a3b8;
font-size: 9.5px;
}
.network-overview-grid .network-ip-row {
grid-column: 1 / -1;
}
.network-overview-grid dt {
margin-bottom: 3px;
color: #94a3b8;
font-size: 10px;
}
.network-overview-grid dd {
overflow: hidden;
color: #334155;
font-size: 11.5px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-connection-dot {
display: inline-block;
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.13);
}
.is-online .account-connection-dot {
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.13);
}
.is-degraded .account-connection-dot {
background: #d99b21;
box-shadow: 0 0 0 3px rgba(217, 155, 33, 0.14);
}
.is-offline .account-connection-dot {
background: #e05252;
box-shadow: 0 0 0 3px rgba(224, 82, 82, 0.14);
}
.account-connection-details {
box-sizing: border-box;
width: 290px;
padding: 12px 14px 11px;
color: #273449;
}
.connection-details-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.connection-details-title {
color: #7a8aa0;
font-size: 11px;
font-weight: 700;
}
.connection-state-badge {
padding: 3px 7px;
border-radius: 999px;
background: #f3f6f9;
}
.connection-account-name,
.connection-account-email {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-account-name {
color: #172033;
font-size: 14px;
}
.connection-account-email {
margin-top: 2px;
color: #7a8aa0;
font-size: 11px;
}
.connection-details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 12px 0 0;
padding: 10px 0;
border-top: 1px solid #edf1f5;
border-bottom: 1px solid #edf1f5;
}
.connection-details-grid div,
.connection-details-grid dt,
.connection-details-grid dd {
min-width: 0;
margin: 0;
}
.connection-details-grid dt {
margin-bottom: 2px;
color: #91a0b3;
font-size: 10px;
}
.connection-details-grid dd {
overflow: hidden;
color: #334155;
font-size: 11px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
:global([data-ctms-theme="dark"]) .account-connection-details {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"]) .network-overview {
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
:global([data-ctms-theme="dark"]) .network-overview-head,
:global([data-ctms-theme="dark"]) .network-overview-grid dd {
color: #e2e8f0;
}
:global([data-ctms-theme="dark"]) .network-overview-grid {
border-top-color: rgba(255, 255, 255, 0.06);
}
:global([data-ctms-theme="dark"]) .security-check-trigger,
:global([data-ctms-theme="dark"]) .security-check-list {
border-color: rgba(255, 255, 255, 0.06);
}
:global([data-ctms-theme="dark"]) .security-check-trigger,
:global([data-ctms-theme="dark"]) .security-check-list span {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"]) .connection-account-name,
:global([data-ctms-theme="dark"]) .connection-details-grid dd {
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .connection-details-grid {
border-color: #334155;
}
:global([data-ctms-theme="dark"]) .connection-state-badge {
background: #243247;
}
</style>
@@ -74,11 +74,15 @@ describe("ApiEndpointPermissions.vue", () => {
it("keeps matrix read-only and renders authorization states", async () => {
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).not.toContain("只读查看各角色权限覆盖");
expect(source).toContain("有效权限矩阵");
expect(source).toContain(">只读</el-tag>");
expect(source).toContain("permission-state--allowed");
expect(source).toContain("permission-state--disabled");
expect(source).toContain("CircleCloseFilled");
expect(source).not.toContain('? "✓" : "—"');
expect(source).not.toContain("CircleCloseFilled");
expect(source).toContain("<template v-else>—</template>");
expect(source).toContain('filterAuthorization.value === "different"');
expect(source).toContain('max-height="calc(100dvh - 330px)"');
expect(source).toContain('label="权限类型" width="120" fixed="left"');
expect(source).not.toContain("defineEmits");
expect(source).not.toContain("el-checkbox");
});
@@ -256,6 +260,9 @@ describe("ApiEndpointPermissions.vue", () => {
]);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
expect((wrapper.vm as any).displayOperations).toHaveLength(5);
expect((wrapper.vm as any).totalPermissionCount).toBe(3);
expect((wrapper.vm as any).filteredPermissionCount).toBe(3);
});
it("keeps contract fee permissions as one module section", () => {
@@ -2,7 +2,11 @@
<div class="api-permissions">
<div class="api-permissions-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">项目级权限矩阵</span>
<div class="toolbar-heading">
<span class="toolbar-title">有效权限矩阵</span>
<el-tag size="small" effect="plain" type="info">只读</el-tag>
</div>
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -29,6 +33,13 @@
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
<el-select v-model="filterAuthorization" placeholder="授权状态" style="width: 140px">
<el-option label="全部授权状态" value="" />
<el-option label="存在角色差异" value="different" />
<el-option label="至少一项授权" value="granted" />
<el-option label="全部未授权" value="ungranted" />
</el-select>
<el-button :disabled="!hasActiveFilters" @click="resetFilters">重置</el-button>
</div>
</div>
@@ -37,10 +48,12 @@
:data="filteredOperations"
border
stripe
max-height="calc(100dvh - 330px)"
empty-text="未找到匹配的权限"
:span-method="spanMethod"
class="perm-matrix-table"
>
<el-table-column prop="permission_category" label="权限类型" width="120">
<el-table-column prop="permission_category" label="权限类型" width="120" fixed="left">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
<span class="hierarchy-bar" />
@@ -49,7 +62,7 @@
</template>
</el-table-column>
<el-table-column prop="module" label="模块" width="160">
<el-table-column prop="module" label="模块" width="160" fixed="left">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--module">
<span class="module-dot" />
@@ -94,7 +107,7 @@
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
>
<template v-if="isOperationAllowed(row.operation_key, role)">✓</template>
<CircleCloseFilled v-else />
<template v-else>—</template>
</span>
</template>
</el-table-column>
@@ -105,7 +118,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
@@ -144,8 +157,20 @@ const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTem
const searchText = ref("");
const filterModule = ref("");
const filterAction = ref("");
const filterAuthorization = ref("");
const operations = ref<Operation[]>([]);
const hasActiveFilters = computed(() => Boolean(
searchText.value || filterModule.value || filterAction.value || filterAuthorization.value
));
const resetFilters = () => {
searchText.value = "";
filterModule.value = "";
filterAction.value = "";
filterAuthorization.value = "";
};
const moduleLabel = projectPermissionModuleLabel;
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
@@ -216,8 +241,14 @@ const filteredOperations = computed(() => {
const matchModule = !filterModule.value || operation.module === filterModule.value;
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
const roleStates = roles.value.map((role) => isOperationAllowed(operation.operation_key, role));
const matchAuthorization =
!filterAuthorization.value ||
(filterAuthorization.value === "different" && roleStates.some(Boolean) && roleStates.some((allowed) => !allowed)) ||
(filterAuthorization.value === "granted" && roleStates.some(Boolean)) ||
(filterAuthorization.value === "ungranted" && roleStates.every((allowed) => !allowed));
return matchSearch && matchModule && matchAction;
return matchSearch && matchModule && matchAction && matchAuthorization;
});
// 按权限类型、模块分组排序
@@ -235,6 +266,12 @@ const filteredOperations = computed(() => {
return filtered;
});
const countUniquePermissions = (rows: DisplayOperation[]): number =>
new Set(rows.map((operation) => operation.operation_key)).size;
const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value));
const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value));
// 权限类型、模块与模块细分列合并行
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
@@ -353,12 +390,13 @@ const SECTION_LABELS: Record<string, string> = {
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
collaboration: "在线协作",
};
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
shared_library: ["医学咨询/FAQ", "注意事项"],
shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"],
};
const getOperationSection = (row: Operation): string => {
@@ -394,19 +432,19 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
<style scoped>
.api-permissions {
padding: 20px 0;
padding: 8px 0;
}
.api-permissions-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 14px 18px;
gap: 10px;
margin-bottom: 8px;
padding: 8px 10px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -414,23 +452,35 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.toolbar-heading {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-title {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.toolbar-result {
color: #7a8a9a;
font-size: 10.5px;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
}
.api-permissions-table-wrapper {
overflow-x: auto;
border-radius: 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -442,7 +492,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.hierarchy-cell {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
height: 100%;
}
@@ -458,7 +508,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 36px;
height: 24px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
}
@@ -472,7 +522,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
}
.category-label {
font-size: 14px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.3px;
@@ -481,15 +531,15 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
/* 模块:实心圆点 + 中等加粗字 */
.module-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.module-label {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
@@ -498,11 +548,11 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.section-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
padding: 2px 7px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 12px;
font-size: 10.5px;
font-weight: 500;
color: #475569;
white-space: nowrap;
@@ -511,17 +561,17 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.action-tag {
flex-shrink: 0;
min-width: 40px;
min-width: 36px;
text-align: center;
}
.operation-name {
font-size: 13px;
font-size: 12px;
color: #475569;
}
@@ -529,10 +579,10 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 18px;
height: 18px;
color: #94a3b8;
font-size: 14px;
font-size: 12px;
font-weight: 600;
}
@@ -544,11 +594,46 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
}
.permission-state--disabled {
color: #94a3b8;
color: #c3ccd5;
font-weight: 500;
}
.permission-state--disabled :deep(svg) {
width: 16px;
height: 16px;
:deep(.api-permissions .el-input__wrapper),
:deep(.api-permissions .el-select__wrapper) {
min-height: 28px;
}
:deep(.api-permissions .el-button) {
min-height: 28px;
padding: 4px 8px;
font-size: 11.5px;
}
:deep(.perm-matrix-table .el-table__header-wrapper th.el-table__cell) {
padding: 6px 0;
font-size: 11px;
}
:deep(.perm-matrix-table .el-table__body-wrapper td.el-table__cell) {
padding: 4px 0;
font-size: 12px;
}
@media (max-width: 1180px) {
.api-permissions-toolbar {
align-items: flex-start;
flex-direction: column;
}
.toolbar-filters {
flex-wrap: wrap;
width: 100%;
}
}
@media (max-width: 720px) {
.toolbar-filters > * {
width: 100% !important;
}
}
</style>
File diff suppressed because it is too large Load Diff
-310
View File
@@ -1,310 +0,0 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="520px"
:close-on-click-modal="true"
:show-close="false"
class="faq-category-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
@close="close"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="faq-category-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.knowledgeMedicalConsult.title }}
</div>
<el-form-item :label="TEXT.modules.knowledgeMedicalConsult.categoryName" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.sortOrder">
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item>
<el-form-item label="图标">
<div class="icon-picker">
<span
v-for="icon in iconOptions"
:key="icon.cls"
:title="icon.label"
:class="['icon-option', {
'icon-option--selected': form.icon === icon.cls,
'icon-option--used': isIconUsedByOther(icon.cls),
}]"
@click="selectIcon(icon.cls)"
><FaqIcon :icon="icon.cls" /></span>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer" :class="{ 'drawer-footer--edit': isEdit }">
<el-button v-if="isEdit && canDelete" type="danger" plain :loading="deleting" @click="onDelete">
{{ TEXT.common.actions.delete }}
</el-button>
<div class="drawer-footer-spacer" v-if="isEdit"></div>
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import FaqIcon from "./FaqIcon.vue";
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
import { useStudyStore } from "../store/study";
import { TEXT, requiredMessage } from "../locales";
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean; canDelete?: boolean; existingIcons?: string[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const deleting = ref(false);
const form = reactive({
study_id: "",
name: "",
description: "",
sort_order: 0,
icon: "",
});
const iconOptions = [
{ cls: "fa-th-large", label: "全部" },
{ cls: "fa-ruler-combined", label: "入排标准" },
{ cls: "fa-calendar-check", label: "访视" },
{ cls: "fa-filter", label: "筛选" },
{ cls: "fa-flag-checkered", label: "启动会" },
{ cls: "fa-capsules", label: "试验药物" },
{ cls: "fa-prescription-bottle", label: "药品" },
{ cls: "fa-vial", label: "实验室" },
{ cls: "fa-heartbeat", label: "AE" },
{ cls: "fa-pills", label: "合并用药" },
{ cls: "fa-file-medical", label: "门诊病历" },
{ cls: "fa-camera", label: "拍照" },
{ cls: "fa-notes-medical", label: "医疗记录" },
{ cls: "fa-clipboard-list", label: "检查表" },
{ cls: "fa-syringe", label: "注射" },
{ cls: "fa-flask", label: "研究" },
{ cls: "fa-book-medical", label: "医学书籍" },
{ cls: "fa-user-md", label: "医生" },
{ cls: "fa-hospital", label: "医院" },
{ cls: "fa-ellipsis-h", label: "其他" },
];
const rules: FormRules = {
name: [{ required: true, message: requiredMessage(TEXT.modules.knowledgeMedicalConsult.categoryName), trigger: "blur" }],
};
const isEdit = computed(() => !!props.category);
const isIconUsedByOther = (icon: string) => {
if (form.icon === icon) return false;
return (props.existingIcons || []).includes(icon);
};
const selectIcon = (icon: string) => {
if (isIconUsedByOther(icon)) return;
form.icon = form.icon === icon ? "" : icon;
};
const reset = () => {
form.name = "";
form.description = "";
form.sort_order = 0;
form.icon = "";
};
watch(
() => props.category,
(val) => {
if (val) {
form.name = val.name;
form.description = val.description || "";
form.sort_order = val.sort_order || 0;
form.icon = val.icon || "";
} else {
reset();
}
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && !props.category) {
reset();
}
}
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!formRef.value) return;
// 验证当前项目存在
if (!study.currentStudy?.id) {
ElMessage.warning(TEXT.common.empty.selectProject);
return;
}
await formRef.value.validate();
submitting.value = true;
try {
const payload: Record<string, any> = {
study_id: study.currentStudy.id,
name: form.name,
description: form.description || null,
sort_order: form.sort_order,
icon: form.icon || null,
};
if (isEdit.value && props.category) {
await updateFaqCategory(props.category.id, payload);
} else {
await createFaqCategory(payload);
}
ElMessage.success(TEXT.common.messages.saveSuccess);
emit("success");
close();
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.response?.data?.detail || TEXT.common.messages.saveFailed;
ElMessage.error(typeof errorMsg === 'string' ? errorMsg : TEXT.common.messages.saveFailed);
} finally {
submitting.value = false;
}
};
const onDelete = async () => {
if (!props.category) return;
const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", props.category.name),
TEXT.common.labels.tips,
).catch(() => null);
if (!ok) return;
deleting.value = true;
try {
await deleteFaqCategory(props.category.id, study.currentStudy?.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} finally {
deleting.value = false;
}
};
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.faq-category-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.drawer-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.drawer-footer--edit {
justify-content: space-between;
}
.drawer-footer-spacer {
flex: 1;
}
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.icon-option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 2px solid #e8eef6;
border-radius: 10px;
font-size: 18px;
color: #697982;
cursor: pointer;
transition: all 0.15s ease;
background: #ffffff;
}
.icon-option:hover {
border-color: #93c5fd;
background: #eff6ff;
transform: scale(1.08);
}
.icon-option--selected {
border-color: #3b82f6;
background: #dbeafe;
color: #1e40af;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.icon-option--used {
opacity: 0.3;
pointer-events: none;
cursor: not-allowed;
border-color: #e8eef6;
background: #f5f5f5;
}
</style>
@@ -97,7 +97,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import { ArrowDown, ArrowUp, Delete, Plus } from "@element-plus/icons-vue";
import FaqIcon from "./FaqIcon.vue";
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
+60 -20
View File
@@ -88,12 +88,18 @@ const openManager = () => {
<style scoped>
.panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
padding: 24px 14px;
background: transparent;
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.12) 0%, transparent 38%),
linear-gradient(180deg, #eef7ff 0%, #f8fbff 52%, #edf3fb 100%);
}
.header {
@@ -101,37 +107,69 @@ const openManager = () => {
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 0 8px 16px;
border-bottom: 1px solid #edf2f8;
color: #8b98aa;
padding: 12px 12px;
border: 1px solid rgba(110, 153, 205, 0.18);
border-radius: 12px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(232, 244, 255, 0.86) 100%);
box-shadow:
0 10px 24px rgba(42, 86, 143, 0.07),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
color: #385b7f;
font-size: 14px;
font-weight: 800;
}
.header > span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.header > span::before {
content: "";
width: 6px;
height: 20px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 14px rgba(47, 123, 232, 0.26);
}
.menu {
flex: 1 1 auto;
max-height: calc(100vh - 240px);
min-height: 0;
overflow: auto;
border-right: 0;
padding: 14px 0 0;
padding: 14px 2px 0;
background: transparent;
}
.menu :deep(.el-menu-item) {
position: relative;
height: 44px;
margin: 4px 0;
padding: 0 10px !important;
border: 1px solid transparent;
border-radius: 10px;
color: #5f6f7a;
color: #506475;
font-size: 14px;
font-weight: 700;
line-height: 44px;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.menu :deep(.el-menu-item:hover) {
background: rgba(31, 95, 184, 0.06);
border-color: rgba(47, 123, 232, 0.1);
background: rgba(255, 255, 255, 0.66);
color: #1f5fb8;
}
.menu :deep(.el-menu-item.is-active) {
background: #e7f4ff;
color: #1f5fb8;
border-color: rgba(47, 123, 232, 0.18);
background:
linear-gradient(135deg, rgba(220, 239, 255, 0.96) 0%, rgba(240, 248, 255, 0.96) 100%);
box-shadow:
0 10px 22px rgba(47, 123, 232, 0.12),
3px 0 0 #2f7be8 inset;
color: #1559ad;
}
.cat-icon {
display: inline-flex;
@@ -142,10 +180,11 @@ const openManager = () => {
height: 24px;
margin-right: 10px;
border-radius: 7px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: linear-gradient(135deg, rgba(221, 234, 249, 0.9) 0%, rgba(242, 248, 255, 0.9) 100%);
color: #517194;
font-size: 13px;
font-weight: 800;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.82) inset;
}
.cat-icon :deep(svg) {
font-size: 13px;
@@ -153,20 +192,21 @@ const openManager = () => {
height: 1em;
}
.menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #2f7be8 0%, #24b7d8 100%);
color: #ffffff;
box-shadow: 0 8px 18px rgba(47, 123, 232, 0.25);
}
.category-create-btn {
height: 34px;
border: 0;
border-radius: 10px;
background: #415a77;
background: linear-gradient(135deg, #2f527a 0%, #203a5c 100%);
font-weight: 800;
box-shadow: 0 8px 18px rgba(65, 90, 119, 0.16);
box-shadow: 0 8px 18px rgba(37, 67, 105, 0.2);
}
.category-create-btn:hover,
.category-create-btn:focus {
background: #344960;
background: linear-gradient(135deg, #254866 0%, #172f4e 100%);
}
.name {
flex: 1;
@@ -183,8 +223,8 @@ const openManager = () => {
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: rgba(88, 111, 137, 0.1);
color: #5d7590;
font-size: 10px;
font-weight: 700;
line-height: 18px;
@@ -192,7 +232,7 @@ const openManager = () => {
}
.menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #cfe7ff 0%, #dcf7ff 100%);
color: #1559ad;
}
</style>
+154 -19
View File
@@ -1,18 +1,20 @@
<template>
<div class="faq-list-card">
<div class="faq-list-card faq-list-card--workbench">
<el-table
:data="items"
v-loading="loading"
style="width: 100%"
class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName"
height="100%"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
<template #default="scope">
<div class="question-cell">
<span class="question-icon">{{ questionInitial(scope.row.question) }}</span>
<span class="question-icon"><el-icon><QuestionFilled /></el-icon></span>
<el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }}
</el-link>
@@ -52,6 +54,7 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { deleteFaqItem } from "../api/faqs";
@@ -81,10 +84,6 @@ const categoryLabel = (categoryId: string) => {
return categoryMap.value[categoryId] || categoryId || "--";
};
const questionInitial = (question?: string) => {
return String(question || "?").trim().slice(0, 1) || "?";
};
const splitDateTime = (value?: string | number | Date | null) => {
const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" ");
@@ -145,9 +144,10 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
border-bottom: 0;
background: transparent;
color: #8a98aa;
height: 46px;
border-bottom: 1px solid #dce8f7;
background: linear-gradient(180deg, #f7fbff 0%, #eef5fc 100%);
color: #5d7088;
font-size: 14px;
font-weight: 800;
}
@@ -166,21 +166,23 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.faq-row td.el-table__cell) {
padding: 12px 0;
padding: 14px 0;
border-bottom: 1px solid #e8eef6;
background: #ffffff;
transition: background 0.18s ease;
}
.faq-table :deep(.faq-row:hover td.el-table__cell) {
background: #f8f9fb;
background: #f7fbff;
}
.faq-table :deep(col:nth-child(1)) { width: 43%; }
.faq-table :deep(col:nth-child(2)) { width: 14%; }
.faq-table :deep(col:nth-child(3)) { width: 14%; }
.faq-table :deep(col:nth-child(4)) { width: 14%; }
.faq-table :deep(col:nth-child(5)) { width: 15%; }
.faq-table--with-actions :deep(col) {
width: 20%;
}
.faq-table--without-actions :deep(col) {
width: 25%;
}
@@ -205,10 +207,19 @@ const remove = async (row: FaqItem) => {
width: 34px;
height: 34px;
border-radius: 10px;
background: #f3f7fb;
color: #5e6f7b;
font-size: 15px;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.42) 0%, transparent 28%),
linear-gradient(135deg, #2f7be8 0%, #23b7d9 100%);
color: #ffffff;
font-size: 17px;
font-weight: 800;
box-shadow:
0 10px 20px rgba(47, 123, 232, 0.2),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.question-icon :deep(.el-icon) {
font-size: 1em;
}
.question-link {
@@ -257,4 +268,128 @@ const remove = async (row: FaqItem) => {
.question-link {
cursor: pointer;
}
.faq-list-card--workbench {
display: flex;
height: 100%;
min-height: 0;
flex: 1 1 auto;
background: #ffffff;
}
.faq-list-card--workbench .faq-table {
height: 100%;
min-height: 0;
flex: 1 1 auto;
}
.faq-list-card--workbench .faq-table :deep(.el-table__inner-wrapper) {
height: 100%;
}
.faq-list-card--workbench .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
background: linear-gradient(180deg, #f4f9ff 0%, #eaf3fc 100%) !important;
color: #49647f;
font-size: 12px;
font-weight: 850;
}
.faq-list-card--workbench .faq-table :deep(.el-table__body) {
border-collapse: separate;
border-spacing: 0 6px;
}
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper) {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
scrollbar-width: none;
}
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap) {
scrollbar-width: none;
}
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__bar.is-vertical) {
display: none !important;
}
.faq-list-card--workbench .faq-table :deep(.el-table__empty-block) {
min-height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell) {
padding: 10px 14px;
border-top: 1px solid rgba(226, 235, 246, 0.92);
border-bottom: 1px solid rgba(226, 235, 246, 0.92);
background: #ffffff;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:first-child) {
border-left: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 9px 0 0 9px;
}
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:last-child) {
border-right: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 0 9px 9px 0;
}
.faq-list-card--workbench .faq-table :deep(.faq-row:hover td.el-table__cell) {
border-color: rgba(111, 159, 220, 0.34);
background: #f5faff;
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
}
.faq-list-card--workbench .question-cell {
gap: 10px;
}
.faq-list-card--workbench .question-icon {
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 14px;
box-shadow:
0 8px 16px rgba(47, 123, 232, 0.18),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.faq-list-card--workbench .question-link {
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.faq-list-card--workbench .category-pill {
height: 24px;
padding: 0 9px;
border-radius: 7px;
background: linear-gradient(135deg, #e7f3ff 0%, #eefaff 100%);
font-size: 12px;
}
.faq-list-card--workbench .time-text {
gap: 0;
font-size: 11px;
}
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row td.el-table__cell) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row:hover td.el-table__cell) {
background: #1d2b42;
}
</style>
+728 -6
View File
@@ -2,27 +2,68 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
const normalizeSource = (source: string) => source.replace(/\r\n/g, "\n");
const readSource = (path: string) => normalizeSource(readFileSync(path, "utf8"));
const readLayoutSource = () => readSource(resolve(__dirname, "./Layout.vue"));
const readAppSource = () => readSource(resolve(__dirname, "../App.vue"));
const readDesktopLayoutSource = () => readSource(resolve(__dirname, "./DesktopLayout.vue"));
const readWebLayoutSource = () => readSource(resolve(__dirname, "./WebLayout.vue"));
const readNavigationSource = () => readSource(resolve(__dirname, "./layout/navigation.ts"));
const readTauriConfigSource = () => readSource(resolve(__dirname, "../../src-tauri/tauri.conf.json"));
const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/src/lib.rs"));
const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue"));
const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue"));
const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts"));
const readDesktopNotificationManagerSource = () => readSource(resolve(__dirname, "../session/desktopNotificationManager.ts"));
const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts"));
const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue"));
const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue"));
const readRegisterSource = () => readSource(resolve(__dirname, "../views/Register.vue"));
const readForgotPasswordSource = () => readSource(resolve(__dirname, "../views/ForgotPassword.vue"));
const readFileTaskFeedbackSource = () => readSource(resolve(__dirname, "../utils/fileTaskFeedback.ts"));
const readAttachmentListSource = () => readSource(resolve(__dirname, "./attachments/AttachmentList.vue"));
const readDocumentDetailSource = () => readSource(resolve(__dirname, "../views/documents/DocumentDetail.vue"));
const readMainStyleSource = () => readSource(resolve(__dirname, "../styles/main.css"));
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
const readProjectMilestonesSource = () => readSource(resolve(__dirname, "../views/ia/ProjectMilestones.vue"));
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource();
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain('defineAsyncComponent(() => import("./DesktopLayout.vue"))');
expect(source).toContain('defineAsyncComponent(() => import("./WebLayout.vue"))');
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
expect(app).toContain("initializeDesktopThemePreference();");
expect(app).toContain("initializeDesktopMenuShortcutSync();");
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
});
it("uses route-only desktop preference storage", () => {
const source = readDesktopLayoutSource();
const webSource = readWebLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
expect(source).toContain("currentDesktopRoutePreference");
expect(source).toContain("toggleDesktopFavoriteRoute");
expect(source).not.toContain("readDesktopRecentRoutes");
expect(source).not.toContain("recordDesktopRecentRoute");
expect(source).not.toContain("desktopRecentRoutes");
expect(source).not.toContain("最近访问");
expect(webSource).toContain("readDesktopFavoriteRoutes");
expect(webSource).toContain("toggleDesktopFavoriteRoute");
expect(webSource).not.toContain("readDesktopRecentRoutes");
expect(webSource).not.toContain("recordDesktopRecentRoute");
expect(webSource).not.toContain("desktopRecentRoutes");
expect(webSource).not.toContain("最近访问");
});
it("shares active route mapping between web and desktop shells", () => {
@@ -34,4 +75,685 @@ describe("desktop layout shell", () => {
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath");
});
it("keeps web project switching routed through the workbench entry", () => {
const webLayout = readWebLayoutSource();
expect(webLayout).not.toContain("workbench-return-button");
expect(webLayout).toContain('<el-dropdown-item command="workbench">');
expect(webLayout).toContain('await router.push("/workbench")');
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value)");
expect(webLayout).not.toContain("hasDropdown: true");
expect(webLayout).not.toContain("type: 'study'");
expect(webLayout).not.toContain('type: "study"');
expect(webLayout).not.toContain("`切换项目:${item.name}`");
expect(webLayout).not.toContain("study.setCurrentStudy(cmd.value)");
expect(webLayout).not.toContain('router.push("/project/overview");');
});
it("keeps desktop context labels and command entry points de-duplicated", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("<h1>{{ TEXT.common.appName }}</h1>");
expect(source).toContain('const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";');
expect(source).toContain('data-tauri-drag-region');
expect(source).toContain('class="toolbar-title"');
expect(source).toContain('class="toolbar-subtitle"');
expect(source).toContain("desktopToolbarTitle");
expect(source).toContain("desktopToolbarSubtitle");
expect(source).not.toContain("desktopBreadcrumbs");
expect(source).not.toContain("breadcrumb-chip");
expect(source).toContain('class="workspace-history-controls"');
expect(source).toContain('title="后退(当前任务)"');
expect(source).toContain('title="前进(当前任务)"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabBack"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabForward"');
expect(source).toContain('class="toolbar-history-divider"');
expect(source).toContain('v-for="(item, index) in desktopToolbarContextItems"');
expect(source).toContain('class="toolbar-context-link"');
expect(source).not.toContain('title="刷新当前视图"');
expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"');
expect(source).not.toContain("desktop-statusbar");
expect(source).not.toContain("status-item");
expect(source).not.toContain("accountProjectRoleLabel");
expect(source).not.toContain("projectStatusLabel");
expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
expect(source).toContain("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems");
expect(source).toContain("hasCurrentStudy: Boolean(study.currentStudy) && !isAdminContext.value");
expect(source).toContain('id: "desktop:refresh"');
expect(source).toContain("shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh)");
expect(source).toContain('command === "ctms.desktop.back"');
expect(source).toContain('command === "ctms.desktop.forward"');
expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject");
});
it("uses macOS overlay titlebar without adding window permissions", () => {
const tauriConfig = readTauriConfigSource();
const layout = readDesktopLayoutSource();
expect(tauriConfig).toContain('"decorations": true');
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
expect(tauriConfig).toContain('"hiddenTitle": true');
expect(tauriConfig).toContain('"backgroundColor": "#f9fafb"');
expect(tauriConfig).toContain('"backgroundThrottling": "disabled"');
expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }');
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("-webkit-app-region: drag;");
expect(layout).toContain("-webkit-app-region: no-drag;");
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
});
it("uses a native macOS application menu and rebuilds a closed main window from the Dock", () => {
const tauriLib = readTauriLibSource();
expect(tauriLib.indexOf("package.name.clone()")).toBeLessThan(tauriLib.indexOf('"文件"'));
expect(tauriLib).toContain('Some("关于 CTMS")');
expect(tauriLib).toContain("short_version: Some(String::new())");
expect(tauriLib).toContain("icon: handle.default_window_icon().cloned()");
expect(tauriLib).toContain('"ctms.desktop.preferences"');
expect(tauriLib).toContain("PredefinedMenuItem::services");
expect(tauriLib).toContain("PredefinedMenuItem::hide_others");
expect(tauriLib).toContain("PredefinedMenuItem::show_all");
expect(tauriLib).toContain("WINDOW_SUBMENU_ID");
expect(tauriLib).toContain("HELP_SUBMENU_ID");
expect(tauriLib).toContain("TOGGLE_SIDEBAR_COMMAND_ID");
expect(tauriLib).toContain('"显示/隐藏导航栏"');
expect(tauriLib).toContain("RunEvent::Reopen");
expect(tauriLib).toContain("WebviewWindowBuilder::from_config");
expect(tauriLib).toContain('find(|config| config.label == "main")');
expect(tauriLib).not.toContain("WindowEvent::CloseRequested");
expect(tauriLib).not.toContain("api.prevent_close()");
expect(tauriLib).not.toContain("window.hide()");
});
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
const layout = readDesktopLayoutSource();
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
const sidebarHeadEnd = layout.indexOf("</header>", sidebarHeadStart);
const sidebarHeadTemplate = layout.slice(sidebarHeadStart, sidebarHeadEnd);
expect(layout).toContain("grid-template-columns: 248px minmax(0, 1fr);");
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);");
expect(layout).toContain("margin-top: 0;");
expect(layout).toContain("const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject)");
expect(layout).toContain("{{ desktopProjectSectionLabel }}");
expect(layout).toContain('command="projectEntry"');
expect(layout).toContain("projectEntryMenuLabel");
expect(layout).toContain("openDesktopProjectEntry");
expect(layout).toContain("const showSidebarProjectChooser = computed(() => !study.currentStudy && !isAdminContext.value);");
expect(layout).toContain("padding: 10px 8px 24px;");
expect(layout).toContain("margin-top: 12px;");
expect(layout).toContain("font-size: 14px;");
expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">');
expect(sidebarHeadTemplate.indexOf("<h1>{{ TEXT.common.appName }}</h1>")).toBeLessThan(
sidebarHeadTemplate.indexOf('<button v-if="showSidebarProjectChooser" class="study-switcher-trigger empty"'),
);
expect(sidebarHeadTemplate).not.toContain("study-context-badge");
expect(layout).not.toContain(".study-context-badge");
expect(layout).not.toContain(".study-name");
expect(sidebarHeadTemplate).not.toContain('@command="handleStudySwitch"');
expect(layout).not.toContain("handleStudySwitch");
expect(layout).not.toContain("`切换项目:${item.name}`");
expect(layout).not.toContain("grid-template-columns: 272px minmax(0, 1fr);");
expect(layout).not.toContain("padding: 54px 16px 14px;");
expect(layout).not.toContain("padding: 44px 12px 12px;");
});
it("persists a fully hidden desktop sidebar with a macOS-safe restore control", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("const desktopSidebarVisible = ref(readDesktopSidebarVisible());");
expect(layout).toContain("desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);");
expect(layout).toContain("'is-sidebar-hidden': !desktopSidebarVisible");
expect(layout).toContain("'is-macos': desktopMetadata.platform === 'macos'");
expect(layout).toContain('class="toolbar-nav-button sidebar-toggle-button"');
expect(layout).toContain(':title="desktopSidebarToggleLabel"');
expect(layout).toContain('<Fold v-if="desktopSidebarVisible" /><Expand v-else />');
expect(layout).toContain('command === "ctms.desktop.toggleSidebar"');
expect(layout).toContain(".desktop-workbench.is-sidebar-hidden {");
expect(layout).toContain("grid-template-columns: 0 minmax(0, 1fr);");
expect(layout).toContain(".desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {");
expect(layout).toContain("padding-left: 88px;");
expect(layout).toContain("pointer-events: none;");
});
it("uses the collaboration route immersive mode in the desktop shell", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(layout).toContain('<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">');
expect(layout).toContain('<header v-if="!route.meta.immersiveWorkspace" class="desktop-toolbar">');
expect(layout).toContain('v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1"');
expect(layout).toContain(".desktop-workbench.is-immersive-workspace {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-main {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-content {");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
const webLayout = readWebLayoutSource();
expect(preferences).toContain('class="preferences-sidebar"');
expect(preferences).toContain('class="preferences-pane"');
expect(preferences).toContain("const activeSectionId = ref<PreferenceSectionId>");
expect(preferences).toContain('grid-template-columns: 232px minmax(0, 1fr);');
expect(preferences).toContain("height: 700px;");
expect(preferences).toContain("max-height: calc(100vh - 96px);");
expect(preferences).toContain("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ value: "system" as const, label: "跟随系统"');
expect(preferences).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
expect(preferences).toContain("width: min(100%, 336px);");
expect(preferences).toContain("white-space: nowrap;");
expect(preferences).toContain('{ id: "shortcuts", label: "快捷键"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
expect(preferences).toContain('{ id: "diagnostics", label: "诊断信息"');
expect(preferences).toContain("testDesktopServerConnection");
expect(preferences).toContain("saveDesktopServerConfig");
expect(preferences).toContain("测试连通性");
expect(preferences).not.toContain("保存服务器");
expect(preferences).toContain('ElMessage.success("服务器设置已保存")');
expect(preferences).toContain("ElMessage.error(`保存失败:${message}`)");
expect(preferences).toContain("normalizeDesktopServerUrl");
expect(preferences).toContain("确认切换服务器");
expect(preferences).toContain('<Transition name="preferences-header" mode="out-in">');
expect(preferences).toContain('<Transition name="preferences-panel" mode="out-in">');
expect(preferences).toContain('<Transition name="connection-feedback">');
expect(preferences).toContain("const selectSection = (sectionId: PreferenceSectionId)");
expect(preferences).toContain("const setConnectionPending = (message: string)");
expect(preferences).toContain("const scheduleConnectionPending = (message: string)");
expect(preferences).toContain("}, 180);");
expect(preferences).toContain('scrollTo({ top: 0, behavior: "smooth" })');
expect(preferences).not.toContain("connectionStatus.value = null");
expect(preferences).toContain("min-width: 100px;");
expect(preferences).toContain("connection-feedback-enter-active");
expect(preferences).toContain("@media (prefers-reduced-motion: reduce)");
expect(preferences).toContain(':global([data-ctms-theme="dark"] .desktop-preferences)');
expect(preferences).not.toContain(':global([data-ctms-theme="dark"]) .desktop-preferences');
expect(desktopLayout).toContain(':global([data-ctms-theme="dark"] .desktop-workbench)');
expect(desktopLayout).not.toContain(':global([data-ctms-theme="dark"]) .desktop-workbench');
expect(desktopLayout).toContain('width="940px"');
expect(webLayout).toContain('width="940px"');
expect(desktopLayout).not.toContain(":global(.desktop-preferences-dialog .el-dialog__body > *)");
expect(desktopLayout).toContain('title: "连接设置"');
expect(webLayout).toContain('title: "连接设置"');
expect(desktopLayout).not.toContain('router.push("/desktop/server-settings")');
expect(webLayout).not.toContain('router.push("/desktop/server-settings")');
expect(desktopLayout).not.toContain('width="720px"');
expect(webLayout).not.toContain('width="720px"');
});
it("keeps client diagnostics out of profile while desktop controls stay in preferences", () => {
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(profile).not.toContain("form-section--desktop");
expect(profile).not.toContain("客户端与通知");
expect(profile).not.toContain("getDesktopNotificationSubscription");
expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
expect(profile).not.toContain("desktopNotificationsEnabled");
expect(profile).not.toContain("form-section--diagnostics");
expect(profile).not.toContain("客户端诊断");
expect(profile).not.toContain("clientDiagnosticRows");
expect(profile).not.toContain("copyClientDiagnostics");
expect(profile).not.toContain("getAppMetadata");
expect(profile).not.toContain("getDesktopServerUrl");
expect(profile).not.toContain("clientRuntime.capabilities");
expect(profile).not.toContain("诊断信息已复制");
expect(preferences).toContain("getDesktopNotificationSubscription");
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
expect(preferences).toContain("clientMetadataRows");
expect(preferences).not.toContain('{ label: "主题"');
expect(preferences).not.toContain('{ label: "服务器"');
});
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('@click="navigateWorkspaceTab(item.path)"');
expect(tabsTemplate).toContain('draggable="true"');
expect(tabsTemplate).toContain('@dragstart="startWorkspaceTabDrag(item.path, $event)"');
expect(tabsTemplate).toContain('@drop.prevent="dropWorkspaceTab(item.path, $event)"');
expect(source).toContain("const draggingWorkspaceTabPath = ref(\"\")");
expect(source).toContain("const navigateWorkspaceTab = (path: string) => {");
expect(source).toContain("if (activeMenu.value === path) return;");
expect(source).toContain("next[existingIndex] = { ...next[existingIndex], ...item };");
expect(source).toContain("const [moved] = next.splice(sourceIndex, 1);");
expect(source).toContain("<KeepAlive :max=\"DESKTOP_WORKSPACE_TAB_CACHE_MAX\">");
expect(source).toContain(":key=\"desktopRouteCacheKey(currentRoute)\"");
expect(source).toContain("const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;");
expect(source).not.toContain('<div :key="currentRoute.fullPath" class="desktop-route-shell">');
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)");
expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"');
expect(tabsTemplate).not.toContain('class="tab-group"');
expect(tabsTemplate).not.toContain("{{ item.group }}");
expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);");
expect(source).toContain("const workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
expect(source).toContain("const restoreLastClosedWorkspaceTab = () => {");
expect(source).toContain("const workspaceTabFallbackPath = computed(() => {");
expect(source).toContain('if (isAdminContext.value) return adminDesktopNavigationItems.value[0]?.path || "/admin/projects";');
expect(source).toContain("router.push(workspaceTabFallbackPath.value);");
expect(source).not.toContain('router.push(study.currentStudy ? "/project/overview" : "/desktop/project-entry");');
expect(source).toContain('id: `workspace-tab:${item.path}`');
expect(source).toContain('title: `切换标签:${item.title}`');
expect(source).toContain('title: "重新打开最近关闭标签"');
});
it("keeps shortcut navigation history isolated to the active workspace tab", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});");
expect(source).toContain("recordWorkspaceTabRoute(current.path, fullPath);");
expect(source).toContain("replaceWorkspaceTabRoute(current.path, fullPath);");
expect(source).toContain("currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath)");
expect(source).toContain("moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1)");
expect(source).toContain("navigateBack: () => navigateCurrentWorkspaceTabHistory(-1)");
expect(source).toContain("navigateForward: () => navigateCurrentWorkspaceTabHistory(1)");
expect(source).toContain('id: "desktop:tab-back"');
expect(source).toContain('id: "desktop:tab-forward"');
expect(source).not.toContain("navigateBack: () => router.back()");
expect(source).not.toContain("navigateForward: () => router.forward()");
});
it("updates a detail workspace tab after its business context has loaded", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("buildWorkspaceTabTitle,");
expect(source).toContain("buildWorkspaceToolbarTitle,");
expect(source).toContain("resolveWorkspaceTabContextTitle,");
expect(source).toContain('} from "./layout/workspaceTabTitle";');
expect(source).toContain("const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});");
expect(source).toContain("title: buildWorkspaceTabTitle({");
expect(source).toContain("const pageTitle = resolveWorkspaceTabContextTitle({");
expect(source).toContain("() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,");
expect(source).toContain("[activeMenu.value]: {");
expect(source).toContain("([pageTitle, objectType]) => {");
expect(source).toContain("if (current) addWorkspaceTab(current);");
expect(source).toContain('{ flush: "sync" },');
expect(source).toContain("if (!previousRoute || previousRoute.path !== route.path) {");
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
});
it("closes transient preview tasks back to their originating workspace tab", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
expect(source).toContain('group: String(route.meta.workspaceTaskGroup || "工作区任务")');
expect(source).toContain("if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path)");
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
expect(source).toContain("provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });");
expect(source).toContain("if (!workspaceTaskOrigins.value[path] && workspaceTabs.value.length <= 1) return false;");
});
it("allows desktop shortcuts to be customized from system preferences", () => {
const preferences = readDesktopPreferencesSource();
const tauriLib = readTauriLibSource();
expect(preferences).toContain("activeSectionId === 'shortcuts'");
expect(preferences).toContain("desktopShortcutFromKeyboardEvent");
expect(preferences).toContain("setDesktopShortcutPreference");
expect(preferences).toContain("resetDesktopShortcutPreferences");
expect(preferences).toContain("startDesktopShortcutCapture($event, item.action)");
expect(preferences).toContain('window.addEventListener("keydown", captureDesktopShortcut, { capture: true })');
expect(preferences).toContain('window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true })');
expect(preferences).toContain("event.stopImmediatePropagation()");
expect(preferences).toContain("恢复默认");
expect(tauriLib).toContain('Some("CmdOrCtrl+R")');
expect(tauriLib).toContain('Some("CmdOrCtrl+[")');
expect(tauriLib).toContain('Some("CmdOrCtrl+]")');
expect(tauriLib).toContain("desktop_menu_set_shortcuts");
expect(tauriLib).toContain("set_menu_accelerator");
});
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('v-for="item in visibleWorkspaceTabs"');
expect(tabsTemplate).toContain('v-if="hasWorkspaceTabsOverflow"');
expect(tabsTemplate).toContain('popper-class="workspace-tabs-overflow-popper"');
expect(tabsTemplate).toContain('<span>全部任务</span>');
expect(tabsTemplate).toContain('{{ workspaceTabs.length }}');
expect(tabsTemplate).toContain('@command="navigateOverflowWorkspaceTab"');
expect(tabsTemplate).toContain('gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))`');
expect(tabsTemplate).toContain('class="workspace-tabs-overflow-header"');
expect(tabsTemplate).toContain('v-model="workspaceTabsSearchQuery"');
expect(tabsTemplate).toContain('v-for="item in filteredWorkspaceTabs"');
expect(tabsTemplate).toContain('@click.stop="closeOtherWorkspaceTabs(activeMenu)"');
expect(tabsTemplate).toContain('@click.stop="closeAllWorkspaceTabs"');
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;");
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;");
expect(source).toContain("const workspaceTabsByRecentUse = computed(() =>");
expect(source).toContain("const visibleWorkspaceTabs = computed(() => {");
expect(source).toContain("const recentVisiblePaths = new Set(");
expect(source).toContain("workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT)");
expect(source).toContain("return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));");
expect(source).toContain("const filteredWorkspaceTabs = computed(() => {");
expect(source).toContain("const closeAllWorkspaceTabs = () => {");
expect(source).not.toContain("visibleWorkspaceTabCapacity");
expect(source).not.toContain("workspaceTabsResizeObserver");
expect(source).toContain(".workspace-tabs-list {\n display: grid;");
expect(source).toContain("padding: 0 10px;");
expect(source).toContain("padding: 0 14px;");
expect(source).toContain("overflow: hidden;");
expect(source).not.toContain("overflow-x: auto;");
});
it("keeps desktop navigation hierarchy aligned with the web sidebar", () => {
const desktopLayout = readDesktopLayoutSource();
const navigation = readNavigationSource();
const sidebarStart = desktopLayout.indexOf('<div class="sidebar-scroll">');
const sidebarEnd = desktopLayout.indexOf("</aside>", sidebarStart);
const sidebarTemplate = desktopLayout.slice(sidebarStart, sidebarEnd);
expect(sidebarTemplate.indexOf('v-if="adminNavigationItems.length"')).toBeLessThan(
sidebarTemplate.indexOf('v-if="projectNavigationItems.length"'),
);
expect(sidebarTemplate).toContain('@click="toggleNavigationGroup(item)"');
expect(sidebarTemplate).toContain(':aria-expanded="isNavigationGroupExpanded(item)"');
expect(sidebarTemplate).toContain('v-show="isNavigationGroupExpanded(item)"');
expect(desktopLayout).toContain("const expandedNavigationGroups = ref<Set<string>>(new Set());");
expect(desktopLayout).toContain("const collapsedNavigationGroups = ref<Set<string>>(new Set());");
expect(desktopLayout).toContain("isNavigationGroupActive(item) && !collapsedNavigationGroups.value.has(key)");
expect(desktopLayout).toContain("nextCollapsed.add(key);");
expect(desktopLayout).toContain("nextCollapsed.delete(key);");
expect(navigation).toContain('label: "系统设置"');
expect(navigation).toContain('label: "邮件服务"');
expect(navigation).toContain('group: "系统设置"');
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("shows the real notification delivery path and keeps native notification access constrained", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopNotificationManager = readDesktopNotificationManagerSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
expect(serverSettings).toContain("connectionDiagnostic");
expect(serverSettings).not.toContain("复制连接诊断");
expect(serverSettings).not.toContain("copyConnectionDiagnostic");
expect(serverSettings).toContain("确认切换服务器");
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
expect(serverSettings).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(serverSettings).not.toContain("服务器连接已确认");
expect(preferences).toContain("connectionDiagnostic");
expect(preferences).not.toContain("复制连接诊断");
expect(preferences).not.toContain("copyConnectionDiagnostic");
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(preferences).toContain("系统权限:{{ notificationPermissionText }}");
expect(preferences).toContain("服务端订阅");
expect(preferences).toContain("真实业务路径");
expect(preferences).toContain("授权并开启");
expect(preferences).toContain("重新检测权限");
expect(preferences).toContain("立即检查业务提醒");
expect(preferences).toContain("系统设置 → 通知 → CTMS");
expect(preferences).toContain("设置 → 系统 → 通知 → CTMS");
expect(preferences).toContain("showSystemNotificationProbe");
expect(preferences).toContain("sendDesktopNotificationTest");
expect(preferences).toContain("发送测试通知");
expect(preferences).toContain("openProjectNotificationCenter");
expect(preferences).toContain("listenDesktopNotificationStatus");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/"));
expect(desktopNotificationManager).toContain("claimDesktopNotifications");
expect(desktopNotificationManager).toContain("acknowledgeDesktopNotifications");
expect(desktopNotificationManager).toContain('state: "permission-required"');
expect(desktopNotificationManager).toContain('state: deliveredIds.length ? "delivered" : "ready"');
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
expect(desktopLayout).toContain("新版本 ${pending.version}");
});
it("routes user-facing file actions through feedback helpers", () => {
const helper = readFileTaskFeedbackSource();
const attachments = readAttachmentListSource();
const documentDetail = readDocumentDetailSource();
expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback");
expect(helper).toContain("export const openFileWithFeedback");
expect(helper).toContain("startDesktopActivity");
expect(helper).toContain("finishDesktopActivity");
expect(helper).toContain("desktopFileActivitiesEnabled");
expect(attachments).toContain("pickFilesWithFeedback");
expect(attachments).toContain("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback");
expect(attachments).toContain("startDesktopActivity");
expect(attachments).toContain('title: "下载附件"');
expect(attachments).toContain('title: "上传附件"');
expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback");
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
});
it("keeps attachment upload mode flat and responsive across web and desktop", () => {
const attachments = readAttachmentListSource();
expect(attachments).not.toContain('class="attachment-list-panel unified-shell"');
expect(attachments).toContain("'unified-shell': displayMode === 'table'");
expect(attachments).toContain("'attachment-list-panel--upload': displayMode === 'upload'");
expect(attachments).toContain("'upload-grid--single': uploadGroups.length === 1");
expect(attachments).toContain(".upload-grid--single {");
expect(attachments).toContain('class="pending-file-info"');
expect(attachments).toContain('class="pending-file-meta"');
});
it("surfaces persistent desktop activity feedback without local task persistence", () => {
const desktopLayout = readDesktopLayoutSource();
const activityCenter = readDesktopActivityCenterSource();
const updateManager = readDesktopUpdateManagerSource();
expect(desktopLayout).toContain('popper-class="desktop-activity-popover"');
expect(desktopLayout).toContain("desktopActivities");
expect(desktopLayout).toContain("listenDesktopActivities");
expect(desktopLayout).toContain("clearFinishedDesktopActivities");
expect(desktopLayout).toContain("desktopActivityBadgeCount");
expect(activityCenter).toContain("DESKTOP_ACTIVITY_CHANGED_EVENT");
expect(activityCenter).toContain("safeActivityText");
expect(activityCenter).toContain("unsafeActivityTextPattern");
expect(activityCenter).not.toContain("localStorage");
expect(activityCenter).not.toContain("sessionStorage");
expect(updateManager).toContain('title: "检查桌面更新"');
expect(updateManager).toContain('title: "安装桌面更新"');
expect(updateManager).toContain("finishUpdateActivity");
});
it("keeps desktop business pages dense and workbench-like", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop workbench density */");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.page");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.ctms-page-shell");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-body");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-inner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .overview");
expect(styles).toContain("padding: 0 !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .table-card");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .hero-banner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .faq-hero");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-bg-dots");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .module-placeholder-surface");
expect(styles).toContain("box-shadow: none !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell");
expect(styles).toContain("height: 34px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-radio-button__inner");
expect(styles).toContain("display: inline-flex;");
expect(styles).toContain("align-items: center;");
expect(styles).toContain("justify-content: center;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
});
it("keeps routed desktop pages stretched to the content boundary", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain(".desktop-toolbar {\n grid-row: 1;");
expect(layout).toContain(".workspace-tabs {\n grid-row: 2;");
expect(layout).toContain(".desktop-content {\n grid-row: 3;");
expect(layout).toContain(".desktop-route-shell {");
expect(layout).toContain("width: 100%;");
expect(layout).toContain("max-width: 100%;");
expect(layout).toContain("box-sizing: border-box;");
});
it("keeps authentication styles self-contained for desktop CSP", () => {
const sources = [
readMainStyleSource(),
readLoginSource(),
readRegisterSource(),
readForgotPasswordSource(),
].join("\n");
expect(sources).not.toContain("fonts.googleapis.com");
expect(sources).not.toContain("fonts.gstatic.com");
expect(sources).not.toMatch(/@import\s+url\(["']https?:\/\//);
});
it("renders Element Plus modals with desktop-native surfaces in the desktop runtime", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop runtime modal surfaces */");
expect(styles).toContain("body.is-desktop-runtime .el-overlay");
expect(styles).toContain("left: 0 !important;");
expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-dialog");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box");
expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog),");
expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog__body) {");
expect(styles).toContain("body.is-desktop-runtime .el-message-box");
expect(styles).toContain("border-radius: 12px;");
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__header");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {\n height: 700px;");
expect(styles).toContain("max-height: calc(100vh - 96px);");
expect(styles).toContain("padding: 0;");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__btns");
expect(styles).toContain("body.is-desktop-runtime .dialog-fade-enter-from .el-dialog");
expect(styles).toContain("body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box");
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog');
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog');
});
it("shares the medical-consult workbench UI across web and desktop shells", () => {
const faq = readFaqSource();
const faqList = readFaqListSource();
const projectOverview = readProjectOverviewSource();
const projectMilestones = readProjectMilestonesSource();
const webLayout = readWebLayoutSource();
expect(faq).toContain("medical-consult-page--workbench");
expect(faq).toContain('class="workbench-toolbar-meta"');
expect(faq).toContain('<PermissionAction action="faq.create">');
expect(faq).not.toContain("const isDesktop = isTauriRuntime();");
expect(faq).not.toContain("page-bg-dots");
expect(faq).not.toContain("list-toolbar");
expect(faq).toContain(".medical-consult-page--workbench .faq-workspace");
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
expect(faqList).toContain('class="faq-list-card faq-list-card--workbench"');
expect(faqList).toContain('height="100%"');
expect(faqList).not.toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--workbench .faq-table");
expect(projectOverview).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).not.toContain("项目关注");
expect(projectOverview).not.toContain("desktop-attention-head");
expect(projectOverview).toContain('class="overview-live-clock">数据 {{ overviewClockText }}</span>');
expect(projectOverview).toContain('v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-live-clock"'));
expect(projectOverview.indexOf('class="overview-live-clock"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain("overviewClockTimer = window.setInterval");
expect(webLayout).not.toContain("headerClockText");
expect(webLayout).not.toContain('key: "updatedAt"');
expect(webLayout).not.toContain('class="header-center"');
expect(webLayout).not.toContain("headerProjectInfo");
expect(webLayout).not.toContain("fetchProjectOverview");
expect(webLayout).not.toContain("loadHeaderOverviewStats");
expect(webLayout).not.toContain("header-status-pill");
expect(webLayout).not.toContain("header-meta-item");
expect(projectMilestones).toContain('class="unified-action-bar milestone-action-bar"');
expect(projectMilestones).not.toContain('class="unified-action-bar bar--flush"');
expect(projectMilestones).not.toContain("sourceLabel");
expect(projectMilestones).not.toContain("dataSourceLabel");
expect(projectMilestones).not.toContain("updatedAtLabel");
expect(projectMilestones).toContain(".milestone-action-bar {\n min-height: 56px;\n box-sizing: border-box;");
expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board");
expect(projectOverview).toContain("const stageStatusSummary");
expect(projectOverview).toContain("const activeStageItems");
expect(projectOverview).toContain("const attentionItems");
expect(projectOverview).toContain("grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);");
expect(projectOverview).toContain("max-height: min(360px, calc(100vh - 300px));");
expect(projectOverview).toContain("@media (max-width: 1240px)");
expect(projectOverview).toContain(".project-overview--desktop .overview-card");
expect(projectOverview).toContain(".project-overview--desktop .overview-card--enrollment");
expect(projectOverview).toContain(".project-overview--desktop :deep(.center-row)");
expect(projectOverview).toContain(".project-overview--desktop :deep(.stage-node)");
});
it("keeps desktop entry surfaces stable at the minimum window size", () => {
const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource();
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(login).toContain("@media (max-width: 1280px)");
expect(login).toContain("width: min(440px, 100%);");
expect(login).toContain("overflow-wrap: anywhere;");
expect(login).toContain("white-space: nowrap;");
expect(serverSettings).toContain("max-height: calc(100vh - 64px);");
expect(serverSettings).toContain("overflow: auto;");
expect(serverSettings).toContain(".diagnostic-grid code");
expect(profile).toContain('<div class="profile-layout">');
expect(profile).not.toContain('class="page"');
expect(profile).toContain("height: min(720px, calc(100vh - 64px));");
expect(profile).toContain("overflow: hidden;");
expect(profile).not.toContain("overflow: auto;");
expect(profile).toContain("overflow-wrap: anywhere;");
expect(profile).toContain(".dialog-close:hover");
expect(profile).toContain(".dialog-close :deep(.el-icon)");
expect(preferences).toContain("flex-wrap: wrap;");
expect(preferences).toContain("overflow-wrap: anywhere;");
});
});
+4 -2
View File
@@ -4,9 +4,11 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { isTauriRuntime } from "../runtime";
import DesktopLayout from "./DesktopLayout.vue";
import WebLayout from "./WebLayout.vue";
const DesktopLayout = defineAsyncComponent(() => import("./DesktopLayout.vue"));
const WebLayout = defineAsyncComponent(() => import("./WebLayout.vue"));
const isDesktop = isTauriRuntime();
</script>
@@ -0,0 +1,207 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy, reactive } from "vue";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
vi.mock("../runtime", () => ({
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
}));
import OnlyOfficeViewer from "./OnlyOfficeViewer.vue";
const dispatchHostMessage = (source: MessageEventSource | null, origin: string, data: Record<string, unknown>) => {
window.dispatchEvent(new MessageEvent("message", { source, origin, data }));
};
describe("OnlyOfficeViewer bridge", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("initializes when the host iframe finishes loading without waiting for host-ready", async () => {
const config = reactive({ token: "signed-config", document: { title: "原始文件名.xlsx" } });
expect(isProxy(config)).toBe(true);
const wrapper = mount(OnlyOfficeViewer, { props: { config } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(postMessage).toHaveBeenCalledOnce();
expect(postMessage.mock.calls[0][0]).toMatchObject({
type: "ctms.onlyoffice.init",
config: { token: "signed-config", document: { title: "原始文件名.xlsx" } },
});
expect(isProxy((postMessage.mock.calls[0][0] as { config: object }).config)).toBe(false);
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
wrapper.unmount();
});
it("reports a host initialization error when the config cannot be posted", async () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const frameWindow = {
postMessage: vi.fn(() => {
throw new DOMException("could not be cloned", "DataCloneError");
}),
} as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_INIT_FAILED" });
wrapper.unmount();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("accepts one initialization from the exact host window and validates the nonce", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
const initMessage = postMessage.mock.calls[0][0] as { type: string; nonce: string; config: Record<string, unknown> };
expect(initMessage.type).toBe("ctms.onlyoffice.init");
expect(initMessage.config).toEqual({ token: "signed-config" });
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: "wrong",
});
expect(wrapper.emitted("ready")).toBeUndefined();
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("ready")).toHaveLength(1);
wrapper.unmount();
});
it("rejects messages with the wrong origin or source", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://evil.example", { type: "ctms.onlyoffice.host-ready" });
dispatchHostMessage(window, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).not.toHaveBeenCalled();
wrapper.unmount();
});
it("enables clipboard only for the explicit collaboration editor mode", () => {
const preview = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(preview.get("iframe").attributes("allow")).toContain("'none'");
preview.unmount();
const editor = mount(OnlyOfficeViewer, { props: { config: {}, allowClipboard: true } });
expect(editor.get("iframe").attributes("allow")).toBe("clipboard-read; clipboard-write");
editor.unmount();
});
it("permits iframe downloads only when the document capability allows them", () => {
const restricted = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(restricted.get("iframe").attributes("sandbox")).not.toContain("allow-downloads");
restricted.unmount();
const downloadable = mount(OnlyOfficeViewer, {
props: { config: {}, allowDownload: true },
});
expect(downloadable.get("iframe").attributes("sandbox")).toContain("allow-downloads");
downloadable.unmount();
});
it("reports host and document timeouts without falling back to a download", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
vi.advanceTimersByTime(15_000);
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
wrapper.unmount();
});
it("forwards a nonce-bound edit-right request from ONLYOFFICE", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as { nonce: string };
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.request-edit-rights",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("requestEditRights")).toHaveLength(1);
wrapper.unmount();
});
it("forwards independent Save As and download capabilities and accepts only nonce-bound file bytes", () => {
const wrapper = mount(OnlyOfficeViewer, {
props: { config: {}, allowSaveAs: true, allowDownload: true },
});
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as {
nonce: string;
allowSaveAs: boolean;
allowDownload: boolean;
};
expect(initMessage.allowSaveAs).toBe(true);
expect(initMessage.allowDownload).toBe(true);
const data = new ArrayBuffer(8);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.save-as",
nonce: initMessage.nonce,
detail: { fileType: "xlsx", title: "副本.xlsx", mimeType: "application/octet-stream", data },
});
expect(wrapper.emitted("saveAs")?.[0]?.[0]).toMatchObject({ fileType: "xlsx", title: "副本.xlsx", data });
const downloadData = new ArrayBuffer(12);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.download",
nonce: initMessage.nonce,
detail: {
fileType: "xlsx",
title: "原始文件.xlsx",
mimeType: "application/octet-stream",
data: downloadData,
},
});
expect(wrapper.emitted("download")?.[0]?.[0]).toMatchObject({
fileType: "xlsx",
title: "原始文件.xlsx",
data: downloadData,
});
wrapper.unmount();
});
it("keeps Save As URLs inside the isolated host and only posts validated bytes to the parent", () => {
const hostSource = readFileSync(resolve(__dirname, "../../public/onlyoffice-host.js"), "utf8");
expect(hostSource).toContain("events.onRequestSaveAs = handleRequestSaveAs");
expect(hostSource).toContain("events.onDownloadAs = handleDownloadAs");
expect(hostSource).toContain('url.origin !== window.location.origin');
expect(hostSource).toContain('url.pathname.startsWith("/onlyoffice/")');
expect(hostSource).toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])");
expect(hostSource).toContain("postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data])");
expect(hostSource).not.toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, url");
});
});
@@ -0,0 +1,216 @@
<template>
<div class="onlyoffice-viewer">
<iframe
:key="nonce"
ref="frameRef"
class="onlyoffice-viewer__frame"
:src="hostUrl.href"
:title="frameTitle"
:sandbox="sandboxPolicy"
:allow="allowPolicy"
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { resolveOnlyOfficeHostUrl } from "../runtime";
import type { OnlyOfficeHostMessage, OnlyOfficeSaveAsPayload } from "../types/onlyoffice";
const HOST_READY = "ctms.onlyoffice.host-ready";
const INIT = "ctms.onlyoffice.init";
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
const WARNING = "ctms.onlyoffice.warning";
const ERROR = "ctms.onlyoffice.error";
const SAVE_AS = "ctms.onlyoffice.save-as";
const SAVE_AS_ERROR = "ctms.onlyoffice.save-as-error";
const DOWNLOAD = "ctms.onlyoffice.download";
const DOWNLOAD_ERROR = "ctms.onlyoffice.download-error";
const REQUEST_EDIT_RIGHTS = "ctms.onlyoffice.request-edit-rights";
const HOST_LOAD_TIMEOUT_MS = 15_000;
const DOCUMENT_LOAD_TIMEOUT_MS = 120_000;
const props = withDefaults(defineProps<{
config: Record<string, unknown>;
allowClipboard?: boolean;
allowSaveAs?: boolean;
allowDownload?: boolean;
frameTitle?: string;
}>(), {
allowClipboard: false,
allowSaveAs: false,
allowDownload: false,
frameTitle: "ONLYOFFICE 文档查看器",
});
const emit = defineEmits<{
ready: [];
warning: [detail: Record<string, unknown>];
error: [detail: Record<string, unknown>];
stateChange: [changed: boolean];
saveAs: [payload: OnlyOfficeSaveAsPayload];
saveAsError: [message: string];
download: [payload: OnlyOfficeSaveAsPayload];
downloadError: [message: string];
requestEditRights: [];
}>();
const frameRef = ref<HTMLIFrameElement | null>(null);
const hostUrl = resolveOnlyOfficeHostUrl();
const allowPolicy = props.allowClipboard
? "clipboard-read; clipboard-write"
: "clipboard-read 'none'; clipboard-write 'none'";
const sandboxPolicy = computed(() => [
"allow-scripts",
"allow-same-origin",
"allow-forms",
"allow-popups",
"allow-modals",
...(props.allowDownload ? ["allow-downloads"] : []),
].join(" "));
const createNonce = () => {
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 nonce = createNonce();
let initialized = false;
let hostTimer: number | undefined;
let documentTimer: number | undefined;
const clearTimers = () => {
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
hostTimer = undefined;
documentTimer = undefined;
};
const emitTimeout = (code: string, message: string) => {
clearTimers();
initialized = true;
emit("error", { code, message });
};
const initializeHost = () => {
if (initialized) return;
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow) return;
try {
// Vue 会将接口响应递归包装为 Proxy,而 Proxy 不能通过 structured clone
// 传递给 iframe。JSON 配置本身只包含 ONLYOFFICE 支持的 JSON 类型。
const config = JSON.parse(JSON.stringify(toRaw(props.config))) as Record<string, unknown>;
frameWindow.postMessage({
type: INIT,
nonce,
config,
allowSaveAs: props.allowSaveAs,
allowDownload: props.allowDownload,
}, hostUrl.origin);
} catch {
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
return;
}
initialized = true;
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
hostTimer = undefined;
documentTimer = window.setTimeout(
() => emitTimeout("DOCUMENT_LOAD_TIMEOUT", "文档转换或加载超时,请稍后重试"),
DOCUMENT_LOAD_TIMEOUT_MS,
);
};
// iframe 的 load 事件发生在宿主页 defer 脚本完成执行之后,可以直接初始化。
// host-ready 仍作为慢加载和桌面 WebView 场景的兜底握手。
const handleFrameLoad = initializeHost;
const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow || event.source !== frameWindow || event.origin !== hostUrl.origin) return;
const message = event.data;
if (!message || typeof message !== "object" || typeof message.type !== "string") return;
if (message.type === HOST_READY && !initialized) {
initializeHost();
return;
}
if (message.nonce !== nonce) return;
if (message.type === DOCUMENT_READY) {
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
documentTimer = undefined;
emit("ready");
} else if (message.type === WARNING) {
emit("warning", message.detail || {});
} else if (message.type === ERROR) {
clearTimers();
emit("error", message.detail || {});
} else if (message.type === "ctms.onlyoffice.document-state-change") {
emit("stateChange", Boolean(message.detail?.changed));
} else if (message.type === SAVE_AS) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("saveAsError", "另存为文件数据无效");
return;
}
emit("saveAs", { fileType, title, mimeType, data });
} else if (message.type === SAVE_AS_ERROR) {
emit("saveAsError", typeof message.detail?.message === "string" ? message.detail.message : "另存为失败");
} else if (message.type === DOWNLOAD) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("downloadError", "下载文件数据无效");
return;
}
emit("download", { fileType, title, mimeType, data });
} else if (message.type === DOWNLOAD_ERROR) {
emit("downloadError", typeof message.detail?.message === "string" ? message.detail.message : "下载失败");
} else if (message.type === REQUEST_EDIT_RIGHTS) {
emit("requestEditRights");
}
};
onMounted(() => {
window.addEventListener("message", handleMessage);
hostTimer = window.setTimeout(
() => emitTimeout("HOST_LOAD_TIMEOUT", "预览服务加载超时,请检查服务状态后重试"),
HOST_LOAD_TIMEOUT_MS,
);
});
onBeforeUnmount(() => {
clearTimers();
window.removeEventListener("message", handleMessage);
if (frameRef.value) frameRef.value.src = "about:blank";
});
</script>
<style scoped>
.onlyoffice-viewer,
.onlyoffice-viewer__frame {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.onlyoffice-viewer {
overflow: hidden;
background: #f3f5f8;
}
.onlyoffice-viewer__frame {
display: block;
border: 0;
background: #fff;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const getDocumentMock = vi.hoisted(() => vi.fn());
const renderMock = vi.hoisted(() => vi.fn());
const destroyMock = vi.hoisted(() => vi.fn());
vi.mock("pdfjs-dist", () => ({
GlobalWorkerOptions: { workerSrc: "" },
getDocument: getDocumentMock,
}));
import PdfViewer from "./PdfViewer.vue";
describe("PdfViewer", () => {
beforeEach(() => {
vi.clearAllMocks();
renderMock.mockReturnValue({ promise: Promise.resolve(), cancel: vi.fn() });
destroyMock.mockResolvedValue(undefined);
getDocumentMock.mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
render: renderMock,
}),
destroy: destroyMock,
}),
destroy: destroyMock,
});
});
it("renders PDF pages to canvas without an iframe", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
await flushPromises();
expect(getDocumentMock).toHaveBeenCalledWith({ url: "blob:test-pdf" });
expect(wrapper.findAll("canvas")).toHaveLength(2);
expect(wrapper.find("iframe").exists()).toBe(false);
expect(renderMock).toHaveBeenCalled();
expect(wrapper.text()).toContain("/ 2");
await wrapper.get("button:nth-of-type(2)").trigger("click");
await flushPromises();
expect(wrapper.text()).toContain("/ 2");
wrapper.unmount();
await flushPromises();
expect(destroyMock).toHaveBeenCalled();
});
it("integrates the dialog close action into the viewer toolbar", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf", showClose: true } });
await flushPromises();
await wrapper.get('[aria-label="关闭预览"]').trigger("click");
expect(wrapper.emitted("close")).toHaveLength(1);
});
it("keeps the visible document center anchored while zooming", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
await flushPromises();
const viewport = wrapper.get(".pdf-viewer__viewport").element as HTMLElement;
Object.defineProperties(viewport, {
clientWidth: { configurable: true, value: 800 },
clientHeight: { configurable: true, value: 600 },
scrollWidth: {
configurable: true,
get: () => Math.max(800, Number.parseInt(wrapper.get("canvas").element.style.width, 10) || 0),
},
scrollHeight: {
configurable: true,
get: () => Math.max(600, Number.parseInt(wrapper.get("canvas").element.style.height, 10) || 0),
},
});
await wrapper.get("button.is-active").trigger("click");
await flushPromises();
await wrapper.get('[aria-label="放大"]').trigger("click");
await flushPromises();
expect(viewport.scrollLeft).toBeGreaterThan(0);
expect(viewport.scrollTop).toBeGreaterThan(0);
});
it("shows the original filename in the center of the toolbar", async () => {
const wrapper = mount(PdfViewer, {
props: { src: "blob:test-pdf", filename: "研究方案最终版.pdf" },
});
await flushPromises();
expect(wrapper.get(".pdf-viewer__filename").text()).toBe("研究方案最终版.pdf");
expect(wrapper.text()).not.toContain("页文档");
});
it("updates the current page while scrolling through the continuous document", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
await flushPromises();
const viewport = wrapper.get(".pdf-viewer__viewport");
const pages = wrapper.findAll(".pdf-viewer__page");
Object.defineProperties(viewport.element, {
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 850 },
});
Object.defineProperties(pages[0].element, {
offsetTop: { configurable: true, value: 0 },
offsetHeight: { configurable: true, value: 800 },
});
Object.defineProperties(pages[1].element, {
offsetTop: { configurable: true, value: 816 },
offsetHeight: { configurable: true, value: 800 },
});
await viewport.trigger("scroll");
expect((wrapper.get(".pdf-viewer__page-input").element as HTMLInputElement).value).toBe("2");
});
});
+835
View File
@@ -0,0 +1,835 @@
<template>
<section class="pdf-viewer" aria-label="PDF 预览器">
<header class="pdf-viewer__toolbar">
<div class="pdf-viewer__group" aria-label="翻页控制">
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber <= 1" aria-label="上一页" title="上一页" @click="previousPage">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 15-5-5 5-5" /></svg>
</button>
<label class="pdf-viewer__page-control">
<span class="sr-only">当前页</span>
<input
v-model.number="pageInput"
class="pdf-viewer__page-input"
type="number"
min="1"
:max="pageCount || 1"
:disabled="busy || !pageCount"
@change="commitPageInput"
@keyup.enter="commitPageInput"
/>
<span class="pdf-viewer__page-total">/ {{ pageCount || 0 }}</span>
</label>
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber >= pageCount" aria-label="下一页" title="下一页" @click="nextPage">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 5 5 5-5 5" /></svg>
</button>
</div>
<div class="pdf-viewer__document-meta" :title="filename || 'PDF 文档'">
<span class="pdf-viewer__filename">{{ filename || "PDF 文档" }}</span>
</div>
<div class="pdf-viewer__actions">
<div class="pdf-viewer__group" aria-label="缩放控制">
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent <= MIN_ZOOM" aria-label="缩小" title="缩小" @click="zoomOut">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10" /></svg>
</button>
<span class="pdf-viewer__zoom">{{ renderedScalePercent }}%</span>
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent >= MAX_ZOOM" aria-label="放大" title="放大" @click="zoomIn">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10M10 5v10" /></svg>
</button>
<button type="button" class="pdf-viewer__button" :class="{ 'is-active': fitWidth }" :disabled="busy" @click="useFitWidth">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 6v8M16.5 6v8M6.5 10h7M8.5 8l-2 2 2 2M11.5 8l2 2-2 2" /></svg>
适合宽度
</button>
</div>
<span v-if="showClose" class="pdf-viewer__divider" aria-hidden="true" />
<button v-if="showClose" type="button" class="pdf-viewer__button pdf-viewer__button--icon pdf-viewer__close" aria-label="关闭预览" title="关闭预览" @click="emit('close')">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15" /></svg>
</button>
</div>
</header>
<div ref="viewportRef" class="pdf-viewer__viewport" @scroll.passive="handleViewportScroll">
<div v-if="errorMessage" class="pdf-viewer__state pdf-viewer__state--error" role="alert">
{{ errorMessage }}
</div>
<div v-else-if="loadingDocument" class="pdf-viewer__state" aria-live="polite">
<span class="pdf-viewer__spinner" aria-hidden="true" />
<span>正在加载 PDF…</span>
</div>
<div v-else class="pdf-viewer__pages">
<article
v-for="pageIndex in pageCount"
:key="pageIndex"
:ref="(element) => setPageElement(element, pageIndex)"
class="pdf-viewer__page"
:class="{ 'is-rendering': renderingPages.has(pageIndex) }"
:data-page-number="pageIndex"
>
<canvas
:ref="(element) => setCanvasElement(element, pageIndex)"
class="pdf-viewer__canvas"
:aria-label="`PDF 第 ${pageIndex} 页`"
/>
<div v-if="renderingPages.has(pageIndex)" class="pdf-viewer__rendering" aria-live="polite">
<span class="pdf-viewer__spinner pdf-viewer__spinner--small" aria-hidden="true" />
正在渲染第 {{ pageIndex }} 页
</div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
getDocument,
GlobalWorkerOptions,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
type PDFPageProxy,
type RenderTask,
} from "pdfjs-dist";
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
const MIN_ZOOM = 25;
const MAX_ZOOM = 300;
const ZOOM_STEP = 25;
const props = defineProps<{
src: string;
filename?: string;
showClose?: boolean;
}>();
const emit = defineEmits<{
close: [];
}>();
const viewportRef = ref<HTMLElement | null>(null);
const loadingDocument = ref(false);
const renderingPages = ref(new Set<number>());
const errorMessage = ref("");
const pageNumber = ref(1);
const pageInput = ref(1);
const pageCount = ref(0);
const zoomPercent = ref(100);
const renderedScalePercent = ref(100);
const fitWidth = ref(true);
let loadingTask: PDFDocumentLoadingTask | null = null;
let pdfDocument: PDFDocumentProxy | null = null;
const renderTasks = new Map<number, RenderTask>();
const canvasElements = new Map<number, HTMLCanvasElement>();
const pageElements = new Map<number, HTMLElement>();
const pageScales = new Map<number, number>();
const pageLayouts = new Map<number, {
pageIndex: number;
page: PDFPageProxy;
canvas: HTMLCanvasElement;
viewport: ReturnType<PDFPageProxy["getViewport"]>;
outputScale: number;
}>();
const renderedPages = new Set<number>();
let loadSequence = 0;
let renderSequence = 0;
let resizeObserver: ResizeObserver | null = null;
let resizeFrame = 0;
const busy = computed(() => loadingDocument.value || !pdfDocument);
const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value));
const describePdfError = (error: unknown) => {
const name = error instanceof Error ? error.name : "";
if (name === "PasswordException") return "该 PDF 已加密,暂时无法在线预览";
if (name === "InvalidPDFException") return "PDF 文件格式无效或已损坏";
return "PDF 加载失败,请下载后查看";
};
const setCanvasElement = (element: unknown, pageIndex: number) => {
if (element instanceof HTMLCanvasElement) canvasElements.set(pageIndex, element);
else canvasElements.delete(pageIndex);
};
const setPageElement = (element: unknown, pageIndex: number) => {
if (element instanceof HTMLElement) pageElements.set(pageIndex, element);
else pageElements.delete(pageIndex);
};
const cancelRenderTasks = () => {
renderTasks.forEach((task) => task.cancel());
renderTasks.clear();
};
const updateRenderingPage = (pageIndex: number, rendering: boolean) => {
const nextPages = new Set(renderingPages.value);
if (rendering) nextPages.add(pageIndex);
else nextPages.delete(pageIndex);
renderingPages.value = nextPages;
};
const updateCurrentPage = (value: number) => {
const nextPage = clamp(value, 1, pageCount.value || 1);
pageNumber.value = nextPage;
pageInput.value = nextPage;
const scale = pageScales.get(nextPage);
if (scale) renderedScalePercent.value = Math.round(scale * 100);
};
const renderPage = async (pageIndex: number, sequence: number) => {
if (sequence !== renderSequence || renderedPages.has(pageIndex) || renderTasks.has(pageIndex)) return;
const layout = pageLayouts.get(pageIndex);
if (!layout) return;
const { page, canvas, viewport, outputScale } = layout;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
updateRenderingPage(pageIndex, true);
const task = page.render({
canvas,
viewport,
background: "#ffffff",
transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
});
renderTasks.set(pageIndex, task);
try {
await task.promise;
if (sequence === renderSequence) renderedPages.add(pageIndex);
} catch (error) {
if (sequence === renderSequence && !(error instanceof Error && error.name === "RenderingCancelledException")) {
errorMessage.value = describePdfError(error);
}
} finally {
if (renderTasks.get(pageIndex) === task) {
renderTasks.delete(pageIndex);
if (sequence === renderSequence) updateRenderingPage(pageIndex, false);
}
}
};
const renderVisiblePages = async (sequence = renderSequence) => {
const viewport = viewportRef.value;
if (!viewport || sequence !== renderSequence) return;
const viewportHeight = Math.max(viewport.clientHeight, 1);
const renderTop = Math.max(0, viewport.scrollTop - viewportHeight);
const renderBottom = viewport.scrollTop + viewportHeight * 2;
const retainTop = Math.max(0, viewport.scrollTop - viewportHeight * 3);
const retainBottom = viewport.scrollTop + viewportHeight * 4;
const pagesToRender: number[] = [];
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
const pageElement = pageElements.get(pageIndex);
if (!pageElement) continue;
const pageTop = pageElement.offsetTop;
const pageBottom = pageTop + pageElement.offsetHeight;
if (pageBottom >= renderTop && pageTop <= renderBottom) pagesToRender.push(pageIndex);
if (renderedPages.has(pageIndex) && (pageBottom < retainTop || pageTop > retainBottom)) {
const canvas = canvasElements.get(pageIndex);
if (canvas) {
canvas.width = 1;
canvas.height = 1;
}
renderedPages.delete(pageIndex);
}
}
await Promise.all(pagesToRender.map((pageIndex) => renderPage(pageIndex, sequence)));
};
const renderAllPages = async (preservePosition = true) => {
const sequence = ++renderSequence;
const document = pdfDocument;
const viewportElement = viewportRef.value;
if (!document || !viewportElement || errorMessage.value) return;
cancelRenderTasks();
renderingPages.value = new Set();
renderedPages.clear();
pageLayouts.clear();
try {
const previousScrollWidth = Math.max(viewportElement.scrollWidth, viewportElement.clientWidth);
const previousScrollHeight = Math.max(viewportElement.scrollHeight, viewportElement.clientHeight);
const horizontalAnchor = (viewportElement.scrollLeft + viewportElement.clientWidth / 2) / Math.max(previousScrollWidth, 1);
const verticalAnchor = (viewportElement.scrollTop + viewportElement.clientHeight / 2) / Math.max(previousScrollHeight, 1);
const pageNumbers = Array.from({ length: pageCount.value }, (_, index) => index + 1);
const pages = await Promise.all(pageNumbers.map((pageIndex) => document.getPage(pageIndex)));
if (sequence !== renderSequence) return;
const availableWidth = Math.max(240, viewportElement.clientWidth - 2);
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
pages.forEach((page, index) => {
const pageIndex = index + 1;
const canvas = canvasElements.get(pageIndex);
if (!canvas) return;
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = fitWidth.value
? clamp(availableWidth / unscaledViewport.width, MIN_ZOOM / 100, MAX_ZOOM / 100)
: zoomPercent.value / 100;
const viewport = page.getViewport({ scale });
canvas.width = 1;
canvas.height = 1;
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
pageScales.set(pageIndex, scale);
pageLayouts.set(pageIndex, { pageIndex, page, canvas, viewport, outputScale });
});
const currentScale = pageScales.get(pageNumber.value);
if (currentScale) renderedScalePercent.value = Math.round(currentScale * 100);
await nextTick();
if (preservePosition) {
viewportElement.scrollLeft = Math.max(0, horizontalAnchor * viewportElement.scrollWidth - viewportElement.clientWidth / 2);
viewportElement.scrollTop = Math.max(0, verticalAnchor * viewportElement.scrollHeight - viewportElement.clientHeight / 2);
} else {
viewportElement.scrollLeft = 0;
viewportElement.scrollTop = 0;
}
await renderVisiblePages(sequence);
} catch (error) {
if (!(error instanceof Error && error.name === "RenderingCancelledException")) {
errorMessage.value = describePdfError(error);
}
} finally {
if (sequence === renderSequence) {
renderTasks.clear();
renderingPages.value = new Set();
}
}
};
const disposeDocument = async () => {
renderSequence += 1;
cancelRenderTasks();
renderingPages.value = new Set();
canvasElements.clear();
pageElements.clear();
pageScales.clear();
pageLayouts.clear();
renderedPages.clear();
const task = loadingTask;
loadingTask = null;
pdfDocument = null;
await task?.destroy().catch(() => {});
};
const loadPdf = async () => {
const sequence = ++loadSequence;
await disposeDocument();
errorMessage.value = "";
pageCount.value = 0;
pageNumber.value = 1;
pageInput.value = 1;
loadingDocument.value = true;
if (!props.src) {
loadingDocument.value = false;
return;
}
try {
const task = getDocument({ url: props.src });
loadingTask = task;
const document = await task.promise;
if (sequence !== loadSequence) {
await task.destroy();
return;
}
pdfDocument = document;
pageCount.value = document.numPages;
loadingDocument.value = false;
await nextTick();
await renderAllPages(false);
} catch (error) {
if (sequence === loadSequence) errorMessage.value = describePdfError(error);
} finally {
if (sequence === loadSequence) loadingDocument.value = false;
}
};
const scrollToPage = (value: number, behavior: ScrollBehavior = "smooth") => {
const nextPage = clamp(value, 1, pageCount.value || 1);
updateCurrentPage(nextPage);
const viewport = viewportRef.value;
const page = pageElements.get(nextPage);
if (!viewport || !page) return;
if (typeof viewport.scrollTo === "function") viewport.scrollTo({ top: page.offsetTop, behavior });
else viewport.scrollTop = page.offsetTop;
};
const previousPage = () => scrollToPage(pageNumber.value - 1);
const nextPage = () => scrollToPage(pageNumber.value + 1);
const handleViewportScroll = () => {
const viewport = viewportRef.value;
if (!viewport || !pageCount.value) return;
const readingLine = viewport.scrollTop + viewport.clientHeight / 2;
let visiblePage = pageCount.value;
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
const page = pageElements.get(pageIndex);
if (!page) continue;
const pageBottom = page.offsetTop + page.offsetHeight + 8;
if (readingLine < pageBottom) {
visiblePage = pageIndex;
break;
}
}
updateCurrentPage(visiblePage);
void renderVisiblePages();
};
const commitPageInput = () => {
scrollToPage(Number(pageInput.value) || 1);
};
const zoomOut = () => {
fitWidth.value = false;
zoomPercent.value = clamp(renderedScalePercent.value - ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
};
const zoomIn = () => {
fitWidth.value = false;
zoomPercent.value = clamp(renderedScalePercent.value + ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
};
const useFitWidth = () => {
fitWidth.value = true;
void renderAllPages(true);
};
watch(() => props.src, () => void loadPdf(), { immediate: true });
watch(zoomPercent, () => {
if (!fitWidth.value) void renderAllPages(true);
});
onMounted(() => {
if (typeof ResizeObserver === "undefined" || !viewportRef.value) return;
resizeObserver = new ResizeObserver(() => {
if (!fitWidth.value) return;
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(() => void renderAllPages(true));
});
resizeObserver.observe(viewportRef.value);
});
onBeforeUnmount(() => {
loadSequence += 1;
window.cancelAnimationFrame(resizeFrame);
resizeObserver?.disconnect();
void disposeDocument();
});
</script>
<style scoped>
.pdf-viewer {
display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
border: 0;
border-radius: 0;
background: #e8edf2;
}
.pdf-viewer__toolbar {
position: relative;
z-index: 2;
display: grid;
min-height: 52px;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
padding: 7px 12px;
border-bottom: 1px solid #dce2e8;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
}
.pdf-viewer__group {
display: flex;
align-items: center;
justify-self: start;
gap: 3px;
}
.pdf-viewer__actions {
display: flex;
align-items: center;
justify-self: end;
gap: 6px;
}
.pdf-viewer__divider {
width: 1px;
height: 24px;
margin-left: 2px;
background: #e1e6eb;
}
.pdf-viewer__page-control {
display: flex;
align-items: center;
gap: 7px;
margin: 0 4px;
color: #66717e;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.pdf-viewer__button {
display: inline-flex;
min-height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 5px 10px;
border: 0;
border-radius: 6px;
color: #43505e;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 1;
cursor: pointer;
transition: color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.pdf-viewer__button--icon {
width: 32px;
padding: 0;
}
.pdf-viewer__button svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.pdf-viewer__button:hover:not(:disabled) {
color: #2f536d;
background: #e8f0f5;
}
.pdf-viewer__button.is-active {
color: #365d77;
background: #e5eef4;
box-shadow: inset 0 0 0 1px rgba(69, 103, 127, 0.12);
}
.pdf-viewer__button:active:not(:disabled) {
transform: translateY(1px);
}
.pdf-viewer__button:focus-visible,
.pdf-viewer__page-input:focus-visible {
outline: 2px solid rgba(64, 111, 145, 0.35);
outline-offset: 1px;
}
.pdf-viewer__button:disabled {
cursor: not-allowed;
opacity: 0.36;
}
.pdf-viewer__page-input {
width: 42px;
height: 28px;
box-sizing: border-box;
border: 1px solid #d7dde4;
border-radius: 6px;
color: #25313d;
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
text-align: center;
appearance: textfield;
}
.pdf-viewer__page-input::-webkit-inner-spin-button,
.pdf-viewer__page-input::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
.pdf-viewer__page-total {
min-width: 26px;
}
.pdf-viewer__document-meta {
min-width: 0;
max-width: min(38vw, 460px);
justify-self: center;
color: #536171;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.pdf-viewer__filename {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.pdf-viewer__zoom {
min-width: 52px;
color: #42505d;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
}
.pdf-viewer__close {
color: #7a8693;
}
.pdf-viewer__close:hover:not(:disabled) {
color: #344252;
background: #eef2f5;
}
.pdf-viewer__viewport {
position: relative;
min-height: 0;
flex: 1;
overflow: auto;
padding: 0;
background-color: #e7ebf0;
background-image: radial-gradient(rgba(92, 106, 121, 0.12) 0.7px, transparent 0.7px);
background-size: 12px 12px;
scrollbar-color: #aeb7c1 transparent;
scrollbar-width: thin;
}
.pdf-viewer__viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.pdf-viewer__viewport::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: #aeb7c1;
background-clip: padding-box;
}
.pdf-viewer__viewport::-webkit-scrollbar-corner {
background: transparent;
}
.pdf-viewer__pages {
width: max-content;
min-width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pdf-viewer__page {
position: relative;
width: max-content;
flex: 0 0 auto;
}
.pdf-viewer__canvas {
display: block;
border: 1px solid rgba(116, 127, 140, 0.2);
background: #fff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.08),
0 12px 34px rgba(42, 51, 62, 0.2);
transition: opacity 0.18s ease;
}
.pdf-viewer__page.is-rendering .pdf-viewer__canvas {
opacity: 0.76;
}
.pdf-viewer__rendering {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border-radius: 999px;
color: #fff;
background: rgba(41, 52, 64, 0.82);
box-shadow: 0 5px 16px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(8px);
font-size: 12px;
}
.pdf-viewer__state {
display: flex;
min-height: 100%;
align-items: center;
justify-content: center;
gap: 10px;
color: #6b7280;
font-size: 14px;
}
.pdf-viewer__state--error {
color: #c45656;
}
.pdf-viewer__spinner {
width: 19px;
height: 19px;
box-sizing: border-box;
border: 2px solid rgba(69, 103, 127, 0.2);
border-top-color: #45677f;
border-radius: 50%;
animation: pdf-viewer-spin 0.8s linear infinite;
}
.pdf-viewer__spinner--small {
width: 13px;
height: 13px;
border-color: rgba(255, 255, 255, 0.32);
border-top-color: #fff;
}
@keyframes pdf-viewer-spin {
to { transform: rotate(360deg); }
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.pdf-viewer {
min-height: 0;
height: 100%;
}
.pdf-viewer__toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 7px;
padding: 8px;
}
.pdf-viewer__document-meta {
display: none;
}
}
</style>
<style>
.pdf-preview-dialog.el-dialog {
overflow: hidden;
padding: 0;
border: 1px solid rgba(205, 214, 223, 0.8);
border-radius: 16px;
background: #f8fafb;
box-shadow: 0 24px 72px rgba(15, 23, 42, 0.24);
}
.pdf-preview-dialog .el-dialog__header {
margin: 0;
padding: 19px 24px 17px;
border-bottom: 1px solid #e6ebef;
background: #fff;
}
.pdf-preview-dialog .el-dialog__title {
color: #202c38;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.01em;
}
.pdf-preview-dialog .el-dialog__headerbtn {
top: 7px;
right: 8px;
width: 44px;
height: 44px;
border-radius: 9px;
transition: background-color 0.16s ease;
}
.pdf-preview-dialog .el-dialog__headerbtn:hover {
background: #f1f4f7;
}
.pdf-preview-dialog .el-dialog__close {
color: #7a8693;
font-size: 20px;
}
.pdf-preview-dialog .el-dialog__body {
padding: 18px 20px 22px;
}
.pdf-preview-dialog--flush.el-dialog {
height: min(84dvh, 880px);
margin-bottom: 0;
background: #e7ebf0;
}
.pdf-preview-dialog--flush .el-dialog__header {
display: none;
}
.pdf-preview-dialog--flush .el-dialog__body {
height: 100%;
box-sizing: border-box;
padding: 0;
}
.pdf-preview-dialog--flush .preview-body,
.pdf-preview-dialog--flush .pdf-viewer {
height: 100%;
min-height: 0;
}
@media (max-width: 760px) {
.pdf-preview-dialog--flush.el-dialog {
width: calc(100vw - 16px) !important;
height: calc(100dvh - 16px);
margin-top: 8px;
}
.pdf-preview-dialog .el-dialog__header {
padding: 16px 18px 14px;
}
.pdf-preview-dialog .el-dialog__body {
padding: 12px;
}
.pdf-preview-dialog--flush .el-dialog__body {
padding: 0;
}
}
</style>
@@ -15,18 +15,38 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain('parts.join(" / ")');
});
it("renders user behavior audit instead of endpoint table or region detail", () => {
it("renders access log audit with IP ranking as a dialog instead of side panels", () => {
const source = readSource();
expect(source).toContain("访问日志");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessDialogLines");
expect(source).toContain("log-surface");
expect(source).toContain("access-table");
expect(source).toContain("filters.keyword");
expect(source).toContain("clientSourceLabel");
expect(source).toContain("IP访问排行");
expect(source).toContain("接口访问审计日志");
expect(source).toContain("terminal-lines");
expect(source).toContain("keyword");
expect(source).toContain("ipRankingDialogVisible");
expect(source).toContain("openIpRankingDialog");
expect(source).toContain('<TimeRangeSelector v-model="ipRankingPeriod"');
expect(source).toContain('class="access-toolbar"');
expect(source).toContain('class="access-toolbar-actions"');
expect(source).toContain('@click="loadData">刷新');
expect(source).not.toContain("来自 permission_access_logs");
expect(source).not.toContain("安全访问只记录匿名、无效令牌和异常状态请求");
expect(source).not.toContain("audit-ranking-section");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("log-mode-card");
expect(source).not.toContain("raw-log-panel");
expect(source).not.toContain("终端式访问流水");
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
expect(source).not.toContain("来源地域明细");
expect(source).not.toContain('prop="endpoint_key" label="接口"');
expect(source).not.toContain("fetchIpLocations");
expect(source).toContain("访问日志加载失败,表格中可能是上次成功获取的数据");
expect(source).toContain("lastUpdatedAt");
expect(source).not.toContain("logs.value = []");
});
it("exposes QA and CTA in role filter presets", () => {
@@ -47,24 +67,43 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain('label="医学审核" value="QA"');
});
it("uses narrow metric cards without helper subtitles", () => {
it("integrates compact metrics into the access overview without helper subtitles", () => {
const source = readSource();
expect(source).toContain("min-height: 64px");
expect(source).toContain("padding: 10px 16px");
expect(source).toContain('class="access-overview"');
expect(source).toContain('aria-label="访问指标"');
expect(source).toContain("min-height: 44px");
expect(source).toContain("padding: 5px 12px");
expect(source).toContain("border-left: 1px solid #edf1f6");
expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:");
expect(source).not.toContain("筛选范围内行为总量");
});
it("keeps audit grid widths consistent with SecurityCenter", () => {
it("uses a full-width log layout instead of the old left-right split", () => {
const source = readSource();
expect(source).toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
expect(source).toContain("access-log-card");
expect(source).toContain("log-surface");
expect(source).toContain("@media (max-width: 1100px)");
expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(source).toContain("@media (max-width: 720px)");
expect(source).toContain("grid-template-columns: 1fr;");
expect(source).not.toContain("audit-ranking-section");
expect(source).not.toContain("grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));");
expect(source).not.toContain("audit-grid");
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
});
it("keeps the access log table edge-to-edge without removing filter or pagination spacing", () => {
const source = readSource();
expect(source).toMatch(/\.access-log-card \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
expect(source).toMatch(/\.audit-filters \{[\s\S]*?padding: 10px 12px;[\s\S]*?margin-bottom: 0;/);
expect(source).toMatch(/\.table-shell \{[\s\S]*?border: 0;[\s\S]*?border-radius: 0;/);
expect(source).toMatch(/\.logs-pagination \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
expect(source).toContain(".access-table :deep(.el-scrollbar__wrap)");
expect(source).toContain(".access-table :deep(.el-scrollbar__bar.is-horizontal)");
});
it("removes the duplicated audit hero copy", () => {
@@ -78,117 +117,259 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("查看详细的接口访问与安全事件记录");
});
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
it("keeps structured access rows compact and delegates long fields to details", () => {
const source = readSource();
expect(source).toContain("terminalLogRows");
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
expect(source).toContain('ref="terminalWindowRef"');
expect(source).toContain("scrollTerminalToBottom");
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
expect(source).toContain('label="时间" width="160"');
expect(source).toContain('label="IP" width="120"');
expect(source).toContain('label="位置" width="110"');
expect(source).toContain('label="账号" min-width="160"');
expect(source).toContain('label="请求" min-width="380"');
expect(source).toContain('label="耗时" width="100"');
expect(source).toContain('label="操作" width="64"');
expect(source).toContain("account-cell");
expect(source).toContain("request-cell");
expect(source).toContain("elapsed-cell");
expect(source).toContain("accountSecondary(row)");
expect(source).toContain("requestMain(row)");
expect(source).toContain("requestSub(row)");
expect(source).toContain("row.elapsed_ms.toFixed(1)");
expect(source).not.toContain('label="账号/IP"');
expect(source).not.toContain('label="类型" width="88"');
expect(source).not.toContain('label="来源" width="132"');
expect(source).not.toContain('label="接口/角色"');
expect(source).not.toContain('label="状态" width="104"');
expect(source).not.toContain('label="账号" min-width="180"');
expect(source).not.toContain('label="请求来源"');
expect(source).not.toContain('label="结果" width="92"');
});
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
it("opens all raw access logs in the same dialog pattern as security logs", () => {
const source = readSource();
expect(source).toContain("buildTerminalLine");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("document.addEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("stopTimers()");
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
expect(source).toContain('const timeRangePreset = ref<AccessTimeRangePreset>("24h")');
expect(source).toContain("权限趋势下钻{{ drilldownRangeLabel");
expect(source).toContain("const applyTrendFilter = async");
expect(source).toContain('params.event_type = "permission"');
expect(source).toContain("defineExpose({ refresh, applyTrendFilter })");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessTerminalWindowRef");
expect(source).toContain("accessDialogLines.join");
expect(source).toContain("openAccessLogDialog");
expect(source).toContain("scrollAccessTerminalToBottom");
expect(source).toContain("fetchAllRawAccessLogLines");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
expect(source).not.toContain('ref="terminalWindowRef"');
expect(source).not.toContain("scrollTerminalToBottom");
});
it("surfaces source IPs in table, terminal logs and the IP ranking dialog", () => {
const source = readSource();
expect(source).toContain("ip=${ip}");
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).toContain("ipRankingItems");
expect(source).toContain('label="IP" width="120"');
expect(source).toContain('label="位置" width="110"');
expect(source).toContain("formatIpLocation(row)");
expect(source).toContain("row.ip_address || \"未知 IP\"");
expect(source).toContain("IP访问排行");
expect(source).toContain("账号");
expect(source).toContain("rank-location");
expect(source).toContain("loadIpRankingData");
expect(source).toContain("fetchAccessRowsForIpRanking");
expect(source).toContain("buildIpRankingRows");
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("ip-rank-grid");
expect(source).toContain("ip-rank-card");
expect(source).toContain("rank-card-stats");
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(source).toContain("rank-no");
expect(source).toContain("riskDescriptorForRow");
expect(source).toContain("row.riskLabel");
expect(source).toContain("row.riskTagType");
expect(source).toContain("riskRankCardClass");
expect(source).toContain("risk-rank-card--danger");
expect(source).toContain("risk-rank-card--warning");
expect(source).toContain("risk-rank-card--info");
expect(source).toContain("权限拒绝");
expect(source).toContain("敏感探测");
expect(source).toContain("TOP {{ ipRankingRows.length }}");
expect(source).toContain("ranking-period-cards");
expect(source).toContain("ranking-period-card");
expect(source).toContain("selectIpRankingPeriod");
expect(source).toContain("全部");
expect(source).toContain("当前时段暂无风险访问");
expect(source).not.toContain("ranking-period-group");
expect(source).not.toContain("ipLine(row)");
expect(source).not.toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).not.toContain("ipRankingItems");
expect(source).not.toContain("ip-rank-row");
expect(source).not.toContain("rank-location");
expect(source).not.toContain('class="rank-risk-tag">异常IP');
expect(source).not.toContain("TOP {{ userStats.length }}");
expect(source).not.toContain("rank-ip-line");
expect(source).not.toContain("来源IP");
expect(source).not.toContain("去重IP");
});
it("merges abnormal security IPs into the IP ranking and limits the list to top 10", () => {
it("includes security events in the unified access log without a separate security dialog", () => {
const source = readSource();
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;");
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>");
expect(source).toContain("securityLogs.value.forEach((event) =>");
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";');
expect(source).toContain('ABNORMAL_IP: "异常IP"');
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)");
expect(source).toContain("TOP {{ ipRankingItems.length }}");
expect(source).toContain('v-for="(item, index) in ipRankingItems"');
expect(source).toContain("异常IP</el-tag>");
});
it("sorts IP ranking by overall access total before risk labels", () => {
const source = readSource();
expect(source).toContain("existing.total += user.total_count;");
expect(source).toContain("existing.riskCount += user.denied_count;");
expect(source).toContain("if (b.total !== a.total) return b.total - a.total;");
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
source.indexOf("if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;"),
);
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
source.indexOf("if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);"),
);
});
it("loads all security pages for complete abnormal IP ranking data", () => {
const source = readSource();
expect(source).toContain("const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });");
expect(source).toContain("const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);");
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
expect(source).toContain("allItems.push(...res.data.items);");
expect(source).toContain("securityLogs.value = allItems;");
expect(source).not.toContain("page_size: 80");
});
it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource();
expect(source).toContain("showSecurityLog");
expect(source).toContain('v-if="showSecurityLog"');
expect(source).toContain("fetchSecurityAccessLogs");
expect(source).toContain("安全事件审计日志");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("auth_status");
expect(source).toContain("account_label");
expect(source).toContain("client_ip");
expect(source).toContain("status_code");
expect(source).toContain("ANONYMOUS");
expect(source).toContain("INVALID_TOKEN");
expect(source).toContain("isSecurityLog");
expect(source).toContain("安全事件");
expect(source).toContain("业务访问");
expect(source).toContain("SECURITY_CATEGORY_LABELS");
expect(source).toContain("SECURITY_SEVERITY_LABELS");
expect(source).toContain("endpointTitle(row)");
expect(source).toContain("endpointMeta(row)");
expect(source).toContain("accessResultLabel(row)");
expect(source).toContain("authStatusLabel(row.auth_status)");
expect(source).toContain("[SECURITY:");
expect(source).not.toContain("showSecurityLog");
expect(source).not.toContain('v-if="showSecurityLog"');
expect(source).not.toContain("fetchSecurityAccessLogs");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
expect(source).not.toContain("安全事件审计日志");
expect(source).not.toContain("securityTerminalLines");
expect(source).not.toContain("securityLogDialogVisible");
expect(source).not.toContain("SecurityAccessLogItem");
});
it("shows interface audit log total instead of current page line count", () => {
it("shows total access logs in the table and access log dialog", () => {
const source = readSource();
expect(source).toContain("{{ formatNumber(total) }} 条");
expect(source).toContain("共 {{ formatNumber(total) }} 条");
expect(source).toContain(":total=\"total\"");
expect(source).toContain("v-model:page-size=\"pageSize\"");
expect(source).toContain(":page-sizes=\"[50, 100, 200]\"");
expect(source).toContain("accessLogDialogVisible");
expect(source).not.toContain("{{ terminalLines.length }} 条");
expect(source).not.toContain("最近 {{ terminalLines.length }} 条");
});
it("opens both audit logs in realtime downloadable dialogs", () => {
it("renders access rows directly and keeps raw access logs downloadable", () => {
const source = readSource();
expect(source).toContain("interfaceLogDialogVisible");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("openInterfaceLogDialog");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("if (!showSecurityLog.value) return");
expect(source).toContain("detailDrawerVisible");
expect(source).toContain("selectedLog");
expect(source).toContain("openLogDetail");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("openAccessLogDialog");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("downloadLogFile");
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("<el-drawer v-model=\"detailDrawerVisible\"");
expect(source).toContain("请求概览");
expect(source).toContain("原始 HTTP 请求");
expect(source).toContain("User-Agent");
expect(source).toContain("更多审计信息");
expect(source).toContain("日志标识");
expect(source).toContain("传输信息");
expect(source).toContain("客户端标识");
expect(source).toContain("requestHeaderEntries(row)");
expect(source).toContain("requestOverviewFieldItems(selectedLog)");
expect(source).toContain("auditMetadataFieldItems(selectedLog)");
expect(source).toContain("requestSnapshotFieldItems(selectedLog)");
expect(source).toContain("buildRawRequestBlock(selectedLog)");
expect(source).toContain("rawRequestBodyLine");
expect(source).toContain("hasCapturedRequestSnapshot");
expect(source).toContain("历史日志未采集原始请求快照");
expect(source).toContain("legacy_permission_endpoint");
expect(source).toContain("# audit_line: ${buildTerminalLine(row)}");
expect(source).not.toContain("detailFieldItems(selectedLog)");
expect(source).not.toContain("<h4>接口</h4>");
expect(source).not.toContain("<h4>访问字段</h4>");
expect(source).not.toContain("<h4>请求快照</h4>");
expect(source).not.toContain("<h4>请求头</h4>");
expect(source).not.toContain("requestHeaderEntries(selectedLog)");
expect(source).not.toContain("未记录请求头");
expect(source).not.toContain("原始请求快照(脱敏)");
expect(source).not.toContain("原始日志行");
expect(source).not.toContain("请求头(脱敏)");
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
expect(source).toContain("下载日志");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("scrollSecurityTerminalToBottom");
expect(source).not.toContain("securityLogDialogVisible");
expect(source).not.toContain("openSecurityLogDialog");
expect(source).not.toContain("downloadSecurityLog");
expect(source).not.toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).not.toContain("securityTerminalWindowRef");
expect(source).not.toContain("scrollSecurityTerminalToBottom");
expect(source).not.toContain("interfaceLogDialogVisible");
});
it("exports all raw access logs across paginated backend pages", () => {
const source = readSource();
expect(source).toContain("const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;");
expect(source).toContain("const rawExportLoading = ref(false);");
expect(source).toContain("fetchAllRawAccessLogLines");
expect(source).toContain("page_size: ACCESS_LOG_EXPORT_PAGE_SIZE");
expect(source).toContain("const totalPages = Math.ceil(first.data.total / ACCESS_LOG_EXPORT_PAGE_SIZE);");
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
expect(source).toContain("raw-access-logs.log");
expect(source).not.toContain("interface-access-audit.log");
});
it("supports account, location and client source filtering on the server query", () => {
const source = readSource();
expect(source).toContain('placeholder="搜索账号、IP、属地、接口或来源"');
expect(source).toContain('<TimeRangeSelector v-model="timeRangePreset"');
expect(source).toContain("onTimePresetChange");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("全部");
expect(source).toContain("24小时");
expect(source).toContain("7天");
expect(source).toContain("30天");
expect(source).toContain("clientSourceOptions");
expect(source).toContain("filters.clientType");
expect(source).toContain("params.client_type = filters.clientType");
expect(source).toContain("params.keyword = keywordToken");
expect(source).toContain("params.client_ip = keywordToken");
expect(source).toContain("onKeywordInput");
});
it("keeps access-log filter controls on one compact visual scale", () => {
const source = readSource();
expect(source).toContain("--filter-control-height: 26px");
expect(source).toContain("--filter-font-size: 12px");
expect(source).toContain('import TimeRangeSelector from "./TimeRangeSelector.vue"');
expect(source).not.toContain(".filter-time-presets :deep(.el-radio-button__inner)");
expect(source).toContain(".audit-filters :deep(.el-select__wrapper)");
expect(source).toContain(".audit-filters :deep(.el-input__wrapper)");
expect(source).toContain('class="filter-reset"');
expect(source).toContain("height: var(--filter-control-height)");
expect(source).toContain("font-size: var(--filter-font-size)");
});
it("does not render the manual date range filter in access logs", () => {
const source = readSource();
expect(source).not.toContain("el-date-picker");
expect(source).not.toContain("datetimerange");
expect(source).not.toContain("dateRange");
expect(source).not.toContain("onDateRangeChange");
expect(source).not.toContain("filter-date");
expect(source).not.toContain("开始时间");
expect(source).not.toContain("结束时间");
});
it("includes request headers in the access log item contract", () => {
const source = readFileSync(resolve(__dirname, "../types/api.ts"), "utf8");
expect(source).toContain('event_type?: "permission" | "security" | string;');
expect(source).toContain("request_headers: Record<string, string> | null;");
expect(source).toContain("request_snapshot: RequestSnapshot | null;");
expect(source).toContain("export interface RequestSnapshot");
expect(source).toContain("query_params?: RequestSnapshotParam[] | null;");
expect(source).toContain("status_code?: number | null;");
expect(source).toContain("auth_status?: string | null;");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
describe("PermissionIpLocations", () => {
it("integrates source metrics into a compact overview above the map", () => {
const source = readSource();
expect(source).toContain('class="ip-overview"');
expect(source).toContain('aria-label="来源指标"');
expect(source).toContain('class="distribution-card"');
expect(source).toContain("min-height: 44px");
expect(source).toContain("grid-template-columns: minmax(0, 1fr) 300px");
expect(source).toContain("padding: 10px");
});
it("keeps compact metric grids responsive", () => {
const source = readSource();
expect(source).toContain("@media (max-width: 1100px)");
expect(source).toContain("@media (max-width: 520px)");
expect(source).toContain(".metric-card:nth-child(odd)");
});
it("uses the complete desktop viewport for fullscreen maps", () => {
const source = readSource();
expect(source).toContain('modal-class="source-map-overlay"');
expect(source).toContain(':option="fullscreenSourceMapOption"');
expect(source).toContain("buildFlowMapOption(flowSourceData.value, \"ctms-world\", 1, \"全球访问链路\")");
expect(source).toContain("buildFlowMapOption(chinaFlowSourceData.value, \"ctms-china\", 0.92, \"中国访问链路\")");
expect(source).toContain(".source-map-overlay .source-map-dialog.el-dialog.is-fullscreen");
expect(source).toContain("inset: 0 !important");
expect(source).toContain("flex: 1 1 auto");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 48px");
});
it("keeps the all-time label and request semantics consistent", () => {
const source = readSource();
expect(source).toContain('all: "全部"');
expect(source).toContain("all: undefined");
expect(source).toContain('all_time: timeRangePreset.value === "all"');
expect(source).not.toContain('all: "近 90 天"');
});
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
const source = readSource();
expect(source).toContain('v-if="!fullscreenVisible"');
expect(source).toContain("useDirtyRect: true");
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
expect(source).not.toContain("zlevel:");
expect(source).toContain("constantSpeed: 72");
expect(source).toContain("trailLength: 0");
expect(source).toContain('symbol: "circle"');
expect(source).toContain('type: "scatter"');
expect(source).toContain("chartAutoresize = { throttle: 120 }");
});
it("does not classify foreign locations as abnormal by geography alone", () => {
const source = readSource();
expect(source).toContain('mapMetric = ref<"traffic" | "risk">("traffic")');
expect(source).toContain("riskColor(item.risk_level)");
expect(source).not.toContain('item.country !== "中国"');
});
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
const source = readSource();
expect(source).toContain('const mapView = ref<MapView>("flow");');
expect(source).toContain('load: () => import("../assets/china.json")');
expect(source).toContain('load: () => import("../assets/world.json")');
expect(source).toContain("item.longitude");
expect(source).toContain("item.latitude");
expect(source).toContain("external_fallback_ip_count");
expect(source).toContain("外部补全");
expect(source).toContain("chinaRankRows");
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
expect(source).not.toContain('from "./worldMapSource"');
});
});
File diff suppressed because it is too large Load Diff
@@ -35,19 +35,26 @@ vi.mock("@/api/projectPermissions", () => ({
status: "healthy", health_score: 95, issues: [],
sample_sufficient: true,
score_breakdown: {
performance: { weight: 40, deduction: 0 },
performance: { weight: 25, deduction: 0 },
deny_rate: { weight: 20, deduction: 0 },
cache: {
weight: 25, deduction: 5, sample_sufficient: true,
scope: "sliding_window", window_seconds: 3600,
},
alerts: { weight: 15, deduction: 0 },
alerts: { weight: 10, deduction: 0 },
runtime: { weight: 20, deduction: 0 },
},
last_hour: {
total_checks: 30, denied_checks: 2, deny_rate: 6.67,
avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0,
},
cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
components: {
database: { status: "healthy", latency_ms: 2.1 },
permission_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 10000, dropped_entries: 0 },
security_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 20000, dropped_entries: 0 },
retention: { status: "healthy", running: true, access_log_retention_days: 90, metric_retention_days: 400 },
},
},
}),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
@@ -98,11 +105,13 @@ describe("PermissionMonitoring.vue", () => {
expect(typeof wrapper.vm.refresh).toBe("function");
});
it("passes admin-only access to security logs", () => {
it("keeps security events in security center instead of access logs", () => {
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
expect(source).toContain("isAdmin?: boolean");
expect(source).toContain(':show-security-log="props.isAdmin"');
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
expect(source).toContain('<PermissionAccessLogs ref="accessLogsRef" />');
expect(source).not.toContain(':show-security-log="props.isAdmin"');
});
it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => {
@@ -113,7 +122,13 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
expect(source).toContain("overflow: hidden");
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
expect(source).toContain("overflow: auto");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain("height: auto;");
expect(source).toContain("overflow-y: auto;");
expect(source).toContain('height: auto;\n min-height: 100%;');
expect(source).toContain('.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"])');
expect(source).toContain("flex: 0 0 52px;");
expect(source).toContain("overscroll-behavior: contain;");
});
it("resizes the source-analysis chart when its tab becomes active", () => {
@@ -132,25 +147,59 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain('label="运行概览"');
expect(source).toContain('label="安全中心"');
expect(source).toContain('label="性能趋势"');
expect(source).toContain('label="访问审计"');
expect(source).toContain('label="运行趋势"');
expect(source).toContain('label="访问日志"');
expect(source).toContain('label="来源分析"');
expect(source).toContain('label="运行趋势" name="trends" lazy');
expect(source).toContain('@drilldown="openTrendDrilldown"');
expect(source).toContain('accessLogsRef.value?.applyTrendFilter(payload)');
expect(source).toContain('label="访问日志" name="logs" lazy');
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
expect(source).toContain('name="security"');
expect(source).toContain('label: "今日监测事件"');
expect(source).toContain('label: "每分钟事件"');
expect(source).toContain('label: "今日通过率"');
expect(source).toContain('label: "历史事件总数"');
expect(source).toContain('class="metric-strip"');
expect(source).toContain('class="metric-strip-detail"');
expect(source).toContain('detail: `历史 ${statsSummary.value.total_logs.toLocaleString()}`');
expect(source).toContain("运行指标");
expect(source).toContain('const formatResponseTime');
expect(source).toContain('return "<0.1ms"');
expect(source).toContain("业务样本不足");
expect(source).not.toContain("组件在线不代表指标已完成评估");
expect(source).not.toContain("评分由性能、拒绝率、缓存、告警和组件状态共同计算");
expect(source).not.toContain("最近一次检查未发现需要处理的问题");
expect(source).toContain('class="health-summary-label">综合状态');
expect(source).toContain('class="health-signal-item"');
expect(source).toContain('item.deduction ? `-${item.deduction}` : "正常"');
expect(source).toContain('class="overview-pane"');
expect(source).toContain('class="overview-hero-card"');
expect(source).toContain('class="operations-card"');
expect(source).toContain('class="operations-grid"');
expect(source).toContain('class="alerts-section"');
expect(source).toContain("grid-template-columns: minmax(210px, 0.85fr)");
expect(source).toContain('class="status-section runtime-section"');
expect(source).toContain('class="empty-alert-state"');
expect(source).toContain('const hasCacheSamples = computed');
expect(source).toContain("异常拒绝排行");
expect(source).toContain("告警数据加载失败,不能据此判断系统正常");
expect(source).toContain("const formatUpdatedTime");
expect(source).toContain("更新 {{ formatUpdatedTime(lastUpdatedAt) }}");
expect(source).toContain("runtimeComponentItems");
expect(source).not.toContain('label="实时概览"');
expect(source).not.toContain('label="趋势分析"');
expect(source).not.toContain('label="访问日志"');
expect(source).not.toContain('label="访问审计"');
expect(source).not.toContain('label="IP属地"');
expect(source).not.toContain('label: "今日总检查"');
expect(source).not.toContain('label: "每分钟请求"');
expect(source).not.toContain('label: "今日允许率"');
expect(source).not.toContain('label: "历史总记录"');
expect(source).not.toContain('label: "历史事件总数"');
expect(source).not.toContain('class="summary-card"');
expect(source).not.toContain('class="overview-toolbar"');
expect(source).not.toContain("被拒绝最多的权限");
expect(source).not.toContain("暂无告警,系统运行正常");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
describe("PermissionTemplateSelector.vue", () => {
it("renders accessible role actions with explicit project status", () => {
const source = readSource();
expect(source).toContain("<button");
expect(source).toContain(":aria-label=");
expect(source).toContain("使用中");
expect(source).toContain("未启用");
expect(source).toContain("配置权限");
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(source).toContain("min-height: 122px;");
expect(source).toContain("margin-top: auto;");
expect(source).not.toContain('PM: "👑"');
});
});
@@ -2,17 +2,20 @@
<div class="template-selector">
<div class="selector-header">
<div class="selector-title-group">
<h3>角色权限概览</h3>
<h3>角色策略概览</h3>
</div>
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
</div>
<div class="template-grid">
<div
<button
v-for="template in sortedTemplates"
:key="template.id"
type="button"
class="template-card"
:class="{ inactive: template.category && !isRoleActive(template.category) }"
:disabled="!template.category"
:aria-label="template.category ? `配置 ${template.name} 权限` : template.name"
@click="template.category && emit('edit-role', template.category!)"
>
<div class="card-top">
@@ -21,7 +24,9 @@
<span class="card-name">{{ template.name }}</span>
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
</div>
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
<span :class="['card-status', isRoleActive(template.category || '') ? 'card-status--active' : 'card-status--inactive']">
{{ isRoleActive(template.category || '') ? '使用中' : '未启用' }}
</span>
</div>
<div class="card-stats">
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
@@ -31,11 +36,11 @@
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countCurrentEnabled(template.category) }} 启用
{{ countCurrentEnabled(template.category) }} 已授权
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countCurrentDisabled(template.category) }} 禁用
{{ countCurrentDisabled(template.category) }} 未授权
</span>
</div>
</template>
@@ -46,16 +51,17 @@
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countEnabled(template) }} 启用
{{ countEnabled(template) }} 已授权
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countDisabled(template) }} 禁用
{{ countDisabled(template) }} 未授权
</span>
</div>
</template>
</div>
</div>
<div class="card-action">配置权限 <span aria-hidden="true">→</span></div>
</button>
</div>
</div>
</template>
@@ -94,10 +100,10 @@ const sortedTemplates = computed(() =>
const roleIcon = (category: string | null) => {
const icons: Record<string, string> = {
PM: "👑", CRA: "📋", PV: "🔍",
QA: "🏥", CTA: "📦",
PM: "PM", CRA: "CRA", PV: "PV",
QA: "QA", CTA: "CTA",
};
return icons[category ?? ""] ?? "📄";
return icons[category ?? ""] ?? "—";
};
const isRoleActive = (role: string) => props.activeRoles?.includes(role) ?? false;
@@ -155,38 +161,45 @@ watch(() => props.refreshKey, loadTemplates);
<style scoped>
.template-selector {
padding: 8px 0 12px;
padding: 4px 0 6px;
}
.selector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 8px;
}
.selector-title-group h3 {
margin: 0 0 4px;
font-size: 15px;
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: start;
gap: 12px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: 6px;
}
.template-card {
position: relative;
padding: 12px 14px;
width: 100%;
min-height: 122px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: 8px;
background: #fff;
cursor: pointer;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
text-align: left;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -197,11 +210,19 @@ watch(() => props.refreshKey, loadTemplates);
transform: translateY(-2px);
}
.template-card:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.25);
outline-offset: 2px;
}
.template-card:disabled {
cursor: default;
}
.template-card.inactive {
border-style: dashed;
border-color: #e2e8f0;
background: #fafbfc;
opacity: 0.7;
}
.template-card.inactive:hover {
@@ -213,12 +234,21 @@ watch(() => props.refreshKey, loadTemplates);
.card-top {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
gap: 7px;
margin-bottom: 7px;
}
.card-icon {
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: #eef5fa;
color: #315f7a;
font-size: 9px;
font-weight: 800;
line-height: 1;
flex-shrink: 0;
}
@@ -232,7 +262,7 @@ watch(() => props.refreshKey, loadTemplates);
}
.card-name {
font-size: 14px;
font-size: 12px;
font-weight: 600;
color: #1a2332;
}
@@ -242,13 +272,49 @@ watch(() => props.refreshKey, loadTemplates);
}
.card-desc {
font-size: 11px;
font-size: 10px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-status {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 2px 5px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 9.5px;
font-weight: 600;
}
.card-status--active {
border-color: #b9ddcc;
background: #edf8f2;
color: #267054;
}
.card-status--inactive {
border-color: #dbe3ea;
background: #f5f7f9;
color: #7a8997;
}
.card-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: auto;
padding-top: 6px;
border-top: 1px solid #edf1f5;
color: #416b83;
font-size: 10.5px;
font-weight: 600;
}
.card-badge {
flex-shrink: 0;
}
@@ -256,11 +322,11 @@ watch(() => props.refreshKey, loadTemplates);
.card-stats {
display: flex;
flex-direction: column;
gap: 8px;
gap: 5px;
}
.stat-bar {
height: 4px;
height: 3px;
border-radius: 2px;
background: #f1f5f9;
overflow: hidden;
@@ -275,20 +341,20 @@ watch(() => props.refreshKey, loadTemplates);
.stat-numbers {
display: flex;
gap: 12px;
gap: 8px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-size: 10.5px;
color: #64748b;
}
.stat-dot {
width: 6px;
height: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
@@ -300,4 +366,22 @@ watch(() => props.refreshKey, loadTemplates);
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
.template-card.inactive .stat-fill { background: #cbd5e1; }
@media (max-width: 1180px) {
.template-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
describe("PermissionTrendCharts", () => {
it("uses accurate permission trend language and removes the misleading all-period option", () => {
const source = readSource();
expect(source).toContain("权限检查趋势");
expect(source).toContain("权限检查量");
expect(source).toContain("权限检查耗时");
expect(source).toContain("权限拒绝率");
expect(source).toContain("权限缓存命中率");
expect(source).not.toContain("系统性能趋势");
expect(source).not.toContain('{ value: "all", label: "全部" }');
});
it("renders dense period summaries with previous-period and sample context", () => {
const source = readSource();
expect(source).toContain('class="metric-strip"');
expect(source).toContain("P95 检查耗时");
expect(source).toContain("活跃范围");
expect(source).toContain("较上期");
expect(source).toContain("cache_sample_available");
expect(source).toContain("不等于命中率为 0");
});
it("uses honest chart semantics and visible health thresholds", () => {
const source = readSource();
expect(source).toContain('stack: "checks"');
expect(source).toContain("smooth: false");
expect(source).toContain('formatter: "慢检查 50ms"');
expect(source).toContain('formatter: "健康线 80%"');
expect(source).toContain("connectNulls: false");
});
it("provides denial, latency and client attribution with access-log drilldown", () => {
const source = readSource();
expect(source).toContain("拒绝最多");
expect(source).toContain("耗时最高");
expect(source).toContain("客户端表现");
expect(source).toContain('@click="onChecksChartClick"');
expect(source).toContain('emit("drilldown"');
expect(source).toContain("openAttributionLogs");
});
it("keeps the information-dense layout responsive", () => {
const source = readSource();
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr))");
expect(source).toContain("grid-template-columns: minmax(0, 2.1fr)");
expect(source).toContain("grid-template-columns: repeat(3, minmax(0, 1fr))");
expect(source).toContain("@media (max-width: 900px)");
});
});
File diff suppressed because it is too large Load Diff
-145
View File
@@ -1,145 +0,0 @@
<template>
<div class="quick-actions-section">
<div class="section-header">
<el-icon class="header-icon"><Pointer /></el-icon>
<span class="header-title">{{ TEXT.common.labels.quickActions }}</span>
</div>
<div class="actions-grid">
<div
v-for="item in visibleActions"
:key="item.path"
class="action-item"
@click="go(item.path)"
>
<div class="action-icon-wrapper">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<span class="action-label">{{ item.label }}</span>
<el-icon class="action-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection
} from "@element-plus/icons-vue";
import { TEXT } from "../locales";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const actions = [
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer },
{ label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
{ label: TEXT.menu.finance, path: "/fees/contracts", icon: Money },
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection },
];
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const visibleActions = computed(() =>
actions.filter((item) =>
hasProjectPermission(
study.currentPermissions,
projectRole.value,
getProjectRoutePermission(item.path),
isSystemAdmin(auth.user),
)
)
);
const go = (path: string) => router.push(path);
</script>
<style scoped>
.quick-actions-section {
background-color: #fff;
border-radius: var(--ctms-radius);
padding: 24px;
border: 1px solid var(--ctms-border-color);
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.header-icon {
font-size: 16px;
color: var(--ctms-text-secondary);
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.action-item {
display: flex;
align-items: center;
padding: 12px 16px;
background-color: #ffffff;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
cursor: pointer;
transition: var(--ctms-transition);
position: relative;
overflow: hidden;
}
.action-item:hover {
background-color: #ffffff;
border-color: var(--ctms-border-color-hover);
}
.action-icon-wrapper {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 12px;
color: var(--ctms-text-secondary);
background-color: var(--ctms-bg-muted);
}
.action-label {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
flex: 1;
}
.action-arrow {
font-size: 14px;
color: var(--ctms-text-disabled);
opacity: 1;
transition: var(--ctms-transition);
}
.action-item:hover .action-arrow {
color: var(--ctms-text-secondary);
}
</style>
+107 -24
View File
@@ -5,21 +5,33 @@ import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
describe("SecurityCenter", () => {
it("renders the security dashboard in the same audit-card style as access logs", () => {
it("renders a compact security analysis summary in the same audit-card style as access logs", () => {
const source = readSource();
expect(source).toContain("audit-filters");
expect(source).toContain("audit-metrics");
expect(source).toContain("audit-grid");
expect(source).toContain("metric-card");
expect(source).toContain("ranking-card");
expect(source).toContain("security-analysis-card");
expect(source).toContain("risk-preview-list");
expect(source).toContain("risk-summary-panel");
expect(source).toContain("risk-summary-title");
expect(source).toContain("topRiskPreview");
expect(source).toContain('class="security-overview"');
expect(source).toContain('aria-label="安全指标"');
expect(source).toContain('class="security-toolbar"');
expect(source).toContain("timeRangePreset");
expect(source).toContain("timeRangeOptions");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("onTimeRangeChange");
expect(source).not.toContain("audit-grid");
expect(source).not.toContain("ranking-card");
expect(source).not.toContain("security-summary");
expect(source).not.toContain("security-card");
});
it("places filters inside the event details section instead of the page header", () => {
const source = readSource();
const tableIndex = source.indexOf('<section class="security-table-wrap">');
const tableIndex = source.indexOf('<section ref="securityEventsSectionRef" class="security-table-wrap">');
const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">');
expect(tableIndex).toBeGreaterThan(-1);
@@ -27,20 +39,42 @@ describe("SecurityCenter", () => {
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
});
it("surfaces abnormal IP location ranking, endpoint type distribution and risk level distribution", () => {
it("keeps the security table edge-to-edge while preserving filter and pagination spacing", () => {
const source = readSource();
expect(source).toContain("异常排行");
expect(source).toContain("事件分布");
expect(source).toContain("访问的接口类型");
expect(source).toMatch(/\.security-table-wrap \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
expect(source).toMatch(/\.table-head \{[\s\S]*?margin-bottom: 0;[\s\S]*?padding: 10px 12px;/);
expect(source).toMatch(/\.pagination-wrap \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
expect(source).toContain(".security-table :deep(.el-scrollbar__wrap)");
expect(source).toContain(".security-table :deep(.el-scrollbar__bar.is-horizontal)");
});
it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
const source = readSource();
expect(source).toContain("异常分析");
expect(source).toContain("查看异常分析");
expect(source).toContain("analysisDialogVisible");
expect(source).toContain("openAnalysisDialog");
expect(source).toContain("jumpToSecurityEvents");
expect(source).toContain("securityEventsSectionRef");
expect(source).toContain("scrollIntoView");
expect(source).toContain('<el-dialog v-model="analysisDialogVisible"');
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("analysis-ranking-dialog");
expect(source).toContain("analysis-ranking-head");
expect(source).toContain("endpoint-summary-chips");
expect(source).toContain("analysis-rank-grid");
expect(source).toContain("analysis-rank-card");
expect(source).toContain("analysis-rank-card-stats");
expect(source).toContain("查看事件明细");
expect(source).not.toContain('<section class="analysis-dialog-panel');
expect(source).toContain("风险等级");
expect(source).toContain("event-distribution-card");
expect(source).toContain("distribution-section");
expect(source).toContain(".event-distribution-card > .section-head");
expect(source).toContain("padding-top: 24px");
expect(source).toContain("ipRiskStats");
expect(source).toContain("endpointTypeStats");
expect(source).toContain("riskLevelStats");
expect(source.match(/v-for="item in riskLevelStats"/g)).toHaveLength(1);
expect(source).not.toContain("compact-risk-section");
expect(source).toContain("resolveEndpointType");
expect(source).toContain("formatIpLocation");
expect(source).toContain("fallbackIpLocation");
@@ -49,11 +83,13 @@ describe("SecurityCenter", () => {
expect(source).toContain("row.ip_isp");
expect(source).toContain("item.location");
expect(source).toContain("categoryText");
expect(source).toContain("rank-main-line");
expect(source).toContain("rank-meta-line");
expect(source).toContain("analysis-rank-card-ip");
expect(source).toContain("analysis-rank-card-meta");
expect(source).toContain("lastSeenAt");
expect(source).toContain("最后访问");
expect(source).toContain("slice(0, 10)");
expect(source).toContain("securitySummary.value.top_ips");
expect(source).toContain("securitySummary.value.endpoint_type_counts");
expect(source).toContain("securitySummary.value.severity_counts");
expect(source).not.toContain("公网位置待解析");
expect(source).not.toContain("异常IP(位置)排行");
expect(source).not.toContain("risk-level-card");
@@ -62,37 +98,84 @@ describe("SecurityCenter", () => {
it("keeps detailed security events searchable and inspectable", () => {
const source = readSource();
expect(source).toContain("detailFilteredEvents");
expect(source).toContain("securityQueryParams");
expect(source).toContain("keyword: keyword.value.trim() || undefined");
expect(source).toContain("paginatedEvents");
expect(source).toContain("openDetail");
expect(source).toContain("安全事件明细");
expect(source).toContain("security-table-wrap");
expect(source).toContain('label="IP"');
expect(source).toContain('label="位置"');
expect(source).not.toContain('label="来源 IP"');
expect(source).not.toContain('label="IP来源"');
expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
expect(source).toContain("loadSecurityEvents");
expect(source).toContain("安全事件加载失败,页面中可能是上次成功获取的数据");
expect(source).toContain("lastUpdatedAt");
expect(source).toContain("安全事件详情");
expect(source).toContain("请求概览");
expect(source).toContain("原始 HTTP 请求");
expect(source).toContain("User-Agent");
expect(source).toContain("构建信息");
expect(source).toContain("更多审计信息");
expect(source).toContain("日志标识");
expect(source).toContain("传输信息");
expect(source).toContain("客户端标识");
expect(source).toContain("securityRequestOverviewFields(selectedEvent)");
expect(source).toContain("securityAuditMetadataFields(selectedEvent)");
expect(source).toContain("securityRequestSnapshotFields(selectedEvent)");
expect(source).toContain("buildSecurityRawRequestBlock(selectedEvent)");
expect(source).toContain("securityClientSourceLabel(selectedEvent)");
expect(source).toContain("历史日志未采集原始请求快照");
expect(source).toContain("历史日志未采集原始 HTTP 请求");
expect(source).not.toContain("securityDetailFieldItems(selectedEvent)");
expect(source).not.toContain("<h4>接口</h4>");
expect(source).not.toContain("<h4>访问字段</h4>");
expect(source).not.toContain("<h4>请求快照</h4>");
expect(source).not.toContain("原始请求快照(脱敏)");
expect(source).not.toContain("请求头(脱敏)");
expect(source).not.toContain("未记录请求头");
});
it("opens full security logs as a dialog inside security center", () => {
const source = readSource();
expect(source).toContain("安全日志");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("securityLogDialogLoading");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("buildSecurityTerminalLine");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("scrollSecurityTerminalToBottom");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("saveFileWithFeedback");
expect(source).toContain("securityRequestTarget(event)");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("securityTerminalLines.join");
expect(source).toContain("security-access-logs.log");
expect(source).toContain("terminal-window dialog-terminal-window");
});
it("labels abnormal IP events and paginates details like account management", () => {
const source = readSource();
expect(source).toContain('{ label: "异常IP", value: "ABNORMAL_IP" }');
expect(source).not.toContain('value: "ABNORMAL_IP"');
expect(source).toContain('label: "风险来源 IP"');
expect(source).toContain('v-model:current-page="page"');
expect(source).toContain('v-model:page-size="pageSize"');
expect(source).toContain(':page-sizes="[10, 20, 50]"');
expect(source).toContain('layout="prev, pager, next, sizes, total"');
});
it("calculates dashboard statistics from all events instead of detail filters or current page", () => {
it("uses server-side pagination and aggregate facets instead of loading every event", () => {
const source = readSource();
expect(source).toContain("statsEvents");
expect(source).toContain("detailFilteredEvents");
expect(source).toContain("securitySummary");
expect(source).toContain("loadSecurityStats");
expect(source).toContain("SECURITY_STATS_PAGE_SIZE");
expect(source).toContain("statsEvents.value");
expect(source).not.toContain("const uniqueIpCount = new Set(events.value");
expect(source).not.toContain("filteredStatsEvents");
expect(source).toContain("response.data.summary");
expect(source).toContain("@current-change=\"loadSecurityEvents\"");
expect(source).not.toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {\n const res = await fetchSecurityAccessLogs");
expect(source).toContain("fetchAllSecurityEvents");
});
});
File diff suppressed because it is too large Load Diff
-145
View File
@@ -1,145 +0,0 @@
<template>
<el-dropdown trigger="click" class="study-selector-dropdown" @command="onCommand">
<div class="selector-trigger">
<el-icon class="trigger-icon"><OfficeBuilding /></el-icon>
<span class="current-study-name">{{ study.currentStudy?.name || TEXT.common.empty.selectProject }}</span>
<el-icon class="arrow-icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="study-dropdown">
<el-dropdown-item v-if="loading" disabled>
<el-icon><Loading /></el-icon>{{ TEXT.common.loading }}
</el-dropdown-item>
<el-dropdown-item v-else-if="!studies.length" disabled>{{ TEXT.common.empty.noData }}</el-dropdown-item>
<el-dropdown-item
v-for="item in studies"
:key="item.id"
:command="{ type: 'switch', study: item }"
:class="{ active: study.currentStudy?.id === item.id }"
>
<div class="study-item">
<div class="name">{{ item.name }}</div>
<div class="code">{{ item.code }}</div>
</div>
</el-dropdown-item>
<el-dropdown-item divided command="clear" class="danger-item">
<el-icon><Close /></el-icon>{{ TEXT.common.actions.exitProject }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import type { Study } from "../types/api";
import { OfficeBuilding, ArrowDown, Close, Loading } from "@element-plus/icons-vue";
import { TEXT } from "../locales";
const router = useRouter();
const study = useStudyStore();
const studies = ref<Study[]>([]);
const loading = ref(false);
const loadStudies = async () => {
loading.value = true;
try {
const { data } = await fetchStudies();
studies.value = data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const onCommand = async (cmd: any) => {
if (cmd === "clear") {
study.clearCurrentStudy();
router.push("/admin/projects");
return;
}
if (cmd?.type === "switch" && cmd.study) {
study.setCurrentStudy(cmd.study as Study);
await study.loadCurrentStudyPermissions().catch(() => {});
router.push("/project/overview");
}
};
onMounted(() => {
loadStudies();
});
</script>
<style scoped>
.selector-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background-color: #fafafa;
border: 1px solid var(--ctms-border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.selector-trigger:hover {
background-color: var(--ctms-primary-light);
border-color: var(--ctms-primary-hover);
}
.trigger-icon {
color: var(--ctms-primary);
font-size: 16px;
}
.current-study-name {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.study-dropdown {
max-height: 320px;
overflow-y: auto;
min-width: 220px;
}
.study-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.study-item .name {
font-weight: 600;
color: var(--ctms-text-main);
}
.study-item .code {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.active {
background-color: var(--ctms-primary-light);
}
.danger-item {
color: var(--ctms-danger);
}
</style>
+3 -2
View File
@@ -29,7 +29,8 @@
import { computed } from "vue";
import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime";
import { clientRuntime } from "../runtime";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -65,7 +66,7 @@ const fileListProxy = computed({
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
const pickNativeAttachments = async () => {
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
const files = await pickFilesWithFeedback({ multiple: true, title: TEXT.common.labels.attachments });
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`))
+2 -2
View File
@@ -47,8 +47,8 @@
<script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue";
import { downloadAttachment } from "../api/attachments";
import { saveFile } from "../runtime";
import { displayDateTime, displayUser } from "../utils/display";
import { saveFileWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -97,7 +97,7 @@ const loadImageUrls = async () => {
const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id);
await saveFile({
await saveFileWithFeedback({
suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data,
@@ -0,0 +1,46 @@
<template>
<el-radio-group
:model-value="props.modelValue"
size="small"
@change="onChange"
>
<el-radio-button
v-for="item in currentOptions"
:key="item.value"
:label="item.value"
>{{ item.label }}</el-radio-button>
</el-radio-group>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface OptionItem {
value: string | number;
label: string;
}
const props = defineProps<{
modelValue: string | number;
options?: readonly OptionItem[] | OptionItem[];
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string | number): void;
(e: "change", value: string | number): void;
}>();
const defaultOptions = [
{ value: "all", label: "全部" },
{ value: "24h", label: "24小时" },
{ value: "7d", label: "7天" },
{ value: "30d", label: "30天" },
] as const;
const currentOptions = computed(() => props.options || defaultOptions);
const onChange = (val: string | number) => {
emit("update:modelValue", val);
emit("change", val);
};
</script>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
describe("web layout fullscreen control", () => {
it("offers an accessible browser fullscreen toggle and synchronizes external exits", () => {
const source = readWebLayoutSource();
expect(source).toContain('v-if="webFullscreenAvailable"');
expect(source).toContain(':aria-label="webFullscreenLabel"');
expect(source).toContain(':aria-pressed="webFullscreenActive"');
expect(source).toContain("toggleWebFullscreen");
expect(source).toContain("listenWebFullscreenChange(syncWebFullscreenState)");
expect(source).toContain("webFullscreenUnlisten?.()");
});
it("removes the application chrome for immersive workspace routes", () => {
const source = readWebLayoutSource();
expect(source).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(source).toContain('v-if="!route.meta.immersiveWorkspace"');
expect(source).toContain('v-if="isMobileNavOpen && !route.meta.immersiveWorkspace"');
});
});
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,11 @@
<template>
<div class="attachment-list-panel unified-shell">
<div
class="attachment-list-panel"
:class="{
'unified-shell': displayMode === 'table',
'attachment-list-panel--upload': displayMode === 'upload',
}"
>
<template v-if="displayMode === 'table'">
<div v-if="showHeader" class="header">
<span>{{ headerTitle }}</span>
@@ -60,7 +66,14 @@
</el-table-column>
</el-table>
</template>
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
<div
v-else
class="upload-grid"
:class="{
'upload-grid--single': uploadGroups.length === 1,
'upload-grid--three': uploadGroups.length > 1 && uploadCardColumns === 3,
}"
>
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
<el-upload
v-if="!nativeFiles"
@@ -92,7 +105,10 @@
'is-disabled': !canUploadGroup(group),
'attachment-upload-card--centered': centerUploadCardContent,
}"
role="button"
:tabindex="canUploadGroup(group) ? 0 : -1"
@click="pickPendingNative(group)"
@keydown.enter.space.prevent="pickPendingNative(group)"
>
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<div class="upload-trigger">
@@ -107,11 +123,17 @@
class="pending-file"
:class="{ 'is-failed': item.status === 'failed' }"
>
<span class="pending-file-name">{{ item.file.name }}</span>
<span v-if="item.status === 'failed'" class="pending-file-status">
{{ item.error || TEXT.common.messages.uploadFailed }}
<span class="pending-file-icon" aria-hidden="true">
<el-icon><Document /></el-icon>
</span>
<el-button link type="danger" size="small" @click="removePendingUpload(group.key, item)">
<span class="pending-file-info">
<span class="pending-file-name">{{ item.file.name }}</span>
<span class="pending-file-meta">
<span>{{ formatFileSize(item.file.size) }}</span>
<span class="pending-file-status">{{ pendingStatusText(item) }}</span>
</span>
</span>
<el-button class="pending-file-remove" link type="danger" size="small" @click="removePendingUpload(group.key, item)">
{{ TEXT.common.actions.remove }}
</el-button>
</div>
@@ -125,13 +147,13 @@
</div>
</div>
<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>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<PdfViewer v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" :filename="previewTitle" show-close @close="previewVisible = false" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
@@ -140,9 +162,10 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { Document, UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
@@ -152,7 +175,10 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
import { detectFilePreviewKind } from "../../utils/officePreview";
type AttachmentEntityGroup = {
entityType: string;
@@ -160,6 +186,8 @@ type AttachmentEntityGroup = {
uploadText?: string;
};
const PdfViewer = defineAsyncComponent(() => import("../PdfViewer.vue"));
type UploadGroup = {
key: string;
label: string;
@@ -197,6 +225,7 @@ const progressMap = reactive<Record<string, number>>({});
const pendingMap = reactive<Record<string, PendingUploadItem[]>>({});
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const previewVisible = ref(false);
const previewUrl = ref("");
const previewObjectUrl = ref("");
@@ -252,6 +281,7 @@ const tableProgress = computed(() => {
return group ? progressMap[group.key] || 0 : 0;
});
const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100);
const desktopActivityEnabled = () => nativeFiles;
const validateFile = (file: File) => {
if (file.size > maxSize.value * 1024 * 1024) {
@@ -280,7 +310,7 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return;
const selected = await pickFiles({ multiple: true, title: group.label });
const selected = await pickFilesWithFeedback({ multiple: true, title: group.label });
selected.forEach((file) => queuePendingFile(group.key, file));
};
@@ -288,18 +318,31 @@ const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
};
const pendingStatusText = (item: PendingUploadItem) => {
if (item.status === "uploading") return "正在上传";
if (item.status === "failed") return item.error || TEXT.common.messages.uploadFailed;
return "待上传";
};
const pendingSnapshot = () =>
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
const uploadFile = async (group: UploadGroup, file: File, targetEntityId: string) => {
const uploadFile = async (
group: UploadGroup,
file: File,
targetEntityId: string,
onProgress?: (progress: number) => void,
) => {
if (!props.studyId || !targetEntityId) return;
if (!validateFile(file)) throw new Error("invalid file");
progressMap[group.key] = 0;
await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progressMap[group.key] = Math.round((evt.loaded / evt.total) * 100);
const progress = Math.round((evt.loaded / evt.total) * 100);
progressMap[group.key] = progress;
onProgress?.(progress);
}
},
});
@@ -312,6 +355,16 @@ const uploadPending = async (entityId?: string) => {
}
if (!targetEntityId) return;
let hasFailure = false;
const totalCount = uploadGroups.value.reduce((sum, group) => sum + (pendingMap[group.key] || []).length, 0);
const activityId = desktopActivityEnabled() && totalCount > 0
? startDesktopActivity({
kind: "upload",
title: "上传附件",
detail: `正在上传 ${totalCount} 个附件`,
progress: 0,
})
: "";
let completedCount = 0;
for (const group of uploadGroups.value) {
const items = [...(pendingMap[group.key] || [])];
const remainItems: PendingUploadItem[] = [];
@@ -319,19 +372,36 @@ const uploadPending = async (entityId?: string) => {
try {
item.status = "uploading";
item.error = "";
await uploadFile(group, item.file, targetEntityId);
await uploadFile(group, item.file, targetEntityId, (progress) => {
if (!activityId || !totalCount) return;
updateDesktopActivity(activityId, {
detail: `正在上传 ${completedCount + 1}/${totalCount}`,
progress: Math.min(99, Math.round(((completedCount + progress / 100) / totalCount) * 100)),
});
});
} catch (e: any) {
hasFailure = true;
item.status = "failed";
item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed;
remainItems.push(item);
} finally {
completedCount += 1;
if (activityId && totalCount) {
updateDesktopActivity(activityId, {
detail: `已处理 ${completedCount}/${totalCount}`,
progress: Math.min(99, Math.round((completedCount / totalCount) * 100)),
});
}
progressMap[group.key] = 0;
}
}
pendingMap[group.key] = remainItems;
}
if (hasFailure) throw new Error(TEXT.common.messages.uploadFailed);
if (hasFailure) {
finishDesktopActivity(activityId, "failed", { detail: "部分附件上传失败" });
throw new Error(TEXT.common.messages.uploadFailed);
}
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
};
const uploadImmediate = async (options: any) => {
@@ -339,11 +409,18 @@ const uploadImmediate = async (options: any) => {
const group = tableUploadGroup.value;
if (!file || !group || !props.entityId) return;
if (!validateFile(file)) return;
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "upload", title: "上传附件", detail: "正在上传附件", progress: 0 })
: "";
try {
await uploadFile(group, file, props.entityId);
ElMessage.success(TEXT.common.messages.uploadSuccess);
await uploadFile(group, file, props.entityId, (progress) => {
updateDesktopActivity(activityId, { detail: "正在上传附件", progress });
});
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
if (!desktopActivityEnabled()) ElMessage.success(TEXT.common.messages.uploadSuccess);
load();
} catch (e: any) {
finishDesktopActivity(activityId, "failed", { detail: "附件上传失败" });
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[group.key] = 0;
@@ -351,7 +428,7 @@ const uploadImmediate = async (options: any) => {
};
const pickImmediateNative = async () => {
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
const [file] = await pickFilesWithFeedback({ multiple: false, title: headerTitle.value });
if (file) await uploadImmediate({ file });
};
@@ -398,40 +475,54 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
};
const download = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "download", title: "下载附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try {
await saveFile({
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "等待选择保存位置", progress: 65 });
await saveFileWithFeedback({
suggestedName: row?.filename || "download",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
data: blob,
}, {
activityId,
title: "下载附件",
pendingDetail: "等待选择保存位置",
completedDetail: "附件已保存",
cancelledDetail: "已取消保存附件",
});
} catch {
finishDesktopActivity(activityId, "failed", { detail: "附件下载失败" });
ElMessage.error(TEXT.common.messages.downloadFailed);
}
};
const openExternally = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "open", title: "打开附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try {
await openFile({
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "正在准备文件", progress: 65 });
await openFileWithFeedback({
suggestedName: row?.filename || "attachment",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
data: blob,
}, {
activityId,
title: "打开附件",
pendingDetail: "正在准备文件",
completedDetail: "已交给系统打开",
});
} catch {
finishDesktopActivity(activityId, "failed", { detail: "附件打开失败" });
ElMessage.error(TEXT.common.messages.previewNotSupported);
}
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
const getMimeType = (row: any) => (row?.mime_type || row?.content_type || "").toLowerCase();
const detectPreviewType = (row: any) => {
const mime = getMimeType(row);
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getFileName(row);
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(row?.filename, row?.mime_type || row?.content_type);
};
const previewImage = async (row: any) => {
@@ -450,7 +541,12 @@ const previewImage = async (row: any) => {
const preview = async (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
const detectedType = detectPreviewType(row);
if (detectedType === "office") {
await router.push(`/office-preview/attachment/${row.id}`);
return;
}
previewType.value = detectedType;
previewTitle.value = row?.filename || TEXT.common.labels.preview;
if (previewType.value === "image" || previewType.value === "pdf") {
previewUrl.value = "";
@@ -513,6 +609,11 @@ defineExpose({
box-shadow: none;
}
.attachment-list-panel--upload {
overflow: visible;
background: transparent;
}
.header {
display: flex;
justify-content: space-between;
@@ -554,6 +655,10 @@ defineExpose({
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.upload-grid--single {
grid-template-columns: minmax(0, 1fr);
}
.attachment-upload-item {
min-width: 0;
}
@@ -565,6 +670,7 @@ defineExpose({
}
.attachment-upload-card {
box-sizing: border-box;
min-height: 72px;
padding: 14px 16px;
border: 1px dashed #d0dced;
@@ -574,6 +680,14 @@ defineExpose({
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.upload-grid--single .attachment-upload-card {
min-height: 92px;
}
.upload-grid--single .upload-trigger {
justify-content: center;
}
.attachment-upload-card:hover,
.attachment-upload-card:focus-within {
border-color: var(--ctms-primary);
@@ -642,50 +756,90 @@ defineExpose({
.pending-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
gap: 8px;
margin-top: 10px;
}
.pending-file {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
min-width: 0;
min-height: 52px;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: var(--ctms-text-regular);
font-size: 12px;
}
.pending-file.is-failed {
border-color: #fecaca;
background: #fff7f7;
color: var(--ctms-danger, #f56c6c);
}
.pending-file-name {
.pending-file-icon {
display: inline-flex;
flex: 0 0 34px;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: #eaf1fb;
color: #314a6b;
font-size: 18px;
}
.pending-file-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.pending-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 600;
}
.pending-file-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--ctms-text-secondary);
font-size: 11px;
}
.pending-file-status {
flex: 0 0 auto;
color: var(--ctms-danger, #f56c6c);
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.pending-file.is-failed .pending-file-status {
color: var(--ctms-danger, #f56c6c);
}
.pending-file-remove {
flex: 0 0 auto;
margin-left: auto;
}
@media (max-width: 640px) {
.upload-grid {
grid-template-columns: 1fr;
}
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
@@ -1,14 +0,0 @@
// 地图数据来源:https://geojson.cn/api/china/1.6.3/china.json
// 组件直接导入本地缓存的 GeoJSON,避免运行时依赖外部网络。
export const normalizeProvinceName = (value?: string | null) => {
return String(value || "")
.trim()
.replace(/特别行政区$/u, "")
.replace(/维吾尔自治区$/u, "")
.replace(/壮族自治区$/u, "")
.replace(/回族自治区$/u, "")
.replace(/自治区$/u, "")
.replace(/省$/u, "")
.replace(/市$/u, "");
};
@@ -0,0 +1,101 @@
<template>
<el-dialog
:model-value="modelValue"
width="420px"
class="collaboration-download-dialog"
append-to-body
destroy-on-close
:show-close="!saving"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<div class="collaboration-download-dialog__title">下载文件</div>
</template>
<div class="collaboration-download-dialog__content">
<div class="collaboration-download-dialog__icon" aria-hidden="true">
<el-icon><Download /></el-icon>
</div>
<div class="collaboration-download-dialog__body">
<div class="collaboration-download-dialog__name" :title="fileName">{{ fileName }}</div>
<p>文件已生成,请选择本机保存位置。</p>
</div>
</div>
<template #footer>
<el-button :disabled="saving" @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('confirm')">选择保存位置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Download } from "@element-plus/icons-vue";
defineProps<{
modelValue: boolean;
fileName: string;
saving?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [];
}>();
</script>
<style>
.collaboration-download-dialog {
border-radius: 12px;
}
.collaboration-download-dialog .el-dialog__header {
margin-right: 0;
padding: 18px 20px 8px;
}
.collaboration-download-dialog .el-dialog__body {
padding: 14px 20px 18px;
}
.collaboration-download-dialog .el-dialog__footer {
padding: 0 20px 18px;
}
.collaboration-download-dialog__title {
color: #172033;
font-size: 17px;
font-weight: 650;
}
.collaboration-download-dialog__content {
display: flex;
align-items: center;
gap: 14px;
}
.collaboration-download-dialog__icon {
display: grid;
flex: 0 0 42px;
width: 42px;
height: 42px;
place-items: center;
border-radius: 10px;
color: #3f6783;
background: #edf3f7;
font-size: 21px;
}
.collaboration-download-dialog__body {
min-width: 0;
}
.collaboration-download-dialog__name {
overflow: hidden;
color: #24364a;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaboration-download-dialog__body p {
margin: 5px 0 0;
color: #718096;
font-size: 13px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,237 @@
<template>
<el-dialog
:model-value="modelValue"
width="900px"
append-to-body
class="collaboration-action-dialog revision-save-as-dialog"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header><strong class="revision-save-as__title">另存为</strong></template>
<div class="revision-save-as">
<aside class="revision-save-as__sidebar">
<div>
<span class="revision-save-as__sidebar-label">保存位置</span>
<button type="button" class="is-active" @click="selectedFolderId = null">
<el-icon><FolderOpened /></el-icon>
<span>项目工作区</span>
</button>
</div>
</aside>
<div class="revision-save-as__browser">
<div class="revision-save-as__toolbar">
<div class="revision-save-as__breadcrumbs">
<button type="button" @click="selectedFolderId = null">项目工作区</button>
<template v-for="folder in breadcrumbs" :key="folder.id">
<span>/</span>
<button type="button" @click="selectedFolderId = folder.id">{{ folder.name }}</button>
</template>
</div>
<el-input v-model="query" clearable placeholder="搜索文件" class="revision-save-as__search" />
</div>
<div v-loading="loadingWorkspace" class="revision-save-as__items">
<button
v-for="folder in visibleFolders"
:key="folder.id"
type="button"
class="revision-save-as__folder"
@click="selectedFolderId = folder.id"
>
<el-icon><Folder /></el-icon>
<span>{{ folder.name }}</span>
</button>
<div v-for="file in visibleFiles" :key="file.id" class="revision-save-as__file" aria-disabled="true">
<span class="revision-save-as__file-badge" :class="`is-${file.file_type}`">{{ file.file_type === "word" ? "W" : file.file_type === "cell" ? "X" : "P" }}</span>
<span>{{ file.title }}</span>
</div>
<el-empty
v-if="!loadingWorkspace && !visibleFolders.length && !visibleFiles.length"
description="当前目录暂无内容"
:image-size="52"
/>
</div>
<div class="revision-save-as__bottom">
<label class="revision-save-as__filename">
<span>文件名称</span>
<el-input v-model="title" maxlength="240" @keyup.enter="submit" />
</label>
<div class="revision-save-as__actions">
<el-button v-if="canCreateFolder" :icon="Plus" text class="revision-save-as__new-folder" @click="createFolder">新建文件夹</el-button>
<span class="revision-save-as__actions-spacer" />
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">另存</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Folder, FolderOpened, Plus } from "@element-plus/icons-vue";
import {
createCollaborationFolder,
fetchCollaborationFiles,
fetchCollaborationFolders,
} from "../../api/collaboration";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import type { CollaborationFile, CollaborationFolder } from "../../types/collaboration";
const props = withDefaults(defineProps<{
modelValue: boolean;
studyId: string;
initialTitle: string;
initialFolderId?: string | null;
saving?: boolean;
canCreateFolder?: boolean;
}>(), {
initialFolderId: null,
saving: false,
canCreateFolder: false,
});
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [payload: { title: string; folderId: string | null }];
}>();
const folders = ref<CollaborationFolder[]>([]);
const files = ref<CollaborationFile[]>([]);
const selectedFolderId = ref<string | null>(null);
const title = ref("");
const query = ref("");
const loadingWorkspace = ref(false);
const breadcrumbs = computed(() => {
const result: CollaborationFolder[] = [];
const seen = new Set<string>();
let folderId = selectedFolderId.value;
while (folderId && !seen.has(folderId)) {
seen.add(folderId);
const folder = folders.value.find((item) => item.id === folderId);
if (!folder) break;
result.unshift(folder);
folderId = folder.parent_id || null;
}
return result;
});
const visibleFolders = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
if (keyword) return folders.value.filter((item) => item.name.toLocaleLowerCase("zh-CN").includes(keyword));
return folders.value.filter((item) => (item.parent_id || null) === selectedFolderId.value);
});
const visibleFiles = computed(() => {
const keyword = query.value.trim().toLocaleLowerCase("zh-CN");
return files.value.filter((item) =>
item.status !== "DELETED" &&
(item.folder_id || null) === selectedFolderId.value &&
(!keyword || item.title.toLocaleLowerCase("zh-CN").includes(keyword)),
);
});
const loadWorkspace = async () => {
if (!props.studyId) return;
loadingWorkspace.value = true;
try {
const [folderResponse, fileResponse] = await Promise.all([
fetchCollaborationFolders(props.studyId),
fetchCollaborationFiles(props.studyId),
]);
folders.value = folderResponse.data;
files.value = fileResponse.data;
} catch (error) {
ElMessage.error(await getApiErrorMessage(error, "工作区加载失败"));
} finally {
loadingWorkspace.value = false;
}
};
const createFolder = async () => {
try {
const { value } = await ElMessageBox.prompt("请输入文件夹名称", "新建文件夹", {
inputPlaceholder: "文件夹名称",
inputValidator: (input) => Boolean(String(input || "").trim()) || "文件夹名称不能为空",
confirmButtonText: "新建",
});
const { data } = await createCollaborationFolder(props.studyId, {
name: value.trim(),
parent_id: selectedFolderId.value,
});
folders.value = [...folders.value, data];
selectedFolderId.value = data.id;
ElMessage.success(`文件夹“${data.name}”已创建`);
} catch (error: any) {
if (error === "cancel" || error === "close") return;
ElMessage.error(await getApiErrorMessage(error, "文件夹创建失败"));
}
};
const submit = () => {
const normalized = title.value.trim();
if (!normalized) {
ElMessage.warning("请输入文件名称");
return;
}
emit("confirm", { title: normalized, folderId: selectedFolderId.value });
};
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
title.value = props.initialTitle;
selectedFolderId.value = props.initialFolderId || null;
query.value = "";
void loadWorkspace();
},
);
</script>
<style scoped>
.revision-save-as { display: grid; height: min(560px, calc(100vh - 112px)); min-height: 430px; overflow: hidden; grid-template-columns: 180px minmax(0, 1fr); background: var(--ctms-bg-card); }
.revision-save-as__title { position: relative; z-index: 1; color: var(--ctms-text-main); font-size: 15px; font-weight: 650; }
.revision-save-as__sidebar { display: flex; min-height: 0; padding: 26px 14px 18px; flex-direction: column; background: color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)); }
.revision-save-as__sidebar-label { display: block; margin: 0 12px 10px; color: var(--ctms-text-disabled); font-size: 11px; letter-spacing: 0.04em; }
.revision-save-as__sidebar button { display: flex; width: 100%; height: 42px; align-items: center; gap: 9px; padding: 0 13px; border: 0; border-radius: 8px; color: var(--ctms-text-regular); background: transparent; font: inherit; font-size: 14px; text-align: left; cursor: pointer; }
.revision-save-as__sidebar button .el-icon { font-size: 17px; }
.revision-save-as__sidebar button.is-active { color: var(--ctms-primary); background: color-mix(in srgb, var(--ctms-primary) 11%, var(--ctms-bg-card)); font-weight: 650; }
.revision-save-as__browser { display: grid; min-width: 0; min-height: 0; grid-template-rows: 66px minmax(0, 1fr) auto; }
.revision-save-as__toolbar { display: flex; min-width: 0; align-items: center; gap: 16px; padding: 12px 18px; }
.revision-save-as__breadcrumbs { display: flex; min-width: 0; align-items: center; gap: 6px; flex: 1; overflow: hidden; color: var(--ctms-text-disabled); }
.revision-save-as__breadcrumbs button { max-width: 150px; overflow: hidden; padding: 3px 2px; border: 0; color: var(--ctms-text-main); background: transparent; font: inherit; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.revision-save-as__breadcrumbs button:hover { color: var(--ctms-primary); }
.revision-save-as__search { width: 220px; }
.revision-save-as__search :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__items { min-height: 0; padding: 16px 22px; overflow: auto; }
.revision-save-as__folder,
.revision-save-as__file { display: flex; width: 100%; min-width: 0; height: 42px; align-items: center; gap: 10px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; font: inherit; font-size: 13px; text-align: left; }
.revision-save-as__folder { color: var(--ctms-text-main); cursor: pointer; }
.revision-save-as__folder:hover { background: var(--ctms-bg-muted); }
.revision-save-as__folder .el-icon { flex: 0 0 auto; color: var(--ctms-primary); font-size: 21px; }
.revision-save-as__folder span,
.revision-save-as__file > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.revision-save-as__file { color: var(--ctms-text-disabled); cursor: default; }
.revision-save-as__file-badge { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 4px; color: #fff; font-size: 9px; font-weight: 750; opacity: 0.42; }
.revision-save-as__file-badge.is-word { background: #4472c4; }
.revision-save-as__file-badge.is-cell { background: #2e8b57; }
.revision-save-as__file-badge.is-slide { background: #d05a35; }
.revision-save-as__items > :deep(.el-empty) { padding-top: 62px; }
.revision-save-as__items > :deep(.el-empty__description) { margin-top: 10px; }
.revision-save-as__items > :deep(.el-empty__description p) { color: var(--ctms-text-disabled); font-size: 12px; }
.revision-save-as__bottom { display: grid; gap: 12px; padding: 14px 18px 16px; background: var(--ctms-bg-card); }
.revision-save-as__filename { display: flex; min-width: 0; align-items: center; gap: 12px; }
.revision-save-as__filename > span { flex: 0 0 auto; color: var(--ctms-text-main); font-size: 13px; }
.revision-save-as__filename .el-input { flex: 1; }
.revision-save-as__filename :deep(.el-input__wrapper) { min-height: 36px; border-radius: 8px; }
.revision-save-as__actions { display: flex; min-width: 0; align-items: center; gap: 10px; }
.revision-save-as__actions > .el-button { min-width: 70px; height: 34px; margin-left: 0; border-radius: 7px; }
.revision-save-as__new-folder.el-button { min-width: auto; padding-left: 0; color: var(--ctms-text-main); }
.revision-save-as__actions-spacer { flex: 1; }
:global(.revision-save-as-dialog.el-dialog) { max-width: calc(100vw - 32px); overflow: hidden; padding: 0; border-radius: 12px; box-shadow: 0 20px 54px rgb(15 23 42 / 20%); }
:global(.revision-save-as-dialog.el-dialog .el-dialog__header) { min-height: 50px; margin: 0; padding: 15px 18px; border-bottom: 0 !important; background: linear-gradient(to right, color-mix(in srgb, var(--ctms-bg-muted) 72%, var(--ctms-bg-card)) 0 180px, var(--ctms-bg-card) 180px 100%); box-shadow: none !important; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__headerbtn) { top: 6px; right: 9px; width: 38px; height: 38px; }
:global(.revision-save-as-dialog.el-dialog .el-dialog__body) { padding: 0; }
</style>
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { buildAdminNavigationItems, buildProjectNavigationItems } from "./navigation";
const labels = (items: ReturnType<typeof buildAdminNavigationItems>) => items.map((item) => item.label);
const webLayoutSource = readFileSync(resolve(__dirname, "../WebLayout.vue"), "utf8");
describe("system management navigation", () => {
it("exposes project management, audit logs, and permission management to PMs", () => {
const items = buildAdminNavigationItems({
hasUser: true,
isAdmin: false,
canAccessAdminPermissions: true,
canAccessAuditLogs: true,
});
expect(labels(items)).toEqual(["项目管理", "审计日志", "权限管理"]);
});
it("does not expose PM system modules to a non-PM account", () => {
const items = buildAdminNavigationItems({
hasUser: true,
isAdmin: false,
canAccessAdminPermissions: false,
canAccessAuditLogs: false,
});
expect(labels(items)).toEqual(["项目管理"]);
});
});
describe("project shared-library navigation", () => {
it("registers online collaboration as an independent shared-library entry", () => {
const groups = buildProjectNavigationItems({
hasCurrentStudy: true,
hasAnyProjectModuleAccess: true,
canAccessProjectPath: () => true,
});
const entries = groups.flatMap((group) => group.children || []);
expect(entries.some((item) => item.path === "/knowledge/collaboration" && item.label === "在线协作")).toBe(true);
});
it("keeps the web sidebar, command navigation, and breadcrumb tree in sync", () => {
expect(webLayoutSource).toContain('index="/knowledge/collaboration"');
expect(webLayoutSource.match(/path: "\/knowledge\/collaboration"/g)?.length).toBeGreaterThanOrEqual(2);
expect(webLayoutSource.match(/TEXT\.menu\.knowledgeCollaboration/g)?.length).toBeGreaterThanOrEqual(3);
});
});
+32 -11
View File
@@ -47,6 +47,8 @@ export const getActiveLayoutPath = (path: string) => {
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
if (/^\/knowledge\/collaboration\/[^/]+$/.test(path)) return path;
if (path.startsWith("/knowledge/collaboration")) return "/knowledge/collaboration";
if (path.startsWith("/projects/")) return "/admin/projects";
if (path.startsWith("/admin/projects/")) return "/admin/projects";
if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring";
@@ -58,6 +60,7 @@ export const buildAdminNavigationItems = (options: {
hasUser: boolean;
isAdmin: boolean;
canAccessAdminPermissions: boolean;
canAccessAuditLogs: boolean;
}): LayoutNavigationItem[] => {
if (!options.hasUser) return [];
@@ -78,15 +81,17 @@ export const buildAdminNavigationItems = (options: {
icon: "project",
keywords: ["project"],
});
if (options.isAdmin || options.canAccessAuditLogs) {
items.push({
label: TEXT.menu.auditLogs,
path: "/admin/audit-logs",
group: TEXT.menu.admin,
icon: "audit",
keywords: ["audit"],
});
}
if (options.isAdmin) {
items.push(
{
label: TEXT.menu.auditLogs,
path: "/admin/audit-logs",
group: TEXT.menu.admin,
icon: "audit",
keywords: ["audit"],
},
{
label: TEXT.menu.systemMonitoring,
path: "/admin/system-monitoring",
@@ -95,11 +100,20 @@ export const buildAdminNavigationItems = (options: {
keywords: ["monitoring"],
},
{
label: "邮件服务",
label: "系统设置",
path: "/admin/email-settings",
group: TEXT.menu.admin,
icon: "settings",
keywords: ["email"],
keywords: ["settings"],
children: [
{
label: "邮件服务",
path: "/admin/email-settings",
group: "系统设置",
icon: "settings",
keywords: ["email", "settings"],
},
],
},
);
}
@@ -119,7 +133,7 @@ export const buildAdminNavigationItems = (options: {
keywords: ["permission", "system"],
},
{
label: "项目权限配置",
label: "项目访问控制",
path: "/admin/permissions/project",
group: TEXT.menu.permissionManagement,
icon: "key",
@@ -280,6 +294,13 @@ export const buildProjectNavigationItems = (options: {
icon: "notebook",
keywords: ["knowledge", "instruction"],
},
{
label: TEXT.menu.knowledgeCollaboration,
path: "/knowledge/collaboration",
group: TEXT.menu.sharedLibrary,
icon: "document",
keywords: ["knowledge", "collaboration", "onlyoffice", "协作"],
},
],
},
];
@@ -295,4 +316,4 @@ export const buildProjectNavigationItems = (options: {
};
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
items.flatMap((item) => [item, ...(item.children || [])]);
items.flatMap((item) => item.children?.length ? item.children : [item]);
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
canMoveWorkspaceTabHistory,
createWorkspaceTabHistory,
currentWorkspaceTabPath,
moveWorkspaceTabHistory,
recordWorkspaceTabPath,
replaceWorkspaceTabPath,
} from "./workspaceTabHistory";
describe("workspace tab history", () => {
it("records full paths without duplicating the current entry", () => {
const initial = createWorkspaceTabHistory("/subjects");
const detail = recordWorkspaceTabPath(initial, "/subjects/subject-1?tab=ae");
expect(detail).toEqual({ entries: ["/subjects", "/subjects/subject-1?tab=ae"], index: 1 });
expect(recordWorkspaceTabPath(detail, "/subjects/subject-1?tab=ae")).toBe(detail);
});
it("moves backward and forward without adding entries", () => {
const detail = recordWorkspaceTabPath(createWorkspaceTabHistory("/subjects"), "/subjects/subject-1");
const backward = moveWorkspaceTabHistory(detail, -1);
const forward = moveWorkspaceTabHistory(backward?.history, 1);
expect(backward?.path).toBe("/subjects");
expect(forward?.path).toBe("/subjects/subject-1");
expect(canMoveWorkspaceTabHistory(backward?.history, -1)).toBe(false);
expect(canMoveWorkspaceTabHistory(backward?.history, 1)).toBe(true);
});
it("drops forward entries after a new navigation branch", () => {
const detail = recordWorkspaceTabPath(createWorkspaceTabHistory("/subjects"), "/subjects/subject-1");
const backward = moveWorkspaceTabHistory(detail, -1);
const branch = recordWorkspaceTabPath(backward?.history, "/subjects/new");
expect(branch).toEqual({ entries: ["/subjects", "/subjects/new"], index: 1 });
expect(canMoveWorkspaceTabHistory(branch, 1)).toBe(false);
});
it("replaces canonical redirects without creating a backward loop", () => {
const canonical = replaceWorkspaceTabPath(
createWorkspaceTabHistory("/file-versions"),
"/trial/study-1/documents",
);
expect(canonical).toEqual({ entries: ["/trial/study-1/documents"], index: 0 });
expect(canMoveWorkspaceTabHistory(canonical, -1)).toBe(false);
expect(currentWorkspaceTabPath(canonical, "/file-versions")).toBe("/trial/study-1/documents");
});
});
@@ -0,0 +1,54 @@
export type WorkspaceTabHistory = {
entries: string[];
index: number;
};
export type WorkspaceTabHistoryMove = {
history: WorkspaceTabHistory;
path: string;
};
const WORKSPACE_TAB_HISTORY_MAX_ENTRIES = 50;
export const createWorkspaceTabHistory = (path: string): WorkspaceTabHistory => ({ entries: [path], index: 0 });
export const recordWorkspaceTabPath = (
history: WorkspaceTabHistory | undefined,
path: string,
): WorkspaceTabHistory => {
if (!history?.entries.length) return createWorkspaceTabHistory(path);
if (history.entries[history.index] === path) return history;
const entries = [...history.entries.slice(0, history.index + 1), path].slice(-WORKSPACE_TAB_HISTORY_MAX_ENTRIES);
return { entries, index: entries.length - 1 };
};
export const replaceWorkspaceTabPath = (
history: WorkspaceTabHistory | undefined,
path: string,
): WorkspaceTabHistory => {
if (!history?.entries.length) return createWorkspaceTabHistory(path);
if (history.entries[history.index] === path) return history;
const entries = [...history.entries];
entries[history.index] = path;
return { entries, index: history.index };
};
export const moveWorkspaceTabHistory = (
history: WorkspaceTabHistory | undefined,
offset: -1 | 1,
): WorkspaceTabHistoryMove | null => {
if (!history?.entries.length) return null;
const index = history.index + offset;
if (index < 0 || index >= history.entries.length) return null;
return { history: { ...history, index }, path: history.entries[index] };
};
export const currentWorkspaceTabPath = (history: WorkspaceTabHistory | undefined, fallbackPath: string) =>
history?.entries[history.index] || fallbackPath;
export const canMoveWorkspaceTabHistory = (history: WorkspaceTabHistory | undefined, offset: -1 | 1) => {
if (!history?.entries.length) return false;
const index = history.index + offset;
return index >= 0 && index < history.entries.length;
};
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceTabTitle,
buildWorkspaceToolbarTitle,
resolveWorkspaceTabContextTitle,
} from "./workspaceTabTitle";
describe("workspace tab title", () => {
it("keeps the module title when no detail context is available", () => {
expect(buildWorkspaceTabTitle({ moduleTitle: "参与者管理", routeTitle: "参与者详情" })).toBe("参与者管理");
});
it("combines the detail object type with its loaded identifier", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "参与者管理",
routeTitle: "参与者详情",
contextTitle: "SUBJ-001",
}),
).toBe("参与者 · SUBJ-001");
});
it("uses a non-detail route title as the context prefix", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "文件版本管理",
routeTitle: "文件版本管理",
contextTitle: "知情同意书",
}),
).toBe("文件版本管理 · 知情同意书");
});
it("avoids duplicate fallback titles", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "设备管理",
routeTitle: "设备详情",
contextTitle: "设备详情",
}),
).toBe("设备详情");
});
it("keeps a loaded context when the same detail route temporarily clears its live context", () => {
expect(
resolveWorkspaceTabContextTitle({
routePath: "/subjects/subject-1",
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
}),
).toBe("SUBJ-001");
});
it("does not leak a stored title into another detail route", () => {
expect(
resolveWorkspaceTabContextTitle({
routePath: "/subjects/subject-2",
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
}),
).toBeUndefined();
});
it("uses the business object type in the desktop toolbar", () => {
expect(
buildWorkspaceToolbarTitle({
moduleTitle: "文件版本管理",
routeTitle: "文件版本管理",
contextTitle: "11",
contextObjectType: "SOP",
}),
).toBe("SOP · 11");
});
it("derives a concise object label from a detail route", () => {
expect(
buildWorkspaceToolbarTitle({
moduleTitle: "参与者管理",
routeTitle: "参与者详情",
contextTitle: "S01",
}),
).toBe("参与者 · S01");
});
});
@@ -0,0 +1,53 @@
interface WorkspaceTabTitleInput {
moduleTitle: string;
routeTitle?: string;
contextTitle?: string;
}
export interface WorkspaceTabContext {
routePath: string;
pageTitle: string;
objectType?: string;
}
interface WorkspaceTabContextTitleInput {
liveContextTitle?: string;
storedContext?: WorkspaceTabContext;
routePath: string;
}
export const resolveWorkspaceTabContextTitle = ({
liveContextTitle,
storedContext,
routePath,
}: WorkspaceTabContextTitleInput) =>
liveContextTitle || (storedContext?.routePath === routePath ? storedContext.pageTitle : undefined);
interface WorkspaceToolbarTitleInput extends WorkspaceTabTitleInput {
contextObjectType?: string;
}
export const buildWorkspaceToolbarTitle = ({
moduleTitle,
routeTitle,
contextTitle,
contextObjectType,
}: WorkspaceToolbarTitleInput) => {
const contextLabel = contextTitle?.trim();
if (!contextLabel) return moduleTitle.trim();
const objectLabel = contextObjectType?.trim() || routeTitle?.replace(/详情$/, "").trim() || moduleTitle.trim();
if (!objectLabel || contextLabel === objectLabel) return contextLabel;
return `${objectLabel} · ${contextLabel}`;
};
export const buildWorkspaceTabTitle = ({ moduleTitle, routeTitle, contextTitle }: WorkspaceTabTitleInput) => {
const moduleLabel = moduleTitle.trim();
const routeLabel = routeTitle?.trim() || moduleLabel;
const contextLabel = contextTitle?.trim();
if (!contextLabel || contextLabel === moduleLabel) return moduleLabel;
if (contextLabel === routeLabel) return routeLabel;
const objectLabel = routeLabel.replace(/详情$/, "").trim() || moduleLabel;
if (contextLabel === objectLabel) return routeLabel;
return `${objectLabel} · ${contextLabel}`;
};
@@ -0,0 +1,7 @@
import type { InjectionKey } from "vue";
export interface WorkspaceTaskController {
closeTransientTask(path: string): boolean;
}
export const workspaceTaskControllerKey: InjectionKey<WorkspaceTaskController> = Symbol("workspaceTaskController");
@@ -1,49 +0,0 @@
<template>
<el-select
:model-value="modelValue"
:multiple="multiple"
:placeholder="placeholder || TEXT.common.placeholders.select"
:filterable="filterable"
:clearable="clearable"
:loading="loading"
style="width: 100%"
@update:model-value="onUpdate"
>
<el-option v-for="opt in options" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
<slot />
</el-select>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { TEXT } from "../../locales";
type OptionItem = {
label: string;
value: string | number;
};
const props = defineProps<{
modelValue?: string | number | Array<string | number> | null;
options?: OptionItem[];
multiple?: boolean;
placeholder?: string;
filterable?: boolean;
clearable?: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string | number | Array<string | number> | null): void;
}>();
const onUpdate = (value: string | number | Array<string | number> | null) => {
emit("update:modelValue", value);
};
const options = computed(() => props.options ?? []);
const multiple = computed(() => props.multiple ?? false);
const filterable = computed(() => props.filterable ?? true);
const clearable = computed(() => props.clearable ?? true);
const loading = computed(() => props.loading ?? false);
</script>
-110
View File
@@ -1,110 +0,0 @@
// 地图数据来源:https://unpkg.com/echarts@4.9.0/map/json/world.json
// 组件直接导入本地缓存的 ECharts 世界地图 GeoJSON,避免运行时依赖外部网络。
import type { IpLocationStatItem } from "../types/api";
export const normalizeWorldCountryName = (value?: string | null) => {
const country = String(value || "").trim();
const aliases: Record<string, string> = {
中国: "China",
China: "China",
"Mainland China": "China",
中国香港: "China",
中国澳门: "China",
中国台湾: "China",
美国: "United States",
"United States": "United States",
"United States of America": "United States",
USA: "United States",
Australia: "Australia",
澳大利亚: "Australia",
Japan: "Japan",
日本: "Japan",
Singapore: "Singapore",
新加坡: "Singapore",
Germany: "Germany",
德国: "Germany",
France: "France",
法国: "France",
"United Kingdom": "United Kingdom",
英国: "United Kingdom",
Canada: "Canada",
加拿大: "Canada",
India: "India",
印度: "India",
Russia: "Russia",
俄罗斯: "Russia",
};
return aliases[country] || country;
};
export const countryCoordinates: Record<string, [number, number]> = {
China: [104.1954, 35.8617],
"United States": [-95.7129, 37.0902],
Netherlands: [5.2913, 52.1326],
Türkiye: [35.2433, 38.9637],
Turkey: [35.2433, 38.9637],
Australia: [133.7751, -25.2744],
Japan: [138.2529, 36.2048],
Singapore: [103.8198, 1.3521],
Germany: [10.4515, 51.1657],
France: [2.2137, 46.2276],
"United Kingdom": [-3.436, 55.3781],
Canada: [-106.3468, 56.1304],
India: [78.9629, 20.5937],
Russia: [105.3188, 61.524],
};
export const cityCoordinates: Record<string, [number, number]> = {
"局域网": [121.86, 31.45],
"北京市": [116.4074, 39.9042],
"天津市": [117.2008, 39.0842],
"河北省": [114.5025, 38.0455],
"山西省": [112.5492, 37.857],
"内蒙古自治区": [111.6708, 40.8183],
"辽宁省": [123.4315, 41.8057],
"吉林省": [125.3245, 43.8868],
"黑龙江省": [126.6424, 45.7567],
"上海市": [121.4737, 31.2304],
"江苏省": [118.7633, 32.0617],
"浙江省": [120.1551, 30.2741],
"安徽省": [117.2272, 31.8206],
"福建省": [119.2965, 26.0745],
"江西省": [115.8582, 28.682],
"山东省": [117.1201, 36.6512],
"河南省": [113.6254, 34.7466],
"湖北省": [114.3055, 30.5928],
"湖南省": [112.9388, 28.2282],
"广东省": [113.2644, 23.1291],
"广西壮族自治区": [108.3669, 22.817],
"海南省": [110.3312, 20.0311],
"重庆": [106.5516, 29.563],
"重庆市": [106.5516, 29.563],
"四川省": [104.0665, 30.5723],
"贵州省": [106.6302, 26.647],
"云南省": [102.8329, 24.8801],
"西藏自治区": [91.1322, 29.6604],
"陕西省": [108.9398, 34.3416],
"甘肃省": [103.8343, 36.0611],
"青海省": [101.7782, 36.6171],
"宁夏回族自治区": [106.2309, 38.4872],
"新疆维吾尔自治区": [87.6168, 43.8256],
"台湾省": [121.5654, 25.033],
"香港特别行政区": [114.1694, 22.3193],
"澳门特别行政区": [113.5439, 22.1987],
"南京": [118.7969, 32.0603],
"南京市": [118.7969, 32.0603],
"San Jose": [-121.8863, 37.3382],
"South Holland": [4.493, 52.0208],
"Istanbul": [28.9784, 41.0082],
};
export const resolveSourceCoordinate = (item: IpLocationStatItem): [number, number] | null => {
if (item.location === "局域网") return cityCoordinates["局域网"];
const city = String(item.city || "").trim();
if (city && cityCoordinates[city]) return cityCoordinates[city];
const province = String(item.province || "").trim();
if (province && cityCoordinates[province]) return cityCoordinates[province];
const country = normalizeWorldCountryName(item.country);
return country ? countryCoordinates[country] || null : null;
};