feat(desktop): 收口桌面工作台视觉与活动反馈
This commit is contained in:
@@ -48,4 +48,34 @@ describe("DocumentList permissions", () => {
|
||||
expect(source).toContain("const displayValue = displayDateTime(value)");
|
||||
expect(source).not.toContain('replace("T", " ").replace("Z", "")');
|
||||
});
|
||||
|
||||
it("keeps desktop document browsing in a master detail workflow", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
||||
expect(source).toContain('class="document-workbench"');
|
||||
expect(source).toContain('class="document-preview-pane"');
|
||||
expect(source).toContain("selectedDocument");
|
||||
expect(source).toContain('@row-click="handleDocumentRowClick"');
|
||||
expect(source).toContain('@row-dblclick="openDocumentDetail"');
|
||||
expect(source).toContain('@keydown.enter.prevent="openSelectedDocument"');
|
||||
expect(source).toContain("selectDocument(row);");
|
||||
expect(source).toContain("return;");
|
||||
expect(source).toContain("goDetail(row.id);");
|
||||
expect(source).not.toContain('@row-click="handleRowClick"');
|
||||
});
|
||||
|
||||
it("routes desktop document actions through preview and context menu controls", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
|
||||
expect(source).toContain("documentContextMenu");
|
||||
expect(source).toContain('class="document-context-menu"');
|
||||
expect(source).toContain("@click=\"openSelectedDocument\"");
|
||||
expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
|
||||
expect(source).toContain("@click=\"deleteSelectedDocument\"");
|
||||
expect(source).toContain(':disabled="isInactiveSite(selectedDocument.site_id)"');
|
||||
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
|
||||
expect(source).toContain('"row-selected"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="page ctms-page-shell page--flush">
|
||||
<div class="page ctms-page-shell page--flush" :class="{ 'document-page--desktop': isDesktop }" @click="closeDocumentContextMenu">
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="filter-container unified-action-bar bar--flush">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
@@ -29,72 +29,148 @@
|
||||
</el-form>
|
||||
</div>
|
||||
<section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@row-click="handleRowClick"
|
||||
:row-class-name="documentRowClass"
|
||||
class="ctms-table"
|
||||
table-layout="fixed"
|
||||
>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
effect="plain"
|
||||
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ displaySite(row.site_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary datetime-stack">
|
||||
<span>{{ splitDateTime(row.updated_at).date }}</span>
|
||||
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
|
||||
<div class="document-workbench" :class="{ 'is-desktop': isDesktop }">
|
||||
<div class="document-table-pane" :tabindex="isDesktop ? 0 : undefined" @keydown.enter.prevent="openSelectedDocument">
|
||||
<el-table
|
||||
:data="sortedItems"
|
||||
v-loading="loading"
|
||||
@row-click="handleDocumentRowClick"
|
||||
@row-dblclick="openDocumentDetail"
|
||||
@row-contextmenu="openDocumentContextMenu"
|
||||
:row-class-name="documentRowClass"
|
||||
class="ctms-table"
|
||||
highlight-current-row
|
||||
table-layout="fixed"
|
||||
>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
effect="plain"
|
||||
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
|
||||
>
|
||||
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ displaySite(row.site_id) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary datetime-stack">
|
||||
<span>{{ splitDateTime(row.updated_at).date }}</span>
|
||||
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<aside v-if="isDesktop" class="document-preview-pane">
|
||||
<template v-if="selectedDocument">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<div class="preview-kicker">当前文档</div>
|
||||
<div class="preview-title">{{ selectedDocument.title || TEXT.common.fallback }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="openSelectedDocument">打开详情</el-button>
|
||||
</div>
|
||||
<dl class="preview-list">
|
||||
<dt>{{ TEXT.modules.fileVersionManagement.columns.scope }}</dt>
|
||||
<dd>{{ displayEnum(TEXT.enums.scopeType, selectedDocument.scope_type) }}</dd>
|
||||
<dt>{{ TEXT.modules.fileVersionManagement.columns.site }}</dt>
|
||||
<dd>{{ displaySite(selectedDocument.site_id) }}</dd>
|
||||
<dt>{{ TEXT.modules.fileVersionManagement.columns.docType }}</dt>
|
||||
<dd>{{ displayText(selectedDocument.doc_type, TEXT.enums.documentType) }}</dd>
|
||||
<dt>{{ TEXT.modules.fileVersionManagement.columns.currentVersion }}</dt>
|
||||
<dd>{{ selectedDocument.current_effective_version?.version_no || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
|
||||
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
|
||||
</dl>
|
||||
<div class="preview-actions">
|
||||
<el-button size="small" @click="openSelectedDocument">打开详情</el-button>
|
||||
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedDocument.site_id)" @click="openSelectedDocumentEditor">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
<el-button
|
||||
v-if="canDelete"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="isInactiveSite(selectedDocument.site_id)"
|
||||
@click="deleteSelectedDocument"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
|
||||
</template>
|
||||
</el-table>
|
||||
<div v-else class="preview-empty">
|
||||
<span>选择一行查看文档摘要</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDesktop && documentContextMenu.visible && selectedDocument"
|
||||
class="document-context-menu"
|
||||
:style="{ left: `${documentContextMenu.x}px`, top: `${documentContextMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click="openSelectedDocument">打开详情</button>
|
||||
<button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
:disabled="isInactiveSite(selectedDocument.site_id)"
|
||||
@click="openSelectedDocumentEditor"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="isInactiveSite(selectedDocument.site_id)"
|
||||
@click="deleteSelectedDocument"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-drawer
|
||||
v-if="editorVisible"
|
||||
v-model="editorVisible"
|
||||
@@ -179,6 +255,7 @@ import type { Site } from "../../types/api";
|
||||
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
import { isTauriRuntime } from "../../runtime";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -192,6 +269,9 @@ const editorVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingDocumentId = ref("");
|
||||
const editorFormRef = ref<FormInstance>();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const selectedDocumentId = ref("");
|
||||
const documentContextMenu = ref({ visible: false, x: 0, y: 0 });
|
||||
let desktopRefreshCleanup: (() => void) | undefined;
|
||||
|
||||
const trialId = computed(() => (route.params.trialId as string) || "");
|
||||
@@ -228,7 +308,13 @@ const canUpdate = computed(() => can("documents.update"));
|
||||
const canDelete = computed(() => can("documents.delete"));
|
||||
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
[
|
||||
row?.id ? "clickable-row" : "",
|
||||
isInactiveSite(row?.site_id) ? "row-inactive" : "",
|
||||
isDesktop && row?.id === selectedDocumentId.value ? "row-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter((item) => {
|
||||
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
|
||||
@@ -240,6 +326,9 @@ const filteredItems = computed(() =>
|
||||
const sortedItems = computed(() =>
|
||||
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
|
||||
);
|
||||
const selectedDocument = computed(() =>
|
||||
sortedItems.value.find((item) => item.id === selectedDocumentId.value) || null
|
||||
);
|
||||
|
||||
const editorForm = reactive({
|
||||
doc_no: "",
|
||||
@@ -402,11 +491,60 @@ const submitEditor = async () => {
|
||||
|
||||
const goDetail = (id: string) => router.push(`/documents/${id}`);
|
||||
|
||||
const handleRowClick = (row: DocumentSummary) => {
|
||||
const selectDocument = (row: DocumentSummary) => {
|
||||
if (!row?.id) return;
|
||||
selectedDocumentId.value = row.id;
|
||||
};
|
||||
|
||||
const handleDocumentRowClick = (row: DocumentSummary) => {
|
||||
if (!row?.id) return;
|
||||
if (isDesktop) {
|
||||
selectDocument(row);
|
||||
return;
|
||||
}
|
||||
goDetail(row.id);
|
||||
};
|
||||
|
||||
const openDocumentDetail = (row?: DocumentSummary | null) => {
|
||||
const target = row?.id ? row : selectedDocument.value;
|
||||
if (target?.id) goDetail(target.id);
|
||||
};
|
||||
|
||||
const openSelectedDocument = () => {
|
||||
closeDocumentContextMenu();
|
||||
openDocumentDetail(selectedDocument.value);
|
||||
};
|
||||
|
||||
const openSelectedDocumentEditor = () => {
|
||||
const target = selectedDocument.value;
|
||||
closeDocumentContextMenu();
|
||||
if (target) openEdit(target);
|
||||
};
|
||||
|
||||
const deleteSelectedDocument = () => {
|
||||
const target = selectedDocument.value;
|
||||
closeDocumentContextMenu();
|
||||
if (target) {
|
||||
void confirmDelete(target);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDocumentContextMenu = () => {
|
||||
if (!documentContextMenu.value.visible) return;
|
||||
documentContextMenu.value = { visible: false, x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const openDocumentContextMenu = (row: DocumentSummary, _column: unknown, event: MouseEvent) => {
|
||||
if (!isDesktop || !row?.id) return;
|
||||
event.preventDefault();
|
||||
selectDocument(row);
|
||||
documentContextMenu.value = {
|
||||
visible: true,
|
||||
x: Math.min(event.clientX, window.innerWidth - 184),
|
||||
y: Math.min(event.clientY, window.innerHeight - 132),
|
||||
};
|
||||
};
|
||||
|
||||
const confirmDelete = async (row: DocumentSummary) => {
|
||||
if (!canDelete.value) {
|
||||
ElMessage.warning("权限不足");
|
||||
@@ -457,6 +595,22 @@ watch(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => sortedItems.value.map((item) => item.id).join("|"),
|
||||
() => {
|
||||
if (!isDesktop) return;
|
||||
const rows = sortedItems.value;
|
||||
if (!rows.length) {
|
||||
selectedDocumentId.value = "";
|
||||
return;
|
||||
}
|
||||
if (!rows.some((item) => item.id === selectedDocumentId.value)) {
|
||||
selectedDocumentId.value = rows[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -551,6 +705,147 @@ watch(
|
||||
0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.document-workbench {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-workbench.is-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 304px;
|
||||
min-height: min(570px, calc(100vh - 178px));
|
||||
}
|
||||
|
||||
.document-table-pane {
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.document-workbench.is-desktop .document-table-pane {
|
||||
border-right: 1px solid #e3e9f1;
|
||||
}
|
||||
|
||||
.document-preview-pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
margin-bottom: 5px;
|
||||
color: #6b7e95;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #142033;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-list dt {
|
||||
color: #7b8da3;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #223349;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8da3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-context-menu {
|
||||
position: fixed;
|
||||
z-index: 2300;
|
||||
display: flex;
|
||||
width: 172px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid #cbd7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.document-context-menu button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #223349;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-context-menu button:hover:not(:disabled) {
|
||||
background: #eef4fb;
|
||||
color: #183756;
|
||||
}
|
||||
|
||||
.document-context-menu button.danger {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.document-context-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #9aaabd;
|
||||
}
|
||||
|
||||
.ctms-table {
|
||||
--el-table-border-color: transparent;
|
||||
--el-table-row-hover-bg-color: #f8f9fb;
|
||||
@@ -583,6 +878,10 @@ watch(
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.ctms-table :deep(.el-table__body tr.row-selected > td) {
|
||||
background: #eaf1f8 !important;
|
||||
}
|
||||
|
||||
.ctms-table :deep(.font-semibold) {
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
@@ -628,6 +927,44 @@ watch(
|
||||
.ctms-table :deep(.cell-actions .el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .document-table-section) {
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .document-preview-pane) {
|
||||
border-color: #26364a;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .preview-title),
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dd) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .preview-kicker),
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dt),
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .preview-empty) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu) {
|
||||
border-color: #334155;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button) {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button:hover:not(:disabled)) {
|
||||
background: #243247;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .document-page--desktop .ctms-table .el-table__body tr.row-selected > td) {
|
||||
background: #243247 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -85,4 +85,36 @@ describe("DrugShipments project permissions", () => {
|
||||
expect(source).toContain('status === "EXCEPTION"');
|
||||
expect(source).not.toContain('remark: [{ required: true');
|
||||
});
|
||||
|
||||
it("keeps desktop shipment browsing in a master detail workflow", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime();");
|
||||
expect(source).toContain('class="shipment-workbench"');
|
||||
expect(source).toContain('class="shipment-preview-pane"');
|
||||
expect(source).toContain('ref="shipmentTablePaneRef"');
|
||||
expect(source).toContain("selectedShipment");
|
||||
expect(source).toContain('@row-click="handleShipmentRowClick"');
|
||||
expect(source).toContain('@row-dblclick="openShipmentDetail"');
|
||||
expect(source).toContain('@keydown.enter.prevent="openSelectedShipment"');
|
||||
expect(source).toContain("selectShipment(row);");
|
||||
expect(source).toContain("shipmentTablePaneRef.value?.focus({ preventScroll: true });");
|
||||
expect(source).toContain("return;");
|
||||
expect(source).toContain("router.push(`/drug/shipments/${row.id}`);");
|
||||
expect(source).not.toContain('@row-click="onRowClick"');
|
||||
});
|
||||
|
||||
it("routes desktop shipment actions through preview and context menu controls", () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
|
||||
expect(source).toContain("shipmentContextMenu");
|
||||
expect(source).toContain('class="shipment-context-menu"');
|
||||
expect(source).toContain('@click="openSelectedShipment"');
|
||||
expect(source).toContain('@click="openSelectedShipmentEditor"');
|
||||
expect(source).toContain('@click="removeSelectedShipment"');
|
||||
expect(source).toContain(':disabled="isInactiveSite(selectedShipment.center_id)"');
|
||||
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
|
||||
expect(source).toContain('"row-selected"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
|
||||
<div v-if="study.currentStudy" class="page-inner">
|
||||
<!-- ==================== 表格卡片(含筛选栏) ==================== -->
|
||||
<div class="table-card">
|
||||
@@ -47,85 +47,174 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="sortedItems"
|
||||
class="shipment-table"
|
||||
style="width: 100%"
|
||||
table-layout="fixed"
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||||
<template #default="{ row }">
|
||||
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
|
||||
<div
|
||||
ref="shipmentTablePaneRef"
|
||||
class="shipment-table-pane"
|
||||
:tabindex="isDesktop ? 0 : undefined"
|
||||
@keydown.enter.prevent="openSelectedShipment"
|
||||
>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="sortedItems"
|
||||
class="shipment-table"
|
||||
style="width: 100%"
|
||||
table-layout="fixed"
|
||||
highlight-current-row
|
||||
:row-class-name="shipmentRowClass"
|
||||
@row-click="handleShipmentRowClick"
|
||||
@row-dblclick="openShipmentDetail"
|
||||
@row-contextmenu="openShipmentContextMenu"
|
||||
>
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
|
||||
<template #default="{ row }">
|
||||
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
|
||||
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
|
||||
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="{ row }">
|
||||
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
v-if="canDelete"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(row.center_id)"
|
||||
@click.stop="remove(row)"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div v-if="!loading" class="table-empty">
|
||||
<div class="empty-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<span>{{ TEXT.modules.drugShipments.empty }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<aside v-if="isDesktop" class="shipment-preview-pane">
|
||||
<template v-if="selectedShipment">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<div class="preview-kicker">当前发运</div>
|
||||
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
|
||||
</div>
|
||||
<dl class="preview-list">
|
||||
<dt>{{ TEXT.common.fields.direction }}</dt>
|
||||
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.status }}</dt>
|
||||
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.shipDate }}</dt>
|
||||
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
|
||||
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.quantity }}</dt>
|
||||
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.batchNo }}</dt>
|
||||
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.carrier }}</dt>
|
||||
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
|
||||
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.remark }}</dt>
|
||||
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
|
||||
</dl>
|
||||
<div class="preview-actions">
|
||||
<el-button size="small" @click="openSelectedShipment">打开详情</el-button>
|
||||
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedShipment.center_id)" @click="openSelectedShipmentEditor">
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isInactiveSite(row.center_id)"
|
||||
@click.stop="remove(row)"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="removeSelectedShipment"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div v-if="!loading" class="table-empty">
|
||||
<div class="empty-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<span>{{ TEXT.modules.drugShipments.empty }}</span>
|
||||
<div v-else class="preview-empty">
|
||||
<span>选择一行查看发运摘要</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
|
||||
|
||||
<div
|
||||
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
|
||||
class="shipment-context-menu"
|
||||
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click="openSelectedShipment">打开详情</button>
|
||||
<button
|
||||
v-if="canUpdate"
|
||||
type="button"
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="openSelectedShipmentEditor"
|
||||
>
|
||||
{{ TEXT.common.actions.edit }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="isInactiveSite(selectedShipment.center_id)"
|
||||
@click="removeSelectedShipment"
|
||||
>
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 编辑抽屉 ==================== -->
|
||||
<el-drawer
|
||||
v-if="drawerVisible"
|
||||
@@ -290,6 +379,7 @@ import { displayDate, displayEnum } from "../../utils/display";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
|
||||
import { isTauriRuntime } from "../../runtime";
|
||||
|
||||
type ShipmentDirection = "SEND" | "RETURN";
|
||||
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
|
||||
@@ -322,6 +412,10 @@ const drawerVisible = ref(false);
|
||||
const editingId = ref("");
|
||||
const formRef = ref<FormInstance>();
|
||||
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
|
||||
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
|
||||
const isDesktop = isTauriRuntime();
|
||||
const selectedShipmentId = ref("");
|
||||
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
|
||||
const filters = reactive({
|
||||
center_id: study.currentSite?.id || "",
|
||||
direction: "" as "" | ShipmentDirection,
|
||||
@@ -363,6 +457,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
|
||||
);
|
||||
const selectedShipment = computed(() =>
|
||||
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
|
||||
);
|
||||
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
|
||||
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
|
||||
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
|
||||
@@ -564,12 +661,69 @@ const handleSearch = () => {
|
||||
|
||||
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
|
||||
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
|
||||
const onRowClick = (row: ShipmentRow) => {
|
||||
[
|
||||
row?.id ? "clickable-row" : "",
|
||||
isInactiveSite(row?.center_id) ? "row-inactive" : "",
|
||||
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const selectShipment = (row: ShipmentRow) => {
|
||||
if (!row?.id) return;
|
||||
selectedShipmentId.value = row.id;
|
||||
shipmentTablePaneRef.value?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
const handleShipmentRowClick = (row: ShipmentRow) => {
|
||||
if (!row?.id) return;
|
||||
if (isDesktop) {
|
||||
selectShipment(row);
|
||||
return;
|
||||
}
|
||||
router.push(`/drug/shipments/${row.id}`);
|
||||
};
|
||||
|
||||
const openShipmentDetail = (row?: ShipmentRow | null) => {
|
||||
const target = row?.id ? row : selectedShipment.value;
|
||||
if (target?.id) router.push(`/drug/shipments/${target.id}`);
|
||||
};
|
||||
|
||||
const openSelectedShipment = () => {
|
||||
closeShipmentContextMenu();
|
||||
openShipmentDetail(selectedShipment.value);
|
||||
};
|
||||
|
||||
const openSelectedShipmentEditor = () => {
|
||||
const target = selectedShipment.value;
|
||||
closeShipmentContextMenu();
|
||||
if (target) openEdit(target);
|
||||
};
|
||||
|
||||
const removeSelectedShipment = () => {
|
||||
const target = selectedShipment.value;
|
||||
closeShipmentContextMenu();
|
||||
if (target) {
|
||||
void remove(target);
|
||||
}
|
||||
};
|
||||
|
||||
const closeShipmentContextMenu = () => {
|
||||
if (!shipmentContextMenu.value.visible) return;
|
||||
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
|
||||
if (!isDesktop || !row?.id) return;
|
||||
event.preventDefault();
|
||||
selectShipment(row);
|
||||
shipmentContextMenu.value = {
|
||||
visible: true,
|
||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
|
||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
|
||||
};
|
||||
};
|
||||
|
||||
const statusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING": return "info";
|
||||
@@ -633,6 +787,22 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => sortedItems.value.map((item) => item.id).join("|"),
|
||||
() => {
|
||||
if (!isDesktop) return;
|
||||
const rows = sortedItems.value;
|
||||
if (!rows.length) {
|
||||
selectedShipmentId.value = "";
|
||||
return;
|
||||
}
|
||||
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
|
||||
selectedShipmentId.value = rows[0].id;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
await load();
|
||||
@@ -733,6 +903,147 @@ onMounted(async () => {
|
||||
0 2px 8px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.shipment-workbench {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.shipment-workbench.is-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 316px;
|
||||
min-height: min(590px, calc(100vh - 186px));
|
||||
}
|
||||
|
||||
.shipment-table-pane {
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.shipment-workbench.is-desktop .shipment-table-pane {
|
||||
border-right: 1px solid #e3e9f1;
|
||||
}
|
||||
|
||||
.shipment-preview-pane {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
margin-bottom: 5px;
|
||||
color: #6b7e95;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: #142033;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: grid;
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-list dt {
|
||||
color: #7b8da3;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #223349;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #7b8da3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shipment-context-menu {
|
||||
position: fixed;
|
||||
z-index: 2300;
|
||||
display: flex;
|
||||
width: 172px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid #cbd7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.shipment-context-menu button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #223349;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.shipment-context-menu button:hover:not(:disabled) {
|
||||
background: #eef4fb;
|
||||
color: #183756;
|
||||
}
|
||||
|
||||
.shipment-context-menu button.danger {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.shipment-context-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #9aaabd;
|
||||
}
|
||||
|
||||
.shipment-table {
|
||||
--el-table-border-color: transparent;
|
||||
--el-table-row-hover-bg-color: #f8f9fb;
|
||||
@@ -771,6 +1082,10 @@ onMounted(async () => {
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.shipment-table :deep(.el-table__body tr.row-selected > td) {
|
||||
background: #eaf1f8 !important;
|
||||
}
|
||||
|
||||
.cell-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1035,6 +1350,15 @@ onMounted(async () => {
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.shipment-workbench.is-desktop {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.shipment-workbench.is-desktop .shipment-table-pane {
|
||||
border-right: 0;
|
||||
}
|
||||
.shipment-preview-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -1042,6 +1366,44 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
|
||||
border-color: #26364a;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
|
||||
border-color: #334155;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
|
||||
background: #243247;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
|
||||
background: #243247 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -978,7 +978,14 @@ const handleExportExcel = async () => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
await saveFileWithFeedback(
|
||||
{ suggestedName: filename, mimeType: contentType, data: blob },
|
||||
{
|
||||
kind: "export",
|
||||
title: "导出文件",
|
||||
completedDetail: "导出文件已保存",
|
||||
},
|
||||
);
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
|
||||
|
||||
Reference in New Issue
Block a user