发布候选:整合桌面端界面与发布稳定化里程碑
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
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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || [])]);
|
||||
Reference in New Issue
Block a user