发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-01 10:53:24 +08:00
parent b283cf1e5c
commit b491b6a146
132 changed files with 17337 additions and 2375 deletions
@@ -0,0 +1,212 @@
<template>
<el-dialog
v-model="visibleProxy"
class="desktop-command-dialog"
width="640px"
align-center
:show-close="false"
:close-on-click-modal="true"
destroy-on-close
@opened="focusSearch"
>
<div class="desktop-command-palette">
<div class="command-search-row">
<el-icon><Search /></el-icon>
<input
ref="searchInputRef"
v-model="query"
class="command-search-input"
autocomplete="off"
placeholder="搜索模块、项目或桌面操作"
@keydown.enter.prevent="runFirstCommand"
@keydown.esc.prevent="visibleProxy = false"
/>
<kbd>Esc</kbd>
</div>
<div class="command-list" role="listbox">
<template v-if="groupedCommands.length">
<section v-for="group in groupedCommands" :key="group.name" class="command-group">
<div class="command-group-title">{{ group.name }}</div>
<button
v-for="command in group.items"
:key="command.id"
type="button"
class="command-item"
@click="runCommand(command)"
>
<span class="command-item-title">{{ command.title }}</span>
<kbd v-if="command.shortcut">{{ command.shortcut }}</kbd>
</button>
</section>
</template>
<div v-else class="command-empty">没有匹配的命令</div>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { Search } from "@element-plus/icons-vue";
import { getVisibleDesktopCommands, type DesktopCommand } from "../types/desktopCommands";
const props = defineProps<{
modelValue: boolean;
commands: DesktopCommand[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const query = ref("");
const searchInputRef = ref<HTMLInputElement>();
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
const filteredCommands = computed(() => getVisibleDesktopCommands(props.commands, query.value));
const groupedCommands = computed(() => {
const map = new Map<string, DesktopCommand[]>();
filteredCommands.value.forEach((command) => {
const items = map.get(command.group) || [];
items.push(command);
map.set(command.group, items);
});
return Array.from(map.entries()).map(([name, items]) => ({ name, items }));
});
const focusSearch = () => {
nextTick(() => searchInputRef.value?.focus());
};
const runCommand = async (command: DesktopCommand) => {
visibleProxy.value = false;
await command.run();
};
const runFirstCommand = () => {
const [first] = filteredCommands.value;
if (first) void runCommand(first);
};
watch(
() => props.modelValue,
(visible) => {
if (visible) {
query.value = "";
focusSearch();
}
},
);
</script>
<style scoped>
.desktop-command-palette {
display: flex;
flex-direction: column;
gap: 10px;
}
.command-search-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 12px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #ffffff;
color: #475569;
}
.command-search-input {
min-width: 0;
border: 0;
outline: 0;
color: #0f172a;
font-size: 15px;
background: transparent;
}
.command-list {
max-height: min(58vh, 520px);
overflow-y: auto;
padding: 4px;
}
.command-group + .command-group {
margin-top: 10px;
}
.command-group-title {
padding: 6px 8px;
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.command-item {
width: 100%;
min-height: 38px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #0f172a;
text-align: left;
cursor: pointer;
}
.command-item:hover,
.command-item:focus-visible {
background: #eef4ff;
outline: none;
}
.command-item-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
kbd {
min-width: 28px;
padding: 2px 6px;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: #f8fafc;
color: #475569;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
text-align: center;
}
.command-empty {
padding: 36px 12px;
color: #64748b;
font-size: 14px;
text-align: center;
}
</style>
<style>
.desktop-command-dialog .el-dialog__header {
display: none;
}
.desktop-command-dialog .el-dialog__body {
padding: 12px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
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");
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource();
expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
});
it("uses route-only desktop preference storage", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
expect(source).toContain("currentDesktopRoutePreference");
});
it("shares active route mapping between web and desktop shells", () => {
const webLayout = readWebLayoutSource();
const desktopLayout = readDesktopLayoutSource();
const navigation = readNavigationSource();
expect(webLayout).toContain("getActiveLayoutPath(route.path)");
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath");
});
});
File diff suppressed because it is too large Load Diff
@@ -154,6 +154,7 @@ import type {
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFile } from "@/runtime";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false,
@@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
const account = row.account_label || "未知账号";
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
const userAgent = row.user_agent || "-";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const terminalLogRows = computed(() =>
@@ -515,14 +517,9 @@ const openSecurityLogDialog = () => {
scrollSecurityTerminalToBottom();
};
const downloadLogFile = (fileName: string, lines: string[]) => {
const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
};
const downloadInterfaceLog = () => {
@@ -70,7 +70,7 @@ const continueSession = () => {
};
const logoutNow = () => {
forceLogout(LOGOUT_REASON_MANUAL);
void forceLogout(LOGOUT_REASON_MANUAL);
};
watch(
+21 -1
View File
@@ -11,9 +11,12 @@
<el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
<div v-if="allowAttachments" class="upload-row">
<div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
<el-upload v-if="!nativeFiles" v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
<el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
</el-upload>
<el-button v-else size="small" class="upload-button" @click="pickNativeAttachments">
{{ TEXT.common.actions.upload }}
</el-button>
</div>
<div class="actions">
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
@@ -26,6 +29,7 @@
import { computed } from "vue";
import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -46,6 +50,7 @@ const emit = defineEmits<{
(e: "cancel"): void;
(e: "clear-quote"): void;
}>();
const nativeFiles = clientRuntime.capabilities().nativeFiles;
const contentProxy = computed({
get: () => props.modelValue,
@@ -58,6 +63,21 @@ 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 existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`))
.map((file) => ({
name: file.name,
size: file.size,
raw: file as any,
status: "ready",
uid: Date.now() + Math.floor(Math.random() * 100000),
}));
emit("update:fileList", [...props.fileList, ...additions]);
};
</script>
<style scoped>
+35 -5
View File
@@ -45,6 +45,9 @@
</template>
<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 { TEXT } from "../locales";
@@ -68,13 +71,37 @@ const quoteContent = (item: any) =>
const contentText = (item: any) =>
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
const attachmentUrl = (id: string) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
const attachmentObjectUrls = reactive<Record<string, string>>({});
const attachmentUrl = (id: string) => attachmentObjectUrls[id] || "";
const revokeAttachmentUrls = () => {
Object.values(attachmentObjectUrls).forEach((url) => URL.revokeObjectURL(url));
Object.keys(attachmentObjectUrls).forEach((key) => delete attachmentObjectUrls[key]);
};
const download = (id: string) => {
window.open(attachmentUrl(id), "_blank");
const loadImageUrls = async () => {
revokeAttachmentUrls();
const files = Object.values(props.attachmentsMap).flat().filter(isImage);
await Promise.all(files.map(async (file) => {
try {
const response = await downloadAttachment(file.id);
attachmentObjectUrls[file.id] = URL.createObjectURL(
new Blob([response.data], { type: response.headers?.["content-type"] || file.content_type }),
);
} catch {
attachmentObjectUrls[file.id] = "";
}
}));
};
const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id);
await saveFile({
suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data,
});
};
const isImage = (file: any) => {
@@ -88,6 +115,9 @@ const isHighlighted = (item: any) => {
if (!props.highlightIds?.length) return false;
return props.highlightIds.includes(item?.id);
};
watch(() => props.attachmentsMap, () => void loadImageUrls(), { deep: true, immediate: true });
onBeforeUnmount(revokeAttachmentUrls);
</script>
<style scoped>
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@
<span>{{ headerTitle }}</span>
<div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
<el-upload
v-if="!nativeFiles"
:http-request="uploadImmediate"
:show-file-list="false"
:limit="1"
@@ -13,6 +14,9 @@
>
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
</el-upload>
<el-button v-else type="primary" :loading="isImmediateUploading" @click="pickImmediateNative">
{{ TEXT.common.actions.upload }}
</el-button>
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
</div>
</div>
@@ -41,6 +45,7 @@
{{ TEXT.common.labels.preview }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
<el-button link type="primary" size="small" @click="openExternally(scope.row)">打开</el-button>
<el-button
v-if="canDelete(scope.row)"
link
@@ -58,6 +63,7 @@
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
<el-upload
v-if="!nativeFiles"
class="attachment-card-upload"
:show-file-list="false"
multiple
@@ -79,6 +85,21 @@
</div>
</div>
</el-upload>
<div
v-else
class="attachment-upload-card"
:class="{
'is-disabled': !canUploadGroup(group),
'attachment-upload-card--centered': centerUploadCardContent,
}"
@click="pickPendingNative(group)"
>
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<div class="upload-trigger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
</div>
</div>
<div v-if="pendingMap[group.key]?.length" class="pending-list">
<div
v-for="item in pendingMap[group.key]"
@@ -122,7 +143,7 @@
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
@@ -131,6 +152,7 @@ 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";
type AttachmentEntityGroup = {
entityType: string;
@@ -182,6 +204,7 @@ const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const previewLoading = ref(false);
const nativeFiles = clientRuntime.capabilities().nativeFiles;
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
const displayMode = computed(() => props.mode || "table");
const maxSize = computed(() => props.maxSizeMb ?? 50);
@@ -240,10 +263,9 @@ const validateFile = (file: File) => {
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
const queuePendingUpload = (fileType: string, uploadFile: any) => {
const queuePendingFile = (fileType: string, file: File) => {
const group = uploadGroups.value.find((item) => item.key === fileType);
if (!group || !canUploadGroup(group)) return;
const file = uploadFile?.raw as File | undefined;
if (!file || !validateFile(file)) return;
const next = pendingMap[fileType] || [];
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
@@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
}
};
const queuePendingUpload = (fileType: string, uploadFile: any) => {
const file = uploadFile?.raw as File | undefined;
if (file) queuePendingFile(fileType, file);
};
const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return;
const selected = await pickFiles({ multiple: true, title: group.label });
selected.forEach((file) => queuePendingFile(group.key, file));
};
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
};
@@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => {
}
};
const pickImmediateNative = async () => {
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
if (file) await uploadImmediate({ file });
};
const load = async () => {
if (!props.studyId || !props.entityId) return;
loading.value = true;
@@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => {
return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
};
const getDownloadUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
const response = await downloadAttachment(row.id);
return new Blob([response.data], {
type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream",
});
};
const getPreviewUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
const download = async (row: any) => {
try {
await saveFile({
suggestedName: row?.filename || "download",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
});
} catch {
ElMessage.error(TEXT.common.messages.downloadFailed);
}
};
const download = (row: any) => {
const url = getDownloadUrl(row);
const link = document.createElement("a");
link.href = url;
link.download = row?.filename || "download";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const openExternally = async (row: any) => {
try {
await openFile({
suggestedName: row?.filename || "attachment",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
});
} catch {
ElMessage.error(TEXT.common.messages.previewNotSupported);
}
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
@@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => {
return "other";
};
const previewImage = async (downloadUrl: string) => {
const previewImage = async (row: any) => {
previewLoading.value = true;
try {
const token = localStorage.getItem("ctms_token");
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(downloadUrl, { headers });
if (!response.ok) throw new Error("preview failed");
const blob = await response.blob();
const blob = await fetchAttachmentBlob(row);
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch {
previewUrl.value = downloadUrl;
previewError.value = TEXT.common.messages.previewNotSupported;
} finally {
previewLoading.value = false;
}
};
const preview = (row: any) => {
const preview = async (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
const downloadUrl = getDownloadUrl(row);
if (previewType.value === "image") {
if (previewType.value === "image" || previewType.value === "pdf") {
previewUrl.value = "";
previewImage(downloadUrl);
} else if (previewType.value === "pdf") {
previewUrl.value = getPreviewUrl(row);
await previewImage(row);
} else {
previewUrl.value = downloadUrl;
previewUrl.value = "";
}
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
@@ -0,0 +1,298 @@
import { TEXT } from "../../locales";
export type LayoutNavigationIcon =
| "audit"
| "calendar"
| "check"
| "coin"
| "dashboard"
| "document"
| "flag"
| "folder"
| "key"
| "monitor"
| "notebook"
| "project"
| "settings"
| "subject"
| "users";
export type LayoutNavigationItem = {
label: string;
path: string;
group: string;
icon: LayoutNavigationIcon;
keywords?: string[];
children?: LayoutNavigationItem[];
};
export const getActiveLayoutPath = (path: string) => {
if (path.startsWith("/project/milestones")) return "/project/milestones";
if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
return "/startup/meeting-auth";
}
if (path.startsWith("/subjects")) return "/subjects";
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
if (path.startsWith("/etmf")) return "/etmf";
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
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 (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";
if (path.startsWith("/admin/permissions/")) return path;
return path;
};
export const buildAdminNavigationItems = (options: {
hasUser: boolean;
isAdmin: boolean;
canAccessAdminPermissions: boolean;
}): LayoutNavigationItem[] => {
if (!options.hasUser) return [];
const items: LayoutNavigationItem[] = [];
if (options.isAdmin) {
items.push({
label: TEXT.menu.accountManagement,
path: "/admin/users",
group: TEXT.menu.admin,
icon: "users",
keywords: ["user", "account"],
});
}
items.push({
label: TEXT.menu.projectManagement,
path: "/admin/projects",
group: TEXT.menu.admin,
icon: "project",
keywords: ["project"],
});
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",
group: TEXT.menu.admin,
icon: "monitor",
keywords: ["monitoring"],
},
{
label: "邮件服务",
path: "/admin/email-settings",
group: TEXT.menu.admin,
icon: "settings",
keywords: ["email"],
},
);
}
if (options.canAccessAdminPermissions) {
items.push({
label: TEXT.menu.permissionManagement,
path: "/admin/permissions/system",
group: TEXT.menu.admin,
icon: "key",
keywords: ["permission"],
children: [
{
label: "系统级权限",
path: "/admin/permissions/system",
group: TEXT.menu.permissionManagement,
icon: "key",
keywords: ["permission", "system"],
},
{
label: "项目权限配置",
path: "/admin/permissions/project",
group: TEXT.menu.permissionManagement,
icon: "key",
keywords: ["permission", "project"],
},
],
});
}
return items;
};
export const buildProjectNavigationItems = (options: {
hasCurrentStudy: boolean;
hasAnyProjectModuleAccess: boolean;
canAccessProjectPath: (path: string) => boolean;
}): LayoutNavigationItem[] => {
if (!options.hasCurrentStudy || !options.hasAnyProjectModuleAccess) return [];
const items: LayoutNavigationItem[] = [
{
label: TEXT.menu.projectOverview,
path: "/project/overview",
group: TEXT.menu.currentProject,
icon: "dashboard",
keywords: ["overview"],
},
{
label: TEXT.menu.projectMilestones,
path: "/project/milestones",
group: TEXT.menu.currentProject,
icon: "calendar",
keywords: ["milestone"],
},
{
label: TEXT.menu.feeContracts,
path: "/fees/contracts",
group: TEXT.menu.currentProject,
icon: "coin",
keywords: ["fee", "contract"],
},
{
label: TEXT.menu.materialManagement,
path: "/drug/shipments",
group: TEXT.menu.currentProject,
icon: "folder",
children: [
{
label: TEXT.menu.drugShipments,
path: "/drug/shipments",
group: TEXT.menu.materialManagement,
icon: "folder",
keywords: ["drug", "shipment"],
},
{
label: TEXT.menu.materialEquipment,
path: "/materials/equipment",
group: TEXT.menu.materialManagement,
icon: "folder",
keywords: ["material", "equipment"],
},
],
},
{
label: TEXT.menu.fileVersionManagement,
path: "/file-versions",
group: TEXT.menu.currentProject,
icon: "document",
keywords: ["document", "file", "version"],
},
{
label: TEXT.menu.startupFeasibilityEthics,
path: "/startup/feasibility-ethics",
group: TEXT.menu.currentProject,
icon: "calendar",
keywords: ["startup", "ethics"],
},
{
label: TEXT.menu.startupMeetingAuth,
path: "/startup/meeting-auth",
group: TEXT.menu.currentProject,
icon: "check",
keywords: ["meeting", "training"],
},
{
label: TEXT.menu.subjects,
path: "/subjects",
group: TEXT.menu.currentProject,
icon: "subject",
keywords: ["subject"],
},
{
label: TEXT.menu.riskIssues,
path: "/risk-issues/sae",
group: TEXT.menu.currentProject,
icon: "flag",
children: [
{
label: TEXT.menu.riskIssueSae,
path: "/risk-issues/sae",
group: TEXT.menu.riskIssues,
icon: "flag",
keywords: ["sae", "risk"],
},
{
label: TEXT.menu.riskIssuePd,
path: "/risk-issues/pd",
group: TEXT.menu.riskIssues,
icon: "flag",
keywords: ["pd", "risk"],
},
{
label: TEXT.menu.riskIssueMonitoringVisits,
path: "/risk-issues/monitoring-visits",
group: TEXT.menu.riskIssues,
icon: "flag",
keywords: ["monitoring", "visit"],
},
],
},
{
label: TEXT.menu.etmf,
path: "/etmf",
group: TEXT.menu.currentProject,
icon: "folder",
keywords: ["etmf"],
},
{
label: TEXT.menu.sharedLibrary,
path: "/knowledge/medical-consult",
group: TEXT.menu.currentProject,
icon: "notebook",
children: [
{
label: TEXT.menu.knowledgeMedicalConsult,
path: "/knowledge/medical-consult",
group: TEXT.menu.sharedLibrary,
icon: "notebook",
keywords: ["knowledge", "medical"],
},
{
label: TEXT.menu.knowledgeNotes,
path: "/knowledge/precautions",
group: TEXT.menu.sharedLibrary,
icon: "notebook",
keywords: ["knowledge", "note"],
},
{
label: TEXT.menu.knowledgeSupportFiles,
path: "/knowledge/support-files",
group: TEXT.menu.sharedLibrary,
icon: "notebook",
keywords: ["knowledge", "support"],
},
{
label: TEXT.menu.knowledgeInstructionFiles,
path: "/knowledge/instruction-files",
group: TEXT.menu.sharedLibrary,
icon: "notebook",
keywords: ["knowledge", "instruction"],
},
],
},
];
return items
.map((item) => {
if (!item.children) return options.canAccessProjectPath(item.path) ? item : null;
const children = item.children.filter((child) => options.canAccessProjectPath(child.path));
if (!children.length) return null;
return { ...item, path: children[0].path, children };
})
.filter(Boolean) as LayoutNavigationItem[];
};
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
items.flatMap((item) => [item, ...(item.children || [])]);