优化
This commit is contained in:
@@ -59,6 +59,10 @@
|
|||||||
## UI 与交互
|
## UI 与交互
|
||||||
|
|
||||||
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
|
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
|
||||||
|
- 路由级页面标题和说明统一由 `AppLayout` 顶部 `page-heading` 根据路由 `meta.title`、`meta.description` 展示。标准列表页正文不得重复渲染 `PageToolbar`、页面标题或说明,只保留查询区与表格区。`QueryToolbar` 只承载筛选项及其内置查询、重置按钮,不把新增、导入、导出或批量操作放入其 `actions` 插槽;重新执行查询即刷新当前结果,标准列表页任何位置都不得再提供独立刷新按钮。新增、导入、导出和批量操作统一放入 `DataTable` 的 `toolbar-actions` 插槽并靠表格工具栏右侧展示。仅没有查询组件的实时工作台或详情页在业务确实需要主动重载时,才可保留刷新;仅详情页或工作台在路由明确设置 `showPageHeading: false` 时,才可在正文使用独立详情头。
|
||||||
|
- 标准列表页的查询面板与表格面板使用 `ListPageLayout` 保持单一 `16px` 间距,不再叠加页面级 margin、空白操作区或额外容器间距。`DataTable` 必须位于连续的 `surface-panel table-panel` 中,使右侧业务按钮、列设置、表头和分页属于同一表格面板;不得使用 `toolbar-start` 重复显示列表标题、数量或说明,数量统一放在 `pagination-left`。当工具栏只有右侧操作和列设置时使用紧凑高度,不保留空白的左侧标题区域。
|
||||||
|
- 操作按钮统一使用 Lucide 图标和固定语义:详情/查看 `Eye`、编辑 `Pencil`、更多 `MoreHorizontal`、新增 `Plus`、删除 `Trash2`、启停 `Power`、刷新 `RefreshCw`、导入 `FileUp`、导出/下载 `Download`、返回 `ArrowLeft`、保存 `Save`、复制 `Copy`。表格高频操作使用“图标 + 文案”,更多操作使用仅图标按钮并提供 `aria-label` 与悬停提示;同一语义不得在不同页面混用其他图标、纯文字按钮或手绘 SVG。危险操作继续使用明确文案、危险色和确认流程,不能只显示图标。
|
||||||
|
- 表格单行操作不超过 3 个时全部直接展示“图标 + 文案”;从第 4 个操作开始才使用仅图标的 `MoreHorizontal` 更多菜单,并优先直接保留详情、编辑、下载等高频操作。进入更多菜单的启停、删除等危险操作仍须显示明确文案和危险语义,并沿用原确认流程;不得为了布局方便把仅有 1-3 个操作提前折叠到更多菜单。
|
||||||
- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。
|
- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。
|
||||||
- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。
|
- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。
|
||||||
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
|
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Boxes, Eye, Pencil, Plus, RefreshCcw, Sparkles } from '@lucide/vue'
|
import { Boxes, Eye, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -174,23 +176,30 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="container-page">
|
<AdminPage as="div">
|
||||||
<header class="page-header"><div><h1>货柜与智能排柜</h1><p>维护平台货柜资料,从柜次进入智能排柜并推送预装清单。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></header>
|
<section class="container-page">
|
||||||
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
<ListPageLayout ariaLabel="货柜列表">
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" @reset="search" />
|
<template #toolbar>
|
||||||
<LoadErrorState v-if="loadError" title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" @reset="search" />
|
||||||
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
|
</template>
|
||||||
<template #toolbar-start><div><h2>货柜列表</h2><p>共 {{ total }} 个柜次</p></div></template>
|
<template v-if="warehouseError || loadError" #state>
|
||||||
<template #toolbar-end><el-button :loading="loading" aria-label="刷新货柜列表" title="刷新" @click="loadRows"><RefreshCcw :size="16" /></el-button></template>
|
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="`查看 ${row.cabinetNo}`" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus === 'EDITING'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
|
<LoadErrorState v-else-if="loadError" title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
|
</template>
|
||||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
<section v-if="!warehouseError && !loadError" class="surface-panel table-panel">
|
||||||
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
|
||||||
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
|
||||||
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
|
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="`查看 ${row.cabinetNo}`" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus === 'EDITING'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
|
||||||
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
|
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
|
||||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
||||||
</DataTable>
|
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
|
||||||
|
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
|
||||||
|
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
|
||||||
|
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
|
||||||
|
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||||
|
</DataTable>
|
||||||
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
||||||
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||||
@@ -223,9 +232,10 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
|
|||||||
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
|
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
</section>
|
</section>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container-page { display: grid; gap: 16px; min-width: 0; }.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.page-header h1,.page-header p,.container-page h2 { margin: 0; }.page-header p,.container-page small,.container-page h2 + p { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 16px; margin-top: 16px; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }@media(max-width:720px){.page-header { flex-direction: column; }.page-header .el-button { width: 100%; min-height: 44px; }.form-grid { grid-template-columns: 1fr; }.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 16px; margin-top: 16px; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }@media(max-width:720px){.form-grid { grid-template-columns: 1fr; }.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft, Plus, RefreshCcw, Sparkles, Trash2, Upload } from '@lucide/vue'
|
import { ArrowLeft, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
@@ -9,7 +9,6 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
|
||||||
@@ -175,14 +174,19 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<section class="business-page workbench">
|
<section class="business-page workbench">
|
||||||
<PageToolbar title="智能排柜" :description="`${container?.cabinetNo || '正在加载'} · ${container?.containerNo || '待录入集装箱号'}`">
|
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
|
||||||
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" /></el-button>
|
<div class="workbench-identity">
|
||||||
<template #actions>
|
<span>当前柜次</span>
|
||||||
<el-button :loading="loading" title="刷新" aria-label="刷新智能排柜" @click="load"><RefreshCcw :size="16" /></el-button>
|
<strong>{{ container?.cabinetNo || '正在加载' }}</strong>
|
||||||
|
<small>集装箱号 {{ container?.containerNo || '待录入' }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
||||||
|
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||||
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||||
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||||
</template>
|
</div>
|
||||||
</PageToolbar>
|
</section>
|
||||||
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||||
@@ -192,7 +196,7 @@ onMounted(load)
|
|||||||
<template v-else-if="plan">
|
<template v-else-if="plan">
|
||||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" empty-text="暂无已分配货物">
|
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" empty-text="暂无已分配货物">
|
||||||
<template #toolbar-start><div><h2>装柜方案</h2><p>计划 {{ plan.planNo }} · 版本 {{ plan.version }};智能建议必须经人工核对。</p></div></template>
|
<template #toolbar-start><div><h2>装柜方案</h2><p>计划 {{ plan.planNo }} · 版本 {{ plan.version }};智能建议必须经人工核对。</p></div></template>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)">保存箱数</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
||||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||||
@@ -215,12 +219,13 @@ onMounted(load)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.workbench { display: grid; gap: 16px; min-width: 0; }
|
.workbench { display: grid; gap: 16px; min-width: 0; }
|
||||||
.page-header,.title-row,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
||||||
.page-header { align-items: flex-start; justify-content: space-between; gap: 16px; }
|
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
|
||||||
.title-row { gap: 8px; }
|
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
|
||||||
.title-row h1,.title-row p,.workbench h2,.workbench h2 + p { margin: 0; }
|
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
|
||||||
.title-row h1 { font-size: 20px; line-height: 1.35; }
|
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.title-row p,.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
.workbench h2,.workbench h2 + p { margin: 0; }
|
||||||
|
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||||
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
||||||
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||||
@@ -232,5 +237,5 @@ onMounted(load)
|
|||||||
.workbench :deep(.el-input-number) { width: 130px; }
|
.workbench :deep(.el-input-number) { width: 130px; }
|
||||||
.assign-dialog p { margin: 0 0 10px; }
|
.assign-dialog p { margin: 0 0 10px; }
|
||||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||||
@media (max-width: 760px) { .page-header,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||||
import { Pencil, RefreshCw } from '@lucide/vue'
|
import { Pencil, RefreshCw, Save } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -86,7 +86,7 @@ onMounted(load)
|
|||||||
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
||||||
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><table><thead><tr><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><table><thead><tr><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存费用</el-button></div></template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { Pencil, Ruler } from '@lucide/vue'
|
import { Pencil, Ruler, Save } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
@@ -210,7 +210,7 @@ onMounted(loadConfig)
|
|||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div></template>
|
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="editorSaving" @click="saveConfig"><Save :size="16" />保存规则</el-button></div></template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
|
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
|
||||||
FileCheck2, FileText, History, Pencil, Power, ReceiptText, RefreshCw,
|
FileCheck2, FileText, History, Pencil, Power, ReceiptText,
|
||||||
Scale, Ship, UserRound, UsersRound,
|
Scale, Ship, UserRound, UsersRound,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -29,6 +29,11 @@ import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/
|
|||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||||
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
||||||
@@ -311,37 +316,37 @@ watch(orderPage, (page, previousPage) => {
|
|||||||
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
|
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
|
||||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||||
-->
|
-->
|
||||||
<div class="customer-detail-page">
|
<AdminPage as="div" class="customer-detail-page">
|
||||||
<section v-if="loadError" class="detail-error" role="alert">
|
<LoadErrorState v-if="loadError" title="客户详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadDetail">
|
||||||
<div><strong>客户详情暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
<el-button type="primary" :loading="loading" @click="loadDetail"><RefreshCw :size="16" />重新加载</el-button>
|
</LoadErrorState>
|
||||||
</section>
|
|
||||||
|
|
||||||
<template v-else>
|
<DetailPageLayout v-else v-loading="loading" aria-label="客户详情">
|
||||||
<header class="detail-header">
|
<template #header>
|
||||||
<div class="detail-heading">
|
<header class="detail-header">
|
||||||
<el-button class="back-button" @click="router.push({ name: 'customers' })"><ArrowLeft :size="17" />返回列表</el-button>
|
<el-button class="back-button" @click="router.push({ name: 'customers' })"><ArrowLeft :size="17" />返回列表</el-button>
|
||||||
<div class="company-mark" aria-hidden="true"><Building2 :size="23" /></div>
|
<div class="company-mark" aria-hidden="true"><Building2 :size="23" /></div>
|
||||||
<div class="company-title">
|
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || tenantId">
|
||||||
<h1>{{ customer?.tenantName || '客户详情' }}</h1>
|
<template #actions>
|
||||||
<p>{{ customer?.tenantCode || tenantId }}</p>
|
<div v-if="customer" class="header-actions">
|
||||||
</div>
|
<el-tag :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
|
||||||
<el-tag v-if="customer" :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
|
<el-button v-if="access.can('platform:customer:edit')" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
|
||||||
</div>
|
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
|
||||||
<div v-if="customer" class="header-actions">
|
</div>
|
||||||
<el-button v-if="access.can('platform:customer:edit')" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
|
</template>
|
||||||
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
|
</PageToolbar>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
</template>
|
||||||
|
|
||||||
<div v-loading="loading" class="detail-content">
|
<template #summary>
|
||||||
<section class="overview-strip" aria-label="客户概况">
|
<section class="overview-strip" aria-label="客户概况">
|
||||||
<article><span class="metric-icon is-blue"><Ship :size="20" /></span><div><p>累计运单</p><strong>{{ orderCount ?? '—' }}<small>票</small></strong><span>{{ access.can('platform:order:list') ? '来自头程订单服务' : '暂无运单查看权限' }}</span></div></article>
|
<article><span class="metric-icon is-blue"><Ship :size="20" /></span><div><p>累计运单</p><strong>{{ orderCount ?? '—' }}<small>票</small></strong><span>{{ access.can('platform:order:list') ? '来自头程订单服务' : '暂无运单查看权限' }}</span></div></article>
|
||||||
<article><span class="metric-icon is-green"><UsersRound :size="20" /></span><div><p>企业用户</p><strong>{{ enterpriseMembers.length }}<small>人</small></strong><span>{{ activeEnterpriseMembers }} 人正常使用</span></div></article>
|
<article><span class="metric-icon is-green"><UsersRound :size="20" /></span><div><p>企业用户</p><strong>{{ enterpriseMembers.length }}<small>人</small></strong><span>{{ activeEnterpriseMembers }} 人正常使用</span></div></article>
|
||||||
<article><span class="metric-icon is-amber"><FileCheck2 :size="20" /></span><div><p>资质文件</p><strong>{{ qualifications.length }}<small>份</small></strong><span>{{ approvedQualifications }} 份已通过</span></div></article>
|
<article><span class="metric-icon is-amber"><FileCheck2 :size="20" /></span><div><p>资质文件</p><strong>{{ qualifications.length }}<small>份</small></strong><span>{{ approvedQualifications }} 份已通过</span></div></article>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div ref="tabList" class="detail-tabs" role="tablist" aria-label="客户详情模块">
|
<div ref="tabList" class="detail-tabs" role="tablist" aria-label="客户详情模块">
|
||||||
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><component :is="tab.icon" :size="18" /><span>{{ tab.label }}</span></button>
|
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><component :is="tab.icon" :size="18" /><span>{{ tab.label }}</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -425,31 +430,24 @@ watch(orderPage, (page, previousPage) => {
|
|||||||
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
|
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
|
||||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
|
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
|
||||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</DetailPageLayout>
|
||||||
|
|
||||||
<el-dialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body>
|
<AppDialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||||
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||||
</el-dialog>
|
</AppDialog>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.customer-detail-page { min-width: 0; }
|
.customer-detail-page { min-width: 0; }
|
||||||
.detail-error { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:20px; border:1px solid #fecdca; border-radius:12px; background:#fffafa; }
|
.detail-header { display:flex; min-width:0; align-items:center; gap:12px; }
|
||||||
.detail-error strong { color:#7a271a; } .detail-error p { margin:5px 0; color:#b54708; font-size:12px; }
|
|
||||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:18px; }
|
|
||||||
.detail-heading { display:flex; min-width:0; align-items:center; gap:12px; }
|
|
||||||
.back-button { flex:0 0 auto; }
|
.back-button { flex:0 0 auto; }
|
||||||
.company-mark { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; background:#eef1ff; color:var(--cc-primary); }
|
.company-mark { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; background:#eef1ff; color:var(--cc-primary); }
|
||||||
.company-title { min-width:0; }
|
.detail-page-toolbar { min-width:0; flex:1; }
|
||||||
.company-title h1 { margin:0; overflow:hidden; color:#172033; font-size:23px; line-height:1.25; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.company-title p { margin:4px 0 0; color:var(--cc-muted); font-size:12px; }
|
|
||||||
.header-actions { display:flex; flex:0 0 auto; gap:8px; }
|
.header-actions { display:flex; flex:0 0 auto; gap:8px; }
|
||||||
.header-actions :deep(.el-button + .el-button) { margin-left:0; }
|
.header-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||||
.detail-content { min-height:460px; }
|
.overview-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.08); }
|
||||||
.overview-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; margin-bottom:18px; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.08); }
|
|
||||||
.overview-strip article { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); }
|
.overview-strip article { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); }
|
||||||
.overview-strip article:last-child { border-right:0; }
|
.overview-strip article:last-child { border-right:0; }
|
||||||
.metric-icon { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; }
|
.metric-icon { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; }
|
||||||
@@ -487,6 +485,6 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
|
|||||||
.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
||||||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
|
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
|
||||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(6,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
|
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(6,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
|
||||||
@media (max-width:720px) { .detail-header { align-items:stretch; flex-direction:column; }.detail-heading { display:grid; grid-template-columns:auto 40px minmax(0,1fr) auto; }.company-mark { width:40px; height:40px; }.company-title h1 { font-size:18px; }.header-actions { display:grid; grid-template-columns:1fr 1fr; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; }.detail-error { align-items:stretch; flex-direction:column; } }
|
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } }
|
||||||
@media (max-width:420px) { .detail-heading { grid-template-columns:1fr auto; }.back-button { grid-column:1 / -1; justify-self:start; }.company-mark { display:none; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function tableButtonByText(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clickMoreAction(text: string) {
|
async function clickMoreAction(text: string) {
|
||||||
tableButtonByText('更多')?.click()
|
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label="更多客户操作"]')?.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')]
|
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')]
|
||||||
.find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled'))
|
.find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled'))
|
||||||
@@ -519,7 +519,7 @@ describe('CustomersView', () => {
|
|||||||
|
|
||||||
expect(tableButtonByText('详情')).toBeDefined()
|
expect(tableButtonByText('详情')).toBeDefined()
|
||||||
expect(tableButtonByText('编辑')).toBeDefined()
|
expect(tableButtonByText('编辑')).toBeDefined()
|
||||||
expect(tableButtonByText('更多')).toBeDefined()
|
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy()
|
||||||
expect(tableButtonByText('重置密码')).toBeUndefined()
|
expect(tableButtonByText('重置密码')).toBeUndefined()
|
||||||
await clickMoreAction('重置密码')
|
await clickMoreAction('重置密码')
|
||||||
expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy()
|
expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Building2, CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, MoreHorizontal, Pencil, Plus, Power, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
|
import { Building2, CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, MoreHorizontal, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { createPlatformExportTask } from '@/modules/downloads'
|
import { createPlatformExportTask } from '@/modules/downloads'
|
||||||
@@ -37,6 +37,12 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|||||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getClientWebUrl } from '@/core/config/runtimeConfig'
|
import { getClientWebUrl } from '@/core/config/runtimeConfig'
|
||||||
@@ -895,13 +901,17 @@ onMounted(() => { void loadCustomers() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page customers-page">
|
<AdminPage as="div" class="customers-page">
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" />
|
<ListPageLayout aria-label="客户管理">
|
||||||
<section v-if="loadError" class="surface-panel customers-error" role="alert"><div><strong>客户列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="loadCustomers">重新加载</el-button></section>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
|
||||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
<template v-if="loadError" #state>
|
||||||
|
<LoadErrorState title="客户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadCustomers">
|
||||||
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
|
</LoadErrorState>
|
||||||
|
</template>
|
||||||
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||||
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
|
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户">
|
||||||
<template #toolbar-start><div class="customers-table-heading"><h2>客户列表</h2><span>共 {{ result.total }} 个客户</span></div></template>
|
|
||||||
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></template>
|
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
@@ -909,7 +919,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button>
|
<el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button>
|
||||||
<el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:review') && (access.can('platform:customer:approve') || access.can('platform:customer:reject'))" link type="warning" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="15" />审核</el-button>
|
<el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:review') && (access.can('platform:customer:approve') || access.can('platform:customer:reject'))" link type="warning" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="15" />审核</el-button>
|
||||||
<el-dropdown v-if="hasMoreActions(row as PlatformTenantResource)" trigger="click" :disabled="rowActionId === String((row as PlatformTenantResource).id)" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, String($event))">
|
<el-dropdown v-if="hasMoreActions(row as PlatformTenantResource)" trigger="click" :disabled="rowActionId === String((row as PlatformTenantResource).id)" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, String($event))">
|
||||||
<el-button link type="primary" aria-label="更多客户操作"><MoreHorizontal :size="16" />更多</el-button>
|
<el-button link type="primary" aria-label="更多客户操作" title="更多操作"><MoreHorizontal :size="16" /></el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="canResetPassword(row as PlatformTenantResource)" command="reset-password" :disabled="passwordSubmitting"><KeyRound :size="15" />重置密码</el-dropdown-item>
|
<el-dropdown-item v-if="canResetPassword(row as PlatformTenantResource)" command="reset-password" :disabled="passwordSubmitting"><KeyRound :size="15" />重置密码</el-dropdown-item>
|
||||||
@@ -924,21 +934,23 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<template #company="{ row }"><div class="customer-company-cell"><span><Building2 :size="18" /></span><strong>{{ (row as PlatformTenantResource).tenantName }}</strong></div></template>
|
<template #company="{ row }"><div class="customer-company-cell"><span><Building2 :size="18" /></span><strong>{{ (row as PlatformTenantResource).tenantName }}</strong></div></template>
|
||||||
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
|
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
|
||||||
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
|
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<el-drawer v-model="editorOpen" :title="editorTitle" size="760px" class="business-drawer customer-editor" :close-on-click-modal="false">
|
<AppDrawer v-model="editorOpen" :title="editorTitle" size="760px" class="business-drawer customer-editor">
|
||||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||||
<div class="form-section"><h3>基础资料</h3><div class="form-grid two-cols">
|
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
|
||||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||||
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
|
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
||||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||||
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></div>
|
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
|
||||||
|
|
||||||
<div class="form-section"><h3>联系与商务</h3><div class="form-grid two-cols">
|
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid two-cols">
|
||||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="联系电话(登录用户名)" prop="contactPhone" required><el-input v-model="form.contactPhone" :disabled="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" placeholder="请输入手机号或固定电话" maxlength="32" /><small v-if="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" class="field-hint">客户已开户,联系电话作为登录用户名不可修改</small></el-form-item>
|
<el-form-item label="联系电话(登录用户名)" prop="contactPhone" required><el-input v-model="form.contactPhone" :disabled="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" placeholder="请输入手机号或固定电话" maxlength="32" /><small v-if="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" class="field-hint">客户已开户,联系电话作为登录用户名不可修改</small></el-form-item>
|
||||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
||||||
@@ -946,9 +958,9 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
|
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
|
||||||
<el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item>
|
<el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
|
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
|
||||||
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></div>
|
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
|
||||||
|
|
||||||
<div class="form-section qualification-section"><div class="qualification-heading"><div><h3>企业认证必填业务项</h3><p>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。</p></div><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
<FormSection class="qualification-section" title="企业认证必填业务项" description="营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||||
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照、法人身份证共同保存。' }}</small></el-form-item>
|
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照、法人身份证共同保存。' }}</small></el-form-item>
|
||||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||||
@@ -967,18 +979,17 @@ onMounted(() => { void loadCustomers() })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
|
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
|
||||||
</div>
|
</FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><div class="drawer-footer customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer"><Save :size="16" />保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||||
</el-drawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<el-dialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close>
|
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||||
<div class="qualification-preview">
|
<div class="qualification-preview">
|
||||||
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
|
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
|
||||||
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
|
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button @click="qualificationPreviewOpen = false">关闭预览</el-button></template>
|
</AppDialog>
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||||
<div v-if="approvalResult" class="approval-result-content">
|
<div v-if="approvalResult" class="approval-result-content">
|
||||||
@@ -1026,12 +1037,10 @@ onMounted(() => { void loadCustomers() })
|
|||||||
<el-alert v-if="archiveError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ archiveError.message }}</span><ApiErrorMeta :details="archiveError" /></div></template></el-alert>
|
<el-alert v-if="archiveError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ archiveError.message }}</span><ApiErrorMeta :details="archiveError" /></div></template></el-alert>
|
||||||
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
|
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.customers-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:18px 20px; border-color:#fecdca; background:#fffafa; }
|
|
||||||
.customers-error strong { color:#7a271a; font-size:16px; } .customers-error p { margin:4px 0 0; color:#b54708; font-size:12px; overflow-wrap:anywhere; }
|
|
||||||
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:10px; } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:16px; } .customers-table-heading span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
|
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:10px; } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:16px; } .customers-table-heading span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
|
||||||
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
|
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||||
.table-actions { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; } .table-actions :deep(.el-button) { margin-left:0; }
|
.table-actions { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; } .table-actions :deep(.el-button) { margin-left:0; }
|
||||||
@@ -1040,7 +1049,7 @@ onMounted(() => { void loadCustomers() })
|
|||||||
.review-sync-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; padding:10px 18px; border-bottom:1px solid #fecdca; background:#fffafa; color:#b42318; font-size:12px; } .review-sync-error > div { display:flex; min-width:0; align-items:center; gap:8px; } .review-sync-error span { overflow-wrap:anywhere; }
|
.review-sync-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; padding:10px 18px; border-bottom:1px solid #fecdca; background:#fffafa; color:#b42318; font-size:12px; } .review-sync-error > div { display:flex; min-width:0; align-items:center; gap:8px; } .review-sync-error span { overflow-wrap:anywhere; }
|
||||||
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
|
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
|
||||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
|
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
|
||||||
.qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
|
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:12px; } .qualification-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
|
||||||
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; }
|
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; }
|
||||||
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
|
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
|
||||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||||
@@ -1064,6 +1073,6 @@ onMounted(() => { void loadCustomers() })
|
|||||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
|
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
|
||||||
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
|
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
|
||||||
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
||||||
@media (max-width:720px) { .customers-error,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Edit } from '@lucide/vue'
|
import { Pencil } from '@lucide/vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
import DataTable from '@/shared/components/DataTable.vue'
|
import DataTable from '@/shared/components/DataTable.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { getApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
@@ -58,21 +60,25 @@ onMounted(() => { void load() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AdminPage>
|
||||||
<div class="business-page overseas-fees-page">
|
<div class="business-page overseas-fees-page">
|
||||||
<section v-if="error" class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section>
|
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
|
||||||
<QueryToolbar v-else v-model="filters" class="surface-panel" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" @reset="() => { filters.keyword = ''; search() }" />
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" @reset="() => { filters.keyword = ''; search() }" /></template>
|
||||||
|
<template v-if="error" #state><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template>
|
||||||
<section v-if="!error" class="surface-panel table-panel">
|
<section v-if="!error" class="surface-panel table-panel">
|
||||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||||
<template #toolbar-start><div class="table-heading"><span>共 {{ total }} 个客户</span></div></template>
|
<template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
|
||||||
<template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Edit :size="15" />编辑</el-button></template>
|
|
||||||
<template #storage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
<template #storage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||||
<template #returnStorage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
<template #returnStorage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><el-table :data="group" size="small"><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><el-table :data="group" size="small"><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -83,5 +89,5 @@ onMounted(() => { void load() })
|
|||||||
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
|
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
|
||||||
.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||||||
.groups h3 { margin:0 0 10px; font-size:15px; }
|
.groups h3 { margin:0 0 10px; font-size:15px; }
|
||||||
@media (max-width:720px) { .groups,.tier-list { grid-template-columns:1fr; } }
|
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .groups,.tier-list { grid-template-columns:1fr; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
BarChart3,
|
|
||||||
Clock3,
|
Clock3,
|
||||||
Expand,
|
Expand,
|
||||||
Info,
|
|
||||||
Minimize,
|
Minimize,
|
||||||
Ship,
|
Ship,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
|
|
||||||
type TransportMode = 'sea' | 'air' | 'land'
|
type TransportMode = 'sea' | 'air' | 'land'
|
||||||
|
|
||||||
@@ -124,130 +124,133 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
|||||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||||
-->
|
-->
|
||||||
<section ref="dashboardRef" class="dashboard-view">
|
<section ref="dashboardRef" class="dashboard-view">
|
||||||
<header class="dashboard-head">
|
<AdminPage as="div" spacing="normal">
|
||||||
<div>
|
<section class="dashboard-context" aria-label="数据看板说明">
|
||||||
<div class="dashboard-title"><BarChart3 :size="21" aria-hidden="true" /><h1>实时数据看板</h1></div>
|
<p>当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
|
||||||
<p><Info :size="14" aria-hidden="true" />当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
|
<div class="dashboard-head-actions">
|
||||||
</div>
|
<span>演示快照:2026-06-02 08:45</span>
|
||||||
<div class="dashboard-head-actions">
|
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
|
||||||
<span>演示快照:2026-06-02 08:45</span>
|
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
|
||||||
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
|
<Expand v-else :size="16" aria-hidden="true" />
|
||||||
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
|
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
|
||||||
<Expand v-else :size="16" aria-hidden="true" />
|
</button>
|
||||||
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
|
</div>
|
||||||
</button>
|
</section>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="dashboard-metrics" aria-label="核心业务指标">
|
<ListPageLayout ariaLabel="平台运营工作台">
|
||||||
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
<template #metrics>
|
||||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||||
<p>{{ metric.label }}</p>
|
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||||
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||||
<span>{{ metric.note }} <b :class="{ 'is-warning': metric.warning }">{{ metric.change }}</b></span>
|
<p>{{ metric.label }}</p>
|
||||||
</article>
|
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||||
</div>
|
<span>{{ metric.note }} <b :class="{ 'is-warning': metric.warning }">{{ metric.change }}</b></span>
|
||||||
|
</article>
|
||||||
<div class="dashboard-grid dashboard-grid--primary">
|
|
||||||
<article class="dashboard-panel dashboard-trend-panel">
|
|
||||||
<div class="dashboard-panel-head">
|
|
||||||
<h2>近 30 天运单量趋势</h2>
|
|
||||||
<div class="dashboard-tabs" aria-label="运输方式">
|
|
||||||
<button v-for="mode in ([['sea', '海运'], ['air', '空运'], ['land', '陆运']] as const)" :key="mode[0]" type="button" :class="{ active: activeMode === mode[0] }" :aria-pressed="activeMode === mode[0]" @click="activeMode = mode[0]">{{ mode[1] }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div class="dashboard-legend">
|
|
||||||
<span v-for="series in currentSeries" :key="series.name"><i :style="{ background: series.color }" />{{ series.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-line-chart">
|
|
||||||
<svg viewBox="0 0 700 228" role="img" :aria-label="`${activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运'}近 30 天运单量趋势演示图`">
|
|
||||||
<g class="dashboard-chart-grid">
|
|
||||||
<line v-for="y in [32, 61, 90, 119, 148, 177, 206]" :key="`y-${y}`" x1="12" :y1="y" x2="688" :y2="y" />
|
|
||||||
<line v-for="x in [12, 124, 237, 350, 463, 576, 688]" :key="`x-${x}`" :x1="x" y1="32" :x2="x" y2="206" />
|
|
||||||
</g>
|
|
||||||
<template v-for="series in currentSeries" :key="series.name">
|
|
||||||
<polygon :points="areaPoints(series.values)" :fill="series.fill" />
|
|
||||||
<polyline :points="linePoints(series.values)" fill="none" :stroke="series.color" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</template>
|
|
||||||
<g class="dashboard-axis-labels">
|
|
||||||
<text x="12" y="224">1 日</text><text x="124" y="224">5 日</text><text x="237" y="224">10 日</text>
|
|
||||||
<text x="350" y="224">15 日</text><text x="463" y="224">20 日</text><text x="576" y="224">25 日</text><text x="663" y="224">30 日</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<table class="sr-only">
|
|
||||||
<caption>{{ activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运' }}近 30 天运单量演示数据</caption>
|
|
||||||
<thead><tr><th>日期</th><th v-for="series in currentSeries" :key="series.name">{{ series.name }}</th></tr></thead>
|
|
||||||
<tbody><tr v-for="(_, index) in currentSeries[0]?.values" :key="index"><th>{{ index + 1 }} 日</th><td v-for="series in currentSeries" :key="series.name">{{ series.values[index] }}</td></tr></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="dashboard-panel">
|
<div class="dashboard-content">
|
||||||
<div class="dashboard-panel-head"><h2>运单状态分布</h2></div>
|
<div class="dashboard-grid dashboard-grid--primary">
|
||||||
<div class="dashboard-donut-wrap">
|
<article class="dashboard-panel dashboard-trend-panel">
|
||||||
<div class="dashboard-donut" :style="{ background: donutBackground(statusSegments) }"><span><Ship :size="24" /><strong>100%</strong><small>演示样本</small></span></div>
|
<div class="dashboard-panel-head">
|
||||||
<div class="dashboard-legend dashboard-legend--centered">
|
<h2>近 30 天运单量趋势</h2>
|
||||||
<span v-for="item in statusSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
|
<div class="dashboard-tabs" aria-label="运输方式">
|
||||||
|
<button v-for="mode in ([['sea', '海运'], ['air', '空运'], ['land', '陆运']] as const)" :key="mode[0]" type="button" :class="{ active: activeMode === mode[0] }" :aria-pressed="activeMode === mode[0]" @click="activeMode = mode[0]">{{ mode[1] }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-legend">
|
||||||
|
<span v-for="series in currentSeries" :key="series.name"><i :style="{ background: series.color }" />{{ series.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-line-chart">
|
||||||
|
<svg viewBox="0 0 700 228" role="img" :aria-label="`${activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运'}近 30 天运单量趋势演示图`">
|
||||||
|
<g class="dashboard-chart-grid">
|
||||||
|
<line v-for="y in [32, 61, 90, 119, 148, 177, 206]" :key="`y-${y}`" x1="12" :y1="y" x2="688" :y2="y" />
|
||||||
|
<line v-for="x in [12, 124, 237, 350, 463, 576, 688]" :key="`x-${x}`" :x1="x" y1="32" :x2="x" y2="206" />
|
||||||
|
</g>
|
||||||
|
<template v-for="series in currentSeries" :key="series.name">
|
||||||
|
<polygon :points="areaPoints(series.values)" :fill="series.fill" />
|
||||||
|
<polyline :points="linePoints(series.values)" fill="none" :stroke="series.color" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</template>
|
||||||
|
<g class="dashboard-axis-labels">
|
||||||
|
<text x="12" y="224">1 日</text><text x="124" y="224">5 日</text><text x="237" y="224">10 日</text>
|
||||||
|
<text x="350" y="224">15 日</text><text x="463" y="224">20 日</text><text x="576" y="224">25 日</text><text x="663" y="224">30 日</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<table class="sr-only">
|
||||||
|
<caption>{{ activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运' }}近 30 天运单量演示数据</caption>
|
||||||
|
<thead><tr><th>日期</th><th v-for="series in currentSeries" :key="series.name">{{ series.name }}</th></tr></thead>
|
||||||
|
<tbody><tr v-for="(_, index) in currentSeries[0]?.values" :key="index"><th>{{ index + 1 }} 日</th><td v-for="series in currentSeries" :key="series.name">{{ series.values[index] }}</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="dashboard-panel">
|
||||||
|
<div class="dashboard-panel-head"><h2>运单状态分布</h2></div>
|
||||||
|
<div class="dashboard-donut-wrap">
|
||||||
|
<div class="dashboard-donut" :style="{ background: donutBackground(statusSegments) }"><span><Ship :size="24" /><strong>100%</strong><small>演示样本</small></span></div>
|
||||||
|
<div class="dashboard-legend dashboard-legend--centered">
|
||||||
|
<span v-for="item in statusSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dashboard-grid dashboard-grid--equal">
|
<div class="dashboard-grid dashboard-grid--equal">
|
||||||
<article class="dashboard-panel">
|
<article class="dashboard-panel">
|
||||||
<div class="dashboard-panel-head"><h2>TOP10 航线货量(TEU)</h2></div>
|
<div class="dashboard-panel-head"><h2>TOP10 航线货量(TEU)</h2></div>
|
||||||
<div class="dashboard-bars" aria-label="TOP10 航线货量静态演示数据">
|
<div class="dashboard-bars" aria-label="TOP10 航线货量静态演示数据">
|
||||||
<div v-for="route in routes" :key="route[0]" class="dashboard-bar-row">
|
<div v-for="route in routes" :key="route[0]" class="dashboard-bar-row">
|
||||||
<span>{{ route[0] }}</span><i><b :style="{ width: `${(route[1] / 900) * 100}%` }" /></i><strong>{{ route[1] }}</strong>
|
<span>{{ route[0] }}</span><i><b :style="{ width: `${(route[1] / 900) * 100}%` }" /></i><strong>{{ route[1] }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="dashboard-panel">
|
||||||
|
<div class="dashboard-panel-head"><h2>收入构成</h2></div>
|
||||||
|
<div class="dashboard-donut-wrap dashboard-donut-wrap--revenue">
|
||||||
|
<div class="dashboard-donut dashboard-donut--solid" :style="{ background: donutBackground(revenueSegments) }" role="img" aria-label="收入构成静态演示饼图" />
|
||||||
|
<div class="dashboard-legend dashboard-legend--centered">
|
||||||
|
<span v-for="item in revenueSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="dashboard-panel">
|
<article class="dashboard-panel dashboard-task-panel">
|
||||||
<div class="dashboard-panel-head"><h2>收入构成</h2></div>
|
<div class="dashboard-panel-head">
|
||||||
<div class="dashboard-donut-wrap dashboard-donut-wrap--revenue">
|
<h2><Clock3 :size="18" aria-hidden="true" />待办事项</h2>
|
||||||
<div class="dashboard-donut dashboard-donut--solid" :style="{ background: donutBackground(revenueSegments) }" role="img" aria-label="收入构成静态演示饼图" />
|
<span>功能入口待真实业务模块接入</span>
|
||||||
<div class="dashboard-legend dashboard-legend--centered">
|
</div>
|
||||||
<span v-for="item in revenueSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
|
<div class="dashboard-table-wrap">
|
||||||
</div>
|
<table>
|
||||||
|
<thead><tr><th>操作</th><th>类型</th><th>内容</th><th>优先级</th><th>截止时间</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="task in tasks" :key="task.content">
|
||||||
|
<td><button type="button" class="dashboard-task-action" disabled>待接入 <ArrowUpRight :size="14" /></button></td>
|
||||||
|
<td>{{ task.type }}</td><td>{{ task.content }}</td>
|
||||||
|
<td><span class="dashboard-priority" :class="{ urgent: task.priority === '紧急' }">{{ task.priority }}</span></td>
|
||||||
|
<td>{{ task.due }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</ListPageLayout>
|
||||||
</div>
|
</AdminPage>
|
||||||
|
|
||||||
<article class="dashboard-panel dashboard-task-panel">
|
|
||||||
<div class="dashboard-panel-head">
|
|
||||||
<h2><Clock3 :size="18" aria-hidden="true" />待办事项</h2>
|
|
||||||
<span>功能入口待真实业务模块接入</span>
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>操作</th><th>类型</th><th>内容</th><th>优先级</th><th>截止时间</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="task in tasks" :key="task.content">
|
|
||||||
<td><button type="button" class="dashboard-task-action" disabled>待接入 <ArrowUpRight :size="14" /></button></td>
|
|
||||||
<td>{{ task.type }}</td><td>{{ task.content }}</td>
|
|
||||||
<td><span class="dashboard-priority" :class="{ urgent: task.priority === '紧急' }">{{ task.priority }}</span></td>
|
|
||||||
<td>{{ task.due }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-view { --dashboard-panel: #fff; display: flex; min-width: 0; flex-direction: column; gap: 18px; color: #172033; }
|
.dashboard-view { --dashboard-panel: var(--cc-surface, #fff); display: block; min-width: 0; color: var(--cc-text, #172033); }
|
||||||
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: #f4f6f8; }
|
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
||||||
.dashboard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
.dashboard-content { display: flex; min-width: 0; flex-direction: column; gap: 18px; }
|
||||||
.dashboard-title { display: flex; align-items: center; gap: 9px; }
|
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
.dashboard-title > svg { color: #667eea; }
|
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
||||||
.dashboard-title h1 { margin: 0; font-size: 20px; line-height: 1.3; }
|
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||||
.dashboard-head p { display: flex; align-items: center; gap: 6px; margin: 7px 0 0; color: #667085; font-size: 12px; line-height: 1.5; }
|
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||||
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: #667085; font-size: 12px; white-space: nowrap; }
|
|
||||||
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid #d8deea; border-radius: 8px; background: #fff; color: #344054; cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
|
||||||
.dashboard-outline-button { padding: 0 13px; }
|
.dashboard-outline-button { padding: 0 13px; }
|
||||||
.dashboard-outline-button:hover { border-color: #667eea; color: #5366c8; }
|
.dashboard-outline-button:hover { border-color: var(--cc-primary); color: var(--cc-primary); }
|
||||||
.dashboard-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
|
.dashboard-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
|
||||||
.dashboard-metric { position: relative; min-width: 0; overflow: hidden; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
|
.dashboard-metric { position: relative; min-width: 0; overflow: hidden; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
|
||||||
.dashboard-metric-mark { position: absolute; top: -38px; right: -30px; width: 110px; height: 110px; border-radius: 50%; background: #edf1ff; }
|
.dashboard-metric-mark { position: absolute; top: -38px; right: -30px; width: 110px; height: 110px; border-radius: 50%; background: #edf1ff; }
|
||||||
@@ -270,8 +273,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
|||||||
.dashboard-panel-head > span { color: #8a91a0; font-size: 11px; }
|
.dashboard-panel-head > span { color: #8a91a0; font-size: 11px; }
|
||||||
.dashboard-tabs { display: flex; gap: 7px; }
|
.dashboard-tabs { display: flex; gap: 7px; }
|
||||||
.dashboard-tabs button { min-width: 58px; padding: 0 12px; }
|
.dashboard-tabs button { min-width: 58px; padding: 0 12px; }
|
||||||
.dashboard-tabs button:hover { border-color: #b8c2ef; color: #5366c8; }
|
.dashboard-tabs button:hover { border-color: var(--cc-primary-light, #b8c2ef); color: var(--cc-primary); }
|
||||||
.dashboard-tabs button.active { border-color: #667eea; background: #667eea; color: #fff; }
|
.dashboard-tabs button.active { border-color: var(--cc-primary); background: var(--cc-primary); color: #fff; }
|
||||||
.dashboard-legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 12px 0; color: #667085; font-size: 11px; }
|
.dashboard-legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 12px 0; color: #667085; font-size: 11px; }
|
||||||
.dashboard-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
.dashboard-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
.dashboard-legend i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
|
.dashboard-legend i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
|
||||||
@@ -314,7 +317,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
|||||||
.dashboard-donut-wrap { min-height: 240px; }
|
.dashboard-donut-wrap { min-height: 240px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.dashboard-head { align-items: stretch; flex-direction: column; }
|
.dashboard-context { align-items: flex-start; flex-direction: column; }
|
||||||
.dashboard-head-actions { align-items: flex-start; flex-direction: column; gap: 9px; white-space: normal; }
|
.dashboard-head-actions { align-items: flex-start; flex-direction: column; gap: 9px; white-space: normal; }
|
||||||
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.dashboard-grid--equal { grid-template-columns: minmax(0, 1fr); }
|
.dashboard-grid--equal { grid-template-columns: minmax(0, 1fr); }
|
||||||
@@ -323,7 +326,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
|||||||
.dashboard-tabs button { min-height: 44px; flex: 1; }
|
.dashboard-tabs button { min-height: 44px; flex: 1; }
|
||||||
}
|
}
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.dashboard-view { gap: 14px; }
|
.dashboard-content { gap: 14px; }
|
||||||
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
.dashboard-metric { min-height: 116px; padding: 13px; }
|
.dashboard-metric { min-height: 116px; padding: 13px; }
|
||||||
.dashboard-metric:last-child { grid-column: 1 / -1; }
|
.dashboard-metric:last-child { grid-column: 1 / -1; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { Download, Eye, RefreshCw, RotateCcw } from '@lucide/vue'
|
import { Download, Eye, RotateCcw } from '@lucide/vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
type PlatformExportTask,
|
type PlatformExportTask,
|
||||||
} from '@/modules/downloads/api/platformExportTasks'
|
} from '@/modules/downloads/api/platformExportTasks'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
|
||||||
@@ -232,22 +234,27 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AdminPage>
|
||||||
<div class="business-page download-center-page">
|
<div class="business-page download-center-page">
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
|
<ListPageLayout ariaLabel="下载任务列表">
|
||||||
|
<template #toolbar>
|
||||||
|
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<section v-if="loadError" class="surface-panel download-error" role="alert">
|
<template v-if="loadError" #state>
|
||||||
<div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
|
<section class="surface-panel download-error" role="alert">
|
||||||
<el-button type="primary" :loading="loading" @click="loadTasks()">重新加载</el-button>
|
<div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
|
||||||
</section>
|
<el-button type="primary" :loading="loading" @click="loadTasks()">重新加载</el-button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||||
<div v-if="syncWarning" class="download-sync-warning" role="status">
|
<div v-if="syncWarning" class="download-sync-warning" role="status">
|
||||||
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
|
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
|
||||||
<ApiErrorMeta :details="syncWarning" />
|
<ApiErrorMeta :details="syncWarning" />
|
||||||
</div>
|
</div>
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
|
||||||
<template #toolbar-start><div class="download-table-heading"><h2>下载任务</h2><span>共 {{ result.total }} 个任务</span></div></template>
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个任务</span></template>
|
||||||
<template #toolbar-actions><el-button :loading="loading" @click="loadTasks()"><RefreshCw :size="16" />刷新</el-button></template>
|
|
||||||
<template #actions="{ row }"><div class="table-actions">
|
<template #actions="{ row }"><div class="table-actions">
|
||||||
<el-button v-if="(row as PlatformExportTask)?.id" link type="primary" @click="openDetail(row as PlatformExportTask)"><Eye :size="15" />详情</el-button>
|
<el-button v-if="(row as PlatformExportTask)?.id" link type="primary" @click="openDetail(row as PlatformExportTask)"><Eye :size="15" />详情</el-button>
|
||||||
<el-button v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" link type="primary" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)"><Download :size="15" />下载</el-button>
|
<el-button v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" link type="primary" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)"><Download :size="15" />下载</el-button>
|
||||||
@@ -263,6 +270,7 @@ onBeforeUnmount(() => {
|
|||||||
<template #failure="{ row }"><span class="failure-cell" :class="{ 'failure-message': Boolean((row as PlatformExportTask).failureMessage) }">{{ (row as PlatformExportTask).failureMessage || '—' }}</span></template>
|
<template #failure="{ row }"><span class="failure-cell" :class="{ 'failure-message': Boolean((row as PlatformExportTask).failureMessage) }">{{ (row as PlatformExportTask).failureMessage || '—' }}</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDrawer
|
<AppDrawer
|
||||||
v-model="detailOpen"
|
v-model="detailOpen"
|
||||||
@@ -305,6 +313,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -313,8 +322,6 @@ onBeforeUnmount(() => {
|
|||||||
.download-error { padding: 18px; }
|
.download-error { padding: 18px; }
|
||||||
.download-error strong { color: var(--cc-danger); font-size: 14px; }
|
.download-error strong { color: var(--cc-danger); font-size: 14px; }
|
||||||
.download-error p { margin: 5px 0; color: var(--cc-text); font-size: 13px; }
|
.download-error p { margin: 5px 0; color: var(--cc-text); font-size: 13px; }
|
||||||
.download-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; }
|
|
||||||
.download-table-heading span { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 12px; }
|
|
||||||
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 18px; border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: 12px; }
|
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 18px; border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: 12px; }
|
||||||
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
|
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
|
||||||
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
|||||||
@@ -86,4 +86,25 @@ describe('CarrierRatesView price-book source contract', () => {
|
|||||||
expect(queryToolbarSource).toContain('flex-wrap: wrap')
|
expect(queryToolbarSource).toContain('flex-wrap: wrap')
|
||||||
expect(source).toContain('@media (max-width: 720px)')
|
expect(source).toContain('@media (max-width: 720px)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps upload actions in the table toolbar instead of the query toolbar', () => {
|
||||||
|
const queryToolbarEnd = source.indexOf('/>', source.indexOf('<QueryToolbar'))
|
||||||
|
const queryToolbarBlock = source.slice(source.indexOf('<QueryToolbar'), queryToolbarEnd + 2)
|
||||||
|
expect(queryToolbarBlock).not.toContain('#actions')
|
||||||
|
expect(source).toContain('<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload">')
|
||||||
|
expect(source).not.toContain('#toolbar-start')
|
||||||
|
expect(source).toContain('<template #pagination-left>共 {{ rows.length }} 个版本')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps list spacing and error state mutually exclusive', () => {
|
||||||
|
expect(source).toContain('<template v-if="loadError" #state>')
|
||||||
|
expect(source).toContain('<section v-if="!loadError" class="surface-panel table-panel"')
|
||||||
|
expect(source).not.toContain('<template #state>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps both row actions directly visible without a premature more menu', () => {
|
||||||
|
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
|
||||||
|
expect(source).toContain('<Download :size="15" />原文件')
|
||||||
|
expect(source).not.toContain('MoreHorizontal')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { AlertTriangle, Download, Eye, FileSpreadsheet, FileUp, Ship, Warehouse } from '@lucide/vue'
|
import { AlertTriangle, Download, Eye, FileSpreadsheet, FileUp, RefreshCw, Ship, Warehouse } from '@lucide/vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import {
|
import {
|
||||||
@@ -593,30 +596,31 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page price-book-page">
|
<AdminPage as="div" class="price-book-page" spacing="normal">
|
||||||
<QueryToolbar
|
<ListPageLayout aria-label="承运商报价版本管理">
|
||||||
v-model="filters"
|
<template #toolbar>
|
||||||
ariaLabel="船期价格表筛选"
|
<QueryToolbar
|
||||||
:fields="queryFields"
|
v-model="filters"
|
||||||
preference-key="price-book-query"
|
class="surface-panel"
|
||||||
@search="applyQuery"
|
ariaLabel="船期价格表筛选"
|
||||||
>
|
:fields="queryFields"
|
||||||
<template #actions>
|
preference-key="price-book-query"
|
||||||
<el-button v-if="canEdit" type="primary" @click="openUpload"><FileUp :size="16" />上传价格表</el-button>
|
@search="applyQuery"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</QueryToolbar>
|
|
||||||
|
|
||||||
<LoadErrorState
|
<template v-if="loadError" #state>
|
||||||
v-if="loadError"
|
<LoadErrorState
|
||||||
title="船期价格表版本加载失败"
|
title="船期价格表版本加载失败"
|
||||||
:message="loadErrorMessage"
|
:message="loadErrorMessage"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@retry="loadPriceBooks()"
|
@retry="loadPriceBooks()"
|
||||||
>
|
>
|
||||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel" aria-live="polite">
|
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -630,7 +634,7 @@ onMounted(() => {
|
|||||||
aria-label="船期价格表版本列表"
|
aria-label="船期价格表版本列表"
|
||||||
empty-text="暂无船期价格表版本"
|
empty-text="暂无船期价格表版本"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div><h2>价格表版本</h2><p>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</p></div></template>
|
<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload"><FileUp :size="16" />上传价格表</el-button></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button link type="primary" @click="openDetail(row as PlatformPriceBookVersion)"><Eye :size="15" />{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button>
|
<el-button link type="primary" @click="openDetail(row as PlatformPriceBookVersion)"><Eye :size="15" />{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button>
|
||||||
@@ -647,8 +651,10 @@ onMounted(() => {
|
|||||||
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
|
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
|
||||||
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
|
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
|
||||||
<template #empty><el-empty description="暂无船期价格表版本" /></template>
|
<template #empty><el-empty description="暂无船期价格表版本" /></template>
|
||||||
|
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
v-model="uploadOpen"
|
v-model="uploadOpen"
|
||||||
@@ -690,16 +696,16 @@ onMounted(() => {
|
|||||||
<strong>价格表详情暂时无法加载</strong>
|
<strong>价格表详情暂时无法加载</strong>
|
||||||
<p>{{ detailError.message }}</p>
|
<p>{{ detailError.message }}</p>
|
||||||
<ApiErrorMeta :details="detailError" />
|
<ApiErrorMeta :details="detailError" />
|
||||||
<el-button type="primary" @click="selectedVersionId && openDetail({ id: selectedVersionId } as PlatformPriceBookVersion)">重新加载</el-button>
|
<el-button type="primary" @click="selectedVersionId && openDetail({ id: selectedVersionId } as PlatformPriceBookVersion)"><RefreshCw :size="16" />重新加载</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-else-if="detail" class="detail-content">
|
<DetailPageLayout v-else-if="detail" class="detail-content" aria-label="船期价格表版本详情">
|
||||||
<header class="detail-summary">
|
<template #header><header class="detail-summary">
|
||||||
<div class="detail-summary__title"><FileSpreadsheet :size="22" /><div><h3>{{ detail.version.originalFileName }}</h3><p>版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
|
<div class="detail-summary__title"><FileSpreadsheet :size="22" /><div><h3>{{ detail.version.originalFileName }}</h3><p>版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
|
||||||
<div class="detail-summary__actions"><el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag><el-button :loading="downloadingIds.has(detail.version.id)" @click="downloadSource(detail.version)"><Download :size="16" />下载原文件</el-button></div>
|
<div class="detail-summary__actions"><el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag><el-button :loading="downloadingIds.has(detail.version.id)" @click="downloadSource(detail.version)"><Download :size="16" />下载原文件</el-button></div>
|
||||||
</header>
|
</header></template>
|
||||||
|
|
||||||
<section class="detail-section version-facts">
|
<template #summary><section class="detail-section version-facts">
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid">
|
||||||
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
|
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
|
||||||
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
|
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
|
||||||
@@ -707,7 +713,7 @@ onMounted(() => {
|
|||||||
<div><dt>警告</dt><dd>{{ detail.version.warningCount }}</dd></div>
|
<div><dt>警告</dt><dd>{{ detail.version.warningCount }}</dd></div>
|
||||||
<div><dt>实际生效区间</dt><dd>{{ formatEffectivePeriod(detail.version) }}</dd></div>
|
<div><dt>实际生效区间</dt><dd>{{ formatEffectivePeriod(detail.version) }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section></template>
|
||||||
|
|
||||||
<section v-if="isPendingDetail && canEdit" class="detail-section mapping-section">
|
<section v-if="isPendingDetail && canEdit" class="detail-section mapping-section">
|
||||||
<header class="detail-section__heading"><div><h3><Warehouse :size="17" />仓库映射</h3><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
|
<header class="detail-section__heading"><div><h3><Warehouse :size="17" />仓库映射</h3><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
|
||||||
@@ -722,7 +728,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-if="mappingWarehousesError" class="mapping-load-error" role="alert">
|
<div v-if="mappingWarehousesError" class="mapping-load-error" role="alert">
|
||||||
<div><strong>平台仓库候选加载失败</strong><p>{{ mappingWarehousesError.message }}</p><ApiErrorMeta :details="mappingWarehousesError" /></div>
|
<div><strong>平台仓库候选加载失败</strong><p>{{ mappingWarehousesError.message }}</p><ApiErrorMeta :details="mappingWarehousesError" /></div>
|
||||||
<el-button :loading="mappingWarehousesLoading" @click="loadMappingWarehouses(true)">重新加载</el-button>
|
<el-button :loading="mappingWarehousesLoading" @click="loadMappingWarehouses(true)"><RefreshCw :size="16" />重新加载</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="mappingWarehousesLoaded" class="mapping-groups">
|
<div v-else-if="mappingWarehousesLoaded" class="mapping-groups">
|
||||||
<section class="mapping-group">
|
<section class="mapping-group">
|
||||||
@@ -789,9 +795,9 @@ onMounted(() => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</DetailPageLayout>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
@@ -7,6 +9,8 @@ import { useAuthStore } from '@/core/auth/auth'
|
|||||||
import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems'
|
import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems'
|
||||||
import FeeItemsView from './FeeItemsView.vue'
|
import FeeItemsView from './FeeItemsView.vue'
|
||||||
|
|
||||||
|
const source = readFileSync(join(process.cwd(), 'src/modules/fee-items/pages/FeeItemsView.vue'), 'utf8')
|
||||||
|
|
||||||
vi.mock('@/modules/fee-items/api/platformFeeItems', () => ({
|
vi.mock('@/modules/fee-items/api/platformFeeItems', () => ({
|
||||||
listPlatformFeeItems: vi.fn(),
|
listPlatformFeeItems: vi.fn(),
|
||||||
getPlatformFeeItem: vi.fn(),
|
getPlatformFeeItem: vi.fn(),
|
||||||
@@ -63,6 +67,11 @@ describe('FeeItemsView public master data and access', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
|
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
|
||||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增费用项目')
|
||||||
|
expect(wrapper.find('.data-table__business-actions').text()).toContain('新增费用项目')
|
||||||
|
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共主数据 · 共 0 项')
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
|
||||||
expect(wrapper.text()).toContain('平台公共主数据')
|
expect(wrapper.text()).toContain('平台公共主数据')
|
||||||
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
|
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
|
||||||
|
|
||||||
@@ -73,10 +82,27 @@ describe('FeeItemsView public master data and access', () => {
|
|||||||
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
|
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the load error instead of an empty table panel', async () => {
|
||||||
|
vi.mocked(listPlatformFeeItems).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||||
|
const wrapper = await mountView(['firstleg:fee-item:view'])
|
||||||
|
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.table-panel').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toContain('费用项目加载失败')
|
||||||
|
expect(wrapper.text()).toContain('Gateway unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders an explicit no-access state without loading customers or fee items', async () => {
|
it('renders an explicit no-access state without loading customers or fee items', async () => {
|
||||||
const wrapper = await mountView([])
|
const wrapper = await mountView([])
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('无权查看费用项目')
|
expect(wrapper.text()).toContain('无权查看费用项目')
|
||||||
expect(listPlatformFeeItems).not.toHaveBeenCalled()
|
expect(listPlatformFeeItems).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps all three row actions visible without a premature more menu', () => {
|
||||||
|
expect(source).toContain('<Pencil :size="15" />编辑')
|
||||||
|
expect(source).toContain('<Power :size="15" />')
|
||||||
|
expect(source).toContain('<Trash2 :size="15" />删除')
|
||||||
|
expect(source).not.toContain('MoreHorizontal')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Pencil, Plus, Power, Trash2 } from '@lucide/vue'
|
import { Pencil, Plus, Power, Save, Trash2 } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +18,12 @@ import {
|
|||||||
} from '@/modules/fee-items/api/platformFeeItems'
|
} from '@/modules/fee-items/api/platformFeeItems'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
|
|
||||||
@@ -319,30 +322,33 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page fee-items-page">
|
<AdminPage as="div" class="fee-items-page" spacing="normal">
|
||||||
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
|
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
|
||||||
|
|
||||||
<template v-else>
|
<ListPageLayout v-else aria-label="费用项目管理">
|
||||||
<QueryToolbar
|
<template #toolbar>
|
||||||
v-model="filters"
|
<QueryToolbar
|
||||||
class="surface-panel"
|
v-model="filters"
|
||||||
ariaLabel="费用项目筛选"
|
class="surface-panel"
|
||||||
:fields="queryFields"
|
ariaLabel="费用项目筛选"
|
||||||
preference-key="fee-items-query"
|
:fields="queryFields"
|
||||||
@search="applyFilters"
|
preference-key="fee-items-query"
|
||||||
/>
|
@search="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<LoadErrorState
|
<template v-if="loadError" #state>
|
||||||
v-if="loadError"
|
<LoadErrorState
|
||||||
title="费用项目加载失败"
|
title="费用项目加载失败"
|
||||||
:message="loadError.message"
|
:message="loadError.message"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@retry="loadFeeItems"
|
@retry="loadFeeItems"
|
||||||
>
|
>
|
||||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -356,9 +362,6 @@ onMounted(() => {
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
empty-text="暂无符合条件的费用项目"
|
empty-text="暂无符合条件的费用项目"
|
||||||
>
|
>
|
||||||
<template #toolbar-start>
|
|
||||||
<div><h2>费用项目列表</h2><p>平台公共主数据 · 共 {{ total }} 项</p></div>
|
|
||||||
</template>
|
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="access.can('firstleg:fee-item:create')"
|
v-if="access.can('firstleg:fee-item:create')"
|
||||||
@@ -395,9 +398,10 @@ onMounted(() => {
|
|||||||
<el-button v-if="access.can('firstleg:fee-item:create')" type="primary" @click="openCreate"><Plus :size="16" />新增第一项费用</el-button>
|
<el-button v-if="access.can('firstleg:fee-item:create')" type="primary" @click="openCreate"><Plus :size="16" />新增第一项费用</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
</template>
|
</template>
|
||||||
|
<template #pagination-left>平台公共主数据 · 共 {{ total }} 项</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDrawer
|
<AppDrawer
|
||||||
v-model="editorOpen"
|
v-model="editorOpen"
|
||||||
@@ -405,27 +409,34 @@ onMounted(() => {
|
|||||||
size="min(620px, 94vw)"
|
size="min(620px, 94vw)"
|
||||||
:loading="editorLoading"
|
:loading="editorLoading"
|
||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
confirm-text="保存费用项目"
|
|
||||||
@confirm="saveFeeItem"
|
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
||||||
<div class="form-grid two-cols">
|
<FormSection title="基本信息" description="费用项目为平台公共主数据,不按客户单独维护。">
|
||||||
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
|
<div class="form-grid two-cols">
|
||||||
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
|
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
|
||||||
</div>
|
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
|
||||||
<el-form-item label="贸易范围" prop="tradeScope" required>
|
</div>
|
||||||
<el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" />
|
<el-form-item label="贸易范围" prop="tradeScope" required>
|
||||||
</el-form-item>
|
<el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" />
|
||||||
<div class="form-section">
|
</el-form-item>
|
||||||
<h3>费用方向</h3>
|
</FormSection>
|
||||||
|
<FormSection title="费用方向" description="选择费用在提货和仓库环节的应收、应付属性。">
|
||||||
<div class="direction-grid">
|
<div class="direction-grid">
|
||||||
<fieldset><legend>提货环节</legend><el-checkbox v-model="form.pickupPayable">应付</el-checkbox><el-checkbox v-model="form.pickupReceivable">应收</el-checkbox></fieldset>
|
<fieldset><legend>提货环节</legend><el-checkbox v-model="form.pickupPayable">应付</el-checkbox><el-checkbox v-model="form.pickupReceivable">应收</el-checkbox></fieldset>
|
||||||
<fieldset><legend>仓库环节</legend><el-checkbox v-model="form.warehousePayable">应付</el-checkbox><el-checkbox v-model="form.warehouseReceivable">应收</el-checkbox></fieldset>
|
<fieldset><legend>仓库环节</legend><el-checkbox v-model="form.warehousePayable">应付</el-checkbox><el-checkbox v-model="form.warehouseReceivable">应收</el-checkbox></fieldset>
|
||||||
</div>
|
</div>
|
||||||
<el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox>
|
<el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox>
|
||||||
</div>
|
</FormSection>
|
||||||
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item>
|
<FormSection title="适用说明" :bordered="false">
|
||||||
|
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item>
|
||||||
|
</FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button :disabled="saving" @click="editorOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
@@ -452,11 +463,11 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<p class="delete-message">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
|
<p class="delete-message">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fee-items-page { display: grid; gap: 16px; }
|
.fee-items-page { min-width: 0; }
|
||||||
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.fee-item-name small { color: var(--cc-muted); font-size: 11px; }
|
.fee-item-name small { color: var(--cc-muted); font-size: 11px; }
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import {
|
|||||||
type PlatformFirstLegOrderStatus,
|
type PlatformFirstLegOrderStatus,
|
||||||
} from '@/modules/first-leg-orders/api/platformFirstLegOrders'
|
} from '@/modules/first-leg-orders/api/platformFirstLegOrders'
|
||||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
@@ -421,9 +424,12 @@ onMounted(() => void loadTenants())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AdminPage>
|
||||||
<div class="business-page">
|
<div class="business-page">
|
||||||
<template v-if="isDetailPage">
|
<template v-if="isDetailPage">
|
||||||
<section class="surface-panel detail-page" v-loading="detailLoading">
|
<section class="surface-panel detail-page" v-loading="detailLoading">
|
||||||
|
<DetailPageLayout ariaLabel="头程订单详情">
|
||||||
|
<template #header>
|
||||||
<header class="detail-page-header">
|
<header class="detail-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>订单详情</h1>
|
<h1>订单详情</h1>
|
||||||
@@ -431,6 +437,7 @@ onMounted(() => void loadTenants())
|
|||||||
</div>
|
</div>
|
||||||
<el-button @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button>
|
<el-button @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="order-detail-head">
|
<div class="order-detail-head">
|
||||||
@@ -498,11 +505,14 @@ onMounted(() => void loadTenants())
|
|||||||
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline>
|
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage">返回订单列表</el-button></el-empty>
|
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button></el-empty>
|
||||||
|
</DetailPageLayout>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<ListPageLayout ariaLabel="头程订单列表">
|
||||||
|
<template #toolbar>
|
||||||
<QueryToolbar
|
<QueryToolbar
|
||||||
v-model="filters"
|
v-model="filters"
|
||||||
ariaLabel="头程订单筛选"
|
ariaLabel="头程订单筛选"
|
||||||
@@ -513,18 +523,21 @@ onMounted(() => void loadTenants())
|
|||||||
@search="searchOrders"
|
@search="searchOrders"
|
||||||
@reset="resetOrders"
|
@reset="resetOrders"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
|
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||||
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
|
<template #title><div class="api-error-alert-title"><span>{{ tenantLoadError.message }}</span><ApiErrorMeta :details="tenantLoadError" /></div></template>
|
||||||
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
|
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
<template v-if="loadError" #state>
|
||||||
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
|
<el-alert type="error" :closable="false" show-icon role="alert">
|
||||||
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
|
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
|
||||||
</el-alert>
|
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
|
||||||
|
</el-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -537,13 +550,13 @@ onMounted(() => void loadTenants())
|
|||||||
empty-text="当前没有符合条件的头程订单"
|
empty-text="当前没有符合条件的头程订单"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div><h2>头程订单列表</h2><p>共 {{ total }} 条订单</p></div></template>
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条订单</span></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions order-row-actions">
|
<div class="table-actions order-row-actions">
|
||||||
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
|
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
|
||||||
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
|
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
|
||||||
<el-dropdown v-if="hasSecondaryActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
|
<el-dropdown v-if="hasSecondaryActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
|
||||||
<el-button link type="primary" aria-label="更多订单操作"><MoreHorizontal :size="17" />更多</el-button>
|
<el-button link type="primary" aria-label="更多订单操作" title="更多订单操作"><MoreHorizontal :size="17" /></el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="canMaintainExpress(row as PlatformFirstLegOrderResource)" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
|
<el-dropdown-item v-if="canMaintainExpress(row as PlatformFirstLegOrderResource)" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
|
||||||
@@ -561,9 +574,9 @@ onMounted(() => void loadTenants())
|
|||||||
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
|
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
|
||||||
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
|
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
|
||||||
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
|
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
|
||||||
<template #empty><el-empty description="当前没有符合条件的头程订单" /></template>
|
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
|
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
|
||||||
@@ -577,6 +590,7 @@ onMounted(() => void loadTenants())
|
|||||||
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
|
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Pencil, Plus } from '@lucide/vue'
|
import { Pencil, Plus, Power } from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
changePlatformManagedUserStatus,
|
changePlatformManagedUserStatus,
|
||||||
createPlatformManagedUser,
|
createPlatformManagedUser,
|
||||||
@@ -22,7 +22,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
|||||||
import FormSection from '@/shared/components/FormSection.vue'
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
@@ -302,8 +301,6 @@ onMounted(loadInitialData)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AdminPage as="div">
|
<AdminPage as="div">
|
||||||
<PageToolbar title="内部用户" description="维护平台内部员工账号、平台角色和使用状态。" />
|
|
||||||
|
|
||||||
<ListPageLayout aria-label="内部用户管理">
|
<ListPageLayout aria-label="内部用户管理">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<QueryToolbar
|
<QueryToolbar
|
||||||
@@ -321,10 +318,9 @@ onMounted(loadInitialData)
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel" :aria-busy="loading">
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
||||||
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
||||||
<template #toolbar-start><div><h2>内部用户列表 <span class="secondary-text">共 {{ total }} 条</span></h2><p>维护平台内部员工账号、平台角色和使用状态。</p></div></template>
|
|
||||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template>
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
@@ -336,7 +332,7 @@ onMounted(loadInitialData)
|
|||||||
:disabled="cannotDisable(row as PlatformManagedUser)"
|
:disabled="cannotDisable(row as PlatformManagedUser)"
|
||||||
:title="(row as PlatformManagedUser).protectedAccount ? '系统管理员不能被禁用' : isCurrentUser(row as PlatformManagedUser) ? '不能禁用当前登录用户' : undefined"
|
:title="(row as PlatformManagedUser).protectedAccount ? '系统管理员不能被禁用' : isCurrentUser(row as PlatformManagedUser) ? '不能禁用当前登录用户' : undefined"
|
||||||
@click="toggleStatus(row as PlatformManagedUser)">
|
@click="toggleStatus(row as PlatformManagedUser)">
|
||||||
{{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }}
|
<Power :size="15" />{{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -348,6 +344,7 @@ onMounted(loadInitialData)
|
|||||||
</template>
|
</template>
|
||||||
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
|
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
|
||||||
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
|
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|||||||
@@ -39,12 +39,11 @@ function mountServerView() {
|
|||||||
directives: { loading: () => undefined },
|
directives: { loading: () => undefined },
|
||||||
stubs: {
|
stubs: {
|
||||||
AdminPage: { template: '<main><slot /></main>' },
|
AdminPage: { template: '<main><slot /></main>' },
|
||||||
PageToolbar: { template: '<header><slot /><slot name="actions" /></header>' },
|
|
||||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||||
FormSection: { template: '<section><slot /></section>' },
|
FormSection: { template: '<section><slot /></section>' },
|
||||||
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
||||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
|
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
|
||||||
DataTable: { template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /></section>' },
|
DataTable: { props: ['data'], template: '<section><slot name="toolbar-actions" /><span data-testid="row-count">{{ data.length }}</span></section>' },
|
||||||
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
||||||
'el-input': true,
|
'el-input': true,
|
||||||
'el-option': true,
|
'el-option': true,
|
||||||
@@ -75,8 +74,7 @@ describe('PermissionsView IAM mode', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(listPlatformPermissions).toHaveBeenCalledTimes(1)
|
expect(listPlatformPermissions).toHaveBeenCalledTimes(1)
|
||||||
expect(wrapper.text()).toContain('权限资源树')
|
expect(wrapper.text()).not.toContain('权限资源树')
|
||||||
expect(wrapper.text()).toContain('共 1 项')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a recoverable state when the permission list fails', async () => {
|
it('shows a recoverable state when the permission list fails', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Braces, FolderTree, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
|
import { Braces, FolderTree, Pencil, Plus, Power, ShieldCheck, Trash2 } from '@lucide/vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
@@ -10,7 +10,6 @@ import AppDrawer from '@/shared/components/AppDrawer.vue'
|
|||||||
import FormSection from '@/shared/components/FormSection.vue'
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
|
||||||
import {
|
import {
|
||||||
changePlatformPermissionStatus,
|
changePlatformPermissionStatus,
|
||||||
createPlatformPermission,
|
createPlatformPermission,
|
||||||
@@ -237,7 +236,6 @@ onMounted(loadPermissions)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AdminPage as="div">
|
<AdminPage as="div">
|
||||||
<PageToolbar title="权限管理" description="维护平台菜单与按钮权限资源。" />
|
|
||||||
<ListPageLayout aria-label="平台权限资源管理">
|
<ListPageLayout aria-label="平台权限资源管理">
|
||||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
|
||||||
<template v-if="loadError" #state>
|
<template v-if="loadError" #state>
|
||||||
@@ -246,15 +244,15 @@ onMounted(loadPermissions)
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||||
<template #toolbar-start><div><h2>权限资源树 <span class="secondary-text">共 {{ permissions.length }} 项</span></h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div></template>
|
|
||||||
<template #toolbar-actions><div class="permission-tree-heading-actions"><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></template>
|
<template #toolbar-actions><div class="permission-tree-heading-actions"><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></template>
|
||||||
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><span v-else class="secondary-text">仅查看</span></template>
|
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)"><Power :size="15" />{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><span v-else class="secondary-text">仅查看</span></template>
|
||||||
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="(row as Permission).type === '目录' || (row as Permission).type === '菜单'" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
|
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="(row as Permission).type === '目录' || (row as Permission).type === '菜单'" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
|
||||||
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
|
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
|
||||||
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
|
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
|
||||||
<template #status="{ row }"><StatusBadge :status="(row as Permission).status" /></template>
|
<template #status="{ row }"><StatusBadge :status="(row as Permission).status" /></template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ function mountView(pinia = createPinia()) {
|
|||||||
directives: { loading: () => undefined },
|
directives: { loading: () => undefined },
|
||||||
stubs: {
|
stubs: {
|
||||||
AdminPage: { template: '<main><slot /></main>' },
|
AdminPage: { template: '<main><slot /></main>' },
|
||||||
PageToolbar: { template: '<header><slot /><slot name="actions" /></header>' },
|
|
||||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||||
FormSection: { template: '<section><slot /></section>' },
|
FormSection: { template: '<section><slot /></section>' },
|
||||||
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
|
||||||
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
|
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
|
||||||
DataTable: { template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /></section>' },
|
DataTable: { props: ['data'], template: '<section><slot name="toolbar-actions" /><span data-testid="row-count">{{ data.length }}</span></section>' },
|
||||||
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
||||||
'el-input': true,
|
'el-input': true,
|
||||||
'el-option': true,
|
'el-option': true,
|
||||||
@@ -98,7 +97,8 @@ describe('RolesView', () => {
|
|||||||
|
|
||||||
expect(listPlatformRoles).toHaveBeenCalledTimes(1)
|
expect(listPlatformRoles).toHaveBeenCalledTimes(1)
|
||||||
expect(listPlatformInternalUsers).toHaveBeenCalledTimes(1)
|
expect(listPlatformInternalUsers).toHaveBeenCalledTimes(1)
|
||||||
expect(wrapper.text()).toContain('角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色')
|
expect(wrapper.get('[data-testid="row-count"]').text()).toBe('1')
|
||||||
|
expect(wrapper.text()).not.toContain('角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色')
|
||||||
expect(wrapper.find('[data-testid="permission-tree"]').exists()).toBe(false)
|
expect(wrapper.find('[data-testid="permission-tree"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
|
import { Pencil, Plus, Power, ShieldCheck, Trash2 } from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
changePlatformRoleStatus,
|
changePlatformRoleStatus,
|
||||||
createPlatformRole,
|
createPlatformRole,
|
||||||
@@ -23,7 +23,6 @@ import AppDrawer from '@/shared/components/AppDrawer.vue'
|
|||||||
import FormSection from '@/shared/components/FormSection.vue'
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import type { Permission, Role } from '@/modules/iam/types'
|
import type { Permission, Role } from '@/modules/iam/types'
|
||||||
@@ -238,7 +237,6 @@ onMounted(loadRoles)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AdminPage as="div">
|
<AdminPage as="div">
|
||||||
<PageToolbar title="角色管理" description="维护平台角色及其菜单、按钮权限。" />
|
|
||||||
<ListPageLayout aria-label="平台角色管理">
|
<ListPageLayout aria-label="平台角色管理">
|
||||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
|
||||||
<template v-if="loadError" #state>
|
<template v-if="loadError" #state>
|
||||||
@@ -247,15 +245,15 @@ onMounted(loadRoles)
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
|
||||||
<template #toolbar-start><div><h2>角色与授权 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div></template>
|
|
||||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
|
||||||
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)">{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
|
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)"><Power :size="15" />{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
|
||||||
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong><small>{{ (row as Role).code }}</small></div></div></template>
|
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong><small>{{ (row as Role).code }}</small></div></div></template>
|
||||||
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
|
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
|
||||||
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
|
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
|
||||||
<template #status="{ row }"><StatusBadge :status="(row as Role).status" /></template>
|
<template #status="{ row }"><StatusBadge :status="(row as Role).status" /></template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('InboundOrderDetailView', () => {
|
|||||||
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
|
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
|
||||||
expect(source).not.toContain('PlatformTenantSelector')
|
expect(source).not.toContain('PlatformTenantSelector')
|
||||||
expect(source).not.toContain('租户')
|
expect(source).not.toContain('租户')
|
||||||
expect(source).toContain('<h1>入库单详情</h1>')
|
expect(source).toContain('<PageToolbar title="入库单详情"')
|
||||||
expect(source).toContain('货品与测量、费用信息、状态历史及其他记录')
|
expect(source).toContain('货品与测量、费用信息、状态历史及其他记录')
|
||||||
expect(source).toContain('class="surface-panel detail-page"')
|
expect(source).toContain('class="surface-panel detail-page"')
|
||||||
expect(source).toContain('class="detail-page-actions"')
|
expect(source).toContain('class="detail-page-actions"')
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft } from '@lucide/vue'
|
import { ArrowLeft, RefreshCw } from '@lucide/vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||||
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
|
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -21,19 +25,17 @@ function backToList() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page inbound-detail-page">
|
<AdminPage as="div" class="business-page inbound-detail-page">
|
||||||
<section class="surface-panel detail-page">
|
<DetailPageLayout class="surface-panel detail-page" aria-label="入库单详情">
|
||||||
<header class="detail-page-header">
|
<template #header>
|
||||||
<div class="detail-page-heading">
|
<PageToolbar title="入库单详情" description="查看基本信息、货品与测量、费用信息、状态历史及其他记录。">
|
||||||
<h1>入库单详情</h1>
|
<div class="detail-page-actions">
|
||||||
<p>查看基本信息、货品与测量、费用信息、状态历史及其他记录。</p>
|
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-page-actions">
|
</PageToolbar>
|
||||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
</template>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="detail-page-content">
|
<FormSection title="入库处理工作区" :bordered="false">
|
||||||
<InboundWorkflowWorkspace
|
<InboundWorkflowWorkspace
|
||||||
v-if="inboundId && customerId"
|
v-if="inboundId && customerId"
|
||||||
:customer-id="customerId"
|
:customer-id="customerId"
|
||||||
@@ -43,23 +45,19 @@ function backToList() {
|
|||||||
v-else
|
v-else
|
||||||
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
|
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormSection>
|
||||||
</section>
|
</DetailPageLayout>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.inbound-detail-page { min-width: 0; }
|
.inbound-detail-page { min-width: 0; }
|
||||||
.detail-page { overflow: hidden; }
|
.detail-page { overflow: hidden; padding: 20px 24px; }
|
||||||
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
|
||||||
.detail-page-heading { min-width: 0; }
|
|
||||||
.detail-page-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
|
|
||||||
.detail-page-heading p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
|
||||||
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
|
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||||
.detail-page-content { min-width: 0; }
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
.detail-page { padding: 16px; }
|
||||||
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||||
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||||
}
|
}
|
||||||
|
@media (max-width: 360px) { .detail-page { padding: 12px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('InboundOrdersView', () => {
|
|||||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||||
expect(document.body.textContent).toContain('RK-001')
|
expect(document.body.textContent).toContain('RK-001')
|
||||||
expect(document.body.textContent).toContain('查看')
|
expect(document.body.textContent).toContain('查看')
|
||||||
expect(document.body.textContent).toContain('所属企业 示例企业有限公司')
|
expect(document.body.textContent).toContain('示例企业有限公司')
|
||||||
expect(document.body.textContent).not.toContain('租户')
|
expect(document.body.textContent).not.toContain('租户')
|
||||||
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
|
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
|
||||||
})
|
})
|
||||||
@@ -209,10 +209,9 @@ describe('InboundOrdersView', () => {
|
|||||||
expect(toolbarRows[0].classList.contains('table-tabs-row')).toBe(true)
|
expect(toolbarRows[0].classList.contains('table-tabs-row')).toBe(true)
|
||||||
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
|
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
|
||||||
expect(toolbarRows).toHaveLength(2)
|
expect(toolbarRows).toHaveLength(2)
|
||||||
expect(wrapper.find('.inbound-stats-row').text()).toContain('当前显示 1 条 · 共 1 条')
|
expect(wrapper.findComponent(DataTable).find('.data-table__pagination-left').text()).toContain('当前显示 1 条 · 共 1 条')
|
||||||
const dataTableToolbar = wrapper.findComponent(DataTable).find('.data-table__toolbar')
|
expect(wrapper.findComponent(DataTable).find('.data-table__toolbar-start').text()).not.toContain('入库单列表')
|
||||||
expect(dataTableToolbar.text()).toContain('所属企业 全部客户')
|
expect(wrapper.text()).not.toContain('刷新')
|
||||||
expect(dataTableToolbar.text()).toContain('刷新')
|
|
||||||
expect(wrapper.text()).not.toContain('全部入库单')
|
expect(wrapper.text()).not.toContain('全部入库单')
|
||||||
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
||||||
expect(tabButtons[0].attributes('tabindex')).toBe('0')
|
expect(tabButtons[0].attributes('tabindex')).toBe('0')
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import { useRouter } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||||
@@ -400,25 +404,32 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page inbound-orders-page">
|
<AdminPage as="div" class="business-page inbound-orders-page">
|
||||||
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="inbound-orders-query" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
|
<ListPageLayout ariaLabel="入库单管理">
|
||||||
<template #actions>
|
<template #toolbar>
|
||||||
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
|
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="inbound-orders-query" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
|
||||||
<SlidersHorizontal :size="16" />
|
<template #actions>
|
||||||
{{ showAdvancedFilters ? '收起筛选' : '更多筛选' }}
|
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
|
||||||
<ChevronUp v-if="showAdvancedFilters" :size="15" />
|
<SlidersHorizontal :size="16" />
|
||||||
<ChevronDown v-else :size="15" />
|
{{ showAdvancedFilters ? '收起筛选' : '更多筛选' }}
|
||||||
</el-button>
|
<ChevronUp v-if="showAdvancedFilters" :size="15" />
|
||||||
|
<ChevronDown v-else :size="15" />
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</QueryToolbar>
|
||||||
</template>
|
</template>
|
||||||
</QueryToolbar>
|
|
||||||
|
|
||||||
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
|
<template v-if="customerLoadError || loadError" #state>
|
||||||
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
|
<div class="inbound-state-stack">
|
||||||
<el-button link type="warning" :loading="customersLoading" @click="loadCustomers">重新加载客户列表</el-button>
|
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||||
</el-alert>
|
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
|
||||||
|
<el-button link type="warning" :loading="customersLoading" @click="loadCustomers"><RefreshCw :size="15" />重新加载客户列表</el-button>
|
||||||
|
</el-alert>
|
||||||
|
<LoadErrorState v-if="loadError" :message="loadError.message" title="入库单暂时无法加载" @retry="loadInbounds"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<LoadErrorState v-if="loadError" class="inbound-load-error" :message="loadError.message" title="入库单暂时无法加载" @retry="loadInbounds"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
|
||||||
<header class="table-toolbar">
|
<header class="table-toolbar">
|
||||||
<div class="table-tabs-row">
|
<div class="table-tabs-row">
|
||||||
<nav class="inbound-tabs" role="tablist" aria-label="入库单处理状态">
|
<nav class="inbound-tabs" role="tablist" aria-label="入库单处理状态">
|
||||||
@@ -426,7 +437,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="inbound-stats-row" aria-label="入库单统计">
|
<div class="inbound-stats-row" aria-label="入库单统计">
|
||||||
<span class="selected-info">当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</span>
|
|
||||||
<span class="total-info">
|
<span class="total-info">
|
||||||
<b>总箱数 {{ formatAggregate(aggregateStats.packages, 0) }}</b>
|
<b>总箱数 {{ formatAggregate(aggregateStats.packages, 0) }}</b>
|
||||||
<i aria-hidden="true" />
|
<i aria-hidden="true" />
|
||||||
@@ -439,8 +449,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||||
<template #toolbar-start><div><h2>入库单列表</h2><p>所属企业 {{ selectedCustomerName }}</p></div></template>
|
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
||||||
<template #toolbar-actions><el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button></template>
|
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
|
||||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||||
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
|
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
|
||||||
@@ -481,13 +490,19 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
|||||||
<template #createdAt="{ row }">{{ formatDate((row as InboundOrderSummary).createdAt) }}</template>
|
<template #createdAt="{ row }">{{ formatDate((row as InboundOrderSummary).createdAt) }}</template>
|
||||||
<template #empty><el-empty :description="listEmptyText" /></template>
|
<template #empty><el-empty :description="listEmptyText" /></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||||
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
|
<DetailPageLayout aria-label="入库单详情抽屉">
|
||||||
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="null" /></div>
|
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)"><RefreshCw :size="16" />重新加载</el-button></section>
|
||||||
|
<div v-else-if="detail" class="detail-content">
|
||||||
|
<FormSection title="入库概览"><InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" /></FormSection>
|
||||||
|
<FormSection title="资源与状态记录" :bordered="false"><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="null" /></FormSection>
|
||||||
|
</div>
|
||||||
|
</DetailPageLayout>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -499,7 +514,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
|||||||
.inbound-tab:hover { color: var(--cc-primary); background: #f8fbff; }
|
.inbound-tab:hover { color: var(--cc-primary); background: #f8fbff; }
|
||||||
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
||||||
.inbound-tab.is-active { border-bottom-color: var(--cc-primary); background: #fff; color: var(--cc-primary); font-weight: 650; }
|
.inbound-tab.is-active { border-bottom-color: var(--cc-primary); background: #fff; color: var(--cc-primary); font-weight: 650; }
|
||||||
.inbound-load-error { margin-top: 12px; }
|
.inbound-state-stack { display: grid; min-width: 0; gap: 12px; }
|
||||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||||
.table-toolbar { display: grid; min-width: 0; border-bottom: 1px solid var(--cc-border); }
|
.table-toolbar { display: grid; min-width: 0; border-bottom: 1px solid var(--cc-border); }
|
||||||
@@ -521,4 +536,5 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
|||||||
.detail-error :deep(.el-button) { margin-top: 14px; }
|
.detail-error :deep(.el-button) { margin-top: 14px; }
|
||||||
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
|
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
|
||||||
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
|
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
|
||||||
|
@media (max-width: 360px) { .inbound-tab { padding-inline: 12px; } .row-actions { align-items: flex-start; flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Edit3 } from '@lucide/vue'
|
import { Pencil } from '@lucide/vue'
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -85,6 +86,8 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
|
|
||||||
<div v-else-if="detail && summary" class="detail-layout">
|
<div v-else-if="detail && summary" class="detail-layout">
|
||||||
|
<DetailPageLayout :ariaLabel="title">
|
||||||
|
<template #summary>
|
||||||
<section class="detail-summary" aria-label="清单基本信息">
|
<section class="detail-summary" aria-label="清单基本信息">
|
||||||
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
||||||
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
|
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
|
||||||
@@ -97,9 +100,10 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
</template>
|
</template>
|
||||||
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
|
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="mode === 'preload' && canEdit && summary.status === 'LOADING'" class="detail-actions">
|
<div v-if="mode === 'preload' && canEdit && summary.status === 'LOADING'" class="detail-actions">
|
||||||
<el-button type="primary" @click="emit('edit')"><Edit3 :size="16" />编辑运输信息</el-button>
|
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
|
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
|
||||||
|
|
||||||
@@ -134,6 +138,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
|||||||
</el-timeline-item>
|
</el-timeline-item>
|
||||||
</el-timeline>
|
</el-timeline>
|
||||||
</section>
|
</section>
|
||||||
|
</DetailPageLayout>
|
||||||
</div>
|
</div>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { RefreshCw } from '@lucide/vue'
|
import { Eye } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
@@ -243,7 +245,10 @@ onMounted(() => void loadWarehouses())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AdminPage>
|
||||||
<div class="business-page loading-lists-page">
|
<div class="business-page loading-lists-page">
|
||||||
|
<ListPageLayout :ariaLabel="title">
|
||||||
|
<template #toolbar>
|
||||||
<QueryToolbar
|
<QueryToolbar
|
||||||
v-model="query"
|
v-model="query"
|
||||||
:ariaLabel="`${title}筛选`"
|
:ariaLabel="`${title}筛选`"
|
||||||
@@ -253,17 +258,16 @@ onMounted(() => void loadWarehouses())
|
|||||||
:search-on-reset="false"
|
:search-on-reset="false"
|
||||||
@search="search"
|
@search="search"
|
||||||
@reset="reset"
|
@reset="reset"
|
||||||
>
|
/>
|
||||||
<template #actions>
|
|
||||||
<el-button :disabled="!hasWarehouse || loading" title="刷新" aria-label="刷新当前清单" @click="load"><RefreshCw :size="16" />刷新</el-button>
|
|
||||||
</template>
|
</template>
|
||||||
</QueryToolbar>
|
|
||||||
|
|
||||||
<LoadErrorState v-if="!canList" :title="`无权查看${title}`" message="当前账号缺少该清单的列表权限。" :show-retry="false" />
|
<template v-if="!canList || error" #state>
|
||||||
<LoadErrorState v-else-if="error" :title="`${title}加载失败`" :message="error.message" :loading="loading" @retry="load">
|
<LoadErrorState v-if="!canList" :title="`无权查看${title}`" message="当前账号缺少该清单的列表权限。" :show-retry="false" />
|
||||||
<template #details><ApiErrorMeta :details="error" /></template>
|
<LoadErrorState v-else-if="error" :title="`${title}加载失败`" :message="error.message" :loading="loading" @retry="load">
|
||||||
</LoadErrorState>
|
<template #details><ApiErrorMeta :details="error" /></template>
|
||||||
<section v-else class="surface-panel table-panel">
|
</LoadErrorState>
|
||||||
|
</template>
|
||||||
|
<section v-if="canList && !error" class="surface-panel table-panel">
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -276,8 +280,7 @@ onMounted(() => void loadWarehouses())
|
|||||||
:empty-text="hasWarehouse ? `暂无符合筛选条件的${title}` : '请先选择仓库后查询'"
|
:empty-text="hasWarehouse ? `暂无符合筛选条件的${title}` : '请先选择仓库后查询'"
|
||||||
:aria-label="title"
|
:aria-label="title"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div class="table-heading"><h2>{{ title }}</h2><span>{{ hasWarehouse ? `共 ${total} 条` : '仓库为必选条件' }}</span></div></template>
|
<template #actions="{ row }"><el-button v-if="canView" link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||||
<template #actions="{ row }"><el-button v-if="canView" link type="primary" @click="openDetail(row)">查看</el-button></template>
|
|
||||||
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template>
|
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template>
|
||||||
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
||||||
<template #status="{ row }"><StatusBadge :status="row.status" /><span class="status-label">{{ row.status === 'LOADING' ? '装柜中' : row.status === 'COMPLETED' ? '装柜完成' : row.status }}</span></template>
|
<template #status="{ row }"><StatusBadge :status="row.status" /><span class="status-label">{{ row.status === 'LOADING' ? '装柜中' : row.status === 'COMPLETED' ? '装柜完成' : row.status }}</span></template>
|
||||||
@@ -287,6 +290,7 @@ onMounted(() => void loadWarehouses())
|
|||||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<LoadingListDetailDrawer
|
<LoadingListDetailDrawer
|
||||||
v-model="detailOpen"
|
v-model="detailOpen"
|
||||||
@@ -318,14 +322,13 @@ onMounted(() => void loadWarehouses())
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
|
||||||
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||||
.table-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px; }
|
.status-label { color: var(--cc-muted); font-size: 12px; }
|
||||||
.table-heading h2 { margin: 0; font-size: 16px; }
|
|
||||||
.table-heading span, .status-label { color: var(--cc-muted); font-size: 12px; }
|
|
||||||
.status-label { margin-left: 6px; }
|
.status-label { margin-left: 6px; }
|
||||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
|
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
|
||||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ describe('PartnersView real API integration', () => {
|
|||||||
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
|
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
|
||||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||||
expect(wrapper.find('.table-panel').exists()).toBe(true)
|
expect(wrapper.find('.table-panel').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增往来单位')
|
||||||
|
expect(wrapper.find('.data-table__business-actions').text()).toContain('新增往来单位')
|
||||||
|
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共数据 · 共 1 个往来单位')
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the create form without selecting an enterprise', async () => {
|
it('opens the create form without selecting an enterprise', async () => {
|
||||||
@@ -187,6 +192,8 @@ describe('PartnersView real API integration', () => {
|
|||||||
expect(wrapper.text()).toContain('Gateway unavailable')
|
expect(wrapper.text()).toContain('Gateway unavailable')
|
||||||
expect(wrapper.text()).toContain('重新加载')
|
expect(wrapper.text()).toContain('重新加载')
|
||||||
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
|
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.table-panel').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the latest detail when an older partner request finishes last', async () => {
|
it('keeps the latest detail when an older partner request finishes last', async () => {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Building2, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
|
import { Building2, Eye, FileUp, MapPin, Pencil, Plus, Power, Save } from '@lucide/vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
|
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -352,20 +358,24 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page partners-page">
|
<AdminPage as="div" class="partners-page" spacing="normal">
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" preference-key="partners-query" @search="searchPartners" />
|
<ListPageLayout aria-label="往来单位管理">
|
||||||
|
<template #toolbar>
|
||||||
|
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" preference-key="partners-query" @search="searchPartners" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="loadError" #state>
|
||||||
|
<LoadErrorState
|
||||||
|
title="往来单位列表暂时无法加载"
|
||||||
|
:message="loadError.message"
|
||||||
|
:loading="loading"
|
||||||
|
@retry="loadPartners()"
|
||||||
|
>
|
||||||
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
|
</LoadErrorState>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section v-if="loadError" class="surface-panel partners-error" role="alert">
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||||
<div>
|
|
||||||
<strong>往来单位列表暂时无法加载</strong>
|
|
||||||
<p>{{ loadError.message }}</p>
|
|
||||||
<ApiErrorMeta :details="loadError" />
|
|
||||||
</div>
|
|
||||||
<el-button type="primary" :loading="loading" @click="loadPartners()">重新加载</el-button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -379,8 +389,7 @@ onMounted(() => {
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
empty-text="暂无符合条件的往来单位"
|
empty-text="暂无符合条件的往来单位"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div class="partners-table-heading"><h2>往来单位列表</h2><span>平台公共数据 · 共 {{ total }} 个往来单位</span></div></template>
|
<template #toolbar-actions><div class="partners-primary-actions"><el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button></div></template>
|
||||||
<template #toolbar-actions><div class="partners-primary-actions"><el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button><el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button></div></template>
|
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
|
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
|
||||||
@@ -393,56 +402,68 @@ onMounted(() => {
|
|||||||
<template #contact="{ row }"><strong>{{ (row as PlatformPartnerResource).contactName }}</strong><span class="partner-subline">{{ (row as PlatformPartnerResource).contactPhone }}</span><span v-if="(row as PlatformPartnerResource).contactEmail" class="partner-subline">{{ (row as PlatformPartnerResource).contactEmail }}</span></template>
|
<template #contact="{ row }"><strong>{{ (row as PlatformPartnerResource).contactName }}</strong><span class="partner-subline">{{ (row as PlatformPartnerResource).contactPhone }}</span><span v-if="(row as PlatformPartnerResource).contactEmail" class="partner-subline">{{ (row as PlatformPartnerResource).contactEmail }}</span></template>
|
||||||
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
|
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
|
||||||
<template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
<template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||||
<template #pagination-left />
|
<template #pagination-left>平台公共数据 · 共 {{ total }} 个往来单位</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<el-drawer v-model="editorOpen" :title="editorLabel" size="680px" class="business-drawer editor-drawer" :close-on-click-modal="false">
|
<AppDrawer
|
||||||
|
v-model="editorOpen"
|
||||||
|
:title="editorLabel"
|
||||||
|
size="min(680px, 94vw)"
|
||||||
|
class="business-drawer editor-drawer"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
|
||||||
<div class="form-section"><h3>单位信息</h3><div class="form-grid two-cols">
|
<FormSection title="单位信息"><div class="form-grid two-cols">
|
||||||
<el-form-item label="单位编码" prop="partnerCode" required><el-input v-model="form.partnerCode" placeholder="如:CUS-001" maxlength="50" /></el-form-item>
|
<el-form-item label="单位编码" prop="partnerCode" required><el-input v-model="form.partnerCode" placeholder="如:CUS-001" maxlength="50" /></el-form-item>
|
||||||
<el-form-item label="单位名称" prop="partnerName" required><el-input v-model="form.partnerName" placeholder="请输入工商登记或常用单位名称" maxlength="128" /></el-form-item>
|
<el-form-item label="单位名称" prop="partnerName" required><el-input v-model="form.partnerName" placeholder="请输入工商登记或常用单位名称" maxlength="128" /></el-form-item>
|
||||||
<el-form-item label="单位类型" prop="types" required><el-select v-model="form.types" multiple collapse-tags collapse-tags-tooltip placeholder="请选择一个或多个单位类型"><el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
|
<el-form-item label="单位类型" prop="types" required><el-select v-model="form.types" multiple collapse-tags collapse-tags-tooltip placeholder="请选择一个或多个单位类型"><el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
|
||||||
<el-form-item label="状态" prop="status" required><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
|
<el-form-item label="状态" prop="status" required><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
|
||||||
</div></div>
|
</div></FormSection>
|
||||||
<div class="form-section"><h3>联系信息</h3><div class="form-grid two-cols">
|
<FormSection title="联系信息"><div class="form-grid two-cols">
|
||||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入手机号或固定电话" maxlength="32" /></el-form-item>
|
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入手机号或固定电话" maxlength="32" /></el-form-item>
|
||||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
||||||
</div></div>
|
</div></FormSection>
|
||||||
<div class="form-section"><h3>地址信息</h3><div class="form-grid two-cols">
|
<FormSection title="地址信息"><div class="form-grid two-cols">
|
||||||
<el-form-item label="国家二字码" prop="countryCode" required><el-input v-model="form.countryCode" placeholder="如:CN" maxlength="2" @input="form.countryCode = String($event).toUpperCase()" /></el-form-item>
|
<el-form-item label="国家二字码" prop="countryCode" required><el-input v-model="form.countryCode" placeholder="如:CN" maxlength="2" @input="form.countryCode = String($event).toUpperCase()" /></el-form-item>
|
||||||
<el-form-item label="省 / 州" prop="province"><el-input v-model="form.province" placeholder="如:广东省" maxlength="64" /></el-form-item>
|
<el-form-item label="省 / 州" prop="province"><el-input v-model="form.province" placeholder="如:广东省" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="城市" prop="city" required><el-input v-model="form.city" placeholder="如:深圳市" maxlength="64" /></el-form-item>
|
<el-form-item label="城市" prop="city" required><el-input v-model="form.city" placeholder="如:深圳市" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="区 / 县" prop="district"><el-input v-model="form.district" placeholder="如:宝安区" maxlength="64" /></el-form-item>
|
<el-form-item label="区 / 县" prop="district"><el-input v-model="form.district" placeholder="如:宝安区" maxlength="64" /></el-form-item>
|
||||||
<el-form-item label="邮政编码" prop="postalCode"><el-input v-model="form.postalCode" placeholder="如:518000" maxlength="20" /></el-form-item>
|
<el-form-item label="邮政编码" prop="postalCode"><el-input v-model="form.postalCode" placeholder="如:518000" maxlength="20" /></el-form-item>
|
||||||
</div><el-form-item label="详细地址" prop="addressLine" required><el-input v-model="form.addressLine" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及门牌信息" maxlength="255" show-word-limit /></el-form-item></div>
|
</div><el-form-item label="详细地址" prop="addressLine" required><el-input v-model="form.addressLine" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及门牌信息" maxlength="255" show-word-limit /></el-form-item></FormSection>
|
||||||
<div class="form-section"><h3>其他信息</h3><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入内部说明(选填)" maxlength="500" show-word-limit /></el-form-item></div>
|
<FormSection title="其他信息" :bordered="false"><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入内部说明(选填)" maxlength="500" show-word-limit /></el-form-item></FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePartner">保存往来单位</el-button></div></template>
|
<template #footer>
|
||||||
</el-drawer>
|
<div class="drawer-footer">
|
||||||
|
<el-button :disabled="saving" @click="editorOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="savePartner"><Save :size="16" />保存往来单位</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AppDrawer>
|
||||||
|
|
||||||
<el-drawer v-model="detailOpen" title="往来单位详情" size="680px" class="business-drawer detail-drawer">
|
<AppDrawer v-model="detailOpen" title="往来单位详情" size="min(680px, 94vw)" class="business-drawer detail-drawer" :loading="detailLoading" :show-footer="false">
|
||||||
<div v-loading="detailLoading" class="partner-detail">
|
<div class="partner-detail">
|
||||||
<template v-if="detail">
|
<DetailPageLayout v-if="detail" aria-label="往来单位详情">
|
||||||
<section><h3>单位信息</h3><dl class="detail-grid">
|
<FormSection title="单位信息"><dl class="detail-grid">
|
||||||
<div><dt>单位编码</dt><dd>{{ detail.partnerCode }}</dd></div>
|
<div><dt>单位编码</dt><dd>{{ detail.partnerCode }}</dd></div>
|
||||||
<div><dt>单位名称</dt><dd>{{ detail.partnerName }}</dd></div>
|
<div><dt>单位名称</dt><dd>{{ detail.partnerName }}</dd></div>
|
||||||
<div><dt>状态</dt><dd><el-tag :type="detail.status === 'ACTIVE' ? 'success' : 'info'">{{ detail.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></dd></div>
|
<div><dt>状态</dt><dd><el-tag :type="detail.status === 'ACTIVE' ? 'success' : 'info'">{{ detail.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></dd></div>
|
||||||
<div><dt>单位类型</dt><dd class="partner-types"><el-tag v-for="type in detail.types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></dd></div>
|
<div><dt>单位类型</dt><dd class="partner-types"><el-tag v-for="type in detail.types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></dd></div>
|
||||||
</dl></section>
|
</dl></FormSection>
|
||||||
<section><h3>联系与地址</h3><dl class="detail-grid">
|
<FormSection title="联系与地址"><dl class="detail-grid">
|
||||||
<div><dt>联系人</dt><dd>{{ detail.contactName }}</dd></div><div><dt>联系电话</dt><dd>{{ detail.contactPhone }}</dd></div>
|
<div><dt>联系人</dt><dd>{{ detail.contactName }}</dd></div><div><dt>联系电话</dt><dd>{{ detail.contactPhone }}</dd></div>
|
||||||
<div><dt>联系邮箱</dt><dd>{{ detail.contactEmail || '-' }}</dd></div><div><dt>邮政编码</dt><dd>{{ detail.postalCode || '-' }}</dd></div>
|
<div><dt>联系邮箱</dt><dd>{{ detail.contactEmail || '-' }}</dd></div><div><dt>邮政编码</dt><dd>{{ detail.postalCode || '-' }}</dd></div>
|
||||||
<div class="detail-span"><dt>详细地址</dt><dd>{{ [detail.countryCode, detail.province, detail.city, detail.district, detail.addressLine].filter(Boolean).join(' ') }}</dd></div>
|
<div class="detail-span"><dt>详细地址</dt><dd>{{ [detail.countryCode, detail.province, detail.city, detail.district, detail.addressLine].filter(Boolean).join(' ') }}</dd></div>
|
||||||
</dl></section>
|
</dl></FormSection>
|
||||||
<section><h3>其他信息</h3><dl class="detail-grid">
|
<FormSection title="其他信息" :bordered="false"><dl class="detail-grid">
|
||||||
<div class="detail-span"><dt>更新时间</dt><dd>{{ formatUpdatedAt(detail.updatedAt) }}</dd></div>
|
<div class="detail-span"><dt>更新时间</dt><dd>{{ formatUpdatedAt(detail.updatedAt) }}</dd></div>
|
||||||
<div class="detail-span"><dt>备注</dt><dd>{{ detail.remark || '-' }}</dd></div>
|
<div class="detail-span"><dt>备注</dt><dd>{{ detail.remark || '-' }}</dd></div>
|
||||||
</dl></section>
|
</dl></FormSection>
|
||||||
</template>
|
</DetailPageLayout>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<BatchImportDialog
|
<BatchImportDialog
|
||||||
v-model="importOpen"
|
v-model="importOpen"
|
||||||
@@ -453,16 +474,11 @@ onMounted(() => {
|
|||||||
:import-file="importPlatformPartners"
|
:import-file="importPlatformPartners"
|
||||||
@success="loadPartners"
|
@success="loadPartners"
|
||||||
/>
|
/>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.partners-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; }
|
.partners-page { min-width: 0; }
|
||||||
.partners-error strong { color: #7a271a; font-size: 13px; }
|
|
||||||
.partners-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
|
|
||||||
.partners-table-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
|
|
||||||
.partners-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
|
|
||||||
.partners-table-heading span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
|
||||||
.partners-primary-actions, .partner-types { display: flex; flex-wrap: wrap; gap: 8px; }
|
.partners-primary-actions, .partner-types { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.partner-cell { display: flex; align-items: center; gap: 10px; }
|
.partner-cell { display: flex; align-items: center; gap: 10px; }
|
||||||
.partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
.partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||||
@@ -472,16 +488,12 @@ onMounted(() => {
|
|||||||
.partner-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
|
.partner-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
.partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); }
|
.partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); }
|
||||||
.partner-detail { min-height: 180px; padding: 20px 24px 28px; box-sizing: border-box; }
|
.partner-detail { min-height: 180px; padding: 20px 24px 28px; box-sizing: border-box; }
|
||||||
.partner-detail section + section { margin-top: 28px; }
|
|
||||||
.partner-detail h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
|
||||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-border); }
|
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-border); }
|
||||||
.detail-grid > div { min-width: 0; padding: 12px 14px; background: #fff; }
|
.detail-grid > div { min-width: 0; padding: 12px 14px; background: #fff; }
|
||||||
.detail-grid .detail-span { grid-column: 1 / -1; }
|
.detail-grid .detail-span { grid-column: 1 / -1; }
|
||||||
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
|
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||||
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
|
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.partners-error { align-items: stretch; flex-direction: column; }
|
|
||||||
.partners-error .el-button { min-height: 44px; }
|
|
||||||
.partners-primary-actions :deep(.el-button) { width: 100%; min-height: 40px; }
|
.partners-primary-actions :deep(.el-button) { width: 100%; min-height: 40px; }
|
||||||
.partners-primary-actions { flex-direction: column; }
|
.partners-primary-actions { flex-direction: column; }
|
||||||
.partner-detail { padding: 16px; }
|
.partner-detail { padding: 16px; }
|
||||||
|
|||||||
@@ -87,9 +87,20 @@ describe('ExpressCompaniesView query and table migration', () => {
|
|||||||
expect(wrapper.text()).toContain('顺丰速运')
|
expect(wrapper.text()).toContain('顺丰速运')
|
||||||
expect(wrapper.text()).toContain('平台公共数据 · 共 1 家快递公司')
|
expect(wrapper.text()).toContain('平台公共数据 · 共 1 家快递公司')
|
||||||
expect(wrapper.find('.metric-strip').exists()).toBe(false)
|
expect(wrapper.find('.metric-strip').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
|
||||||
expect(listPlatformExpressCompanies).toHaveBeenCalledWith(1, 20, '', '')
|
expect(listPlatformExpressCompanies).toHaveBeenCalledWith(1, 20, '', '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders the error state instead of an empty table panel', async () => {
|
||||||
|
vi.mocked(listPlatformExpressCompanies).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||||
|
const wrapper = await mountView()
|
||||||
|
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.table-panel').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toContain('快递公司加载失败')
|
||||||
|
expect(wrapper.text()).toContain('Gateway unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
it('does not request with edited filters until search is submitted', async () => {
|
it('does not request with edited filters until search is submitted', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await wrapper.find('input[placeholder="快递公司名称或编码"]').setValue('顺丰')
|
await wrapper.find('input[placeholder="快递公司名称或编码"]').setValue('顺丰')
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { ExternalLink, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
|
import { ExternalLink, FileUp, Pencil, Plus, Power, Save, Truck } from '@lucide/vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
|
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import FormSection from '@/shared/components/FormSection.vue'
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -284,18 +283,12 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<div class="business-page express-company-page">
|
<div class="business-page express-company-page">
|
||||||
<PageToolbar title="快递公司" :description="`平台公共数据 · 共 ${total} 家快递公司`">
|
|
||||||
<template #actions>
|
|
||||||
<el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
|
||||||
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
|
||||||
</template>
|
|
||||||
</PageToolbar>
|
|
||||||
<ListPageLayout ariaLabel="快递公司列表">
|
<ListPageLayout ariaLabel="快递公司列表">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
|
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
|
||||||
</template>
|
</template>
|
||||||
<template #state>
|
<template v-if="loadError" #state>
|
||||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
<el-alert type="error" :closable="false" show-icon class="state-alert">
|
||||||
<template #title>快递公司加载失败</template>
|
<template #title>快递公司加载失败</template>
|
||||||
<p>{{ loadError.message }}</p>
|
<p>{{ loadError.message }}</p>
|
||||||
<ApiErrorMeta :details="loadError" />
|
<ApiErrorMeta :details="loadError" />
|
||||||
@@ -303,7 +296,7 @@ onMounted(() => {
|
|||||||
</el-alert>
|
</el-alert>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section class="surface-panel table-panel">
|
<section v-if="!loadError" class="surface-panel table-panel">
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -317,6 +310,7 @@ onMounted(() => {
|
|||||||
row-key="id"
|
row-key="id"
|
||||||
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
|
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
|
||||||
>
|
>
|
||||||
|
<template #toolbar-actions><div class="table-actions"><el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button></div></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions">
|
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions">
|
||||||
<el-button
|
<el-button
|
||||||
@@ -351,7 +345,7 @@ onMounted(() => {
|
|||||||
<el-button v-if="!appliedFilters.keyword.trim() && !appliedFilters.status && access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增第一家快递公司</el-button>
|
<el-button v-if="!appliedFilters.keyword.trim() && !appliedFilters.status && access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增第一家快递公司</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
</template>
|
</template>
|
||||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 家快递公司</span></template>
|
<template #pagination-left="{ total: tableTotal }"><span>平台公共数据 · 共 {{ tableTotal }} 家快递公司</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
@@ -389,7 +383,7 @@ onMounted(() => {
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
<FormSection title="备注"><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></el-form-item></FormSection>
|
<FormSection title="备注"><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></el-form-item></FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveExpressCompany">保存快递公司</el-button></div></template>
|
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveExpressCompany"><Save :size="16" />保存快递公司</el-button></div></template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails } from '@/shared/api/http'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||||
import {
|
import {
|
||||||
createOceanBooking,
|
createOceanBooking,
|
||||||
getOceanBooking,
|
getOceanBooking,
|
||||||
@@ -183,22 +188,26 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page booking-detail">
|
<AdminPage as="div" class="booking-detail">
|
||||||
<div class="detail-heading">
|
<DetailPageLayout aria-label="订舱详情">
|
||||||
<el-button @click="router.push('/ocean-bookings')">返回列表</el-button>
|
<template #header>
|
||||||
<div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div>
|
<div class="detail-heading">
|
||||||
<div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /><span>{{ statusLabel(detail.status) }}</span></div>
|
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
</div>
|
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '—'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
|
||||||
|
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="detail.status" /><span>{{ statusLabel(detail.status) }}</span></div></template>
|
||||||
|
</PageToolbar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" />
|
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load" />
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert>
|
<el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert>
|
||||||
<section class="surface-panel">
|
<section class="surface-panel">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
|
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
|
<el-button v-for="item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)">{{ actionLabels[item] }}</el-button>
|
||||||
<el-button v-if="canSave" type="primary" :loading="saving" @click="save">保存</el-button>
|
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
|
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
|
||||||
@@ -238,12 +247,12 @@ onMounted(load)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="surface-panel">
|
<section class="surface-panel">
|
||||||
<div class="section-title"><h2>箱型需求</h2><el-button v-if="editable" @click="addContainer">新增箱型</el-button></div>
|
<div class="section-title"><h2>箱型需求</h2><el-button v-if="editable" @click="addContainer"><Plus :size="16" />新增箱型</el-button></div>
|
||||||
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
|
<div v-for="(container, index) in form.data.containerRequirements" :key="index" class="container-row">
|
||||||
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
|
<el-input v-model="container.containerType" :disabled="!editable" placeholder="箱型,如 40HC" />
|
||||||
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
|
<el-input v-model="container.containerKind" :disabled="!editable" placeholder="类型,如 FCL" />
|
||||||
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
|
<el-input-number v-model="container.quantity" :disabled="!editable" :min="1" :max="999" />
|
||||||
<el-button v-if="editable" :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)">删除</el-button>
|
<el-button v-if="editable" type="danger" plain :disabled="form.data.containerRequirements.length === 1" @click="removeContainer(index)"><Trash2 :size="15" />删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -251,9 +260,10 @@ onMounted(load)
|
|||||||
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
||||||
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
|
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
</DetailPageLayout>
|
||||||
|
|
||||||
<el-dialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :close-on-click-modal="false">
|
<AppDialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" confirm-text="提交申请" @confirm="submitChange">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" /></el-form-item>
|
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" /></el-form-item>
|
||||||
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" /></el-form-item>
|
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" /></el-form-item>
|
||||||
@@ -261,11 +271,10 @@ onMounted(load)
|
|||||||
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||||
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" /></el-form-item>
|
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="changeDialogOpen = false">取消</el-button><el-button type="primary" :loading="actionSaving" :disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" @click="submitChange">提交申请</el-button></template>
|
</AppDialog>
|
||||||
</el-dialog>
|
</AdminPage>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p,.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px;margin-left:auto}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.container-row{display:grid;grid-template-columns:1fr 1fr 160px auto;gap:10px;margin-top:10px}.history-panel strong{font-size:14px}@media(max-width:900px){.form-grid,.party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.container-row{grid-template-columns:1fr 1fr}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid,.party-grid,.container-row{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{flex-direction:column}.actions{width:100%}}
|
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:20px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:18px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.subsection{display:grid;gap:10px;padding:14px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.container-row{display:grid;grid-template-columns:1fr 1fr 160px auto;gap:10px;margin-top:10px}.history-panel strong{font-size:14px}@media(max-width:900px){.form-grid,.party-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.container-row{grid-template-columns:1fr 1fr}}@media(max-width:600px){.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.form-grid,.party-grid,.container-row{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{flex-direction:column}.actions{width:100%}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }) })
|
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }) })
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import DataTable from '@/shared/components/DataTable.vue'
|
import DataTable from '@/shared/components/DataTable.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import { getApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
|
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
|
||||||
@@ -41,18 +44,19 @@ function search() { page.value = 1; void load() }
|
|||||||
watch([page, pageSize], () => void load()); onMounted(load)
|
watch([page, pageSize], () => void load()); onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page ocean-bookings-page">
|
<AdminPage as="div" class="ocean-bookings-page">
|
||||||
<QueryToolbar v-model="query" ariaLabel="订舱筛选" :fields="fields" @search="search" />
|
<ListPageLayout aria-label="订舱列表">
|
||||||
<LoadErrorState v-if="error" title="订舱列表加载失败" :message="error.message" @retry="load" />
|
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
|
||||||
<section v-else class="surface-panel table-panel">
|
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load" /></template>
|
||||||
|
<section v-if="!error" class="surface-panel table-panel">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||||
<template #toolbar-start><div class="table-heading"><h2>订舱列表</h2><span>共 {{ total }} 条</span></div></template>
|
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
|
||||||
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()">新增订舱</el-button></template>
|
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" link type="primary" @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" link @click="open(row.id)"><Pencil :size="15" />编辑</el-button></template>
|
||||||
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" link type="primary" @click="open(row.id)">查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" link @click="open(row.id)">编辑</el-button></template>
|
|
||||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||||
<template #status="{ row }"><StatusBadge :status="row.status" /></template>
|
<template #status="{ row }"><StatusBadge :status="row.status" /></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</ListPageLayout>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>.ocean-bookings-page{display:grid;gap:16px}.table-heading{display:flex;align-items:baseline;gap:10px}.table-heading h2{margin:0;font-size:16px}.table-heading span{color:var(--cc-muted);font-size:12px}</style>
|
<style scoped>.ocean-bookings-page{min-width:0}</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { Eye, Save } from '@lucide/vue'
|
||||||
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||||||
@@ -76,7 +77,7 @@ defineExpose({ validate })
|
|||||||
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
|
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
|
||||||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
|
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')">查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm">保存</el-button></div>
|
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowLeft } from '@lucide/vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||||
@@ -12,5 +16,14 @@ const router = useRouter(); const saving = ref(false); const attachmentOpen = re
|
|||||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
|
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
|
||||||
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
|
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
|
||||||
</script>
|
</script>
|
||||||
<template><main class="business-page truck-editor-page"><div class="editor-actions"><el-button @click="router.push({ name: 'trucking' })">返回列表</el-button></div><section v-if="error" class="surface-panel editor-error"><ApiErrorMeta :details="error" /><span>{{ error.message }}</span></section><section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section><TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" /></main></template>
|
<template>
|
||||||
<style scoped>.truck-editor-page { min-width: 0; }.editor-actions { display:flex; justify-content:flex-end; }.form-panel { padding:24px; }.editor-error { display:flex; align-items:center; gap:12px; padding:14px 18px; color:#b42318; }.editor-error :deep(.api-error-meta) { margin-top:0; order:2; } @media (max-width:720px) { .form-panel { padding:16px; } }</style>
|
<AdminPage as="div" class="truck-editor-page">
|
||||||
|
<DetailPageLayout aria-label="新增拖车预约">
|
||||||
|
<template #header><div class="editor-actions"><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||||
|
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||||
|
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||||
|
</DetailPageLayout>
|
||||||
|
</AdminPage>
|
||||||
|
</template>
|
||||||
|
<style scoped>.truck-editor-page { min-width: 0; }.editor-actions { display:flex; justify-content:flex-end; }.form-panel { padding:24px; } @media (max-width:720px) { .editor-actions { justify-content:flex-start; }.form-panel { padding:16px; } }</style>
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ArrowLeft } from '@lucide/vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||||
|
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||||
@@ -27,15 +31,28 @@ async function changeStatus(status: TruckAppointmentStatus) { if (!resource.valu
|
|||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<main class="business-page truck-detail-page"><div class="detail-actions"><el-button @click="router.push({ name: 'trucking' })">返回列表</el-button></div>
|
<AdminPage as="div" class="truck-detail-page">
|
||||||
<LoadErrorState v-if="error && !resource" class="surface-panel" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<DetailPageLayout aria-label="拖车预约详情">
|
||||||
<template v-else-if="resource"><section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section><section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section><section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section><TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" /></template>
|
<template #header>
|
||||||
</main>
|
<div class="detail-heading">
|
||||||
|
<el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
|
<PageToolbar v-if="resource" class="truck-page-toolbar" as="section" :title="resource.appointmentNo || '拖车预约'" :description="[resource.customerName, resource.orderNo].filter(Boolean).join(' · ')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<template v-else-if="resource">
|
||||||
|
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
|
||||||
|
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||||
|
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||||
|
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
|
||||||
|
</template>
|
||||||
|
</DetailPageLayout>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
|
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
|
||||||
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
|
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
|
||||||
.truck-detail-page { min-width:0; }.detail-actions { display:flex; justify-content:flex-end; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
|
.truck-detail-page { min-width:0; }.detail-heading { display:flex; min-width:0; align-items:flex-start; gap:16px; }.truck-page-toolbar { min-width:0; flex:1; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
|
||||||
@media (max-width: 720px) { .form-panel { padding:16px; } }
|
@media (max-width: 720px) { .detail-heading { flex-direction:column; }.truck-page-toolbar { width:100%; }.form-panel { padding:16px; } }
|
||||||
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
|
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import TruckAppointmentsView from './TruckAppointmentsView.vue'
|
import TruckAppointmentsView from './TruckAppointmentsView.vue'
|
||||||
import { listTruckAppointments } from '../api/platformTrucking'
|
import { listTruckAppointments } from '../api/platformTrucking'
|
||||||
import type { TruckAppointmentResource } from '../types'
|
import type { TruckAppointmentResource } from '../types'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentsView.vue'), 'utf8')
|
||||||
|
|
||||||
vi.mock('../api/platformTrucking', () => ({
|
vi.mock('../api/platformTrucking', () => ({
|
||||||
listTruckAppointments: vi.fn(),
|
listTruckAppointments: vi.fn(),
|
||||||
@@ -31,4 +35,12 @@ describe('TruckAppointmentsView', () => {
|
|||||||
expect(wrapper.text()).toContain('宁波运输')
|
expect(wrapper.text()).toContain('宁波运输')
|
||||||
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the table toolbar for create and keeps a single empty state', () => {
|
||||||
|
expect(source).toContain('<template #toolbar-actions>')
|
||||||
|
expect(source).not.toContain('<template #toolbar-start>')
|
||||||
|
expect(source).not.toContain('PageToolbar')
|
||||||
|
expect(source).not.toContain('empty-hint')
|
||||||
|
expect(source.match(/empty-text=/g)).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Eye, Pencil, Plus, RefreshCw } from '@lucide/vue'
|
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { listTruckAppointments } from '../api/platformTrucking'
|
import { listTruckAppointments } from '../api/platformTrucking'
|
||||||
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
|
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
|
||||||
|
|
||||||
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const loaded = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
|
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
|
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
|
||||||
const fields = computed<QueryField[]>(() => [
|
const fields = computed<QueryField[]>(() => [
|
||||||
@@ -42,19 +44,19 @@ function statusLabel(value: unknown) {
|
|||||||
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
|
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
|
||||||
: String(value || '—')
|
: String(value || '—')
|
||||||
}
|
}
|
||||||
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total; loaded.value = true } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
|
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
|
||||||
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; page.value = 1; void load() }
|
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; page.value = 1; void load() }
|
||||||
function reset() { filters.value = { keyword: '', status: '', containerBookingId: '', containerType: '' }; search(filters.value) }
|
function reset() { filters.value = { keyword: '', status: '', containerBookingId: '', containerType: '' }; search(filters.value) }
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
|
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="business-page trucking-page">
|
<AdminPage as="div" class="trucking-page">
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" @reset="reset" />
|
<ListPageLayout aria-label="拖车预约列表">
|
||||||
<section v-if="loadError" class="surface-panel trucking-error" role="alert"><div><strong>拖车预约列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="load">重新加载</el-button></section>
|
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" @reset="reset" /></template>
|
||||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
<template v-if="loadError" #state><LoadErrorState title="拖车预约列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||||
|
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约">
|
||||||
<template #toolbar-start><div class="table-heading"><h2>拖车预约列表</h2><span>共 {{ total }} 条</span></div></template>
|
|
||||||
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template>
|
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template>
|
||||||
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
|
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
|
||||||
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
|
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
|
||||||
@@ -62,12 +64,12 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
|
|||||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||||
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
<div v-if="!loading && loaded && rows.length === 0 && !loadError" class="empty-hint"><RefreshCw :size="16" />暂无拖车预约</div>
|
</ListPageLayout>
|
||||||
</section>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.trucking-page { min-width: 0; }
|
.trucking-page { min-width: 0; }
|
||||||
.table-panel { overflow:hidden; }.trucking-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:18px 20px; border-color:#fecdca; background:#fffafa; }.trucking-error strong { display:block; }.trucking-error p { margin:4px 0 0; color:var(--cc-muted); font-size:12px; }.table-heading { display:flex; align-items:baseline; gap:10px; }.table-heading h2 { margin:0; font-size:16px; }.table-heading span { color:var(--el-text-color-secondary); font-size:13px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
|
.table-panel { overflow:hidden; }
|
||||||
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
|
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, RefreshCw, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getPlatformUnifiedOrder,
|
getPlatformUnifiedOrder,
|
||||||
@@ -998,22 +998,21 @@ onMounted(() => {
|
|||||||
>{{ tab.label }}</button>
|
>{{ tab.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
|
<div class="filter-mode-actions">
|
||||||
<template #actions>
|
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||||||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
||||||
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
||||||
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
</el-button>
|
||||||
</el-button>
|
</div>
|
||||||
</template>
|
|
||||||
</QueryToolbar>
|
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset" />
|
||||||
</div>
|
</div>
|
||||||
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
|
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
|
||||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
|
|
||||||
<div v-else id="unified-orders-results" class="table-card" role="tabpanel">
|
<div v-else id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleSelectionChange">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleSelectionChange">
|
||||||
<template #toolbar-start><div><h2>统一订单列表</h2><p>共 {{ total }} 条订单</p></div></template>
|
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
|
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -1021,7 +1020,7 @@ onMounted(() => {
|
|||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
|
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
|
||||||
<el-dropdown trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
|
<el-dropdown trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
|
||||||
<el-button link type="primary" aria-label="更多订单操作"><MoreHorizontal :size="17" />更多</el-button>
|
<el-button link type="primary" aria-label="更多订单操作" title="更多操作"><MoreHorizontal :size="17" /></el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="can('platform:order:note')" command="note"><StickyNote :size="15" />添加备注</el-dropdown-item>
|
<el-dropdown-item v-if="can('platform:order:note')" command="note"><StickyNote :size="15" />添加备注</el-dropdown-item>
|
||||||
@@ -1045,6 +1044,7 @@ onMounted(() => {
|
|||||||
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
|
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
|
||||||
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
|
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
|
||||||
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
|
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
@@ -1070,6 +1070,7 @@ onMounted(() => {
|
|||||||
.page-intro :deep(.el-button) { flex: 0 0 auto; }
|
.page-intro :deep(.el-button) { flex: 0 0 auto; }
|
||||||
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||||
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
|
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
|
||||||
|
.filter-mode-actions { display: flex; justify-content: flex-end; padding: 12px 16px 0; }
|
||||||
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
|
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
|
||||||
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
|
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
|
||||||
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
|
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
|
||||||
@@ -1077,8 +1078,8 @@ onMounted(() => {
|
|||||||
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
|
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
|
||||||
.status-tab.is-active::after { background: var(--cc-primary); }
|
.status-tab.is-active::after { background: var(--cc-primary); }
|
||||||
.orders-query-toolbar { border: 0; }
|
.orders-query-toolbar { border: 0; }
|
||||||
.orders-load-error { margin-top: 12px; }
|
.orders-load-error { margin-top: 0; }
|
||||||
.table-card { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
.table-card { overflow: hidden; }
|
||||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||||
.primary-cell { display: grid; min-width: 0; gap: 4px; }
|
.primary-cell { display: grid; min-width: 0; gap: 4px; }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { Check, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
import { Check, Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -267,12 +268,9 @@ onMounted(() => { void load() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="smart-loading" aria-labelledby="smart-loading-title">
|
<section class="smart-loading" aria-label="智能排柜工作台">
|
||||||
<header class="smart-loading__heading">
|
<header class="smart-loading__heading">
|
||||||
<div>
|
<div class="smart-loading__context"><span>当前柜次</span><strong>{{ container?.cabinetNo || container?.containerNo || `柜次 ${bookingId}` }}</strong><small>{{ container?.warehouseName || '仓库待加载' }}</small></div>
|
||||||
<h3 id="smart-loading-title">智能排柜</h3>
|
|
||||||
<p>{{ container?.cabinetNo || container?.containerNo || `柜次 ${bookingId}` }} · {{ container?.warehouseName || '仓库待加载' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="smart-loading__actions">
|
<div class="smart-loading__actions">
|
||||||
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
|
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
|
||||||
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
|
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
|
||||||
@@ -299,11 +297,11 @@ onMounted(() => { void load() })
|
|||||||
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
|
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="plan" class="smart-loading__section">
|
<section v-if="plan" class="smart-loading__section surface-panel table-panel">
|
||||||
<div class="smart-loading__section-title"><h4>当前装柜方案</h4><span>版本 {{ plan.version }} · {{ plan.planNo || '—' }}</span></div>
|
<div class="smart-loading__section-title"><h4>当前装柜方案</h4><span>版本 {{ plan.version }} · {{ plan.planNo || '—' }}</span></div>
|
||||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading" empty-text="装柜草稿暂无明细">
|
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading" empty-text="装柜草稿暂无明细">
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<el-button link type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)">调整</el-button>
|
<el-button link type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)"><Pencil :size="14" />调整</el-button>
|
||||||
<el-button link type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
|
<el-button link type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></template>
|
||||||
@@ -315,9 +313,11 @@ onMounted(() => { void load() })
|
|||||||
|
|
||||||
<section class="smart-loading__section">
|
<section class="smart-loading__section">
|
||||||
<div class="smart-loading__section-title"><h4>候选货物</h4><span>后端按入库完成时间排序</span></div>
|
<div class="smart-loading__section-title"><h4>候选货物</h4><span>后端按入库完成时间排序</span></div>
|
||||||
<QueryToolbar v-model="filters" ariaLabel="候选货物筛选" :fields="queryFields" @search="applyFilters" @reset="applyFilters" />
|
<ListPageLayout ariaLabel="智能排柜候选货物">
|
||||||
|
<template #toolbar><QueryToolbar v-model="filters" ariaLabel="候选货物筛选" :fields="queryFields" @search="applyFilters" @reset="applyFilters" /></template>
|
||||||
|
<section class="surface-panel table-panel">
|
||||||
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
|
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
|
||||||
<template #actions="{ row }"><el-button link type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)">加入</el-button></template>
|
<template #actions="{ row }"><el-button link type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)"><Plus :size="14" />加入</el-button></template>
|
||||||
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
|
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
|
||||||
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></template>
|
||||||
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
|
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
|
||||||
@@ -325,6 +325,8 @@ onMounted(() => { void load() })
|
|||||||
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
|
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
|
||||||
<template #explanation="{ row }"><el-tag size="small" :type="fittingCount(row as SmartLoadingCandidate) > 0 ? 'success' : 'danger'">{{ fittingCount(row as SmartLoadingCandidate) > 0 ? '可加入' : '冲突' }}</el-tag><span>{{ candidateExplanation(row as SmartLoadingCandidate) }}</span></template>
|
<template #explanation="{ row }"><el-tag size="small" :type="fittingCount(row as SmartLoadingCandidate) > 0 ? 'success' : 'danger'">{{ fittingCount(row as SmartLoadingCandidate) > 0 ? '可加入' : '冲突' }}</el-tag><span>{{ candidateExplanation(row as SmartLoadingCandidate) }}</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -356,13 +358,16 @@ onMounted(() => { void load() })
|
|||||||
.smart-loading { display: grid; gap: 14px; }
|
.smart-loading { display: grid; gap: 14px; }
|
||||||
.smart-loading__heading, .smart-loading__actions, .smart-loading__section-title { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
.smart-loading__heading, .smart-loading__actions, .smart-loading__section-title { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
.smart-loading__heading, .smart-loading__section-title { justify-content: space-between; }
|
.smart-loading__heading, .smart-loading__section-title { justify-content: space-between; }
|
||||||
.smart-loading__heading h3, .smart-loading__section-title h4 { margin: 0; font-size: 15px; }
|
.smart-loading__section-title h4 { margin: 0; font-size: 15px; }
|
||||||
.smart-loading__heading p, .smart-loading__section-title span, .smart-loading small { display: block; margin: 3px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
.smart-loading__context { display: grid; min-width: 0; gap: 3px; }
|
||||||
|
.smart-loading__context span { color: var(--cc-text-secondary, #64748b); font-size: 11px; }
|
||||||
|
.smart-loading__context strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.smart-loading__section-title span, .smart-loading small { display: block; margin: 3px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||||
.smart-loading__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
.smart-loading__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||||
.smart-loading__summary > div { min-width: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 5px; background: var(--el-fill-color-extra-light); }
|
.smart-loading__summary > div { min-width: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 5px; background: var(--el-fill-color-extra-light); }
|
||||||
.smart-loading__summary span { display: block; margin-bottom: 4px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
.smart-loading__summary span { display: block; margin-bottom: 4px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||||
.smart-loading__summary strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
|
.smart-loading__summary strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
|
||||||
.smart-loading__section { display: grid; gap: 10px; padding-top: 4px; }
|
.smart-loading__section { display: grid; min-width: 0; gap: 10px; padding-top: 4px; overflow: hidden; }
|
||||||
.smart-loading__error p { margin: 6px 0 0; }
|
.smart-loading__error p { margin: 6px 0 0; }
|
||||||
.smart-loading :deep(.el-table__cell small), .smart-loading :deep(.el-table__cell strong) { display: block; }
|
.smart-loading :deep(.el-table__cell small), .smart-loading :deep(.el-table__cell strong) { display: block; }
|
||||||
.smart-loading :deep(.el-table__cell small) { margin-top: 2px; color: var(--cc-text-secondary, #64748b); }
|
.smart-loading :deep(.el-table__cell small) { margin-top: 2px; color: var(--cc-text-secondary, #64748b); }
|
||||||
|
|||||||
@@ -392,12 +392,9 @@ onMounted(() => { void refresh() })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="workflow-panel" aria-label="订单后半程作业">
|
<section class="workflow-panel" aria-label="订单后半程作业">
|
||||||
<div class="workflow-heading">
|
<div class="workflow-heading" aria-label="订单作业上下文">
|
||||||
<div>
|
<div class="workflow-context"><span>入库作业</span><strong>#{{ props.inboundId }}</strong></div>
|
||||||
<h2>后半程作业</h2>
|
<el-tooltip content="刷新作业数据" placement="top"><el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" /></el-button></el-tooltip>
|
||||||
<p>查看订单的分柜、预装、拣货和装柜进度。</p>
|
|
||||||
</div>
|
|
||||||
<el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" />刷新</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
|
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
|
||||||
@@ -421,7 +418,7 @@ onMounted(() => { void refresh() })
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<template v-if="isSplit">
|
<template v-if="isSplit">
|
||||||
<el-card shadow="never" class="workflow-card">
|
<el-card shadow="never" class="workflow-card surface-panel table-panel">
|
||||||
<template #header><div class="card-title"><span>入库预装清单</span><el-tag>{{ preloadStatusLabel(preload?.status) }}</el-tag></div></template>
|
<template #header><div class="card-title"><span>入库预装清单</span><el-tag>{{ preloadStatusLabel(preload?.status) }}</el-tag></div></template>
|
||||||
<el-alert v-if="!canPreloadView" type="warning" :closable="false" show-icon>当前账号无预装清单查询权限。</el-alert>
|
<el-alert v-if="!canPreloadView" type="warning" :closable="false" show-icon>当前账号无预装清单查询权限。</el-alert>
|
||||||
<LoadErrorState v-else-if="preloadLoadError" title="预装清单暂时无法加载" :message="preloadLoadError.message" :loading="loading" @retry="loadPreload">
|
<LoadErrorState v-else-if="preloadLoadError" title="预装清单暂时无法加载" :message="preloadLoadError.message" :loading="loading" @retry="loadPreload">
|
||||||
@@ -434,7 +431,6 @@ onMounted(() => { void refresh() })
|
|||||||
<el-button link type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
|
<el-button link type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
|
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
|
||||||
<template #toolbar-start><div><strong>明细</strong><p>入库级清单由人工维护;柜次候选和智能建议在下方柜次工作台加载。</p></div></template>
|
|
||||||
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
|
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
<el-input v-model="splitRemark" :disabled="!canSplit || Boolean(busy)" class="workflow-remark" type="textarea" :rows="2" maxlength="1000" show-word-limit aria-label="分柜备注" placeholder="分柜备注(可选)" />
|
<el-input v-model="splitRemark" :disabled="!canSplit || Boolean(busy)" class="workflow-remark" type="textarea" :rows="2" maxlength="1000" show-word-limit aria-label="分柜备注" placeholder="分柜备注(可选)" />
|
||||||
@@ -533,8 +529,9 @@ onMounted(() => { void refresh() })
|
|||||||
.workflow-panel { display: grid; gap: 14px; }
|
.workflow-panel { display: grid; gap: 14px; }
|
||||||
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.workflow-heading { justify-content: space-between; }
|
.workflow-heading { justify-content: space-between; }
|
||||||
.workflow-heading h2 { margin: 0; font-size: 17px; }
|
.workflow-context { display: grid; min-width: 0; gap: 3px; }
|
||||||
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
.workflow-context span { color: var(--cc-text-secondary, #64748b); font-size: 11px; }
|
||||||
|
.workflow-context strong { overflow: hidden; color: var(--cc-text, #1f2937); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.workflow-card { border-radius: 12px; }
|
.workflow-card { border-radius: 12px; }
|
||||||
.card-title { justify-content: space-between; }
|
.card-title { justify-content: space-between; }
|
||||||
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { getPlatformUnifiedOrder, type UnifiedOrderDetailResource } from '@/modules/unified-orders/api/unifiedOrders'
|
import { getPlatformUnifiedOrder, type UnifiedOrderDetailResource } from '@/modules/unified-orders/api/unifiedOrders'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import UnifiedOrderWorkflowPanel from '@/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'
|
import UnifiedOrderWorkflowPanel from '@/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ onMounted(() => { void load() })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<AdminPage>
|
||||||
<section class="workflow-page">
|
<section class="workflow-page">
|
||||||
<LoadErrorState v-if="error" title="后半程作业暂时无法加载" :message="error.message" :loading="loading" @retry="load">
|
<LoadErrorState v-if="error" title="后半程作业暂时无法加载" :message="error.message" :loading="loading" @retry="load">
|
||||||
<template #details><ApiErrorMeta :details="error" /></template>
|
<template #details><ApiErrorMeta :details="error" /></template>
|
||||||
@@ -54,6 +56,7 @@ onMounted(() => { void load() })
|
|||||||
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
|
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
|
||||||
<el-skeleton v-else :rows="5" animated />
|
<el-skeleton v-else :rows="5" animated />
|
||||||
</section>
|
</section>
|
||||||
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -190,9 +190,10 @@ describe('LocationsView', () => {
|
|||||||
|
|
||||||
const tableToolbar = wrapper.find('.table-panel .data-table__toolbar')
|
const tableToolbar = wrapper.find('.table-panel .data-table__toolbar')
|
||||||
expect(tableToolbar.exists()).toBe(true)
|
expect(tableToolbar.exists()).toBe(true)
|
||||||
expect(tableToolbar.text()).toContain('库位列表')
|
|
||||||
expect(tableToolbar.text()).toContain('共 2 个库位')
|
|
||||||
expect(tableToolbar.text()).toContain('新增库位')
|
expect(tableToolbar.text()).toContain('新增库位')
|
||||||
|
expect(tableToolbar.text()).not.toContain('库位列表')
|
||||||
|
expect(tableToolbar.text()).not.toContain('共 2 个库位')
|
||||||
|
expect(wrapper.find('.data-table__pagination-left').text()).toContain('共 2 个库位')
|
||||||
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
|
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
|
||||||
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC' })
|
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC' })
|
||||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
|
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Save, Trash2 } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import {
|
import {
|
||||||
changePlatformLocationStatus,
|
changePlatformLocationStatus,
|
||||||
@@ -499,32 +504,38 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page location-page">
|
<AdminPage as="div" class="location-page">
|
||||||
<QueryToolbar
|
<ListPageLayout aria-label="库位列表页面">
|
||||||
v-model="filters"
|
<template #toolbar>
|
||||||
ariaLabel="库位筛选"
|
<QueryToolbar
|
||||||
:fields="queryFields"
|
v-model="filters"
|
||||||
preference-key="locations-query"
|
ariaLabel="库位筛选"
|
||||||
@search="applyFilters"
|
:fields="queryFields"
|
||||||
/>
|
preference-key="locations-query"
|
||||||
|
@search="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #state>
|
||||||
|
<LoadErrorState
|
||||||
|
v-if="warehouseError"
|
||||||
|
title="仓库列表加载失败"
|
||||||
|
:message="warehouseError.message"
|
||||||
|
:loading="warehouseLoading"
|
||||||
|
@retry="loadWarehouses"
|
||||||
|
/>
|
||||||
|
<el-alert v-else-if="!warehouseLoading && domesticWarehouses.length === 0" type="info" :closable="false" show-icon title="暂无可选国内仓">
|
||||||
|
请先在仓库管理中建立国内仓,再维护库位。
|
||||||
|
</el-alert>
|
||||||
|
<LoadErrorState
|
||||||
|
v-else-if="loadError"
|
||||||
|
title="库位列表加载失败"
|
||||||
|
:message="loadError.message"
|
||||||
|
:loading="loading"
|
||||||
|
@retry="loadLocations"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
|
<section v-if="!loadError" class="surface-panel table-panel" aria-label="库位列表区域">
|
||||||
<template #title>仓库列表加载失败</template>
|
|
||||||
<p>{{ warehouseError.message }}</p>
|
|
||||||
<ApiErrorMeta :details="warehouseError" />
|
|
||||||
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
|
|
||||||
</el-alert>
|
|
||||||
<el-alert v-else-if="!warehouseLoading && domesticWarehouses.length === 0" type="info" :closable="false" show-icon title="暂无可选国内仓">
|
|
||||||
请先在仓库管理中建立国内仓,再维护库位。
|
|
||||||
</el-alert>
|
|
||||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
|
||||||
<template #title>库位列表加载失败</template>
|
|
||||||
<p>{{ loadError.message }}</p>
|
|
||||||
<ApiErrorMeta :details="loadError" />
|
|
||||||
<el-button size="small" @click="loadLocations()">重新查询</el-button>
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<section class="surface-panel table-panel" aria-label="库位列表区域">
|
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -538,7 +549,6 @@ onMounted(() => {
|
|||||||
aria-label="库位列表"
|
aria-label="库位列表"
|
||||||
:empty-text="emptyDescription"
|
:empty-text="emptyDescription"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div><h2>库位列表</h2><p>共 {{ total }} 个库位</p></div></template>
|
|
||||||
<template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
|
<template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions location-actions">
|
<div class="table-actions location-actions">
|
||||||
@@ -558,7 +568,7 @@ onMounted(() => {
|
|||||||
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
|
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
|
||||||
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
|
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
|
||||||
>
|
>
|
||||||
<el-button link type="primary" aria-label="更多库位操作"><MoreHorizontal :size="16" />更多</el-button>
|
<el-button link type="primary" aria-label="更多库位操作" title="更多库位操作"><MoreHorizontal :size="16" /></el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
|
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
|
||||||
@@ -610,18 +620,22 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<el-drawer
|
<AppDrawer
|
||||||
v-model="drawerOpen"
|
v-model="drawerOpen"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
size="min(620px, 100vw)"
|
size="min(620px, 94vw)"
|
||||||
class="business-drawer location-drawer"
|
class="location-drawer"
|
||||||
|
:loading="drawerLoading"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
:show-confirm="false"
|
||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="!saving"
|
:close-on-press-escape="!saving"
|
||||||
:show-close="!saving"
|
:show-close="!saving"
|
||||||
>
|
>
|
||||||
<div v-loading="drawerLoading" class="drawer-content">
|
<div class="drawer-content">
|
||||||
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
|
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
|
||||||
<p>{{ drawerError.message }}</p>
|
<p>{{ drawerError.message }}</p>
|
||||||
<ApiErrorMeta :details="drawerError" />
|
<ApiErrorMeta :details="drawerError" />
|
||||||
@@ -689,9 +703,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<section class="form-section">
|
<FormSection title="库位标识" description="库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。">
|
||||||
<h3>库位标识</h3>
|
|
||||||
<p class="section-description">库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。</p>
|
|
||||||
<div class="form-grid two-cols">
|
<div class="form-grid two-cols">
|
||||||
<el-form-item label="库位编码" prop="locationCode" required>
|
<el-form-item label="库位编码" prop="locationCode" required>
|
||||||
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
|
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
|
||||||
@@ -706,10 +718,9 @@ onMounted(() => {
|
|||||||
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
|
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FormSection>
|
||||||
|
|
||||||
<section class="form-section">
|
<FormSection title="资源属性">
|
||||||
<h3>资源属性</h3>
|
|
||||||
<div class="form-grid two-cols">
|
<div class="form-grid two-cols">
|
||||||
<el-form-item label="库位类型" prop="locationType" required>
|
<el-form-item label="库位类型" prop="locationType" required>
|
||||||
<el-select v-model="form.locationType" placeholder="请选择库位类型">
|
<el-select v-model="form.locationType" placeholder="请选择库位类型">
|
||||||
@@ -735,25 +746,24 @@ onMounted(() => {
|
|||||||
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
|
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
|
||||||
<small>状态只能通过列表中的启停操作变更。</small>
|
<small>状态只能通过列表中的启停操作变更。</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FormSection>
|
||||||
|
|
||||||
<section class="form-section">
|
<FormSection title="备注">
|
||||||
<h3>备注</h3>
|
|
||||||
<el-form-item label="内部说明">
|
<el-form-item label="内部说明">
|
||||||
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
|
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</section>
|
</FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="drawer-footer">
|
<div class="drawer-footer">
|
||||||
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
|
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
|
||||||
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation">保存库位</el-button>
|
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation"><Save :size="16" />保存库位</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</AppDrawer>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -154,7 +154,11 @@ describe('PalletsView', () => {
|
|||||||
expect(wrapper.text()).toContain('8 kg')
|
expect(wrapper.text()).toContain('8 kg')
|
||||||
expect(wrapper.text()).toContain('黄色')
|
expect(wrapper.text()).toContain('黄色')
|
||||||
expect(wrapper.text()).not.toContain('更新时间')
|
expect(wrapper.text()).not.toContain('更新时间')
|
||||||
expect(wrapper.text()).toContain('托盘列表')
|
const tableToolbar = wrapper.find('.table-panel .data-table__toolbar')
|
||||||
|
expect(tableToolbar.exists()).toBe(true)
|
||||||
|
expect(tableToolbar.text()).toContain('新增托盘')
|
||||||
|
expect(tableToolbar.text()).not.toContain('托盘列表')
|
||||||
|
expect(wrapper.find('.data-table__pagination-left').text()).toContain('共 1 个托盘')
|
||||||
expect(wrapper.text()).not.toContain('启停托盘')
|
expect(wrapper.text()).not.toContain('启停托盘')
|
||||||
expect(wrapper.text()).not.toContain('全部状态')
|
expect(wrapper.text()).not.toContain('全部状态')
|
||||||
expect(wrapper.text()).toContain('全部托盘类型')
|
expect(wrapper.text()).toContain('全部托盘类型')
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
import { Boxes, Eye, Pencil, Plus, Save, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import {
|
import {
|
||||||
@@ -368,35 +372,38 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pallet-page">
|
<AdminPage as="div" class="pallet-page">
|
||||||
<QueryToolbar
|
<ListPageLayout aria-label="托盘列表页面">
|
||||||
v-model="filters"
|
<template #toolbar>
|
||||||
class="surface-panel pallet-filter-toolbar"
|
<QueryToolbar
|
||||||
ariaLabel="托盘筛选"
|
v-model="filters"
|
||||||
:fields="queryFields"
|
class="surface-panel pallet-filter-toolbar"
|
||||||
preference-key="pallets-query"
|
ariaLabel="托盘筛选"
|
||||||
:search-on-reset="false"
|
:fields="queryFields"
|
||||||
@search="applyFilters"
|
preference-key="pallets-query"
|
||||||
@reset="resetFilters"
|
:search-on-reset="false"
|
||||||
/>
|
@search="applyFilters"
|
||||||
|
@reset="resetFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #state>
|
||||||
|
<LoadErrorState
|
||||||
|
v-if="warehouseError"
|
||||||
|
title="仓库列表暂时无法加载"
|
||||||
|
:message="warehouseError.message"
|
||||||
|
:loading="warehouseLoading"
|
||||||
|
@retry="loadWarehouses"
|
||||||
|
/>
|
||||||
|
<LoadErrorState
|
||||||
|
v-else-if="loadError"
|
||||||
|
title="托盘列表暂时无法加载"
|
||||||
|
:message="loadError.message"
|
||||||
|
:loading="loading"
|
||||||
|
@retry="loadPallets"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<LoadErrorState
|
<section v-if="!loadError" class="table-panel surface-panel" aria-label="托盘列表区域">
|
||||||
v-if="warehouseError"
|
|
||||||
title="仓库列表暂时无法加载"
|
|
||||||
:message="warehouseError.message"
|
|
||||||
:loading="warehouseLoading"
|
|
||||||
@retry="loadWarehouses"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LoadErrorState
|
|
||||||
v-if="loadError"
|
|
||||||
title="托盘列表暂时无法加载"
|
|
||||||
:message="loadError.message"
|
|
||||||
:loading="loading"
|
|
||||||
@retry="loadPallets"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="table-panel surface-panel" aria-label="托盘列表区域">
|
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -409,7 +416,6 @@ onMounted(() => {
|
|||||||
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
|
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
|
||||||
aria-label="托盘列表"
|
aria-label="托盘列表"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div><h2>托盘列表</h2><p>共 {{ total }} 个托盘</p></div></template>
|
|
||||||
<template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
<template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<div class="table-actions pallet-row-actions">
|
<div class="table-actions pallet-row-actions">
|
||||||
@@ -431,11 +437,13 @@ onMounted(() => {
|
|||||||
<template #capacity="{ row }"><strong>{{ formatCapacity((row as PlatformPalletResource).capacityCbm) }}</strong></template>
|
<template #capacity="{ row }"><strong>{{ formatCapacity((row as PlatformPalletResource).capacityCbm) }}</strong></template>
|
||||||
<template #weight="{ row }"><strong>{{ formatWeight((row as PlatformPalletResource).weightKg) }}</strong></template>
|
<template #weight="{ row }"><strong>{{ formatWeight((row as PlatformPalletResource).weightKg) }}</strong></template>
|
||||||
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
|
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
|
||||||
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个托盘</span></template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<el-drawer v-model="detailOpen" title="托盘详情" size="520px" class="business-drawer">
|
<AppDrawer v-model="detailOpen" title="托盘详情" size="min(520px, 94vw)" :loading="detailLoading" :show-confirm="false">
|
||||||
<div v-loading="detailLoading" class="detail-body">
|
<div class="detail-body">
|
||||||
<template v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
|
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
|
||||||
<h3 class="detail-section-title">归属信息</h3>
|
<h3 class="detail-section-title">归属信息</h3>
|
||||||
@@ -460,7 +468,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||||
</el-drawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<el-drawer v-model="editorOpen" size="560px" class="business-drawer pallet-editor-drawer" :close-on-click-modal="false">
|
<el-drawer v-model="editorOpen" size="560px" class="business-drawer pallet-editor-drawer" :close-on-click-modal="false">
|
||||||
<template #header="{ titleId, titleClass }">
|
<template #header="{ titleId, titleClass }">
|
||||||
@@ -473,11 +481,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
||||||
<section class="editor-section" aria-labelledby="pallet-ownership-heading">
|
<FormSection class="editor-section" title="归属仓库" description="托盘创建后固定归属于所选仓库,不支持跨仓移动。" aria-labelledby="pallet-ownership-heading">
|
||||||
<div class="editor-section-heading">
|
<span id="pallet-ownership-heading" class="sr-only">归属仓库</span>
|
||||||
<WarehouseIcon :size="18" aria-hidden="true" />
|
|
||||||
<div><h3 id="pallet-ownership-heading">归属仓库</h3><p>托盘创建后固定归属于所选仓库,不支持跨仓移动。</p></div>
|
|
||||||
</div>
|
|
||||||
<div v-if="form.id" class="editor-context">
|
<div v-if="form.id" class="editor-context">
|
||||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 平台公共资源</span></div>
|
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 平台公共资源</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,13 +496,10 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</section>
|
</FormSection>
|
||||||
|
|
||||||
<section class="editor-section" aria-labelledby="pallet-spec-heading">
|
<FormSection class="editor-section" title="托盘规格" description="编码由系统自动生成,请维护类型、容积、重量和颜色。" aria-labelledby="pallet-spec-heading">
|
||||||
<div class="editor-section-heading">
|
<span id="pallet-spec-heading" class="sr-only">托盘规格</span>
|
||||||
<Boxes :size="18" aria-hidden="true" />
|
|
||||||
<div><h3 id="pallet-spec-heading">托盘规格</h3><p>编码由系统自动生成,请维护类型、容积、重量和颜色。</p></div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-field-grid">
|
<div class="editor-field-grid">
|
||||||
<el-form-item label="托盘编码">
|
<el-form-item label="托盘编码">
|
||||||
<el-input :model-value="form.palletCode || '保存后自动生成'" disabled aria-label="托盘编码(系统生成)" />
|
<el-input :model-value="form.palletCode || '保存后自动生成'" disabled aria-label="托盘编码(系统生成)" />
|
||||||
@@ -529,16 +531,16 @@ onMounted(() => {
|
|||||||
<el-form-item label="备注" prop="remark" class="editor-remark-field">
|
<el-form-item label="备注" prop="remark" class="editor-remark-field">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写其他说明" />
|
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写其他说明" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</section>
|
</FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="drawer-footer pallet-editor-footer">
|
<div class="drawer-footer pallet-editor-footer">
|
||||||
<span><i aria-hidden="true">*</i> 为必填项</span>
|
<span><i aria-hidden="true">*</i> 为必填项</span>
|
||||||
<div><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div>
|
<div><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet"><Save :size="16" />保存托盘</el-button></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</section>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||||
import { ChevronDown, Download, FileSpreadsheet, MapPin, Pencil, Plus, Power, Upload, Warehouse as WarehouseIcon } from '@lucide/vue'
|
import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Pencil, Plus, Power, Save, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import FormSection from '@/shared/components/FormSection.vue'
|
||||||
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
@@ -253,8 +258,7 @@ const loadErrorDescription = computed(() => {
|
|||||||
loadError.value.requestId ? `requestId ${loadError.value.requestId}` : '',
|
loadError.value.requestId ? `requestId ${loadError.value.requestId}` : '',
|
||||||
loadError.value.traceId ? `traceId ${loadError.value.traceId}` : '',
|
loadError.value.traceId ? `traceId ${loadError.value.traceId}` : '',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
const retained = loaded.value ? '当前仍显示最近一次成功加载的数据。' : ''
|
return [loadError.value.message, references.join(' · ')].filter(Boolean).join(' ')
|
||||||
return [loadError.value.message, references.join(' · '), retained].filter(Boolean).join(' ')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -665,16 +669,22 @@ onMounted(() => void loadWarehouses())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="business-page">
|
<AdminPage as="div">
|
||||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
<ListPageLayout aria-label="仓库列表页面">
|
||||||
<template #title>仓库数据加载失败</template>
|
<template #toolbar>
|
||||||
<p>{{ loadErrorDescription }}</p>
|
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" preference-key="warehouses-query" @search="queryWarehouses" />
|
||||||
<el-button size="small" @click="loadWarehouses()">重新查询</el-button>
|
</template>
|
||||||
</el-alert>
|
<template v-if="loadError" #state>
|
||||||
|
<LoadErrorState
|
||||||
|
title="仓库数据加载失败"
|
||||||
|
:message="loadErrorDescription"
|
||||||
|
:loading="loading"
|
||||||
|
retry-text="重新查询"
|
||||||
|
@retry="loadWarehouses"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" preference-key="warehouses-query" @search="queryWarehouses" />
|
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
|
||||||
|
|
||||||
<section class="surface-panel table-panel" aria-live="polite">
|
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pageSize"
|
||||||
@@ -688,10 +698,9 @@ onMounted(() => void loadWarehouses())
|
|||||||
aria-label="公共仓库列表"
|
aria-label="公共仓库列表"
|
||||||
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
|
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
|
||||||
>
|
>
|
||||||
<template #toolbar-start><div><h2>公共仓库</h2><p>国内收货仓、FBA 地址和海外仓统一维护 · 共 {{ total }} 个</p></div></template>
|
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
|
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
|
||||||
<el-button :disabled="!warehouseReady" @click="openImport"><Upload :size="16" />导入仓库</el-button>
|
<el-button :disabled="!warehouseReady" @click="openImport"><FileUp :size="16" />导入仓库</el-button>
|
||||||
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
|
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
|
||||||
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
|
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
|
||||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item></el-dropdown-menu></template>
|
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item></el-dropdown-menu></template>
|
||||||
@@ -721,12 +730,12 @@ onMounted(() => void loadWarehouses())
|
|||||||
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||||
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
|
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</section>
|
</section>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
|
<AppDrawer v-model="editorOpen" :title="editorLabel" size="min(620px, 94vw)" class="editor-drawer" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false">
|
||||||
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
|
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
|
||||||
<div class="form-section">
|
<FormSection title="基础信息">
|
||||||
<h3>基础信息</h3>
|
|
||||||
<div class="form-grid two-cols">
|
<div class="form-grid two-cols">
|
||||||
<el-form-item v-if="isFbaCreate" label="FBA 编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 LAX9" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
|
<el-form-item v-if="isFbaCreate" label="FBA 编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 LAX9" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
|
||||||
<el-form-item v-if="isFbaCreate" label="国家或地区" required>
|
<el-form-item v-if="isFbaCreate" label="国家或地区" required>
|
||||||
@@ -749,10 +758,9 @@ onMounted(() => void loadWarehouses())
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormSection>
|
||||||
|
|
||||||
<div class="form-section">
|
<FormSection title="地址信息">
|
||||||
<h3>地址信息</h3>
|
|
||||||
<div class="form-grid two-cols">
|
<div class="form-grid two-cols">
|
||||||
<el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
|
<el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
|
||||||
<el-select v-if="usesUsStateSelect" v-model="form.province" filterable :placeholder="regionPlaceholder" @change="onRegionChange"><el-option v-for="state in usStateOptions" :key="state.value" :label="state.label" :value="state.value" /></el-select>
|
<el-select v-if="usesUsStateSelect" v-model="form.province" filterable :placeholder="regionPlaceholder" @change="onRegionChange"><el-option v-for="state in usStateOptions" :key="state.value" :label="state.label" :value="state.value" /></el-select>
|
||||||
@@ -763,28 +771,26 @@ onMounted(() => void loadWarehouses())
|
|||||||
<el-form-item label="邮编" :required="isFbaCreate" :class="{ 'has-field-error': formErrors.postalCode }"><el-input v-model="form.postalCode" maxlength="20" :placeholder="isDomesticForm ? '例如 322000' : '例如 90802'" @input="clearFieldError('postalCode')" /><p v-if="formErrors.postalCode" class="field-error" role="alert">{{ formErrors.postalCode }}</p></el-form-item>
|
<el-form-item label="邮编" :required="isFbaCreate" :class="{ 'has-field-error': formErrors.postalCode }"><el-input v-model="form.postalCode" maxlength="20" :placeholder="isDomesticForm ? '例如 322000' : '例如 90802'" @input="clearFieldError('postalCode')" /><p v-if="formErrors.postalCode" class="field-error" role="alert">{{ formErrors.postalCode }}</p></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" :placeholder="addressPlaceholder" @input="clearFieldError('address')" /><p v-if="formErrors.address" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item>
|
<el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" :placeholder="addressPlaceholder" @input="clearFieldError('address')" /><p v-if="formErrors.address" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item>
|
||||||
</div>
|
</FormSection>
|
||||||
|
|
||||||
<section v-if="!isFbaCreate" class="form-section">
|
<FormSection v-if="!isFbaCreate" title="编码与状态">
|
||||||
<h3>编码与状态</h3>
|
|
||||||
<div class="form-grid two-cols compact-grid">
|
<div class="form-grid two-cols compact-grid">
|
||||||
<el-form-item label="仓库编码" :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="系统自动生成" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
|
<el-form-item label="仓库编码" :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="系统自动生成" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
|
||||||
<el-form-item label="状态"><el-radio-group v-model="form.status" class="warehouse-status-toggle"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
|
<el-form-item label="状态"><el-radio-group v-model="form.status" class="warehouse-status-toggle"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</FormSection>
|
||||||
|
|
||||||
<section v-if="!isFbaCreate" class="form-section">
|
<FormSection v-if="!isFbaCreate" title="联系信息">
|
||||||
<h3>联系信息</h3>
|
|
||||||
<div class="form-grid two-cols compact-grid">
|
<div class="form-grid two-cols compact-grid">
|
||||||
<el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item>
|
<el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item>
|
||||||
<el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
<el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
||||||
<el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item>
|
<el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="选填" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="选填" /></el-form-item>
|
||||||
</section>
|
</FormSection>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
|
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse"><Save :size="16" />{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
|
||||||
</el-drawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<el-dialog v-model="importOpen" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
|
<el-dialog v-model="importOpen" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
|
||||||
<div class="import-content">
|
<div class="import-content">
|
||||||
@@ -796,7 +802,7 @@ onMounted(() => void loadWarehouses())
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
|
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
|
||||||
<el-button><Upload :size="16" />选择 XLSX 文件</el-button>
|
<el-button><FileUp :size="16" />选择 XLSX 文件</el-button>
|
||||||
<template #tip><div class="el-upload__tip">最多 5MB、1000 行;请使用标准模板,FBA 名称可不填并默认使用编码。</div></template>
|
<template #tip><div class="el-upload__tip">最多 5MB、1000 行;请使用标准模板,FBA 名称可不填并默认使用编码。</div></template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<div v-if="importResult" class="import-result" role="status">
|
<div v-if="importResult" class="import-result" role="status">
|
||||||
@@ -814,9 +820,9 @@ onMounted(() => void loadWarehouses())
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button :loading="importing" :disabled="!importFile" @click="runImport(true)">预检文件</el-button><el-button type="primary" :loading="importing" :disabled="!canCommitImport" @click="runImport(false)">确认导入</el-button></template>
|
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button :loading="importing" :disabled="!importFile" @click="runImport(true)">预检文件</el-button><el-button type="primary" :loading="importing" :disabled="!canCommitImport" @click="runImport(false)"><FileUp :size="16" />确认导入</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('DataTable column preferences', () => {
|
|||||||
slots: {
|
slots: {
|
||||||
'toolbar-start': '<h2>订单列表</h2>',
|
'toolbar-start': '<h2>订单列表</h2>',
|
||||||
'toolbar-actions': '<button>新增订单</button>',
|
'toolbar-actions': '<button>新增订单</button>',
|
||||||
'toolbar-end': '<button>刷新</button>',
|
'toolbar-end': '<button>辅助操作</button>',
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
|
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
|
||||||
@@ -109,10 +109,32 @@ describe('DataTable column preferences', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.findAll('.data-table__toolbar')).toHaveLength(1)
|
expect(wrapper.findAll('.data-table__toolbar')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('.data-table__toolbar').classes()).not.toContain('is-compact')
|
||||||
expect(wrapper.get('.data-table__toolbar-start').text()).toBe('订单列表')
|
expect(wrapper.get('.data-table__toolbar-start').text()).toBe('订单列表')
|
||||||
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('新增订单')
|
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('新增订单')
|
||||||
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('刷新')
|
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('辅助操作')
|
||||||
|
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
|
||||||
|
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('辅助操作')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a compact right-aligned toolbar when no leading content is provided', () => {
|
||||||
|
const wrapper = shallowMount(DataTable, {
|
||||||
|
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table' },
|
||||||
|
slots: { 'toolbar-actions': '<button>新增订单</button>' },
|
||||||
|
global: {
|
||||||
|
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
|
||||||
|
stubs: {
|
||||||
|
ElTable: TableStub,
|
||||||
|
ElTableColumn: ColumnStub,
|
||||||
|
ElButton: ButtonStub,
|
||||||
|
ElPopover: PopoverStub,
|
||||||
|
},
|
||||||
|
directives: { loading: {} },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('.data-table__toolbar').classes()).toContain('is-compact')
|
||||||
|
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false)
|
||||||
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
|
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
|
||||||
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('刷新')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,8 +250,12 @@ function setPage(value: number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="data-table">
|
<div class="data-table">
|
||||||
<div v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end']" class="data-table__toolbar">
|
<div
|
||||||
<div class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
|
v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end']"
|
||||||
|
class="data-table__toolbar"
|
||||||
|
:class="{ 'is-compact': !$slots['toolbar-start'] }"
|
||||||
|
>
|
||||||
|
<div v-if="$slots['toolbar-start']" class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
|
||||||
<div class="data-table__toolbar-actions">
|
<div class="data-table__toolbar-actions">
|
||||||
<div v-if="$slots['toolbar-actions']" class="data-table__business-actions"><slot name="toolbar-actions" /></div>
|
<div v-if="$slots['toolbar-actions']" class="data-table__business-actions"><slot name="toolbar-actions" /></div>
|
||||||
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
|
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
|
||||||
@@ -324,6 +328,7 @@ function setPage(value: number) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.data-table { min-width: 0; }
|
.data-table { min-width: 0; }
|
||||||
.data-table__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
|
.data-table__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
|
||||||
|
.data-table__toolbar.is-compact { min-height: 56px; justify-content: flex-end; padding: 8px 14px; }
|
||||||
.data-table__toolbar-start { min-width: 0; flex: 1; }
|
.data-table__toolbar-start { min-width: 0; flex: 1; }
|
||||||
.data-table__toolbar-start :deep(h2) { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
|
.data-table__toolbar-start :deep(h2) { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
|
||||||
.data-table__toolbar-start :deep(p) { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
.data-table__toolbar-start :deep(p) { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||||
@@ -338,5 +343,5 @@ function setPage(value: number) {
|
|||||||
.data-table__page-buttons { gap: 4px; }
|
.data-table__page-buttons { gap: 4px; }
|
||||||
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
|
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
|
||||||
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
|
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
|
||||||
@media (max-width: 720px) { .data-table__toolbar { align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: nowrap; justify-content: flex-start; } .data-table__business-actions { flex: 1 1 auto; justify-content: flex-start; } .data-table__toolbar-end { flex: 0 1 auto; justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
|
@media (max-width: 720px) { .data-table__toolbar, .data-table__toolbar.is-compact { min-height: 0; align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: nowrap; justify-content: flex-start; } .data-table__business-actions { flex: 1 1 auto; justify-content: flex-start; } .data-table__toolbar-end { flex: 0 1 auto; justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { min-height: 40px; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Comment, Fragment, Text, isVNode, useSlots } from 'vue'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
ariaLabel: '列表页面',
|
ariaLabel: '列表页面',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
type RegionName = 'toolbar' | 'metrics' | 'state' | 'default'
|
||||||
|
|
||||||
|
function hasRenderableContent(value: unknown): boolean {
|
||||||
|
if (Array.isArray(value)) return value.some(hasRenderableContent)
|
||||||
|
if (!isVNode(value)) return typeof value === 'string' ? value.trim().length > 0 : value != null && typeof value !== 'boolean'
|
||||||
|
if (value.type === Comment) return false
|
||||||
|
if (value.type === Text) return hasRenderableContent(value.children)
|
||||||
|
if (value.type === Fragment) return hasRenderableContent(value.children)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRegion(name: RegionName) {
|
||||||
|
return hasRenderableContent(slots[name]?.())
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="list-page-layout" :aria-label="ariaLabel">
|
<section class="list-page-layout" :aria-label="ariaLabel">
|
||||||
<div v-if="$slots.toolbar" class="list-page-layout__toolbar"><slot name="toolbar" /></div>
|
<div v-if="hasRegion('toolbar')" class="list-page-layout__toolbar"><slot name="toolbar" /></div>
|
||||||
<div v-if="$slots.metrics" class="list-page-layout__metrics"><slot name="metrics" /></div>
|
<div v-if="hasRegion('metrics')" class="list-page-layout__metrics"><slot name="metrics" /></div>
|
||||||
<div v-if="$slots.state" class="list-page-layout__state"><slot name="state" /></div>
|
<div v-if="hasRegion('state')" class="list-page-layout__state"><slot name="state" /></div>
|
||||||
<div v-if="$slots.default" class="list-page-layout__content"><slot /></div>
|
<div v-if="hasRegion('default')" class="list-page-layout__content"><slot /></div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.list-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
|
.list-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
|
||||||
.list-page-layout__toolbar, .list-page-layout__metrics, .list-page-layout__state, .list-page-layout__content { min-width: 0; }
|
.list-page-layout__toolbar, .list-page-layout__metrics, .list-page-layout__state, .list-page-layout__content { min-width: 0; }
|
||||||
|
.list-page-layout__toolbar:empty, .list-page-layout__metrics:empty, .list-page-layout__state:empty, .list-page-layout__content:empty { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ describe('admin page layout components', () => {
|
|||||||
expect(wrapper.text()).toContain('表格')
|
expect(wrapper.text()).toContain('表格')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('removes registered but empty list regions from flex layout', () => {
|
||||||
|
const wrapper = mount(ListPageLayout, {
|
||||||
|
slots: {
|
||||||
|
toolbar: '<div>筛选</div>',
|
||||||
|
state: '<template v-if="false"><div>错误</div></template>',
|
||||||
|
default: '<div>表格</div>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toBe('筛选表格')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps plain-text list regions visible', () => {
|
||||||
|
const wrapper = mount(ListPageLayout, {
|
||||||
|
slots: { state: '正在同步', default: '表格内容' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('.list-page-layout__state').text()).toBe('正在同步')
|
||||||
|
expect(wrapper.get('.list-page-layout__content').text()).toBe('表格内容')
|
||||||
|
})
|
||||||
|
|
||||||
it('supports detail regions and form section descriptions', () => {
|
it('supports detail regions and form section descriptions', () => {
|
||||||
const detail = mount(DetailPageLayout, {
|
const detail = mount(DetailPageLayout, {
|
||||||
slots: { header: '<h1>详情</h1>', default: '<p>正文</p>', footer: '<button>保存</button>' },
|
slots: { header: '<h1>详情</h1>', default: '<p>正文</p>', footer: '<button>保存</button>' },
|
||||||
|
|||||||
Reference in New Issue
Block a user