release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user