优化
This commit is contained in:
@@ -33,12 +33,16 @@
|
|||||||
|
|
||||||
## 业务与安全约束
|
## 业务与安全约束
|
||||||
|
|
||||||
|
- 当前产品层级只有平台和客户(企业)两级;Admin 不把“租户”作为用户可见概念,不新增租户选择器、目标租户文案或依赖租户上下文才能使用的页面流程。多租户产品能力后续单独设计。
|
||||||
|
- 平台仓库是公共主数据,不与客户绑定。托盘、库位等仓储资源页面必须能够独立查询全部客户和全部仓库,选择客户不得缩小、清空或禁用仓库候选。
|
||||||
|
- 后端兼容字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。
|
||||||
|
|
||||||
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
|
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
|
||||||
- 认证统一经过 `src/core/auth/auth.ts` 和 `src/shared/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。
|
- 认证统一经过 `src/core/auth/auth.ts` 和 `src/shared/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。
|
||||||
- 当前登录和部分数据仍可能是模拟实现。接入 `baiyue-firstleg` Gateway 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。
|
- 当前登录和部分数据仍可能是模拟实现。接入 `baiyue-firstleg` Gateway 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。
|
||||||
- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。
|
- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。
|
||||||
- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。
|
- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。
|
||||||
- Admin 维护企业所属仓库、往来单位、快递公司等主数据时,必须通过平台企业列表显式选择目标企业;不得从本地存储推断租户。默认可优先选择历史租户 `10001`(不存在时选择列表第一项),企业雪花 ID 必须按字符串透传,切换企业时不得保留上一企业的编辑、详情或导入上下文。
|
- Admin 维护客户专属数据时,必须通过平台客户列表显式选择目标客户,不得从本地存储推断客户。平台仓库等公共主数据不要求先选择客户;切换客户时不得保留上一客户的编辑、详情或导入上下文。
|
||||||
|
|
||||||
## UI 与交互
|
## UI 与交互
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
name: "虫虫出海 Admin"
|
||||||
|
description: "克制、清晰且高密度可扫描的企业级运营工作台"
|
||||||
|
colors:
|
||||||
|
primary: "#667eea"
|
||||||
|
primary-hover: "#5366c8"
|
||||||
|
primary-soft: "#eaf1ff"
|
||||||
|
work-canvas: "#f4f6f8"
|
||||||
|
surface: "#ffffff"
|
||||||
|
surface-muted: "#f8fafc"
|
||||||
|
text: "#172033"
|
||||||
|
text-secondary: "#475467"
|
||||||
|
muted: "#667085"
|
||||||
|
muted-light: "#98a2b3"
|
||||||
|
border: "#e3e8ef"
|
||||||
|
border-subtle: "#edf0f4"
|
||||||
|
success: "#067647"
|
||||||
|
success-soft: "#ecfdf3"
|
||||||
|
warning: "#b54708"
|
||||||
|
warning-soft: "#fffaeb"
|
||||||
|
danger: "#b42318"
|
||||||
|
danger-soft: "#fef3f2"
|
||||||
|
info: "#3538cd"
|
||||||
|
info-soft: "#eff4ff"
|
||||||
|
typography:
|
||||||
|
headline:
|
||||||
|
fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif'
|
||||||
|
fontSize: "20px"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.35
|
||||||
|
letterSpacing: "0"
|
||||||
|
title:
|
||||||
|
fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif'
|
||||||
|
fontSize: "16px"
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: "0"
|
||||||
|
body:
|
||||||
|
fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif'
|
||||||
|
fontSize: "13px"
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: "0"
|
||||||
|
label:
|
||||||
|
fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif'
|
||||||
|
fontSize: "12px"
|
||||||
|
fontWeight: 650
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: "0"
|
||||||
|
supporting:
|
||||||
|
fontFamily: '"PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif'
|
||||||
|
fontSize: "11px"
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: "0"
|
||||||
|
rounded:
|
||||||
|
xs: "3px"
|
||||||
|
sm: "4px"
|
||||||
|
md: "6px"
|
||||||
|
panel: "8px"
|
||||||
|
nav: "10px"
|
||||||
|
accent: "12px"
|
||||||
|
pill: "999px"
|
||||||
|
circle: "50%"
|
||||||
|
spacing:
|
||||||
|
xxs: "4px"
|
||||||
|
xs: "8px"
|
||||||
|
sm: "10px"
|
||||||
|
md: "12px"
|
||||||
|
lg: "16px"
|
||||||
|
xl: "20px"
|
||||||
|
xxl: "24px"
|
||||||
|
xxxl: "32px"
|
||||||
|
components:
|
||||||
|
dashboard-tab-selected:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.surface}"
|
||||||
|
typography: "{typography.label}"
|
||||||
|
rounded: "{rounded.panel}"
|
||||||
|
padding: "0 12px"
|
||||||
|
height: "36px"
|
||||||
|
dashboard-button-outline:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text-secondary}"
|
||||||
|
typography: "{typography.label}"
|
||||||
|
rounded: "{rounded.panel}"
|
||||||
|
padding: "0 13px"
|
||||||
|
height: "36px"
|
||||||
|
surface-panel:
|
||||||
|
backgroundColor: "{colors.surface}"
|
||||||
|
textColor: "{colors.text}"
|
||||||
|
rounded: "{rounded.panel}"
|
||||||
|
padding: "20px"
|
||||||
|
status-badge:
|
||||||
|
backgroundColor: "{colors.success-soft}"
|
||||||
|
textColor: "{colors.success}"
|
||||||
|
typography: "{typography.supporting}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: "2px 8px"
|
||||||
|
height: "24px"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design System: 虫虫出海 Admin
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Creative North Star: "冷静的运营工作面"**
|
||||||
|
|
||||||
|
虫虫出海 Admin 是面向平台员工的高密度运营工作台。界面以冷灰工作面承接连续任务,以白色边框面板划分可操作区域,以柏悦蓝标记主操作、焦点和当前选中项;视觉表达克制,优先让字段、状态和操作顺序一眼可扫。
|
||||||
|
|
||||||
|
系统不依赖装饰性大色块制造层级。面板边界、浅层级色、紧凑字号和稳定间距共同组织信息;状态色只承担分类、进度、告警和错误反馈,不替代文字标签。壳层可以建立明确品牌存在感,业务内容区保持安静、可复用、可扩展。
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
|
||||||
|
- 冷灰画布与白色面板构成稳定工作面。
|
||||||
|
- 柏悦蓝只承载主操作、焦点和选中态。
|
||||||
|
- 12–13px 信息层与 16–20px 标题层形成紧凑层级。
|
||||||
|
- 边框和浅色填充负责大多数深度,阴影保持稀缺。
|
||||||
|
- 桌面端追求密度,窄屏端保证 44px 级触控与可横向退化的数据表格。
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
主色清晰但不过量;中性冷灰负责内容秩序,语义色只在需要判断状态时出现。
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
- **柏悦蓝**:用于主操作、当前导航、页内选中态、关键链接和可见焦点。
|
||||||
|
- **柏悦蓝悬停色**:用于白底描边操作的悬停文字与边框反馈。
|
||||||
|
- **柏悦蓝浅底**:用于图标容器、头像底和弱强调背景,不作为大面积业务面板底色。
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- **冷灰工作面**:应用画布与全屏看板背景,使白色业务面板保持清晰边界。
|
||||||
|
- **白色业务面**:卡片、表格、筛选栏、抽屉和控件的默认承载面。
|
||||||
|
- **浅层级灰**:表头、说明块和次级区域背景。
|
||||||
|
- **墨色正文**:标题、主值和核心业务文本。
|
||||||
|
- **次级墨灰**:表头、次级数据和操作文案。
|
||||||
|
- **说明灰**:帮助文本、辅助标签、时间与空值提示。
|
||||||
|
- **冷灰边线**:面板、分区和表格结构的主要深度手段。
|
||||||
|
|
||||||
|
### Secondary
|
||||||
|
|
||||||
|
- **成功绿 / 浅成功绿**:已完成、启用和正向状态。
|
||||||
|
- **告警橙 / 浅告警橙**:待处理、临近期限和需关注状态。
|
||||||
|
- **危险红 / 浅危险红**:错误、失败、停用和高风险状态。
|
||||||
|
- **信息靛蓝 / 浅信息靛蓝**:信息型状态和非主操作分类。
|
||||||
|
|
||||||
|
### Named Rules
|
||||||
|
|
||||||
|
**The One Primary Rule.** 柏悦蓝是唯一的全局交互主色;图表分类色和语义色不得升级为第二主色。
|
||||||
|
|
||||||
|
**The Status Has Words Rule.** 状态色必须与状态文字、图标或结构共同出现,绝不只靠颜色传达业务含义。
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
**Display Font:** PingFang SC(回退至 Microsoft YaHei、Segoe UI、sans-serif)
|
||||||
|
**Body Font:** PingFang SC(回退至 Microsoft YaHei、Segoe UI、sans-serif)
|
||||||
|
**Label/Mono Font:** 业务标签沿用正文栈;权限码与接口值使用 Cascadia Code(回退至 Consolas、monospace)
|
||||||
|
|
||||||
|
**Character:** 统一的系统无衬线字栈保证中文字段紧凑清晰,不使用展示型字体制造品牌感。层级主要来自字号、字重和颜色,而不是大幅字距或全大写。
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
- **Headline**(700,20px,1.35):页面级标题、详情页标题和空状态主标题。
|
||||||
|
- **Title**(700,16px,1.4):面板标题、表格区标题和主要分区标题。
|
||||||
|
- **Body**(400,13px,1.5):业务内容、表单值和操作说明。
|
||||||
|
- **Label**(650,12px,1.5):表头、筛选标签、按钮和强调型元数据。
|
||||||
|
- **Supporting**(400,11px,1.5):帮助文本、时间、附注、次级值和状态胶囊。
|
||||||
|
|
||||||
|
### Named Rules
|
||||||
|
|
||||||
|
**The Scan Before Drama Rule.** 业务页标题不以超大字号争夺空间;20px 页面标题、16px 分区标题与 12–13px 内容层已经构成完整层级。
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
应用壳层在桌面端使用固定 240px 侧栏、64px 顶部栏和 20px 内容边距;折叠侧栏为 76px。业务页面以 16px 垂直节奏堆叠,数据看板使用 14–18px 卡片间距,面板常用 16–24px 内边距。筛选栏、指标条、表格面板和抽屉分区通过一致边框对齐,避免每个模块自行发明容器结构。
|
||||||
|
|
||||||
|
960px 以下优先减少多列网格;720px 以下侧栏变为遮罩抽屉,页面边距收至 14–18px,筛选控件纵向铺满,抽屉占满宽度,操作按钮保证可触控高度;420px 以下指标条转为单列或受控双列。表格保留语义列和首列操作,通过水平滚动退化,不把关键操作固定到最右侧。最低 320px 宽度下仍保持内容可访问。
|
||||||
|
|
||||||
|
**The Operation First Rule.** 业务数据表格的“操作”列始终是第一列;布局收窄时保留该顺序。
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
系统采用“平面为主、壳层有深度”的混合策略。业务面板默认依靠冷灰画布、白色表面、1px 边框和浅层级填充建立关系;常驻阴影集中在固定侧栏、粘性顶部栏、头像与粘性操作区,卡片阴影只在明确悬停状态出现。
|
||||||
|
|
||||||
|
### Shadow Vocabulary
|
||||||
|
|
||||||
|
- **侧栏壳层**(`4px 0 32px rgba(0,0,0,.08)`):固定侧栏与内容工作面的分界。
|
||||||
|
- **顶部栏壳层**(`0 8px 32px rgba(0,0,0,.15), 0 2px 8px rgba(0,0,0,.1)`):粘性顶部栏的结构性悬浮。
|
||||||
|
- **头像状态**(`0 4px 12px rgba(0,0,0,.15)`):顶部栏内小面积实体的清晰分离。
|
||||||
|
- **卡片悬停**(`0 6px 16px rgba(23,42,70,.08)`):仅用于可点击卡片的悬停反馈。
|
||||||
|
- **粘性底栏**(`0 -8px 22px rgba(15,23,42,.05)`):需要在长表单中保持可见的操作区。
|
||||||
|
|
||||||
|
### Named Rules
|
||||||
|
|
||||||
|
**The Flat Business Surface Rule.** 普通卡片、表格和筛选面板静止时不加阴影;边框与层级色已经足够。
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
业务面板和主要交互以轻度圆角为主:8px 是卡片、面板、页内按钮和多数复合控件的主轮廓;6px 用于说明块和紧凑内部容器,10–12px 只用于导航或更具身份感的壳层元素。状态胶囊使用完全圆角,头像、数据图例点和进度点使用圆形。结构边线保持 1px,不使用厚描边装饰内容区。
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
- **Shape:** 页内按钮和分段选择使用轻度圆角(8px);Element Plus 通用按钮保留其既有组件轮廓。
|
||||||
|
- **Primary:** 柏悦蓝底配白字,用于查询、提交和当前选中项;同一区域避免并列多个主按钮。
|
||||||
|
- **Hover / Focus:** 描边按钮悬停时转为柏悦蓝系边框与文字;全局键盘焦点使用 3px 半透明蓝色轮廓并保留外偏移。
|
||||||
|
- **Secondary / Ghost:** 白底冷灰描边承载重置、返回、全屏和低优先级操作;禁用态使用浅灰底、浅灰字并移除可点击光标。
|
||||||
|
|
||||||
|
### Chips
|
||||||
|
|
||||||
|
- **Style:** 状态胶囊最小高度 24px,左右 8px 内边距,11px 半粗文字;默认使用中性灰,成功、告警、危险与信息状态使用各自的浅底深字组合。
|
||||||
|
- **State:** 胶囊用于呈现状态,不替代按钮;可筛选标签的选中态仍回到柏悦蓝交互语义。
|
||||||
|
|
||||||
|
### Cards / Containers
|
||||||
|
|
||||||
|
- **Corner Style:** 白色业务面板采用 8px 圆角。
|
||||||
|
- **Background:** 业务内容用白色,表头和内部次级区域用浅层级灰。
|
||||||
|
- **Shadow Strategy:** 默认无阴影,遵守 Elevation & Depth 中的平面业务面规则。
|
||||||
|
- **Border:** 1px 冷灰边线;表格内部使用更浅的分隔线。
|
||||||
|
- **Internal Padding:** 常规面板 20px,紧凑工具栏 14–16px,抽屉分区 22–24px。
|
||||||
|
|
||||||
|
### Inputs / Fields
|
||||||
|
|
||||||
|
- **Style:** 复用 Element Plus 输入、选择和日期控件;筛选字段桌面端常用 260–360px,白底、细边框、紧凑高度。
|
||||||
|
- **Focus:** 使用柏悦蓝焦点边框和全局可见焦点轮廓,不以阴影替代键盘焦点。
|
||||||
|
- **Error / Disabled:** 错误保留具体文案与请求元信息;禁用态降低对比并保持不可操作语义。
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- **Style:** 桌面侧栏 240px,折叠为 76px;一级项高 46px,子项高 42px。默认文字为中性灰,悬停以浅蓝渐变和轻微横移反馈,当前项使用柏悦蓝文字、加粗字重与左侧内嵌标记。720px 以下转换为遮罩式侧栏,不压缩正文到不可读宽度。
|
||||||
|
|
||||||
|
### Data Tables
|
||||||
|
|
||||||
|
- **Style:** 表头高 44px,12px 半粗文字与浅灰底;单元格垂直内边距 12px,主值 13px、次值 11px。
|
||||||
|
- **Structure:** 操作列在第一列;表格面板裁切外轮廓,表尾用顶边线连接分页和结果统计。
|
||||||
|
- **Responsive:** 窄屏保留水平滚动与 44px 操作触控高度,不隐藏关键字段而制造上下文丢失。
|
||||||
|
|
||||||
|
### Dashboard Panels
|
||||||
|
|
||||||
|
- **Style:** 指标卡和图表面板沿用 8px 白色边框面板。图表分类色只在图形、图例点和数据标记中出现;面板标题、轴标签和说明仍使用全局中性色层级。
|
||||||
|
- **State:** 全屏模式回到冷灰工作面;数据未接入时必须明确标注为静态演示,不把占位数据伪装为实时数据。
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do:
|
||||||
|
|
||||||
|
- **Do** 优先复用 `--cc-primary`、`--cc-border`、`--cc-muted` 和既有白色面板结构。
|
||||||
|
- **Do** 用 1px 边线、浅层级灰和稳定间距组织密集数据。
|
||||||
|
- **Do** 为加载、空数据、错误、禁用和无权限状态保留清晰文字与可恢复操作。
|
||||||
|
- **Do** 在 720px 以下检查侧栏、筛选栏、表格、抽屉与按钮的触控和滚动退化。
|
||||||
|
- **Do** 使用 Lucide 图标,并为纯图形操作提供文本或无障碍名称。
|
||||||
|
|
||||||
|
### Don't:
|
||||||
|
|
||||||
|
- **Don't** 在普通业务面板上批量添加阴影、玻璃效果或装饰渐变。
|
||||||
|
- **Don't** 把图表分类色或状态色用于主操作、导航选中态或大面积背景。
|
||||||
|
- **Don't** 用表情符号充当功能图标,也不要只靠颜色表达状态。
|
||||||
|
- **Don't** 用超大标题、宽字距或低密度留白牺牲运营页面的扫描效率。
|
||||||
|
- **Don't** 把核心操作放到表格末列或右侧固定列。
|
||||||
@@ -35,7 +35,7 @@ pnpm test
|
|||||||
|
|
||||||
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
||||||
|
|
||||||
工作台已从一期菜单和默认入口移除;登录后按当前角色进入第一个有权限的业务页面。历史工作台仍保留为不展示菜单的原型路由。一期 Admin 正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
|
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。一期 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
|
||||||
|
|
||||||
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
|
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ API_PROXY_TARGET=http://8.163.30.128:9000
|
|||||||
|
|
||||||
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||||
|
|
||||||
仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。
|
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
|
||||||
|
|
||||||
头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、详情、新增、编辑、启停、标准模板下载和 XLSX 整批导入接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。内部用户管理仅维护平台员工账号,已接入服务端分页查询、关键词与状态筛选、新增、编辑、平台角色调整、启停、最后登录展示和密码重置接口;账号凭据仍由 Auth Service 独占保存。
|
头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、详情、新增、编辑、启停、标准模板下载和 XLSX 整批导入接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。内部用户管理仅维护平台员工账号,已接入服务端分页查询、关键词与状态筛选、新增、编辑、平台角色调整、启停、最后登录展示和密码重置接口;账号凭据仍由 Auth Service 独占保存。
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
|
|||||||
'platform:firstleg:inbound:list',
|
'platform:firstleg:inbound:list',
|
||||||
'platform:location:list',
|
'platform:location:list',
|
||||||
'platform:pallet:list',
|
'platform:pallet:list',
|
||||||
'firstleg:fba-pricing:rule:view',
|
|
||||||
'firstleg:fba-pricing:rate:view',
|
'firstleg:fba-pricing:rate:view',
|
||||||
'platform:express:list',
|
'platform:express:list',
|
||||||
'platform:counterparty:list',
|
'platform:counterparty:list',
|
||||||
@@ -49,11 +48,11 @@ describe('AdminSidebarMenu', () => {
|
|||||||
expect(groupTitles).toContain('供应商中心')
|
expect(groupTitles).toContain('供应商中心')
|
||||||
expect(groupTitles).toContain('系统管理')
|
expect(groupTitles).toContain('系统管理')
|
||||||
expect(groupTitles).not.toContain('财务管理')
|
expect(groupTitles).not.toContain('财务管理')
|
||||||
expect(paths).not.toContain('/dashboard')
|
expect(paths).toContain('/dashboard')
|
||||||
expect(paths).toContain('/inbounds')
|
expect(paths).toContain('/inbounds')
|
||||||
expect(paths).toContain('/master-data/express-companies')
|
expect(paths).toContain('/master-data/express-companies')
|
||||||
expect(paths).not.toContain('/orders/first-leg')
|
expect(paths).not.toContain('/orders/first-leg')
|
||||||
expect(paths).toContain('/first-leg-pricing/customer-rules')
|
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||||
expect(paths).toContain('/downloads')
|
expect(paths).toContain('/downloads')
|
||||||
expect(paths).not.toContain('/prototype/reports/overview')
|
expect(paths).not.toContain('/prototype/reports/overview')
|
||||||
expect(paths).toContain('/system/permissions')
|
expect(paths).toContain('/system/permissions')
|
||||||
@@ -76,7 +75,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('菜单权限')
|
expect(wrapper.text()).toContain('菜单权限')
|
||||||
expect(wrapper.text()).not.toContain('首页数据看板')
|
expect(wrapper.text()).toContain('首页数据看板')
|
||||||
expect(wrapper.text()).not.toContain('客户列表')
|
expect(wrapper.text()).not.toContain('客户列表')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
|
import WorkspaceTabs from './WorkspaceTabs.vue'
|
||||||
|
|
||||||
|
vi.mock('element-plus', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('element-plus')>()
|
||||||
|
return { ...actual, ElMessage: { error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountTabs() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.token = 'token'
|
||||||
|
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
|
||||||
|
auth.permissions = ['platform:customer:list', 'platform:warehouse:list']
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/customers', name: 'customers', component: { template: '<p />' }, meta: { requiresAuth: true, title: '客户管理' } },
|
||||||
|
{ path: '/warehouses', name: 'warehouses', component: { template: '<p />' }, meta: { requiresAuth: true, title: '仓库管理' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await router.push('/customers')
|
||||||
|
await router.isReady()
|
||||||
|
const workspace = useWorkspaceTabsStore()
|
||||||
|
workspace.initialize('1')
|
||||||
|
workspace.open({ id: 'warehouses', title: '仓库管理', path: '/warehouses' })
|
||||||
|
workspace.open({ id: 'customers', title: '客户管理', path: '/customers' })
|
||||||
|
const wrapper = mount(WorkspaceTabs, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } })
|
||||||
|
return { wrapper, router, workspace, unsaved: useUnsavedWorkStore() }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WorkspaceTabs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.mocked(ElMessageBox.confirm).mockReset()
|
||||||
|
})
|
||||||
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
|
|
||||||
|
it('keeps a dirty tab open when discard confirmation is cancelled', async () => {
|
||||||
|
const { wrapper, workspace, unsaved } = await mountTabs()
|
||||||
|
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||||
|
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||||
|
|
||||||
|
await wrapper.get('[aria-label="关闭页签:客户管理"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(workspace.tabs.map((tab) => tab.id)).toContain('customers')
|
||||||
|
expect(wrapper.find('.workspace-tab.is-dirty').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes confirmed dirty work and activates the most recent remaining tab', async () => {
|
||||||
|
const { wrapper, router, workspace, unsaved } = await mountTabs()
|
||||||
|
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||||
|
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||||
|
|
||||||
|
await wrapper.get('[aria-label="关闭页签:客户管理"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(workspace.tabs.map((tab) => tab.id)).toEqual(['warehouses'])
|
||||||
|
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||||
|
expect(unsaved.findForWorkspace('customers')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the mouse wheel to scroll an overflowing tab list horizontally', async () => {
|
||||||
|
const { wrapper } = await mountTabs()
|
||||||
|
const tabList = wrapper.get<HTMLElement>('.workspace-tab-list')
|
||||||
|
Object.defineProperties(tabList.element, {
|
||||||
|
clientWidth: { configurable: true, value: 320 },
|
||||||
|
scrollWidth: { configurable: true, value: 720 },
|
||||||
|
})
|
||||||
|
|
||||||
|
await tabList.trigger('wheel', { deltaY: 120 })
|
||||||
|
|
||||||
|
expect(tabList.element.scrollLeft).toBe(120)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { MoreHorizontal, X } from '@lucide/vue'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
|
import { workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
|
||||||
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
|
|
||||||
|
type CloseCommand = 'current' | 'others' | 'right' | 'all'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const access = useAccessPreviewStore()
|
||||||
|
const unsaved = useUnsavedWorkStore()
|
||||||
|
const workspace = useWorkspaceTabsStore()
|
||||||
|
const tabListRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const activeIndex = computed(() => workspace.tabs.findIndex((tab) => tab.id === workspace.activeId))
|
||||||
|
const hasRightTabs = computed(() => activeIndex.value >= 0 && activeIndex.value < workspace.tabs.length - 1)
|
||||||
|
|
||||||
|
function blockingEntries(tabIds: readonly string[]) {
|
||||||
|
return tabIds.flatMap((tabId) => unsaved.findForWorkspace(tabId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDiscard(tabIds: readonly string[]) {
|
||||||
|
const blocking = blockingEntries(tabIds)
|
||||||
|
if (blocking.length === 0) return true
|
||||||
|
const labels = [...new Set(blocking.map((entry) => entry.label))]
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
|
||||||
|
'放弃未保存的修改?',
|
||||||
|
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning' },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusActiveTab() {
|
||||||
|
await nextTick()
|
||||||
|
const active = tabListRef.value?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
||||||
|
active?.focus({ preventScroll: true })
|
||||||
|
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToFallback(path: string | null) {
|
||||||
|
const destination = path || access.firstAccessiblePath
|
||||||
|
if (!destination) {
|
||||||
|
await router.push('/403')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (destination === route.fullPath) {
|
||||||
|
const candidate = workspaceTabCandidate(route)
|
||||||
|
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
|
||||||
|
await focusActiveTab()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await router.push(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTabs(tabIds: readonly string[]) {
|
||||||
|
const uniqueIds = [...new Set(tabIds)].filter((id) => workspace.tabs.some((tab) => tab.id === id))
|
||||||
|
if (uniqueIds.length === 0 || !await confirmDiscard(uniqueIds)) return
|
||||||
|
const activeWillClose = workspace.activeId !== null && uniqueIds.includes(workspace.activeId)
|
||||||
|
unsaved.removeWorkspaces(uniqueIds)
|
||||||
|
const fallback = workspace.removeByIds(uniqueIds)
|
||||||
|
if (activeWillClose) await navigateToFallback(fallback?.path ?? null)
|
||||||
|
else await focusActiveTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateTab(path: string) {
|
||||||
|
if (path === route.fullPath) return
|
||||||
|
try {
|
||||||
|
await router.push(path)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('页签切换失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function idsForCommand(command: CloseCommand) {
|
||||||
|
if (command === 'all') return workspace.tabs.map((tab) => tab.id)
|
||||||
|
if (command === 'others') return workspace.tabs.filter((tab) => tab.id !== workspace.activeId).map((tab) => tab.id)
|
||||||
|
if (command === 'right') return workspace.tabs.slice(activeIndex.value + 1).map((tab) => tab.id)
|
||||||
|
return workspace.activeId ? [workspace.activeId] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommand(command: CloseCommand) {
|
||||||
|
void closeTabs(idsForCommand(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabKeydown(event: KeyboardEvent, index: number) {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||||
|
event.preventDefault()
|
||||||
|
const buttons = [...(tabListRef.value?.querySelectorAll<HTMLElement>('[role="tab"]') ?? [])]
|
||||||
|
if (buttons.length === 0) return
|
||||||
|
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||||
|
buttons[(index + direction + buttons.length) % buttons.length]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabWheel(event: WheelEvent) {
|
||||||
|
const tabList = tabListRef.value
|
||||||
|
if (!tabList || tabList.scrollWidth <= tabList.clientWidth) return
|
||||||
|
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
||||||
|
if (delta === 0) return
|
||||||
|
const maxScrollLeft = tabList.scrollWidth - tabList.clientWidth
|
||||||
|
const canScroll = delta < 0 ? tabList.scrollLeft > 0 : tabList.scrollLeft < maxScrollLeft
|
||||||
|
if (!canScroll) return
|
||||||
|
event.preventDefault()
|
||||||
|
tabList.scrollLeft = Math.max(0, Math.min(maxScrollLeft, tabList.scrollLeft + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => workspace.activeId,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
const active = tabListRef.value?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||||
|
active?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="workspace-tabs" aria-label="页面工作区">
|
||||||
|
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
|
||||||
|
<div
|
||||||
|
v-for="(tab, index) in workspace.tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="workspace-tab"
|
||||||
|
:class="{ 'is-active': tab.id === workspace.activeId, 'is-dirty': unsaved.dirtyWorkspaceIds.has(tab.id) }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="workspace-tab-activate"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="tab.id === workspace.activeId"
|
||||||
|
:tabindex="tab.id === workspace.activeId ? 0 : -1"
|
||||||
|
:title="tab.title"
|
||||||
|
@click="activateTab(tab.path)"
|
||||||
|
@keydown="handleTabKeydown($event, index)"
|
||||||
|
>
|
||||||
|
<span class="workspace-tab-label">{{ tab.title }}</span>
|
||||||
|
<span v-if="unsaved.dirtyWorkspaceIds.has(tab.id)" class="workspace-tab-dirty" aria-label="有未保存修改" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="workspace-tab-close"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`关闭页签:${tab.title}`"
|
||||||
|
:title="`关闭 ${tab.title}`"
|
||||||
|
@click="closeTabs([tab.id])"
|
||||||
|
><X :size="14" aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
|
||||||
|
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
|
||||||
|
<MoreHorizontal :size="18" aria-hidden="true" />
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="current" :disabled="!workspace.activeId">关闭当前</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="others" :disabled="workspace.tabs.length <= 1">关闭其他</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="right" :disabled="!hasRightTabs">关闭右侧</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="all" :disabled="workspace.tabs.length === 0" divided>关闭全部</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workspace-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 64px;
|
||||||
|
z-index: 14;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
height: 44px;
|
||||||
|
align-items: stretch;
|
||||||
|
border-bottom: 1px solid var(--cc-border);
|
||||||
|
background: #f7f9fc;
|
||||||
|
box-shadow: 0 5px 14px rgba(23, 42, 70, .045);
|
||||||
|
}
|
||||||
|
.workspace-tab-list {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 6px 8px 0;
|
||||||
|
overscroll-behavior-inline: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.workspace-tab-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||||
|
.workspace-tab {
|
||||||
|
display: flex;
|
||||||
|
min-width: 126px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: 38px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
color: #526178;
|
||||||
|
}
|
||||||
|
.workspace-tab:hover { background: #eef3fa; color: #24344d; }
|
||||||
|
.workspace-tab.is-active { border-color: var(--cc-border); background: #fff; color: #172033; }
|
||||||
|
.workspace-tab-activate {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 4px 0 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.workspace-tab-label { overflow: hidden; font-size: 12px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.workspace-tab-dirty { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #d97706; }
|
||||||
|
.workspace-tab-close {
|
||||||
|
display: grid;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
place-items: center;
|
||||||
|
margin-right: 3px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #7b8799;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.workspace-tab-close:hover { background: #dde6f2; color: #26364d; }
|
||||||
|
.workspace-tab-activate:focus-visible,
|
||||||
|
.workspace-tab-close:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
||||||
|
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--cc-border); color: #526178; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.workspace-tabs { top: 64px; height: 48px; }
|
||||||
|
.workspace-tab-list { align-items: center; padding: 4px 6px; }
|
||||||
|
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
|
||||||
|
.workspace-tab.is-active { border-color: #cbd7e7; }
|
||||||
|
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
|
||||||
|
.workspace-tab-more { height: 48px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.workspace-tab-list { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, ref } from 'vue'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import AppLayout from './AppLayout.vue'
|
import AppLayout from './AppLayout.vue'
|
||||||
|
|
||||||
|
const CachedPage = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const count = ref(0)
|
||||||
|
return { count }
|
||||||
|
},
|
||||||
|
template: '<button class="cached-count" @click="count += 1">{{ count }}</button>',
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('element-plus', async (importOriginal) => {
|
vi.mock('element-plus', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('element-plus')>()
|
const actual = await importOriginal<typeof import('element-plus')>()
|
||||||
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
||||||
@@ -21,15 +30,16 @@ async function mountLayout(path = '/') {
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', component: { template: '<p>page</p>' } },
|
{ path: '/', name: 'home', component: { template: '<p>page</p>' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } },
|
||||||
{ path: '/customers', component: { template: '<p>customers</p>' }, meta: { title: '客户管理', showPageHeading: false } },
|
{ path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } },
|
||||||
|
{ path: '/warehouses', name: 'warehouses', component: { template: '<p>warehouses</p>' }, meta: { title: '仓库管理', requiresAuth: true } },
|
||||||
{ path: '/login', component: { template: '<p>login</p>' } },
|
{ path: '/login', component: { template: '<p>login</p>' } },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
await router.push(path)
|
await router.push(path)
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
const wrapper = mount(AppLayout, {
|
const wrapper = mount(AppLayout, {
|
||||||
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||||
})
|
})
|
||||||
return { wrapper, router }
|
return { wrapper, router }
|
||||||
}
|
}
|
||||||
@@ -56,4 +66,18 @@ describe('AppLayout', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(router.currentRoute.value.path).toBe('/login')
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens business routes as workspace tabs and keeps route component state while switching', async () => {
|
||||||
|
const { wrapper, router } = await mountLayout('/customers')
|
||||||
|
expect(wrapper.find('.workspace-tabs-stub').exists()).toBe(true)
|
||||||
|
await wrapper.get('.cached-count').trigger('click')
|
||||||
|
expect(wrapper.get('.cached-count').text()).toBe('1')
|
||||||
|
|
||||||
|
await router.push('/warehouses')
|
||||||
|
await flushPromises()
|
||||||
|
await router.push('/customers')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('.cached-count').text()).toBe('1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import { getApiErrorDetails } from '@/shared/api/http'
|
|||||||
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
|
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
|
||||||
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
|
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
|
||||||
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
|
import AdminSidebarMenu from '@/app/components/AdminSidebarMenu.vue'
|
||||||
|
import WorkspaceTabs from '@/app/components/WorkspaceTabs.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
|
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
|
||||||
|
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
const unsaved = useUnsavedWorkStore()
|
const unsaved = useUnsavedWorkStore()
|
||||||
|
const workspace = useWorkspaceTabsStore()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||||
@@ -24,10 +28,38 @@ const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
|||||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||||
|
|
||||||
access.initializeAfterLogin()
|
access.initializeAfterLogin()
|
||||||
|
workspace.initialize(auth.user?.id || auth.user?.account || '')
|
||||||
|
|
||||||
|
function syncCurrentRoute() {
|
||||||
|
const candidate = workspaceTabCandidate(route)
|
||||||
|
if (candidate) workspace.open(candidate, unsaved.dirtyWorkspaceIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pruneInaccessibleTabs() {
|
||||||
|
const inaccessible = workspace.tabs.filter((tab) => !canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
|
||||||
|
const discardedDirtyWork = inaccessible.flatMap((tab) => unsaved.findForWorkspace(tab.id))
|
||||||
|
if (discardedDirtyWork.length) {
|
||||||
|
await ElMessageBox.alert(
|
||||||
|
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
|
||||||
|
'权限已变化',
|
||||||
|
{ confirmButtonText: '知道了', type: 'warning' },
|
||||||
|
).catch(() => undefined)
|
||||||
|
}
|
||||||
|
const previousActiveId = workspace.activeId
|
||||||
|
const result = workspace.prune((tab) => canAccessWorkspaceRoute(router.resolve(tab.path), access.permissionCodes))
|
||||||
|
if (result.removedIds.length === 0) return
|
||||||
|
unsaved.removeWorkspaces(result.removedIds)
|
||||||
|
if (!previousActiveId || !result.removedIds.includes(previousActiveId)) return
|
||||||
|
const destination = result.fallback?.path || access.firstAccessiblePath || '/403'
|
||||||
|
if (destination !== route.fullPath) await router.replace(destination)
|
||||||
|
else syncCurrentRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
|
const dirtyLabels = unsaved.entries.filter((entry) => entry.dirty).map((entry) => entry.label)
|
||||||
|
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
|
||||||
|
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
|
||||||
confirmButtonText: '退出',
|
confirmButtonText: '退出',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
@@ -37,6 +69,7 @@ async function handleLogout() {
|
|||||||
|
|
||||||
access.reset()
|
access.reset()
|
||||||
unsaved.clear()
|
unsaved.clear()
|
||||||
|
workspace.reset(true)
|
||||||
try {
|
try {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -59,7 +92,17 @@ function handleMenuToggle() {
|
|||||||
collapsed.value = !collapsed.value
|
collapsed.value = !collapsed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
watch(() => route.fullPath, () => {
|
||||||
|
mobileMenuOpen.value = false
|
||||||
|
syncCurrentRoute()
|
||||||
|
}, { immediate: true })
|
||||||
|
watch(() => access.permissionSignature, () => { void pruneInaccessibleTabs() }, { immediate: true })
|
||||||
|
watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) => {
|
||||||
|
if (!userId || userId === previousUserId) return
|
||||||
|
workspace.reset(false)
|
||||||
|
workspace.initialize(userId)
|
||||||
|
void pruneInaccessibleTabs().then(syncCurrentRoute)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -91,7 +134,19 @@ watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
|||||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="page-container"><RouterView /></main>
|
<WorkspaceTabs v-if="workspace.tabs.length" />
|
||||||
|
<main class="page-container">
|
||||||
|
<RouterView v-slot="{ Component, route: renderedRoute }">
|
||||||
|
<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">
|
||||||
|
<component
|
||||||
|
:is="Component"
|
||||||
|
v-if="renderedRoute.meta.keepAlive !== false"
|
||||||
|
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
|
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
||||||
|
</RouterView>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { adminNavigation, getNavigationLeaves } from '@/core/navigation/adminNav
|
|||||||
import router from './index'
|
import router from './index'
|
||||||
|
|
||||||
describe('admin router access metadata', () => {
|
describe('admin router access metadata', () => {
|
||||||
it('uses the root route only as a permission-aware entry point while retaining the dashboard prototype', () => {
|
it('uses the root route as a permission-aware entry point and registers the formal dashboard', () => {
|
||||||
const home = router.getRoutes().find((route) => route.name === 'home')
|
const home = router.getRoutes().find((route) => route.name === 'home')
|
||||||
|
|
||||||
expect(home?.path).toBe('/')
|
expect(home?.path).toBe('/')
|
||||||
expect(home?.meta.permission).toBeUndefined()
|
expect(home?.meta.permission).toBeUndefined()
|
||||||
expect(router.getRoutes().some((route) => route.name === 'dashboard')).toBe(true)
|
const dashboard = router.getRoutes().find((route) => route.name === 'dashboard')
|
||||||
|
expect(dashboard?.path).toBe('/dashboard')
|
||||||
|
expect(dashboard?.meta.permission).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('protects warehouse management with its stable list permission', () => {
|
it('protects warehouse management with its stable list permission', () => {
|
||||||
@@ -22,7 +24,6 @@ describe('admin router access metadata', () => {
|
|||||||
const expected = [
|
const expected = [
|
||||||
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
|
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
|
||||||
['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'],
|
['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'],
|
||||||
['fba-customer-rules', '/first-leg-pricing/customer-rules', 'firstleg:fba-pricing:rule:view'],
|
|
||||||
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
|
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
|
||||||
['locations', '/master-data/locations', 'platform:location:list'],
|
['locations', '/master-data/locations', 'platform:location:list'],
|
||||||
['pallets', '/master-data/pallets', 'platform:pallet:list'],
|
['pallets', '/master-data/pallets', 'platform:pallet:list'],
|
||||||
@@ -35,6 +36,14 @@ describe('admin router access metadata', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not register removed duplicate pricing and FBA placeholder pages', () => {
|
||||||
|
const removedRouteNames = ['fba-customer-rules', 'general-fba', 'general-voyage']
|
||||||
|
|
||||||
|
for (const name of removedRouteNames) {
|
||||||
|
expect(router.getRoutes().some((route) => route.name === name)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('protects express company records with a stable master-data permission', () => {
|
it('protects express company records with a stable master-data permission', () => {
|
||||||
const route = router.getRoutes().find((item) => item.name === 'express-companies')
|
const route = router.getRoutes().find((item) => item.name === 'express-companies')
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -1,4 +1,5 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
import AppLayout from '@/app/layouts/AppLayout.vue'
|
import AppLayout from '@/app/layouts/AppLayout.vue'
|
||||||
import { decideRouteAccess } from '@/core/routing/accessDecision'
|
import { decideRouteAccess } from '@/core/routing/accessDecision'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
@@ -10,7 +11,11 @@ import { iamRoutes } from '@/modules/iam/routes'
|
|||||||
import { masterDataRoutes } from '@/modules/master-data/routes'
|
import { masterDataRoutes } from '@/modules/master-data/routes'
|
||||||
import { warehouseResourceRoutes } from '@/modules/warehouse-resources/routes'
|
import { warehouseResourceRoutes } from '@/modules/warehouse-resources/routes'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
|
import { canAccessWorkspaceRoute, isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
|
||||||
|
import { readStoredActiveWorkspacePath, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
import { customerRoutes } from '@/modules/customers/routes'
|
import { customerRoutes } from '@/modules/customers/routes'
|
||||||
|
import { dashboardRoutes } from '@/modules/dashboard/routes'
|
||||||
import { warehouseRoutes } from '@/modules/warehouses/routes'
|
import { warehouseRoutes } from '@/modules/warehouses/routes'
|
||||||
import { prototypeModuleRoutes } from '@/prototype/routes'
|
import { prototypeModuleRoutes } from '@/prototype/routes'
|
||||||
|
|
||||||
@@ -32,8 +37,9 @@ const router = createRouter({
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: () => import('@/app/pages/ModuleView.vue'),
|
component: () => import('@/app/pages/ModuleView.vue'),
|
||||||
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' },
|
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面', workspaceTab: false, keepAlive: false },
|
||||||
},
|
},
|
||||||
|
...dashboardRoutes,
|
||||||
...prototypeModuleRoutes,
|
...prototypeModuleRoutes,
|
||||||
...customerRoutes,
|
...customerRoutes,
|
||||||
...warehouseRoutes,
|
...warehouseRoutes,
|
||||||
@@ -48,7 +54,7 @@ const router = createRouter({
|
|||||||
path: '403',
|
path: '403',
|
||||||
name: 'forbidden',
|
name: 'forbidden',
|
||||||
component: () => import('@/app/pages/ForbiddenView.vue'),
|
component: () => import('@/app/pages/ForbiddenView.vue'),
|
||||||
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限' },
|
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限', workspaceTab: false, keepAlive: false },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -56,6 +62,29 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from) => {
|
||||||
|
const fromId = workspaceRouteId(from)
|
||||||
|
if (!fromId || to.fullPath === from.fullPath) return true
|
||||||
|
const isNonDestructiveWorkspaceSwitch = isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId
|
||||||
|
if (isNonDestructiveWorkspaceSwitch) return true
|
||||||
|
|
||||||
|
const unsaved = useUnsavedWorkStore()
|
||||||
|
const blocking = unsaved.findForWorkspace(fromId)
|
||||||
|
if (blocking.length === 0) return true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
|
||||||
|
'放弃未保存的修改?',
|
||||||
|
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning' },
|
||||||
|
)
|
||||||
|
unsaved.removeWorkspaces([fromId])
|
||||||
|
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流管理后台` : '柏悦物流管理后台'
|
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流管理后台` : '柏悦物流管理后台'
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -87,6 +116,13 @@ router.beforeEach((to) => {
|
|||||||
return { name: 'forbidden', query: { from: decision.from }, replace: true }
|
return { name: 'forbidden', query: { from: decision.from }, replace: true }
|
||||||
}
|
}
|
||||||
if (to.name === 'home') {
|
if (to.name === 'home') {
|
||||||
|
const restoredPath = auth.user?.id ? readStoredActiveWorkspacePath(auth.user.id) : null
|
||||||
|
if (restoredPath) {
|
||||||
|
const restoredRoute = router.resolve(restoredPath)
|
||||||
|
if (canAccessWorkspaceRoute(restoredRoute, access.permissionCodes)) {
|
||||||
|
return { path: restoredPath, replace: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
return access.firstAccessiblePath
|
return access.firstAccessiblePath
|
||||||
? { path: access.firstAccessiblePath, replace: true }
|
? { path: access.firstAccessiblePath, replace: true }
|
||||||
: { name: 'forbidden', replace: true }
|
: { name: 'forbidden', replace: true }
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ describe('server access store', () => {
|
|||||||
|
|
||||||
expect(access.can('platform:warehouse:list')).toBe(true)
|
expect(access.can('platform:warehouse:list')).toBe(true)
|
||||||
expect(access.can('platform:user:list')).toBe(false)
|
expect(access.can('platform:user:list')).toBe(false)
|
||||||
expect(access.firstAccessiblePath).toBe('/warehouses')
|
expect(access.firstAccessiblePath).toBe('/dashboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes no permissions when the server snapshot is empty', () => {
|
it('keeps only the unprotected dashboard when the server snapshot is empty', () => {
|
||||||
const access = useAccessPreviewStore()
|
const access = useAccessPreviewStore()
|
||||||
expect(access.permissionCodes.size).toBe(0)
|
expect(access.permissionCodes.size).toBe(0)
|
||||||
expect(access.filteredNavigation).toHaveLength(0)
|
expect(access.filteredNavigation).toHaveLength(1)
|
||||||
expect(access.firstAccessiblePath).toBeNull()
|
expect(access.firstAccessiblePath).toBe('/dashboard')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,20 +53,17 @@ describe('adminNavigation', () => {
|
|||||||
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
|
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
|
||||||
'产品信息',
|
'产品信息',
|
||||||
'费用项目',
|
'费用项目',
|
||||||
'FBA编码地址',
|
'仓库管理',
|
||||||
'船期价格表',
|
'船期价格表',
|
||||||
'车辆列表',
|
'车辆列表',
|
||||||
'仓库管理',
|
|
||||||
'头程订单',
|
|
||||||
'头程计费',
|
|
||||||
'下载中心',
|
'下载中心',
|
||||||
'菜单权限',
|
|
||||||
])
|
])
|
||||||
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
|
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
|
||||||
'用户管理',
|
'用户管理',
|
||||||
'角色权限',
|
'角色权限',
|
||||||
'租户管理',
|
'租户管理',
|
||||||
'品牌设置',
|
'品牌设置',
|
||||||
|
'菜单权限',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,8 +73,8 @@ describe('adminNavigation', () => {
|
|||||||
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
|
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
|
||||||
|
|
||||||
expect(paths).toContain('/dashboard')
|
expect(paths).toContain('/dashboard')
|
||||||
expect(paths).toContain('/orders/first-leg')
|
expect(paths).not.toContain('/orders/first-leg')
|
||||||
expect(paths).toContain('/first-leg-pricing/customer-rules')
|
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||||
expect(paths).toContain('/downloads')
|
expect(paths).toContain('/downloads')
|
||||||
expect(paths).toContain('/prototype/finance/profit-report')
|
expect(paths).toContain('/prototype/finance/profit-report')
|
||||||
expect(paths).toContain('/prototype/system-management/brand-settings')
|
expect(paths).toContain('/prototype/system-management/brand-settings')
|
||||||
@@ -95,9 +92,9 @@ describe('adminNavigation', () => {
|
|||||||
expect(paths).toContain('/system/permissions')
|
expect(paths).toContain('/system/permissions')
|
||||||
expect(paths).not.toContain('/prototype/finance/profit-report')
|
expect(paths).not.toContain('/prototype/finance/profit-report')
|
||||||
expect(paths).not.toContain('/customers')
|
expect(paths).not.toContain('/customers')
|
||||||
expect(filtered.map((item) => item.title)).toEqual(['通用'])
|
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '系统管理'])
|
||||||
expect(filtered).not.toBe(adminNavigation)
|
expect(filtered).not.toBe(adminNavigation)
|
||||||
expect(filtered[0]).not.toBe(adminNavigation.find((item) => item.key === 'general'))
|
expect(filtered[0]).not.toBe(adminNavigation.find((item) => item.key === 'system'))
|
||||||
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
|
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,14 +107,15 @@ describe('adminNavigation', () => {
|
|||||||
]))
|
]))
|
||||||
const paths = getNavigationLeaves(filtered).map((item) => item.path)
|
const paths = getNavigationLeaves(filtered).map((item) => item.path)
|
||||||
|
|
||||||
expect(filtered.map((item) => item.title)).toEqual(['国内仓', '供应商中心', '通用'])
|
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
|
||||||
expect(paths).toEqual([
|
expect(paths).toEqual([
|
||||||
|
'/dashboard',
|
||||||
'/inbounds',
|
'/inbounds',
|
||||||
'/master-data/express-companies',
|
'/master-data/express-companies',
|
||||||
'/master-data/counterparties',
|
'/master-data/counterparties',
|
||||||
'/warehouses',
|
'/warehouses',
|
||||||
])
|
])
|
||||||
expect(paths).not.toContain('/dashboard')
|
expect(paths).toContain('/dashboard')
|
||||||
expect(paths).not.toContain('/orders/first-leg')
|
expect(paths).not.toContain('/orders/first-leg')
|
||||||
expect(paths).not.toContain('/prototype/finance/profit-report')
|
expect(paths).not.toContain('/prototype/finance/profit-report')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
|
BarChart3,
|
||||||
ContactRound,
|
ContactRound,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
@@ -35,7 +36,7 @@ export interface AdminNavigationLeaf extends AdminNavigationNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const adminNavigation: AdminNavigationNode[] = [
|
export const adminNavigation: AdminNavigationNode[] = [
|
||||||
{ key: 'dashboard', title: '首页数据看板', icon: ClipboardList, path: '/dashboard', hidden: true },
|
{ key: 'dashboard', title: '首页数据看板', icon: BarChart3, path: '/dashboard' },
|
||||||
{
|
{
|
||||||
key: 'customers',
|
key: 'customers',
|
||||||
title: '客户管理',
|
title: '客户管理',
|
||||||
@@ -159,22 +160,12 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
icon: Database,
|
icon: Database,
|
||||||
iconColor: '#722ed1',
|
iconColor: '#722ed1',
|
||||||
children: [
|
children: [
|
||||||
// { key: 'general-fba', title: 'FBA编码地址', icon: Database, path: '/prototype/general/fba-address', hidden: true },
|
|
||||||
// { key: 'general-voyage', title: '船期价格表', icon: Database, path: '/prototype/general/voyage-price-list', hidden: true },
|
|
||||||
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
|
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
|
||||||
// {
|
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
|
||||||
// key: 'first-leg-pricing',
|
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items', hidden: true },
|
||||||
// title: '头程计费',
|
|
||||||
// icon: CircleDollarSign,
|
|
||||||
// children: [
|
|
||||||
// { key: 'fba-customer-rules', title: '客户计费规则', icon: Database, path: '/first-leg-pricing/customer-rules', permission: 'firstleg:fba-pricing:rule:view' },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: false },
|
|
||||||
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items', hidden: false },
|
|
||||||
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
||||||
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
||||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: false },
|
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: true },
|
||||||
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,27 @@ describe('unsaved work store', () => {
|
|||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not let an obsolete component remove or overwrite a reclaimed registration', () => {
|
||||||
|
const store = useUnsavedWorkStore()
|
||||||
|
const oldOwner = Symbol('old')
|
||||||
|
const newOwner = Symbol('new')
|
||||||
|
store.claim('customer-form', oldOwner)
|
||||||
|
store.upsert({ key: 'customer-form', label: '旧页面', dirty: true, requiredPermission: null }, oldOwner)
|
||||||
|
|
||||||
|
store.claim('customer-form', newOwner)
|
||||||
|
store.upsert({ key: 'customer-form', label: '新页面', dirty: true, requiredPermission: null }, newOwner)
|
||||||
|
store.remove('customer-form', oldOwner)
|
||||||
|
store.release('customer-form', oldOwner)
|
||||||
|
store.upsert({ key: 'customer-form', label: '过期页面', dirty: false, requiredPermission: null }, oldOwner)
|
||||||
|
|
||||||
|
expect(store.entries).toEqual([{
|
||||||
|
key: 'customer-form',
|
||||||
|
label: '新页面',
|
||||||
|
dirty: true,
|
||||||
|
requiredPermission: null,
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
|
||||||
it('clears every registered entry', () => {
|
it('clears every registered entry', () => {
|
||||||
const store = useUnsavedWorkStore()
|
const store = useUnsavedWorkStore()
|
||||||
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
|
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
|
||||||
@@ -80,4 +101,16 @@ describe('unsaved work store', () => {
|
|||||||
|
|
||||||
expect(store.entries).toEqual([])
|
expect(store.entries).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('finds and removes dirty work owned by a workspace tab', () => {
|
||||||
|
const store = useUnsavedWorkStore()
|
||||||
|
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||||
|
store.upsert({ key: 'warehouse-form', label: '仓库资料', dirty: true, requiredPermission: null, workspaceTabId: 'warehouses' })
|
||||||
|
|
||||||
|
expect(store.findForWorkspace('customers').map((entry) => entry.key)).toEqual(['customer-form'])
|
||||||
|
expect(store.dirtyWorkspaceIds).toEqual(new Set(['customers', 'warehouses']))
|
||||||
|
|
||||||
|
store.removeWorkspaces(['customers'])
|
||||||
|
expect(store.entries.map((entry) => entry.key)).toEqual(['warehouse-form'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
export interface UnsavedWorkEntry {
|
export interface UnsavedWorkEntry {
|
||||||
@@ -6,12 +6,19 @@ export interface UnsavedWorkEntry {
|
|||||||
label: string
|
label: string
|
||||||
dirty: boolean
|
dirty: boolean
|
||||||
requiredPermission: string | null
|
requiredPermission: string | null
|
||||||
|
workspaceTabId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
|
export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
|
||||||
const entries = ref<UnsavedWorkEntry[]>([])
|
const entries = ref<UnsavedWorkEntry[]>([])
|
||||||
|
const owners = new Map<string, symbol>()
|
||||||
|
|
||||||
function upsert(entry: UnsavedWorkEntry) {
|
function claim(key: string, owner: symbol) {
|
||||||
|
owners.set(key, owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsert(entry: UnsavedWorkEntry, owner?: symbol) {
|
||||||
|
if (owner && owners.get(entry.key) !== owner) return
|
||||||
const index = entries.value.findIndex((item) => item.key === entry.key)
|
const index = entries.value.findIndex((item) => item.key === entry.key)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
entries.value.push(entry)
|
entries.value.push(entry)
|
||||||
@@ -20,7 +27,16 @@ export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
|
|||||||
entries.value[index] = entry
|
entries.value[index] = entry
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(key: string) {
|
function remove(key: string, owner?: symbol) {
|
||||||
|
if (owner && owners.get(key) !== owner) return
|
||||||
|
if (!owner) owners.delete(key)
|
||||||
|
const index = entries.value.findIndex((item) => item.key === key)
|
||||||
|
if (index !== -1) entries.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(key: string, owner: symbol) {
|
||||||
|
if (owners.get(key) !== owner) return
|
||||||
|
owners.delete(key)
|
||||||
const index = entries.value.findIndex((item) => item.key === key)
|
const index = entries.value.findIndex((item) => item.key === key)
|
||||||
if (index !== -1) entries.value.splice(index, 1)
|
if (index !== -1) entries.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
@@ -32,9 +48,28 @@ export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findForWorkspace(workspaceTabId: string) {
|
||||||
|
return entries.value.filter((entry) => entry.dirty && entry.workspaceTabId === workspaceTabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWorkspaces(workspaceTabIds: readonly string[]) {
|
||||||
|
const ids = new Set(workspaceTabIds)
|
||||||
|
entries.value
|
||||||
|
.filter((entry) => entry.workspaceTabId && ids.has(entry.workspaceTabId))
|
||||||
|
.forEach((entry) => owners.delete(entry.key))
|
||||||
|
entries.value = entries.value.filter((entry) => !entry.workspaceTabId || !ids.has(entry.workspaceTabId))
|
||||||
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
|
owners.clear()
|
||||||
entries.value = []
|
entries.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entries, upsert, remove, findBlocking, clear }
|
const dirtyWorkspaceIds = computed<ReadonlySet<string>>(() => new Set(
|
||||||
|
entries.value
|
||||||
|
.filter((entry) => entry.dirty && entry.workspaceTabId)
|
||||||
|
.map((entry) => entry.workspaceTabId as string),
|
||||||
|
))
|
||||||
|
|
||||||
|
return { entries, dirtyWorkspaceIds, claim, upsert, remove, release, findBlocking, findForWorkspace, removeWorkspaces, clear }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ describe('useUnsavedWorkRegistration', () => {
|
|||||||
label: '新增客户',
|
label: '新增客户',
|
||||||
dirty: true,
|
dirty: true,
|
||||||
requiredPermission: 'customer:update',
|
requiredPermission: 'customer:update',
|
||||||
|
workspaceTabId: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
harness.model.name = ''
|
harness.model.name = ''
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
|
import { computed, getCurrentInstance, onActivated, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
|
|
||||||
interface UnsavedWorkRegistrationOptions<T extends object> {
|
interface UnsavedWorkRegistrationOptions<T extends object> {
|
||||||
@@ -13,12 +13,16 @@ export function useUnsavedWorkRegistration<T extends object>(
|
|||||||
options: UnsavedWorkRegistrationOptions<T>,
|
options: UnsavedWorkRegistrationOptions<T>,
|
||||||
) {
|
) {
|
||||||
const store = useUnsavedWorkStore()
|
const store = useUnsavedWorkStore()
|
||||||
|
const routeName = getCurrentInstance()?.proxy?.$route?.name
|
||||||
|
const workspaceTabId = routeName === undefined || routeName === null ? null : String(routeName)
|
||||||
|
const owner = Symbol(options.key)
|
||||||
const snapshot = computed(() => JSON.stringify(options.model))
|
const snapshot = computed(() => JSON.stringify(options.model))
|
||||||
const baseline = ref(snapshot.value)
|
const baseline = ref(snapshot.value)
|
||||||
|
store.claim(options.key, owner)
|
||||||
|
|
||||||
function sync() {
|
function sync() {
|
||||||
if (!options.isOpen.value) {
|
if (!options.isOpen.value) {
|
||||||
store.remove(options.key)
|
store.remove(options.key, owner)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +31,8 @@ export function useUnsavedWorkRegistration<T extends object>(
|
|||||||
label: options.label.value,
|
label: options.label.value,
|
||||||
dirty: snapshot.value !== baseline.value,
|
dirty: snapshot.value !== baseline.value,
|
||||||
requiredPermission: options.requiredPermission.value,
|
requiredPermission: options.requiredPermission.value,
|
||||||
})
|
workspaceTabId,
|
||||||
|
}, owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureBaseline() {
|
function captureBaseline() {
|
||||||
@@ -41,7 +46,8 @@ export function useUnsavedWorkRegistration<T extends object>(
|
|||||||
{ flush: 'sync' },
|
{ flush: 'sync' },
|
||||||
)
|
)
|
||||||
sync()
|
sync()
|
||||||
onScopeDispose(() => store.remove(options.key))
|
onActivated(sync)
|
||||||
|
onScopeDispose(() => store.release(options.key, owner))
|
||||||
|
|
||||||
return { captureBaseline }
|
return { captureBaseline }
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import 'vue-router'
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
requiresAuth?: boolean
|
||||||
|
permission?: string
|
||||||
|
permissionAnyOf?: string[]
|
||||||
|
showPageHeading?: boolean
|
||||||
|
workspaceTab?: boolean
|
||||||
|
keepAlive?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RouteLocationNormalizedLoaded, RouteLocationResolved } from 'vue-router'
|
||||||
|
import type { WorkspaceTabCandidate } from './types'
|
||||||
|
|
||||||
|
type WorkspaceRoute = RouteLocationNormalizedLoaded | RouteLocationResolved
|
||||||
|
|
||||||
|
export function workspaceRouteId(route: WorkspaceRoute) {
|
||||||
|
return route.name === undefined || route.name === null ? null : String(route.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceRoute(route: WorkspaceRoute) {
|
||||||
|
return Boolean(
|
||||||
|
workspaceRouteId(route)
|
||||||
|
&& route.meta.workspaceTab !== false
|
||||||
|
&& route.matched.some((record) => record.meta.requiresAuth),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessWorkspaceRoute(route: WorkspaceRoute, permissions: ReadonlySet<string>) {
|
||||||
|
if (!isWorkspaceRoute(route)) return false
|
||||||
|
const permission = typeof route.meta.permission === 'string' ? route.meta.permission : undefined
|
||||||
|
const alternatives = route.meta.permissionAnyOf ?? []
|
||||||
|
if (!permission && alternatives.length === 0) return true
|
||||||
|
return Boolean(
|
||||||
|
(permission && permissions.has(permission))
|
||||||
|
|| alternatives.some((code) => permissions.has(code)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceTabCandidate(route: WorkspaceRoute): WorkspaceTabCandidate | null {
|
||||||
|
const id = workspaceRouteId(route)
|
||||||
|
if (!id || !isWorkspaceRoute(route)) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: typeof route.meta.title === 'string' && route.meta.title.trim() ? route.meta.title.trim() : '未命名页面',
|
||||||
|
path: route.fullPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface WorkspaceTab {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
lastVisitedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTabCandidate {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTabsSnapshot {
|
||||||
|
tabs: WorkspaceTab[]
|
||||||
|
activeId: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { WORKSPACE_TAB_LIMIT, readWorkspaceTabsSnapshot, useWorkspaceTabsStore } from './workspaceTabs'
|
||||||
|
|
||||||
|
describe('workspace tabs store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one tab per route name and restores it only for the same account', () => {
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValueOnce(10).mockReturnValueOnce(20)
|
||||||
|
const store = useWorkspaceTabsStore()
|
||||||
|
store.initialize('user-a')
|
||||||
|
store.open({ id: 'customers', title: '客户管理', path: '/customers' })
|
||||||
|
store.open({ id: 'customers', title: '客户详情', path: '/customers/10001' })
|
||||||
|
|
||||||
|
expect(store.tabs).toEqual([{ id: 'customers', title: '客户详情', path: '/customers/10001', lastVisitedAt: 20 }])
|
||||||
|
expect(readWorkspaceTabsSnapshot('user-a').activeId).toBe('customers')
|
||||||
|
expect(readWorkspaceTabsSnapshot('user-b').tabs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evicts the least recently used clean inactive tab at the soft limit', () => {
|
||||||
|
let now = 0
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => ++now)
|
||||||
|
const store = useWorkspaceTabsStore()
|
||||||
|
store.initialize('user-a')
|
||||||
|
for (let index = 0; index < WORKSPACE_TAB_LIMIT; index += 1) {
|
||||||
|
store.open({ id: `route-${index}`, title: `页面 ${index}`, path: `/route-${index}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
store.open({ id: 'route-new', title: '新页面', path: '/route-new' }, new Set(['route-0']))
|
||||||
|
|
||||||
|
expect(store.tabs).toHaveLength(WORKSPACE_TAB_LIMIT)
|
||||||
|
expect(store.tabs.some((tab) => tab.id === 'route-0')).toBe(true)
|
||||||
|
expect(store.tabs.some((tab) => tab.id === 'route-1')).toBe(false)
|
||||||
|
expect(store.activeId).toBe('route-new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the most recently visited remaining tab and rotates the closed cache key', () => {
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValueOnce(10).mockReturnValueOnce(20).mockReturnValueOnce(30)
|
||||||
|
const store = useWorkspaceTabsStore()
|
||||||
|
store.initialize('user-a')
|
||||||
|
store.open({ id: 'customers', title: '客户', path: '/customers' })
|
||||||
|
store.open({ id: 'warehouses', title: '仓库', path: '/warehouses' })
|
||||||
|
store.open({ id: 'pallets', title: '托盘', path: '/pallets' })
|
||||||
|
const previousKey = store.cacheKey('pallets')
|
||||||
|
|
||||||
|
const fallback = store.removeByIds(['pallets'])
|
||||||
|
|
||||||
|
expect(fallback?.id).toBe('warehouses')
|
||||||
|
expect(store.activeId).toBe('warehouses')
|
||||||
|
expect(store.cacheKey('pallets')).not.toBe(previousKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops corrupt persisted data instead of breaking application startup', () => {
|
||||||
|
sessionStorage.setItem('cc-admin-workspace-tabs:v1:user-a', '{broken')
|
||||||
|
expect(readWorkspaceTabsSnapshot('user-a')).toEqual({ tabs: [], activeId: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { WorkspaceTab, WorkspaceTabCandidate, WorkspaceTabsSnapshot } from './types'
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'cc-admin-workspace-tabs:v1'
|
||||||
|
export const WORKSPACE_TAB_LIMIT = 12
|
||||||
|
export const MAX_CACHED_WORKSPACE_VIEWS = 24
|
||||||
|
|
||||||
|
function storageKey(userId: string) {
|
||||||
|
return `${STORAGE_PREFIX}:${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function validTab(value: unknown): value is WorkspaceTab {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const tab = value as Partial<WorkspaceTab>
|
||||||
|
return typeof tab.id === 'string'
|
||||||
|
&& Boolean(tab.id)
|
||||||
|
&& typeof tab.title === 'string'
|
||||||
|
&& typeof tab.path === 'string'
|
||||||
|
&& tab.path.startsWith('/')
|
||||||
|
&& typeof tab.lastVisitedAt === 'number'
|
||||||
|
&& Number.isFinite(tab.lastVisitedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWorkspaceTabsSnapshot(userId: string): WorkspaceTabsSnapshot {
|
||||||
|
if (!userId) return { tabs: [], activeId: null }
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(sessionStorage.getItem(storageKey(userId)) || 'null') as Partial<WorkspaceTabsSnapshot> | null
|
||||||
|
if (!parsed || !Array.isArray(parsed.tabs)) return { tabs: [], activeId: null }
|
||||||
|
const tabs = parsed.tabs.filter(validTab)
|
||||||
|
const activeId = typeof parsed.activeId === 'string' && tabs.some((tab) => tab.id === parsed.activeId)
|
||||||
|
? parsed.activeId
|
||||||
|
: null
|
||||||
|
return { tabs, activeId }
|
||||||
|
} catch {
|
||||||
|
return { tabs: [], activeId: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredActiveWorkspacePath(userId: string) {
|
||||||
|
const snapshot = readWorkspaceTabsSnapshot(userId)
|
||||||
|
return snapshot.tabs.find((tab) => tab.id === snapshot.activeId)?.path ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkspaceTabsStore = defineStore('workspace-tabs', () => {
|
||||||
|
const tabs = ref<WorkspaceTab[]>([])
|
||||||
|
const activeId = ref<string | null>(null)
|
||||||
|
const initializedUserId = ref('')
|
||||||
|
const cacheEpochs = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
const activeTab = computed(() => tabs.value.find((tab) => tab.id === activeId.value) ?? null)
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (!initializedUserId.value) return
|
||||||
|
sessionStorage.setItem(storageKey(initializedUserId.value), JSON.stringify({
|
||||||
|
tabs: tabs.value,
|
||||||
|
activeId: activeId.value,
|
||||||
|
} satisfies WorkspaceTabsSnapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize(userId: string) {
|
||||||
|
if (!userId || initializedUserId.value === userId) return
|
||||||
|
initializedUserId.value = userId
|
||||||
|
const snapshot = readWorkspaceTabsSnapshot(userId)
|
||||||
|
tabs.value = snapshot.tabs
|
||||||
|
activeId.value = snapshot.activeId
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpCacheEpoch(tabId: string) {
|
||||||
|
cacheEpochs.value = {
|
||||||
|
...cacheEpochs.value,
|
||||||
|
[tabId]: (cacheEpochs.value[tabId] ?? 0) + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeByIds(ids: readonly string[]) {
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
if (idSet.size === 0) return activeTab.value
|
||||||
|
const activeWasRemoved = activeId.value !== null && idSet.has(activeId.value)
|
||||||
|
for (const id of idSet) bumpCacheEpoch(id)
|
||||||
|
tabs.value = tabs.value.filter((tab) => !idSet.has(tab.id))
|
||||||
|
if (activeWasRemoved) {
|
||||||
|
activeId.value = [...tabs.value].sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)[0]?.id ?? null
|
||||||
|
} else if (activeId.value && !tabs.value.some((tab) => tab.id === activeId.value)) {
|
||||||
|
activeId.value = null
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
return activeTab.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceLimit(dirtyTabIds: ReadonlySet<string>) {
|
||||||
|
while (tabs.value.length > WORKSPACE_TAB_LIMIT) {
|
||||||
|
const candidate = tabs.value
|
||||||
|
.filter((tab) => tab.id !== activeId.value && !dirtyTabIds.has(tab.id))
|
||||||
|
.sort((left, right) => left.lastVisitedAt - right.lastVisitedAt)[0]
|
||||||
|
if (!candidate) break
|
||||||
|
removeByIds([candidate.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(candidate: WorkspaceTabCandidate, dirtyTabIds: ReadonlySet<string> = new Set()) {
|
||||||
|
const now = Date.now()
|
||||||
|
const existing = tabs.value.find((tab) => tab.id === candidate.id)
|
||||||
|
if (existing) {
|
||||||
|
existing.title = candidate.title
|
||||||
|
existing.path = candidate.path
|
||||||
|
existing.lastVisitedAt = now
|
||||||
|
} else {
|
||||||
|
tabs.value.push({ ...candidate, lastVisitedAt: now })
|
||||||
|
}
|
||||||
|
activeId.value = candidate.id
|
||||||
|
enforceLimit(dirtyTabIds)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(keep: (tab: WorkspaceTab) => boolean) {
|
||||||
|
const removedIds = tabs.value.filter((tab) => !keep(tab)).map((tab) => tab.id)
|
||||||
|
return { removedIds, fallback: removeByIds(removedIds) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(tabId: string | null) {
|
||||||
|
if (!tabId) return 'workspace-route'
|
||||||
|
return `${tabId}:${cacheEpochs.value[tabId] ?? 0}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(removePersisted = false) {
|
||||||
|
if (removePersisted && initializedUserId.value) sessionStorage.removeItem(storageKey(initializedUserId.value))
|
||||||
|
tabs.value = []
|
||||||
|
activeId.value = null
|
||||||
|
cacheEpochs.value = {}
|
||||||
|
initializedUserId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeId,
|
||||||
|
activeTab,
|
||||||
|
initializedUserId,
|
||||||
|
initialize,
|
||||||
|
open,
|
||||||
|
removeByIds,
|
||||||
|
prune,
|
||||||
|
cacheKey,
|
||||||
|
bumpCacheEpoch,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
})
|
||||||
+11
-1
@@ -8,12 +8,15 @@ import router from '@/app/router'
|
|||||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { registerAuthSessionExpiredHandler } from '@/shared/api/http'
|
import { registerAuthSessionExpiredHandler } from '@/shared/api/http'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
const auth = useAuthStore(pinia)
|
const auth = useAuthStore(pinia)
|
||||||
|
const unsaved = useUnsavedWorkStore(pinia)
|
||||||
|
const workspace = useWorkspaceTabsStore(pinia)
|
||||||
|
|
||||||
await auth.restoreSession()
|
await auth.restoreSession()
|
||||||
|
|
||||||
@@ -22,7 +25,8 @@ registerAuthSessionExpiredHandler(async () => {
|
|||||||
if (currentRoute.name === 'login') return
|
if (currentRoute.name === 'login') return
|
||||||
|
|
||||||
useAccessPreviewStore(pinia).reset()
|
useAccessPreviewStore(pinia).reset()
|
||||||
useUnsavedWorkStore(pinia).clear()
|
unsaved.clear()
|
||||||
|
workspace.reset(true)
|
||||||
const redirect = sanitizeInternalPath(currentRoute.fullPath)
|
const redirect = sanitizeInternalPath(currentRoute.fullPath)
|
||||||
ElMessage.warning('登录状态已过期,请重新登录')
|
ElMessage.warning('登录状态已过期,请重新登录')
|
||||||
await router.replace({
|
await router.replace({
|
||||||
@@ -31,4 +35,10 @@ registerAuthSessionExpiredHandler(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', (event) => {
|
||||||
|
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = ''
|
||||||
|
})
|
||||||
|
|
||||||
createApp(App).use(pinia).use(router).mount('#app')
|
createApp(App).use(pinia).use(router).mount('#app')
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ describe('LoginView authentication lifecycle', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
['/customers?page=2', '/customers?page=2'],
|
['/customers?page=2', '/customers?page=2'],
|
||||||
['/customers/reviews', '/customers/reviews'],
|
['/customers/reviews', '/customers/reviews'],
|
||||||
['//example.com', '/403'],
|
['//example.com', '/dashboard'],
|
||||||
['https://example.com', '/403'],
|
['https://example.com', '/dashboard'],
|
||||||
[['/customers', '/orders/first-leg'], '/403'],
|
[['/customers', '/orders/first-leg'], '/dashboard'],
|
||||||
[null, '/403'],
|
[null, '/dashboard'],
|
||||||
])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => {
|
])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => {
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
setActivePinia(pinia)
|
setActivePinia(pinia)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
getCustomerPricingRule,
|
||||||
|
listCustomerPricingRules,
|
||||||
|
type CustomerPricingRule,
|
||||||
|
} from '@/modules/fba-pricing'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
|
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
|
||||||
|
|
||||||
|
vi.mock('@/modules/fba-pricing', () => ({
|
||||||
|
createCustomerPricingRuleVersion: vi.fn(),
|
||||||
|
disableCustomerPricingRule: vi.fn(),
|
||||||
|
getCustomerPricingRule: vi.fn(),
|
||||||
|
listCustomerPricingRules: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
class ResizeObserverStub implements ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule: CustomerPricingRule = {
|
||||||
|
id: '91', tenantId: '8', ruleCode: 'FBA-SEA-KG', customerId: '8', customerName: '柏悦物流',
|
||||||
|
businessType: 'FBA_SEA', chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167,
|
||||||
|
roundingScale: 3, roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-08-01T00:00:00',
|
||||||
|
effectiveTo: null, ruleVersion: 2, status: 'ACTIVE', remark: '标准规则', createdAt: null, updatedAt: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
describe('CustomerBillingRulesPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.token = 'token'
|
||||||
|
auth.permissions = ['firstleg:fba-pricing:rule:view', 'firstleg:fba-pricing:rule:edit']
|
||||||
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
|
vi.mocked(listCustomerPricingRules).mockResolvedValue([rule])
|
||||||
|
vi.mocked(getCustomerPricingRule).mockResolvedValue(rule)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads only the current customer rules and exposes version actions', async () => {
|
||||||
|
mount(CustomerBillingRulesPanel, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { customerId: '8', customerName: '柏悦物流', customerCode: 'TEN8' },
|
||||||
|
global: { plugins: [ElementPlus] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listCustomerPricingRules).toHaveBeenCalledWith({ tenantId: '8' })
|
||||||
|
expect(document.body.textContent).toContain('FBA-SEA-KG')
|
||||||
|
expect(document.body.textContent).toContain('FBA 海运')
|
||||||
|
expect(document.body.textContent).toContain('新建规则')
|
||||||
|
expect(document.body.textContent).toContain('新版本')
|
||||||
|
expect(document.body.textContent).toContain('停用')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads rule details with the customer tenant scope', async () => {
|
||||||
|
mount(CustomerBillingRulesPanel, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { customerId: '8', customerName: '柏悦物流' },
|
||||||
|
global: { plugins: [ElementPlus] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const detailButtons = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||||
|
.filter((button) => button.textContent?.trim() === '详情')
|
||||||
|
const detailButton = detailButtons.at(-1)
|
||||||
|
detailButton?.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(getCustomerPricingRule).toHaveBeenCalledWith('8', '91')
|
||||||
|
expect(document.body.textContent).toContain('标准规则')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { CircleSlash2, Eye, Plus, RefreshCw, Scale } from '@lucide/vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||||
|
import {
|
||||||
|
createCustomerPricingRuleVersion,
|
||||||
|
disableCustomerPricingRule,
|
||||||
|
getCustomerPricingRule,
|
||||||
|
listCustomerPricingRules,
|
||||||
|
type CustomerPricingRule,
|
||||||
|
type CustomerRuleFormModel,
|
||||||
|
} from '@/modules/fba-pricing'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||||
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
customerId: string
|
||||||
|
customerName: string
|
||||||
|
customerCode?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const access = useAccessPreviewStore()
|
||||||
|
const canView = computed(() => access.can('firstleg:fba-pricing:rule:view'))
|
||||||
|
const canEdit = computed(() => canView.value && access.can('firstleg:fba-pricing:rule:edit'))
|
||||||
|
const rules = ref<CustomerPricingRule[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const selectedRule = ref<CustomerPricingRule | null>(null)
|
||||||
|
const editorVisible = ref(false)
|
||||||
|
const editorSaving = ref(false)
|
||||||
|
const editorDirty = ref(false)
|
||||||
|
const editorForm = ref<FormInstance>()
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||||
|
{ key: 'rule', label: '规则版本', minWidth: 160, slotName: 'rule' },
|
||||||
|
{ key: 'businessType', label: '业务类型', minWidth: 120, slotName: 'businessType' },
|
||||||
|
{ key: 'chargeMode', label: '计费方式', width: 105, slotName: 'chargeMode' },
|
||||||
|
{ key: 'taxMode', label: '税务模式', width: 100, slotName: 'taxMode' },
|
||||||
|
{ key: 'conversionFactor', prop: 'conversionFactor', label: '换算系数', width: 105 },
|
||||||
|
{ key: 'rounding', label: '舍入规则', minWidth: 165, slotName: 'rounding' },
|
||||||
|
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
|
||||||
|
{ key: 'period', label: '有效期', minWidth: 225, slotName: 'period' },
|
||||||
|
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const pagedRules = computed(() => rules.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
|
|
||||||
|
const emptyForm = (): CustomerRuleFormModel => ({
|
||||||
|
ruleCode: '',
|
||||||
|
customerId: props.customerId,
|
||||||
|
customerName: props.customerName,
|
||||||
|
businessType: 'FBA_SEA',
|
||||||
|
chargeMode: 'KG',
|
||||||
|
taxMode: 'TAX_INCLUDED',
|
||||||
|
conversionFactor: 167,
|
||||||
|
roundingScale: 3,
|
||||||
|
roundingMode: 'CEILING',
|
||||||
|
currency: 'CNY',
|
||||||
|
effectiveFrom: '',
|
||||||
|
effectiveTo: null,
|
||||||
|
remark: null,
|
||||||
|
})
|
||||||
|
const form = reactive<CustomerRuleFormModel>(emptyForm())
|
||||||
|
|
||||||
|
const formRules: FormRules<CustomerRuleFormModel> = {
|
||||||
|
ruleCode: [{ required: true, message: '请输入规则编码', trigger: 'blur' }],
|
||||||
|
businessType: [{ required: true, message: '请输入业务类型', trigger: 'blur' }],
|
||||||
|
chargeMode: [{ required: true, message: '请选择计费方式', trigger: 'change' }],
|
||||||
|
taxMode: [{ required: true, message: '请选择税务模式', trigger: 'change' }],
|
||||||
|
conversionFactor: [{ required: true, message: '请输入大于 0 的换算系数', trigger: 'blur', type: 'number', min: 0.000001 }],
|
||||||
|
roundingScale: [{ required: true, message: '请选择保留位数', trigger: 'change', type: 'number', min: 0, max: 4 }],
|
||||||
|
roundingMode: [{ required: true, message: '请选择舍入方式', trigger: 'change' }],
|
||||||
|
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
|
||||||
|
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||||
|
key: `customer-pricing-rule-${props.customerId}`,
|
||||||
|
label: computed(() => `${props.customerName}的计费规则版本`),
|
||||||
|
isOpen: editorVisible,
|
||||||
|
model: form,
|
||||||
|
requiredPermission: computed(() => 'firstleg:fba-pricing:rule:edit'),
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(form, () => {
|
||||||
|
if (editorVisible.value) editorDirty.value = true
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
if (!canView.value) return
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
rules.value = await listCustomerPricingRules({ tenantId: props.customerId })
|
||||||
|
page.value = 1
|
||||||
|
} catch (error) {
|
||||||
|
rules.value = []
|
||||||
|
loadError.value = getApiErrorDetails(error, '客户计费规则加载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm(source?: CustomerPricingRule) {
|
||||||
|
Object.assign(form, emptyForm(), source ? {
|
||||||
|
ruleCode: source.ruleCode,
|
||||||
|
businessType: source.businessType,
|
||||||
|
chargeMode: source.chargeMode,
|
||||||
|
taxMode: source.taxMode,
|
||||||
|
conversionFactor: source.conversionFactor,
|
||||||
|
roundingScale: source.roundingScale,
|
||||||
|
roundingMode: source.roundingMode,
|
||||||
|
currency: source.currency,
|
||||||
|
remark: source.remark,
|
||||||
|
} : {})
|
||||||
|
form.customerId = props.customerId
|
||||||
|
form.customerName = props.customerName
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditor(source?: CustomerPricingRule) {
|
||||||
|
resetForm(source)
|
||||||
|
editorDirty.value = false
|
||||||
|
editorVisible.value = true
|
||||||
|
await nextTick()
|
||||||
|
captureBaseline()
|
||||||
|
editorForm.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAbandon(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
|
||||||
|
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beforeEditorClose(done: () => void) {
|
||||||
|
if (editorSaving.value) return
|
||||||
|
if (!editorDirty.value || await confirmAbandon()) done()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeEditor() {
|
||||||
|
if (editorDirty.value && !await confirmAbandon()) {
|
||||||
|
editorVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editorVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVersion() {
|
||||||
|
if (!await editorForm.value?.validate()) return
|
||||||
|
if (form.effectiveTo && form.effectiveTo <= form.effectiveFrom) {
|
||||||
|
ElMessage.warning('失效时间必须晚于生效时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editorSaving.value = true
|
||||||
|
try {
|
||||||
|
await createCustomerPricingRuleVersion(props.customerId, {
|
||||||
|
...form,
|
||||||
|
ruleCode: form.ruleCode.trim(),
|
||||||
|
customerId: props.customerId,
|
||||||
|
customerName: props.customerName,
|
||||||
|
businessType: form.businessType.trim(),
|
||||||
|
effectiveTo: form.effectiveTo || null,
|
||||||
|
remark: form.remark?.trim() || null,
|
||||||
|
})
|
||||||
|
editorDirty.value = false
|
||||||
|
editorVisible.value = false
|
||||||
|
ElMessage.success('计费规则新版本已创建')
|
||||||
|
await loadRules()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(getApiErrorDetails(error, '计费规则版本创建失败').message)
|
||||||
|
} finally {
|
||||||
|
editorSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: CustomerPricingRule) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailError.value = null
|
||||||
|
selectedRule.value = row
|
||||||
|
try {
|
||||||
|
selectedRule.value = await getCustomerPricingRule(props.customerId, row.id)
|
||||||
|
} catch (error) {
|
||||||
|
detailError.value = getApiErrorDetails(error, '计费规则详情加载失败')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableRule(row: CustomerPricingRule) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认停用规则“${row.ruleCode}”V${row.ruleVersion ?? '—'}吗?历史费用不会改变。`, '停用计费规则', {
|
||||||
|
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning',
|
||||||
|
})
|
||||||
|
await disableCustomerPricingRule(props.customerId, row.id)
|
||||||
|
ElMessage.success('计费规则已停用')
|
||||||
|
await loadRules()
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(getApiErrorDetails(error, '计费规则停用失败').message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRule(value: unknown) { return value as CustomerPricingRule }
|
||||||
|
function formatDate(value: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '长期有效' }
|
||||||
|
function modeLabel(value: string) { return value === 'KG' ? '按 KG' : value === 'CBM' ? '按 CBM' : value }
|
||||||
|
function taxLabel(value: string) { return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value }
|
||||||
|
function businessTypeLabel(value: string) { return value === 'FBA_SEA' ? 'FBA 海运' : value }
|
||||||
|
|
||||||
|
onMounted(loadRules)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="billing-rules-panel">
|
||||||
|
<div class="panel-title billing-heading">
|
||||||
|
<div><h2>计费规则</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的专属 FBA 计费版本</p></div>
|
||||||
|
<div class="heading-actions">
|
||||||
|
<el-button v-if="canView" :loading="loading" aria-label="刷新计费规则" @click="loadRules"><RefreshCw :size="16" />刷新</el-button>
|
||||||
|
<el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor()"><Plus :size="16" />新建规则</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!canView" class="billing-state">
|
||||||
|
<Scale :size="30" /><strong>暂无计费规则查看权限</strong><p>需要权限 firstleg:fba-pricing:rule:view。</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loadError" class="billing-error" role="alert">
|
||||||
|
<div><strong>客户计费规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
|
||||||
|
<el-button type="primary" plain :loading="loading" @click="loadRules">重新加载</el-button>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
v-else
|
||||||
|
v-model:page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:data="pagedRules"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:total="rules.length"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="当前客户暂无计费规则"
|
||||||
|
>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button link type="primary" @click="openDetail(asRule(row))"><Eye :size="15" />详情</el-button>
|
||||||
|
<el-button v-if="canEdit" link type="primary" @click="openEditor(asRule(row))"><Plus :size="15" />新版本</el-button>
|
||||||
|
<el-button v-if="canEdit && row.status === 'ACTIVE'" link type="danger" @click="disableRule(asRule(row))"><CircleSlash2 :size="15" />停用</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #rule="{ row }"><div class="primary-cell"><strong>{{ row.ruleCode }}</strong><small>V{{ row.ruleVersion ?? '—' }}</small></div></template>
|
||||||
|
<template #businessType="{ row }">{{ businessTypeLabel(row.businessType) }}</template>
|
||||||
|
<template #chargeMode="{ row }"><el-tag effect="plain">{{ modeLabel(row.chargeMode) }}</el-tag></template>
|
||||||
|
<template #taxMode="{ row }">{{ taxLabel(row.taxMode) }}</template>
|
||||||
|
<template #rounding="{ row }">保留 {{ row.roundingScale }} 位 · {{ row.roundingMode }}</template>
|
||||||
|
<template #period="{ row }"><span>{{ formatDate(row.effectiveFrom) }}</span><small class="subline">至 {{ formatDate(row.effectiveTo) }}</small></template>
|
||||||
|
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></template>
|
||||||
|
<template #empty>
|
||||||
|
<div class="billing-state"><Scale :size="28" /><strong>当前客户暂无计费规则</strong><p v-if="canEdit">创建第一条规则后,入库计费才能匹配该客户的计费口径。</p></div>
|
||||||
|
</template>
|
||||||
|
<template #pagination-left>共 {{ rules.length }} 个规则版本</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<AppDrawer v-model="detailVisible" title="计费规则详情" size="min(600px, 94vw)" :loading="detailLoading" :show-footer="false">
|
||||||
|
<div v-if="detailError" class="billing-error" role="alert"><div><strong>规则详情加载失败</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /></div></div>
|
||||||
|
<div v-else-if="selectedRule" class="rule-detail">
|
||||||
|
<section><h3>规则归属</h3><dl class="detail-grid"><div><dt>规则编码</dt><dd>{{ selectedRule.ruleCode }}</dd></div><div><dt>版本</dt><dd>V{{ selectedRule.ruleVersion ?? '—' }}</dd></div><div><dt>适用客户</dt><dd>{{ selectedRule.customerName }}</dd></div><div><dt>业务类型</dt><dd>{{ businessTypeLabel(selectedRule.businessType) }}</dd></div><div><dt>状态</dt><dd><el-tag :type="selectedRule.status === 'ACTIVE' ? 'success' : 'info'">{{ selectedRule.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></dd></div></dl></section>
|
||||||
|
<section><h3>计费口径</h3><dl class="detail-grid"><div><dt>计费方式</dt><dd>{{ modeLabel(selectedRule.chargeMode) }}</dd></div><div><dt>税务模式</dt><dd>{{ taxLabel(selectedRule.taxMode) }}</dd></div><div><dt>换算系数</dt><dd>{{ selectedRule.conversionFactor }}</dd></div><div><dt>舍入规则</dt><dd>保留 {{ selectedRule.roundingScale }} 位,{{ selectedRule.roundingMode }}</dd></div><div><dt>币种</dt><dd>{{ selectedRule.currency }}</dd></div></dl></section>
|
||||||
|
<section><h3>版本有效期</h3><dl class="detail-grid"><div><dt>生效时间</dt><dd>{{ formatDate(selectedRule.effectiveFrom) }}</dd></div><div><dt>失效时间</dt><dd>{{ formatDate(selectedRule.effectiveTo) }}</dd></div><div class="detail-wide"><dt>备注</dt><dd>{{ selectedRule.remark || '—' }}</dd></div></dl></section>
|
||||||
|
</div>
|
||||||
|
</AppDrawer>
|
||||||
|
|
||||||
|
<AppDrawer v-model="editorVisible" title="创建计费规则版本" size="min(760px, 94vw)" :confirm-loading="editorSaving" confirm-text="创建版本" :before-close="beforeEditorClose" @confirm="saveVersion" @cancel="closeEditor">
|
||||||
|
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top" class="rule-form">
|
||||||
|
<div class="customer-context"><span>适用客户</span><strong>{{ customerName }}</strong><small>{{ customerCode || customerId }}</small></div>
|
||||||
|
<section class="form-section"><h3>规则归属</h3><div class="form-grid"><el-form-item label="规则编码" prop="ruleCode"><el-input v-model="form.ruleCode" maxlength="50" placeholder="例如 FBA-SEA-TAX-KG" /></el-form-item><el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" placeholder="例如 FBA_SEA" /></el-form-item></div></section>
|
||||||
|
<section class="form-section"><h3>计费口径</h3><div class="form-grid"><el-form-item label="计费方式" prop="chargeMode"><el-segmented v-model="form.chargeMode" :options="[{ label: '按 KG', value: 'KG' }, { label: '按 CBM', value: 'CBM' }]" /></el-form-item><el-form-item label="税务模式" prop="taxMode"><el-select v-model="form.taxMode"><el-option label="包税" value="TAX_INCLUDED" /><el-option label="不包税" value="TAX_EXCLUDED" /></el-select></el-form-item><el-form-item label="换算系数" prop="conversionFactor"><el-input-number v-model="form.conversionFactor" :min="0.000001" :precision="6" :controls="false" /></el-form-item><el-form-item label="保留小数位" prop="roundingScale"><el-select v-model="form.roundingScale"><el-option v-for="scale in [0, 1, 2, 3, 4]" :key="scale" :label="`${scale} 位`" :value="scale" /></el-select></el-form-item><el-form-item label="舍入方式" prop="roundingMode"><el-select v-model="form.roundingMode"><el-option label="向上取整 CEILING" value="CEILING" /><el-option label="向上舍入 UP" value="UP" /><el-option label="向下舍入 DOWN" value="DOWN" /><el-option label="向下取整 FLOOR" value="FLOOR" /><el-option label="四舍五入 HALF_UP" value="HALF_UP" /><el-option label="五舍六入 HALF_DOWN" value="HALF_DOWN" /><el-option label="银行家舍入 HALF_EVEN" value="HALF_EVEN" /></el-select></el-form-item><el-form-item label="币种" prop="currency"><el-select v-model="form.currency" filterable allow-create default-first-option><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></div></section>
|
||||||
|
<section class="form-section"><h3>生效范围</h3><div class="form-grid"><el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item><el-form-item label="失效时间"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="不填表示长期有效" /></el-form-item><el-form-item label="备注" class="wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></div></section>
|
||||||
|
</el-form>
|
||||||
|
</AppDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.billing-rules-panel { min-width:0; }
|
||||||
|
.billing-heading { align-items:center; }
|
||||||
|
.heading-actions,.row-actions { display:flex; align-items:center; gap:8px; }
|
||||||
|
.heading-actions :deep(.el-button),.row-actions :deep(.el-button) { margin-left:0; }
|
||||||
|
.billing-state { display:flex; min-height:220px; align-items:center; justify-content:center; flex-direction:column; gap:8px; color:#8797b2; text-align:center; }
|
||||||
|
.billing-state strong { color:#475467; font-size:13px; }.billing-state p { max-width:520px; margin:0; color:var(--cc-muted); font-size:12px; line-height:1.7; }
|
||||||
|
.billing-error { display:flex; align-items:center; justify-content:space-between; gap:18px; padding:16px; border-radius:12px; background:#fff4f3; }.billing-error strong { color:#7a271a; }.billing-error p { margin:5px 0; color:#b42318; font-size:12px; }
|
||||||
|
.primary-cell { display:flex; flex-direction:column; gap:3px; }.primary-cell small,.subline { display:block; margin-top:3px; color:var(--cc-muted); font-size:11px; }
|
||||||
|
.rule-detail { display:grid; gap:24px; }.rule-detail section + section,.form-section + .form-section { padding-top:22px; border-top:1px solid var(--cc-border); }.rule-detail h3,.form-section h3 { margin:0 0 14px; color:var(--cc-text); font-size:15px; }
|
||||||
|
.detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px 24px; margin:0; }.detail-grid > div { min-width:0; }.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-wide { grid-column:1 / -1; }
|
||||||
|
.rule-form { display:grid; gap:24px; }.customer-context { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:#f5f7ff; }.customer-context span,.customer-context small { color:var(--cc-muted); font-size:12px; }.customer-context strong { color:#25324a; }.form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:2px 20px; }.wide { grid-column:1 / -1; }.form-grid :deep(.el-select),.form-grid :deep(.el-input-number),.form-grid :deep(.el-date-editor) { width:100%; }
|
||||||
|
@media (max-width:760px) { .billing-heading { align-items:stretch; flex-direction:column; }.heading-actions { width:100%; }.heading-actions :deep(.el-button) { min-height:44px; flex:1; }.form-grid,.detail-grid { grid-template-columns:1fr; }.wide,.detail-wide { grid-column:auto; }.billing-error { align-items:stretch; flex-direction:column; } }
|
||||||
|
@media (max-width:480px) { .customer-context { grid-template-columns:1fr; gap:4px; }.row-actions { flex-wrap:wrap; } }
|
||||||
|
</style>
|
||||||
@@ -53,6 +53,7 @@ describe('CustomerDetailView', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() })
|
||||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
|
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
|
||||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
@@ -82,6 +83,13 @@ describe('CustomerDetailView', () => {
|
|||||||
expect(document.body.textContent).not.toContain('0 人正常使用')
|
expect(document.body.textContent).not.toContain('0 人正常使用')
|
||||||
expect(document.body.textContent).toContain('已替换')
|
expect(document.body.textContent).toContain('已替换')
|
||||||
expect(document.body.textContent).toContain('12票')
|
expect(document.body.textContent).toContain('12票')
|
||||||
|
|
||||||
|
const billingTab = [...document.querySelectorAll<HTMLButtonElement>('.detail-tabs button')]
|
||||||
|
.find((button) => button.textContent?.trim() === '计费规则')
|
||||||
|
billingTab?.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('暂无计费规则查看权限')
|
||||||
|
expect(document.body.textContent).not.toContain('计费规则尚未接入')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens an authenticated qualification preview', async () => {
|
it('opens an authenticated qualification preview', async () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||||
|
|
||||||
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
|
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
|
||||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||||
@@ -280,7 +281,7 @@ onBeforeUnmount(clearPreviews)
|
|||||||
|
|
||||||
<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"><div class="panel-title"><div><h2>计费规则</h2><p>客户专属费用与箱规计价配置</p></div><Scale :size="19" /></div><div class="module-empty"><Scale :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" /></section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import DashboardView from './DashboardView.vue'
|
||||||
|
|
||||||
|
describe('DashboardView', () => {
|
||||||
|
it('labels placeholder values and keeps task actions non-operational', () => {
|
||||||
|
const wrapper = mount(DashboardView)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('静态演示数据')
|
||||||
|
expect(wrapper.text()).toContain('演示快照:2026-06-02 08:45')
|
||||||
|
const taskActions = wrapper.findAll('.dashboard-task-action')
|
||||||
|
expect(taskActions).toHaveLength(3)
|
||||||
|
expect(taskActions.every((button) => button.attributes('disabled') !== undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches transport series and exposes the chart values as a data table', async () => {
|
||||||
|
const wrapper = mount(DashboardView)
|
||||||
|
const airButton = wrapper.findAll('button').find((button) => button.text() === '空运')
|
||||||
|
|
||||||
|
expect(airButton).toBeDefined()
|
||||||
|
await airButton!.trigger('click')
|
||||||
|
|
||||||
|
expect(airButton!.attributes('aria-pressed')).toBe('true')
|
||||||
|
expect(wrapper.text()).toContain('空运普货')
|
||||||
|
const caption = wrapper.get('.dashboard-line-chart table caption')
|
||||||
|
expect(caption.text()).toBe('空运近 30 天运单量演示数据')
|
||||||
|
expect(wrapper.findAll('.dashboard-line-chart tbody tr')).toHaveLength(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves semantic route names and values in the volume ranking', () => {
|
||||||
|
const wrapper = mount(DashboardView)
|
||||||
|
const ranking = wrapper.get('.dashboard-bars')
|
||||||
|
|
||||||
|
expect(ranking.attributes('role')).toBeUndefined()
|
||||||
|
expect(ranking.text()).toContain('上海 → 洛杉矶')
|
||||||
|
expect(ranking.text()).toContain('850')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
BarChart3,
|
||||||
|
Clock3,
|
||||||
|
Expand,
|
||||||
|
Info,
|
||||||
|
Minimize,
|
||||||
|
Ship,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
type TransportMode = 'sea' | 'air' | 'land'
|
||||||
|
|
||||||
|
interface TrendSeries {
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
fill: string
|
||||||
|
values: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardRef = ref<HTMLElement | null>(null)
|
||||||
|
const activeMode = ref<TransportMode>('sea')
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{ label: '今日新运单', value: '128', unit: '票', note: '较昨日', change: '↑ 12.3%', tone: 'blue', warning: false },
|
||||||
|
{ label: '在途柜量', value: '3,842', unit: 'TEU', note: '较上周', change: '↑ 8.5%', tone: 'green', warning: false },
|
||||||
|
{ label: '本月营收', value: '¥856', unit: '万', note: '较上月', change: '↑ 15.2%', tone: 'orange', warning: false },
|
||||||
|
{ label: '活跃客户', value: '368', unit: '家', note: '本周新增', change: '+6 家', tone: 'purple', warning: false },
|
||||||
|
{ label: '待清关票数', value: '47', unit: '票', note: '即将超期', change: '3 票', tone: 'rose', warning: true },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const trendSeries: Record<TransportMode, TrendSeries[]> = {
|
||||||
|
sea: [
|
||||||
|
{ name: '海运 FCL', color: '#3478f6', fill: 'rgba(52, 120, 246, .13)', values: [28, 32, 25, 38, 35, 42, 30, 36, 40, 45, 33, 48, 38, 42, 50, 35, 40, 45, 38, 52, 42, 48, 55, 40, 45, 50, 38, 42, 48, 55] },
|
||||||
|
{ name: '海运 LCL', color: '#6d3bd1', fill: 'rgba(109, 59, 209, .09)', values: [12, 15, 10, 18, 14, 20, 16, 15, 18, 22, 14, 20, 16, 18, 25, 15, 18, 20, 16, 22, 18, 20, 24, 16, 18, 22, 15, 18, 20, 24] },
|
||||||
|
],
|
||||||
|
air: [
|
||||||
|
{ name: '空运普货', color: '#f79009', fill: 'rgba(247, 144, 9, .12)', values: [18, 20, 17, 24, 19, 26, 22, 25, 28, 23, 20, 29, 25, 27, 32, 26, 23, 30, 28, 34, 29, 31, 36, 30, 33, 35, 28, 32, 34, 38] },
|
||||||
|
{ name: '空运敏感货', color: '#d92d20', fill: 'rgba(217, 45, 32, .08)', values: [6, 8, 7, 9, 8, 12, 10, 9, 11, 13, 9, 12, 10, 14, 15, 11, 10, 13, 12, 16, 13, 15, 17, 12, 14, 16, 13, 15, 17, 18] },
|
||||||
|
],
|
||||||
|
land: [
|
||||||
|
{ name: '中欧班列', color: '#079455', fill: 'rgba(7, 148, 85, .11)', values: [12, 14, 13, 18, 16, 20, 17, 19, 22, 20, 18, 23, 21, 24, 27, 22, 20, 25, 23, 28, 25, 27, 30, 26, 28, 31, 25, 29, 32, 34] },
|
||||||
|
{ name: '跨境卡航', color: '#0891b2', fill: 'rgba(8, 145, 178, .08)', values: [8, 10, 9, 12, 11, 14, 12, 13, 16, 14, 13, 17, 15, 18, 20, 16, 15, 19, 17, 21, 18, 20, 23, 19, 21, 24, 20, 22, 25, 27] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSegments = [
|
||||||
|
{ label: '已确认', value: 16, color: '#3478f6' },
|
||||||
|
{ label: '已订舱', value: 12, color: '#6d3bd1' },
|
||||||
|
{ label: '已做柜', value: 9, color: '#f79009' },
|
||||||
|
{ label: '已清关', value: 11, color: '#55b715' },
|
||||||
|
{ label: '运输中', value: 23, color: '#168cf0' },
|
||||||
|
{ label: '已完成', value: 29, color: '#18b7b9' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const revenueSegments = [
|
||||||
|
{ label: '海运费', value: 58, color: '#3478f6' },
|
||||||
|
{ label: '附加费', value: 16, color: '#6d3bd1' },
|
||||||
|
{ label: '仓储费', value: 10, color: '#55b715' },
|
||||||
|
{ label: '清关费', value: 8, color: '#f79009' },
|
||||||
|
{ label: '陆运费', value: 5, color: '#e72b8c' },
|
||||||
|
{ label: '其他', value: 3, color: '#18b7b9' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
['上海 → 洛杉矶', 850], ['宁波 → 长滩', 620], ['深圳 → 汉堡', 480], ['上海 → 纽约', 420],
|
||||||
|
['深圳 → 洛杉矶', 380], ['青岛 → 鹿特丹', 350], ['上海 → 汉堡', 310], ['天津 → 长滩', 280],
|
||||||
|
['宁波 → 纽约', 250], ['青岛 → 汉堡', 220],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
{ type: '订舱确认', content: 'BY20260528003 · 美森快船待确认 S/O', priority: '重要', due: '2026-06-01' },
|
||||||
|
{ type: '账单到期', content: '3 笔应收账单即将到期,合计 ¥128,500', priority: '重要', due: '2026-06-03' },
|
||||||
|
{ type: '报关提醒', content: 'BY20260525001 · 截关时间临近,请尽快申报', priority: '紧急', due: '2026-06-01' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const currentSeries = computed(() => trendSeries[activeMode.value])
|
||||||
|
|
||||||
|
function linePoints(values: number[]) {
|
||||||
|
const max = 60
|
||||||
|
return values.map((value, index) => {
|
||||||
|
const x = 12 + (index * 676) / (values.length - 1)
|
||||||
|
const y = 206 - (value / max) * 174
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
}).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function areaPoints(values: number[]) {
|
||||||
|
return `12,206 ${linePoints(values)} 688,206`
|
||||||
|
}
|
||||||
|
|
||||||
|
function donutBackground(items: ReadonlyArray<{ value: number; color: string }>) {
|
||||||
|
let offset = 0
|
||||||
|
const stops = items.map((item) => {
|
||||||
|
const start = offset
|
||||||
|
offset += item.value
|
||||||
|
return `${item.color} ${start}% ${offset}%`
|
||||||
|
})
|
||||||
|
return `conic-gradient(${stops.join(', ')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
|
||||||
|
else await document.exitFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFullscreenState() {
|
||||||
|
isFullscreen.value = document.fullscreenElement === dashboardRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('fullscreenchange', syncFullscreenState))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFullscreenState))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
THESIS: 用一屏可扫描的经营驾驶舱承载平台概览,拒绝把数据拆成互不关联的装饰卡片。
|
||||||
|
OWN-WORLD: 延续 Admin 冷灰工作面、白色数据面板和柏悦蓝主色;状态色只承担分类与告警。
|
||||||
|
STORY: 运营人员先识别核心体量,再查看趋势与构成,最后定位待处理事项。
|
||||||
|
FIRST VIEWPORT: 顶部状态栏与五项核心指标占据首屏上半部,主趋势图和状态环图紧随其后。
|
||||||
|
FORM: 既有后台视觉体系的经营驾驶舱结构,基于用户提供的正式原型直接扩展。
|
||||||
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||||
|
-->
|
||||||
|
<section ref="dashboardRef" class="dashboard-view">
|
||||||
|
<header class="dashboard-head">
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-title"><BarChart3 :size="21" aria-hidden="true" /><h1>实时数据看板</h1></div>
|
||||||
|
<p><Info :size="14" aria-hidden="true" />当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-head-actions">
|
||||||
|
<span>演示快照:2026-06-02 08:45</span>
|
||||||
|
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
|
||||||
|
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
|
||||||
|
<Expand v-else :size="16" aria-hidden="true" />
|
||||||
|
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dashboard-metrics" aria-label="核心业务指标">
|
||||||
|
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
|
||||||
|
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||||
|
<p>{{ metric.label }}</p>
|
||||||
|
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||||
|
<span>{{ metric.note }} <b :class="{ 'is-warning': metric.warning }">{{ metric.change }}</b></span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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 class="dashboard-grid dashboard-grid--equal">
|
||||||
|
<article class="dashboard-panel">
|
||||||
|
<div class="dashboard-panel-head"><h2>TOP10 航线货量(TEU)</h2></div>
|
||||||
|
<div class="dashboard-bars" aria-label="TOP10 航线货量静态演示数据">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-view { --dashboard-panel: #fff; display: flex; min-width: 0; flex-direction: column; gap: 18px; color: #172033; }
|
||||||
|
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: #f4f6f8; }
|
||||||
|
.dashboard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
||||||
|
.dashboard-title { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.dashboard-title > svg { color: #667eea; }
|
||||||
|
.dashboard-title h1 { margin: 0; font-size: 20px; line-height: 1.3; }
|
||||||
|
.dashboard-head p { display: flex; align-items: center; gap: 6px; margin: 7px 0 0; color: #667085; font-size: 12px; line-height: 1.5; }
|
||||||
|
.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:hover { border-color: #667eea; color: #5366c8; }
|
||||||
|
.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-mark { position: absolute; top: -38px; right: -30px; width: 110px; height: 110px; border-radius: 50%; background: #edf1ff; }
|
||||||
|
.dashboard-metric.is-green .dashboard-metric-mark { background: #e8f7ee; }
|
||||||
|
.dashboard-metric.is-orange .dashboard-metric-mark { background: #fff3e3; }
|
||||||
|
.dashboard-metric.is-purple .dashboard-metric-mark { background: #f1eaff; }
|
||||||
|
.dashboard-metric.is-rose .dashboard-metric-mark { background: #fdeaf1; }
|
||||||
|
.dashboard-metric p { position: relative; margin: 0 0 25px; color: #5f6573; font-size: 13px; font-weight: 700; }
|
||||||
|
.dashboard-metric strong { position: relative; display: block; overflow-wrap: anywhere; font-size: 31px; line-height: 1; letter-spacing: -.02em; }
|
||||||
|
.dashboard-metric strong small { color: #525866; font-size: 12px; font-weight: 600; letter-spacing: 0; }
|
||||||
|
.dashboard-metric > span:last-child { position: relative; display: block; margin-top: 17px; color: #8a91a0; font-size: 11px; }
|
||||||
|
.dashboard-metric > span b { margin-left: 4px; color: #ef4444; font-weight: 700; }
|
||||||
|
.dashboard-metric > span b.is-warning { color: #d92d20; }
|
||||||
|
.dashboard-grid { display: grid; min-width: 0; gap: 18px; }
|
||||||
|
.dashboard-grid--primary { grid-template-columns: minmax(0, 1.8fr) minmax(300px, .9fr); }
|
||||||
|
.dashboard-grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.dashboard-panel { min-width: 0; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
|
||||||
|
.dashboard-panel-head { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.dashboard-panel-head h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 16px; line-height: 1.4; }
|
||||||
|
.dashboard-panel-head > span { color: #8a91a0; font-size: 11px; }
|
||||||
|
.dashboard-tabs { display: flex; gap: 7px; }
|
||||||
|
.dashboard-tabs button { min-width: 58px; padding: 0 12px; }
|
||||||
|
.dashboard-tabs button:hover { border-color: #b8c2ef; color: #5366c8; }
|
||||||
|
.dashboard-tabs button.active { border-color: #667eea; background: #667eea; color: #fff; }
|
||||||
|
.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 i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
|
||||||
|
.dashboard-legend--centered { justify-content: center; margin-top: 18px; }
|
||||||
|
.dashboard-line-chart { width: 100%; min-height: 240px; }
|
||||||
|
.dashboard-line-chart svg { display: block; width: 100%; height: auto; }
|
||||||
|
.dashboard-chart-grid line { stroke: #e8ebf1; stroke-width: 1; }
|
||||||
|
.dashboard-axis-labels { fill: #8a91a0; font-size: 10px; }
|
||||||
|
.dashboard-donut-wrap { display: grid; min-height: 268px; align-content: center; justify-items: center; padding-top: 10px; }
|
||||||
|
.dashboard-donut { position: relative; display: grid; width: 166px; height: 166px; place-items: center; border-radius: 50%; transform: rotate(-90deg); }
|
||||||
|
.dashboard-donut::after { position: absolute; width: 92px; height: 92px; border-radius: 50%; background: #fff; content: ''; }
|
||||||
|
.dashboard-donut > span { z-index: 1; display: flex; align-items: center; flex-direction: column; color: #667085; transform: rotate(90deg); }
|
||||||
|
.dashboard-donut > span svg { color: #667eea; }
|
||||||
|
.dashboard-donut > span strong { margin-top: 3px; color: #172033; font-size: 18px; }
|
||||||
|
.dashboard-donut > span small { font-size: 10px; }
|
||||||
|
.dashboard-donut-wrap--revenue { min-height: 320px; }
|
||||||
|
.dashboard-donut--solid { width: 210px; height: 210px; }
|
||||||
|
.dashboard-donut--solid::after { display: none; }
|
||||||
|
.dashboard-bars { display: grid; gap: 8px; margin-top: 16px; }
|
||||||
|
.dashboard-bar-row { display: grid; grid-template-columns: 104px minmax(80px, 1fr) 32px; align-items: center; gap: 9px; color: #667085; font-size: 11px; }
|
||||||
|
.dashboard-bar-row > span { overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dashboard-bar-row > i { height: 15px; overflow: hidden; border-radius: 4px; background: #f0f3f8; }
|
||||||
|
.dashboard-bar-row > i b { display: block; height: 100%; border-radius: inherit; background: #3478f6; }
|
||||||
|
.dashboard-bar-row > strong { color: #475467; font-size: 10px; }
|
||||||
|
.dashboard-task-panel { padding: 0; overflow: hidden; }
|
||||||
|
.dashboard-task-panel .dashboard-panel-head { padding: 16px 20px; }
|
||||||
|
.dashboard-table-wrap { overflow-x: auto; border-top: 1px solid #e8ebf1; }
|
||||||
|
.dashboard-table-wrap table { width: 100%; min-width: 760px; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.dashboard-table-wrap th, .dashboard-table-wrap td { padding: 13px 16px; border-bottom: 1px solid #edf0f4; text-align: left; }
|
||||||
|
.dashboard-table-wrap th { background: #f8fafc; color: #475467; font-weight: 650; }
|
||||||
|
.dashboard-table-wrap tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.dashboard-task-action { min-height: 30px; padding: 0 9px; font-size: 11px; }
|
||||||
|
.dashboard-task-action:disabled { border-color: #e4e7ec; background: #f9fafb; color: #98a2b3; cursor: not-allowed; }
|
||||||
|
.dashboard-priority { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; background: #fff3e8; color: #c54d0a; font-size: 11px; font-weight: 700; }
|
||||||
|
.dashboard-priority.urgent { background: #fef3f2; color: #b42318; }
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.dashboard-grid--primary { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.dashboard-donut-wrap { min-height: 240px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dashboard-head { align-items: stretch; flex-direction: column; }
|
||||||
|
.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-grid--equal { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.dashboard-panel-head { align-items: flex-start; flex-direction: column; }
|
||||||
|
.dashboard-tabs { width: 100%; overflow-x: auto; }
|
||||||
|
.dashboard-tabs button { min-height: 44px; flex: 1; }
|
||||||
|
}
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.dashboard-view { gap: 14px; }
|
||||||
|
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.dashboard-metric { min-height: 116px; padding: 13px; }
|
||||||
|
.dashboard-metric:last-child { grid-column: 1 / -1; }
|
||||||
|
.dashboard-metric-mark { top: -30px; right: -24px; width: 80px; height: 80px; }
|
||||||
|
.dashboard-metric p { margin-bottom: 12px; font-size: 11px; }
|
||||||
|
.dashboard-metric strong { font-size: 23px; }
|
||||||
|
.dashboard-metric strong small { font-size: 10px; }
|
||||||
|
.dashboard-metric > span:last-child { margin-top: 10px; font-size: 10px; }
|
||||||
|
.dashboard-panel { padding: 16px; }
|
||||||
|
.dashboard-task-panel { padding: 0; }
|
||||||
|
.dashboard-bar-row { grid-template-columns: 90px minmax(72px, 1fr) 28px; gap: 7px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export const dashboardRoutes = [
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/modules/dashboard/pages/DashboardView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '首页数据看板',
|
||||||
|
description: '平台业务概览(当前展示静态演示数据)',
|
||||||
|
showPageHeading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies RouteRecordRaw[]
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
const source = readFileSync('src/modules/fba-pricing/customer-rules/CustomerRulesView.vue', 'utf8')
|
|
||||||
const queryToolbarSource = readFileSync('src/shared/components/QueryToolbar.vue', 'utf8')
|
|
||||||
|
|
||||||
describe('CustomerRulesView', () => {
|
|
||||||
it('keeps the operation column first and exposes version-only actions', () => {
|
|
||||||
const table = source.indexOf('const columns')
|
|
||||||
const operation = source.indexOf("label: '操作'", table)
|
|
||||||
const ruleCode = source.indexOf("label: '规则版本'", table)
|
|
||||||
expect(operation).toBeGreaterThan(0)
|
|
||||||
expect(operation).toBeLessThan(ruleCode)
|
|
||||||
expect(source).toContain('新建版本')
|
|
||||||
expect(source).toContain('停用规则')
|
|
||||||
expect(source).not.toContain('编辑规则')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('covers customer scope, permission, unsaved work, error and responsive states', () => {
|
|
||||||
expect(source).not.toContain('PlatformTenantSelector')
|
|
||||||
expect(source).toContain("key: 'customerId'")
|
|
||||||
expect(source).toContain("'aria-label': '所属企业'")
|
|
||||||
expect(source).toContain('listPlatformTenants')
|
|
||||||
expect(source).toContain("firstleg:fba-pricing:rule:edit")
|
|
||||||
expect(source).toContain('useUnsavedWorkRegistration')
|
|
||||||
expect(source).toContain('LoadErrorState')
|
|
||||||
expect(source).toContain('detailError')
|
|
||||||
expect(source).toContain('@retry="retryDetail"')
|
|
||||||
expect(source).not.toContain('请选择客户')
|
|
||||||
expect(source).not.toContain('请选择所属企业')
|
|
||||||
expect(source).not.toContain('当前接口要求选择客户')
|
|
||||||
expect(source).not.toContain('租户')
|
|
||||||
expect(source).toContain('@media (max-width: 480px)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses compact semantic filter widths and keeps the customer filter empty by default', () => {
|
|
||||||
expect(source).toContain("customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: ''")
|
|
||||||
expect(source).toContain("width: 280,\n placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户'")
|
|
||||||
expect(source).toContain("key: 'ruleCode', type: 'input', width: 220")
|
|
||||||
expect(source).toContain("key: 'customerName', type: 'input', width: 260")
|
|
||||||
expect(source).toContain("key: 'chargeMode', type: 'select', width: 180")
|
|
||||||
expect(source).toContain("key: 'status', type: 'select', width: 160")
|
|
||||||
expect(source).not.toContain('customers.value[0]')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resets to all customers, uses full-width mobile fields and allows an empty customer filter', () => {
|
|
||||||
expect(queryToolbarSource).toContain("for (const field of props.fields) next[field.key] = resetValue(field)")
|
|
||||||
expect(queryToolbarSource).toContain("if (field.type === 'select') return '全部'")
|
|
||||||
expect(queryToolbarSource).toContain('@media (max-width: 720px)')
|
|
||||||
expect(queryToolbarSource).toContain('{ width: 100%; }')
|
|
||||||
expect(source).not.toContain('if (!selectedCustomerId.value)')
|
|
||||||
expect(source).toContain('...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {})')
|
|
||||||
expect(source).toContain('void loadRules()')
|
|
||||||
expect(source).toContain("const tableEmptyText = '暂无符合筛选条件的客户计费规则'")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses each record scope for single-rule actions', () => {
|
|
||||||
expect(source).toContain('getCustomerPricingRule(row.tenantId, row.id)')
|
|
||||||
expect(source).toContain('editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value')
|
|
||||||
expect(source).toContain('disableCustomerPricingRule(row.tenantId, row.id)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the complete pricing rule contract', () => {
|
|
||||||
for (const label of ['适用客户', '业务类型', '计费方式', '税务模式', '换算系数', '舍入规则', '币种', '有效期']) {
|
|
||||||
expect(source).toContain(label)
|
|
||||||
}
|
|
||||||
expect(source).toContain('QueryToolbar')
|
|
||||||
expect(source).toContain('DataTable')
|
|
||||||
expect(source).toContain('AppDrawer')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('matches the backend status and field constraints', () => {
|
|
||||||
expect(source).toContain("{ label: '已停用', value: 'INACTIVE' }")
|
|
||||||
expect(source).not.toContain("value: 'DISABLED'")
|
|
||||||
expect(source).toContain('maxlength="50"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the mature admin list skeleton and grouped detail sections', () => {
|
|
||||||
expect(source).toContain('class="business-page customer-rules-page"')
|
|
||||||
expect(source).toContain('class="surface-panel table-panel"')
|
|
||||||
expect(source).toContain('class="panel-heading"')
|
|
||||||
for (const heading of ['规则归属', '计费口径', '版本有效期']) {
|
|
||||||
expect(source).toContain(`<h3>${heading}</h3>`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import { CircleSlash2, Eye, Plus } from '@lucide/vue'
|
|
||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
|
||||||
import { listPlatformTenants, type PlatformTenantResource as PlatformCustomerContract } from '@/modules/iam'
|
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
||||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
||||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
||||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
||||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
|
||||||
import {
|
|
||||||
createCustomerPricingRuleVersion,
|
|
||||||
disableCustomerPricingRule,
|
|
||||||
getCustomerPricingRule,
|
|
||||||
listCustomerPricingRules,
|
|
||||||
} from './api'
|
|
||||||
import type { CustomerPricingRule, CustomerRuleFormModel } from './types'
|
|
||||||
|
|
||||||
const access = useAccessPreviewStore()
|
|
||||||
const canEdit = computed(() => access.can('firstleg:fba-pricing:rule:edit'))
|
|
||||||
const customers = ref<Array<{ id: string; name: string; code: string }>>([])
|
|
||||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const rules = ref<CustomerPricingRule[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<ApiErrorDetails | null>(null)
|
|
||||||
const detailVisible = ref(false)
|
|
||||||
const detailLoading = ref(false)
|
|
||||||
const detailError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const selectedRule = ref<CustomerPricingRule | null>(null)
|
|
||||||
const editorVisible = ref(false)
|
|
||||||
const editorSaving = ref(false)
|
|
||||||
const editorForm = ref<FormInstance>()
|
|
||||||
const editorDirty = ref(false)
|
|
||||||
const editorScopeCustomerId = ref('')
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
|
|
||||||
type RuleFilters = QueryModel & {
|
|
||||||
customerId: string
|
|
||||||
ruleCode: string
|
|
||||||
customerName: string
|
|
||||||
chargeMode: '' | 'KG' | 'CBM'
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = ref<RuleFilters>({ customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: '' })
|
|
||||||
const appliedFilters = ref<RuleFilters>({ ...filters.value })
|
|
||||||
const selectedCustomerId = computed(() => appliedFilters.value.customerId)
|
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
|
||||||
{
|
|
||||||
key: 'customerId',
|
|
||||||
type: 'select',
|
|
||||||
width: 280,
|
|
||||||
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
|
|
||||||
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(customerLoadError.value) },
|
|
||||||
options: customers.value.map((customer) => ({
|
|
||||||
value: customer.id,
|
|
||||||
label: `${customer.name}(${customer.code || customer.id})`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{ key: 'ruleCode', type: 'input', width: 220, placeholder: '规则编码' },
|
|
||||||
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
|
|
||||||
{
|
|
||||||
key: 'chargeMode', type: 'select', width: 180, placeholder: '全部计费方式', options: [
|
|
||||||
{ label: '按 KG', value: 'KG' },
|
|
||||||
{ label: '按 CBM', value: 'CBM' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status', type: 'select', width: 160, placeholder: '全部状态', options: [
|
|
||||||
{ label: '生效中', value: 'ACTIVE' },
|
|
||||||
{ label: '已停用', value: 'INACTIVE' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
|
||||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
|
||||||
{ key: 'rule', label: '规则版本', minWidth: 170, slotName: 'rule', showOverflowTooltip: true },
|
|
||||||
{ key: 'customer', label: '适用客户', minWidth: 190, slotName: 'customer', showOverflowTooltip: true },
|
|
||||||
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 110, showOverflowTooltip: true },
|
|
||||||
{ key: 'chargeMode', label: '计费方式', width: 105, slotName: 'chargeMode' },
|
|
||||||
{ key: 'taxMode', label: '税务模式', width: 100, slotName: 'taxMode' },
|
|
||||||
{ key: 'conversionFactor', prop: 'conversionFactor', label: '换算系数', width: 105 },
|
|
||||||
{ key: 'rounding', label: '舍入规则', minWidth: 155, slotName: 'rounding' },
|
|
||||||
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
|
|
||||||
{ key: 'period', label: '有效期', minWidth: 225, slotName: 'period' },
|
|
||||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const filteredRules = computed(() => rules.value.filter((rule) => {
|
|
||||||
const customerName = appliedFilters.value.customerName.trim().toLowerCase()
|
|
||||||
return (!customerName || rule.customerName.toLowerCase().includes(customerName))
|
|
||||||
&& (!appliedFilters.value.chargeMode || rule.chargeMode === appliedFilters.value.chargeMode)
|
|
||||||
&& (!appliedFilters.value.status || rule.status === appliedFilters.value.status)
|
|
||||||
}))
|
|
||||||
const pagedRules = computed(() => filteredRules.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
|
||||||
const tableEmptyText = '暂无符合筛选条件的客户计费规则'
|
|
||||||
|
|
||||||
const emptyForm = (): CustomerRuleFormModel => ({
|
|
||||||
ruleCode: '',
|
|
||||||
customerId: '',
|
|
||||||
customerName: '',
|
|
||||||
businessType: 'FBA',
|
|
||||||
chargeMode: 'KG',
|
|
||||||
taxMode: 'TAX_INCLUDED',
|
|
||||||
conversionFactor: 167,
|
|
||||||
roundingScale: 3,
|
|
||||||
roundingMode: 'CEILING',
|
|
||||||
currency: 'CNY',
|
|
||||||
effectiveFrom: '',
|
|
||||||
effectiveTo: null,
|
|
||||||
remark: null,
|
|
||||||
})
|
|
||||||
const form = reactive<CustomerRuleFormModel>(emptyForm())
|
|
||||||
|
|
||||||
const formRules: FormRules<CustomerRuleFormModel> = {
|
|
||||||
ruleCode: [{ required: true, message: '请输入规则编码', trigger: 'blur' }],
|
|
||||||
customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }],
|
|
||||||
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
|
||||||
businessType: [{ required: true, message: '请输入业务类型', trigger: 'blur' }],
|
|
||||||
chargeMode: [{ required: true, message: '请选择计费方式', trigger: 'change' }],
|
|
||||||
taxMode: [{ required: true, message: '请选择税务模式', trigger: 'change' }],
|
|
||||||
conversionFactor: [{ required: true, message: '请输入大于 0 的换算系数', trigger: 'blur', type: 'number', min: 0.000001 }],
|
|
||||||
roundingScale: [{ required: true, message: '请选择保留位数', trigger: 'change', type: 'number', min: 0, max: 4 }],
|
|
||||||
roundingMode: [{ required: true, message: '请选择舍入方式', trigger: 'change' }],
|
|
||||||
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
|
|
||||||
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
|
|
||||||
}
|
|
||||||
|
|
||||||
useUnsavedWorkRegistration({
|
|
||||||
key: 'customer-pricing-rule-version',
|
|
||||||
label: computed(() => '客户计费规则版本'),
|
|
||||||
isOpen: computed(() => editorVisible.value),
|
|
||||||
model: form,
|
|
||||||
requiredPermission: computed(() => 'firstleg:fba-pricing:rule:edit'),
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(form, () => {
|
|
||||||
if (editorVisible.value) editorDirty.value = true
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
async function loadCustomers() {
|
|
||||||
customerLoadError.value = null
|
|
||||||
try {
|
|
||||||
const records: PlatformCustomerContract[] = []
|
|
||||||
let currentPage = 1
|
|
||||||
let pages = 1
|
|
||||||
do {
|
|
||||||
const result = await listPlatformTenants({ page: currentPage, size: 200 })
|
|
||||||
records.push(...result.records)
|
|
||||||
pages = Math.max(1, result.pages)
|
|
||||||
currentPage += 1
|
|
||||||
} while (currentPage <= pages)
|
|
||||||
customers.value = records.map((customer) => ({
|
|
||||||
id: String(customer.id),
|
|
||||||
name: customer.tenantName,
|
|
||||||
code: customer.tenantCode,
|
|
||||||
}))
|
|
||||||
} catch (cause) {
|
|
||||||
customers.value = []
|
|
||||||
customerLoadError.value = getApiErrorDetails(cause, '客户列表加载失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRules() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
rules.value = await listCustomerPricingRules({
|
|
||||||
...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {}),
|
|
||||||
ruleCode: appliedFilters.value.ruleCode.trim(),
|
|
||||||
})
|
|
||||||
} catch (cause) {
|
|
||||||
error.value = getApiErrorDetails(cause, '客户计费规则加载失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyQuery(model: QueryModel) {
|
|
||||||
const nextCustomerId = String(model.customerId || '')
|
|
||||||
if (nextCustomerId !== selectedCustomerId.value) {
|
|
||||||
rules.value = []
|
|
||||||
selectedRule.value = null
|
|
||||||
detailVisible.value = false
|
|
||||||
detailError.value = null
|
|
||||||
editorVisible.value = false
|
|
||||||
editorDirty.value = false
|
|
||||||
error.value = null
|
|
||||||
}
|
|
||||||
appliedFilters.value = {
|
|
||||||
customerId: nextCustomerId,
|
|
||||||
ruleCode: String(model.ruleCode || ''),
|
|
||||||
customerName: String(model.customerName || ''),
|
|
||||||
chargeMode: (model.chargeMode || '') as RuleFilters['chargeMode'],
|
|
||||||
status: String(model.status || ''),
|
|
||||||
}
|
|
||||||
page.value = 1
|
|
||||||
void loadRules()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDetail(row: CustomerPricingRule) {
|
|
||||||
detailVisible.value = true
|
|
||||||
detailLoading.value = true
|
|
||||||
detailError.value = null
|
|
||||||
selectedRule.value = row
|
|
||||||
try {
|
|
||||||
selectedRule.value = await getCustomerPricingRule(row.tenantId, row.id)
|
|
||||||
} catch (cause) {
|
|
||||||
detailError.value = getApiErrorDetails(cause, '规则详情加载失败')
|
|
||||||
} finally {
|
|
||||||
detailLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDetail() {
|
|
||||||
if (selectedRule.value) void openDetail(selectedRule.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRule(row: unknown): CustomerPricingRule {
|
|
||||||
return row as CustomerPricingRule
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm(source?: CustomerPricingRule) {
|
|
||||||
Object.assign(form, emptyForm(), source ? {
|
|
||||||
ruleCode: source.ruleCode,
|
|
||||||
customerId: source.customerId,
|
|
||||||
customerName: source.customerName,
|
|
||||||
businessType: source.businessType,
|
|
||||||
chargeMode: source.chargeMode,
|
|
||||||
taxMode: source.taxMode,
|
|
||||||
conversionFactor: source.conversionFactor,
|
|
||||||
roundingScale: source.roundingScale,
|
|
||||||
roundingMode: source.roundingMode,
|
|
||||||
currency: source.currency,
|
|
||||||
effectiveTo: source.effectiveTo,
|
|
||||||
remark: source.remark,
|
|
||||||
} : { customerId: selectedCustomerId.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditor(source?: CustomerPricingRule) {
|
|
||||||
editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value
|
|
||||||
resetForm(source)
|
|
||||||
editorDirty.value = false
|
|
||||||
editorVisible.value = true
|
|
||||||
await nextTick()
|
|
||||||
editorForm.value?.clearValidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeEditor() {
|
|
||||||
if (editorDirty.value) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
|
|
||||||
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
editorVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editorVisible.value = false
|
|
||||||
editorDirty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function beforeEditorClose(done: () => void) {
|
|
||||||
if (editorSaving.value) return
|
|
||||||
if (!editorDirty.value) {
|
|
||||||
done()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
|
|
||||||
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
|
||||||
})
|
|
||||||
editorDirty.value = false
|
|
||||||
done()
|
|
||||||
} catch {
|
|
||||||
editorVisible.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveVersion() {
|
|
||||||
if (!await editorForm.value?.validate()) return
|
|
||||||
editorSaving.value = true
|
|
||||||
try {
|
|
||||||
await createCustomerPricingRuleVersion(editorScopeCustomerId.value, {
|
|
||||||
...form,
|
|
||||||
ruleCode: form.ruleCode.trim(),
|
|
||||||
customerId: String(form.customerId),
|
|
||||||
customerName: form.customerName.trim(),
|
|
||||||
businessType: form.businessType.trim(),
|
|
||||||
effectiveTo: form.effectiveTo || null,
|
|
||||||
remark: form.remark?.trim() || null,
|
|
||||||
})
|
|
||||||
editorDirty.value = false
|
|
||||||
editorVisible.value = false
|
|
||||||
ElMessage.success('客户计费规则新版本已创建')
|
|
||||||
await loadRules()
|
|
||||||
} catch (cause) {
|
|
||||||
ElMessage.error(getApiErrorDetails(cause, '规则版本创建失败').message)
|
|
||||||
} finally {
|
|
||||||
editorSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableRule(row: CustomerPricingRule) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`停用规则“${row.ruleCode}”的当前版本?历史费用不会改变。`, '停用规则', {
|
|
||||||
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning',
|
|
||||||
})
|
|
||||||
await disableCustomerPricingRule(row.tenantId, row.id)
|
|
||||||
ElMessage.success('规则已停用')
|
|
||||||
await loadRules()
|
|
||||||
} catch (cause) {
|
|
||||||
if (cause === 'cancel' || cause === 'close') return
|
|
||||||
ElMessage.error(getApiErrorDetails(cause, '规则停用失败').message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
|
||||||
if (!value) return '长期有效'
|
|
||||||
return value.replace('T', ' ').slice(0, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeLabel(mode: string) {
|
|
||||||
return mode === 'KG' ? '按 KG' : mode === 'CBM' ? '按 CBM' : mode
|
|
||||||
}
|
|
||||||
|
|
||||||
function taxLabel(mode: string) {
|
|
||||||
return mode === 'TAX_INCLUDED' ? '包税' : mode === 'TAX_EXCLUDED' ? '不包税' : mode
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
access.initializeAfterLogin()
|
|
||||||
void loadCustomers()
|
|
||||||
void loadRules()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="business-page customer-rules-page">
|
|
||||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户计费规则筛选" :fields="queryFields" @search="applyQuery" />
|
|
||||||
|
|
||||||
<LoadErrorState v-if="customerLoadError" title="客户列表加载失败" :message="customerLoadError.message" compact @retry="loadCustomers" />
|
|
||||||
<LoadErrorState v-if="error" title="客户计费规则加载失败" :message="error.message" @retry="loadRules" />
|
|
||||||
<section v-else class="surface-panel table-panel" aria-live="polite" :aria-busy="loading">
|
|
||||||
<div class="panel-heading">
|
|
||||||
<div>
|
|
||||||
<h2>客户计费规则</h2>
|
|
||||||
<p>按企业维护 FBA 的 KG / CBM 计费版本 · 共 {{ filteredRules.length }} 个规则版本</p>
|
|
||||||
</div>
|
|
||||||
<el-button v-if="canEdit" type="primary" :disabled="loading || !selectedCustomerId" @click="openEditor()">
|
|
||||||
<Plus :size="16" aria-hidden="true" />新建版本
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<DataTable
|
|
||||||
v-model:page="page"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:data="pagedRules"
|
|
||||||
:columns="columns"
|
|
||||||
:loading="loading"
|
|
||||||
:total="filteredRules.length"
|
|
||||||
:page-sizes="[10, 20, 50]"
|
|
||||||
row-key="id"
|
|
||||||
:empty-text="tableEmptyText"
|
|
||||||
>
|
|
||||||
<template #actions="{ row }">
|
|
||||||
<div class="row-actions">
|
|
||||||
<el-button link type="primary" @click="openDetail(asRule(row))"><Eye :size="15" aria-hidden="true" />详情</el-button>
|
|
||||||
<el-button v-if="canEdit" link type="primary" @click="openEditor(asRule(row))"><Plus :size="15" aria-hidden="true" />新版本</el-button>
|
|
||||||
<el-button v-if="canEdit && row.status === 'ACTIVE'" link type="danger" @click="disableRule(asRule(row))"><CircleSlash2 :size="15" aria-hidden="true" />停用</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #rule="{ row }"><div class="primary-cell"><strong>{{ row.ruleCode }}</strong><small>版本 V{{ row.ruleVersion ?? '—' }}</small></div></template>
|
|
||||||
<template #customer="{ row }"><strong>{{ row.customerName }}</strong><small class="subline">ID {{ row.customerId }}</small></template>
|
|
||||||
<template #chargeMode="{ row }"><el-tag effect="plain">{{ modeLabel(row.chargeMode) }}</el-tag></template>
|
|
||||||
<template #taxMode="{ row }">{{ taxLabel(row.taxMode) }}</template>
|
|
||||||
<template #rounding="{ row }">保留 {{ row.roundingScale }} 位 · {{ row.roundingMode }}</template>
|
|
||||||
<template #period="{ row }"><span>{{ formatDate(row.effectiveFrom) }}</span><small class="subline">至 {{ formatDate(row.effectiveTo) }}</small></template>
|
|
||||||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></template>
|
|
||||||
<template #pagination-left>共 {{ filteredRules.length }} 个规则版本</template>
|
|
||||||
</DataTable>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<AppDrawer v-model="detailVisible" title="客户计费规则详情" size="min(600px, 94vw)" :loading="detailLoading" :show-footer="false">
|
|
||||||
<LoadErrorState v-if="detailError" title="规则详情加载失败" :message="detailError.message" compact @retry="retryDetail" />
|
|
||||||
<div v-else-if="selectedRule" class="rule-detail">
|
|
||||||
<section>
|
|
||||||
<h3>规则归属</h3>
|
|
||||||
<dl class="detail-grid">
|
|
||||||
<div><dt>规则编码</dt><dd>{{ selectedRule.ruleCode }}</dd></div>
|
|
||||||
<div><dt>规则版本</dt><dd>V{{ selectedRule.ruleVersion ?? '—' }}</dd></div>
|
|
||||||
<div><dt>适用客户</dt><dd>{{ selectedRule.customerName }}</dd></div>
|
|
||||||
<div><dt>客户 ID</dt><dd>{{ selectedRule.customerId }}</dd></div>
|
|
||||||
<div><dt>业务类型</dt><dd>{{ selectedRule.businessType }}</dd></div>
|
|
||||||
<div><dt>状态</dt><dd><el-tag :type="selectedRule.status === 'ACTIVE' ? 'success' : 'info'">{{ selectedRule.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h3>计费口径</h3>
|
|
||||||
<dl class="detail-grid">
|
|
||||||
<div><dt>计费方式</dt><dd>{{ modeLabel(selectedRule.chargeMode) }}</dd></div>
|
|
||||||
<div><dt>税务模式</dt><dd>{{ taxLabel(selectedRule.taxMode) }}</dd></div>
|
|
||||||
<div><dt>换算系数</dt><dd>{{ selectedRule.conversionFactor }}</dd></div>
|
|
||||||
<div><dt>舍入规则</dt><dd>保留 {{ selectedRule.roundingScale }} 位,{{ selectedRule.roundingMode }}</dd></div>
|
|
||||||
<div><dt>币种</dt><dd>{{ selectedRule.currency }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h3>版本有效期</h3>
|
|
||||||
<dl class="detail-grid">
|
|
||||||
<div><dt>生效时间</dt><dd>{{ formatDate(selectedRule.effectiveFrom) }}</dd></div>
|
|
||||||
<div><dt>失效时间</dt><dd>{{ formatDate(selectedRule.effectiveTo) }}</dd></div>
|
|
||||||
<div class="detail-wide"><dt>备注</dt><dd>{{ selectedRule.remark || '—' }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AppDrawer>
|
|
||||||
|
|
||||||
<AppDrawer
|
|
||||||
v-model="editorVisible"
|
|
||||||
title="创建客户计费规则版本"
|
|
||||||
size="min(760px, 94vw)"
|
|
||||||
:confirm-loading="editorSaving"
|
|
||||||
confirm-text="创建版本"
|
|
||||||
:before-close="beforeEditorClose"
|
|
||||||
@confirm="saveVersion"
|
|
||||||
@cancel="closeEditor"
|
|
||||||
>
|
|
||||||
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top" class="rule-form">
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>规则归属</h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<el-form-item label="规则编码" prop="ruleCode"><el-input v-model="form.ruleCode" maxlength="50" placeholder="例如 FBA-TAX-KG" /></el-form-item>
|
|
||||||
<el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.customerId" /></el-form-item>
|
|
||||||
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.customerName" maxlength="128" /></el-form-item>
|
|
||||||
<el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" /></el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>计费口径</h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<el-form-item label="计费方式" prop="chargeMode"><el-segmented v-model="form.chargeMode" :options="[{ label: '按 KG', value: 'KG' }, { label: '按 CBM', value: 'CBM' }]" /></el-form-item>
|
|
||||||
<el-form-item label="税务模式" prop="taxMode"><el-select v-model="form.taxMode"><el-option label="包税" value="TAX_INCLUDED" /><el-option label="不包税" value="TAX_EXCLUDED" /></el-select></el-form-item>
|
|
||||||
<el-form-item label="换算系数" prop="conversionFactor"><el-input-number v-model="form.conversionFactor" :min="0.000001" :precision="6" :controls="false" /></el-form-item>
|
|
||||||
<el-form-item label="保留小数位" prop="roundingScale"><el-select v-model="form.roundingScale"><el-option v-for="scale in [0, 1, 2, 3, 4]" :key="scale" :label="`${scale} 位`" :value="scale" /></el-select></el-form-item>
|
|
||||||
<el-form-item label="舍入方式" prop="roundingMode"><el-select v-model="form.roundingMode"><el-option label="向上取整 CEILING" value="CEILING" /><el-option label="向上舍入 UP" value="UP" /><el-option label="向下舍入 DOWN" value="DOWN" /><el-option label="向下取整 FLOOR" value="FLOOR" /><el-option label="四舍五入 HALF_UP" value="HALF_UP" /><el-option label="五舍六入 HALF_DOWN" value="HALF_DOWN" /><el-option label="银行家舍入 HALF_EVEN" value="HALF_EVEN" /></el-select></el-form-item>
|
|
||||||
<el-form-item label="币种" prop="currency"><el-select v-model="form.currency" filterable allow-create default-first-option><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-section">
|
|
||||||
<h2>生效范围</h2>
|
|
||||||
<div class="form-grid">
|
|
||||||
<el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item>
|
|
||||||
<el-form-item label="失效时间"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="不填表示长期有效" /></el-form-item>
|
|
||||||
<el-form-item label="备注" class="wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-form>
|
|
||||||
</AppDrawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.customer-rules-page { min-width: 0; }
|
|
||||||
.row-actions .el-button { display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.row-actions { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
|
|
||||||
.primary-cell { display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.primary-cell small, .subline { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }
|
|
||||||
.rule-detail { display: grid; gap: 24px; }
|
|
||||||
.rule-detail section + section { padding-top: 22px; border-top: 1px solid var(--cc-border); }
|
|
||||||
.rule-detail h3 { margin: 0 0 14px; color: var(--cc-text); font-size: 15px; }
|
|
||||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; margin: 0; }
|
|
||||||
.detail-grid > div { min-width: 0; }
|
|
||||||
.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-wide { grid-column: 1 / -1; }
|
|
||||||
.rule-form { display: grid; gap: 24px; }
|
|
||||||
.form-section { display: grid; gap: 14px; }
|
|
||||||
.form-section + .form-section { padding-top: 22px; border-top: 1px solid var(--cc-border); }
|
|
||||||
.form-section h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
|
|
||||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 20px; }
|
|
||||||
.wide { grid-column: 1 / -1; }
|
|
||||||
.form-grid :deep(.el-select), .form-grid :deep(.el-input-number), .form-grid :deep(.el-date-editor) { width: 100%; }
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
|
||||||
.wide { grid-column: auto; }
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.detail-grid { grid-template-columns: 1fr; }
|
|
||||||
.detail-wide { grid-column: auto; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export {
|
||||||
|
createCustomerPricingRuleVersion,
|
||||||
|
disableCustomerPricingRule,
|
||||||
|
getCustomerPricingRule,
|
||||||
|
listCustomerPricingRules,
|
||||||
|
} from './customer-rules/api'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CreateCustomerRuleVersionRequest,
|
||||||
|
CustomerPricingRule,
|
||||||
|
CustomerRuleChargeMode,
|
||||||
|
CustomerRuleFormModel,
|
||||||
|
CustomerRuleStatus,
|
||||||
|
} from './customer-rules/types'
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
export const fbaPricingRoutes = [
|
export const fbaPricingRoutes = [
|
||||||
{
|
|
||||||
path: 'first-leg-pricing/customer-rules',
|
|
||||||
name: 'fba-customer-rules',
|
|
||||||
component: () => import('./customer-rules/CustomerRulesView.vue'),
|
|
||||||
meta: {
|
|
||||||
title: '客户计费规则',
|
|
||||||
description: '维护 FBA 客户计费规则和生效版本',
|
|
||||||
permission: 'firstleg:fba-pricing:rule:view',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'first-leg-pricing/carrier-rates',
|
path: 'first-leg-pricing/carrier-rates',
|
||||||
name: 'fba-carrier-rates',
|
name: 'fba-carrier-rates',
|
||||||
component: () => import('./carrier-rates/pages/CarrierRatesView.vue'),
|
component: () => import('./carrier-rates/pages/CarrierRatesView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '船司 FBA 价格',
|
title: '船期价格表',
|
||||||
description: '维护起运仓、船司和 FBA 目的仓价格版本',
|
description: '维护起运仓、渠道、船司和 FBA 目的仓价格版本',
|
||||||
permission: 'firstleg:fba-pricing:rate:view',
|
permission: 'firstleg:fba-pricing:rate:view',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,10 +136,7 @@ async function mountView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectScope(wrapper: VueWrapper) {
|
async function selectScope(wrapper: VueWrapper) {
|
||||||
let toolbar = wrapper.findComponent(QueryToolbar)
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), customerId: tenantId })
|
|
||||||
await flushPromises()
|
|
||||||
toolbar = wrapper.findComponent(QueryToolbar)
|
|
||||||
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId })
|
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
|
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
|
||||||
@@ -168,20 +165,19 @@ describe('LocationsView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts with all customers and queries the full list without empty IDs', async () => {
|
it('loads all warehouses and queries the full location list without requiring a customer or warehouse', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
const fields = toolbar.props('fields') as QueryField[]
|
const fields = toolbar.props('fields') as QueryField[]
|
||||||
|
|
||||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||||
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(true)
|
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(false)
|
||||||
expect(fields[0]?.placeholder).toBe('全部客户')
|
expect(fields[0]?.placeholder).toBe('全部仓库')
|
||||||
expect((toolbar.props('modelValue') as QueryModel).customerId).toBe('')
|
|
||||||
expect(fields.map(({ key, width }) => [key, width])).toEqual([
|
expect(fields.map(({ key, width }) => [key, width])).toEqual([
|
||||||
['customerId', 260], ['warehouseId', 240], ['zoneCode', 210],
|
['warehouseId', 300], ['zoneCode', 210],
|
||||||
['locationCode', 220], ['locationType', 170], ['status', 160],
|
['locationCode', 220], ['locationType', 170], ['status', 160],
|
||||||
])
|
])
|
||||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||||
expect(listPlatformLocations).toHaveBeenCalledWith({
|
expect(listPlatformLocations).toHaveBeenCalledWith({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 20,
|
size: 20,
|
||||||
@@ -200,16 +196,15 @@ describe('LocationsView', () => {
|
|||||||
expect(wrapper.text()).not.toContain('企业 ID')
|
expect(wrapper.text()).not.toContain('企业 ID')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads the selected customer, warehouse, and location hierarchy without narrowing snowflake IDs', async () => {
|
it('filters by the selected warehouse without exposing or sending a customer id', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await selectScope(wrapper)
|
await selectScope(wrapper)
|
||||||
|
|
||||||
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false)
|
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false)
|
||||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(tenantId, 1, 200)
|
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 20,
|
size: 20,
|
||||||
tenantId,
|
|
||||||
warehouseId,
|
warehouseId,
|
||||||
zoneCode: '',
|
zoneCode: '',
|
||||||
locationCode: '',
|
locationCode: '',
|
||||||
@@ -223,6 +218,19 @@ describe('LocationsView', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens creation without a list filter and requires the warehouse inside the form', async () => {
|
||||||
|
const wrapper = await mountView()
|
||||||
|
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增库位')
|
||||||
|
|
||||||
|
expect(createButton?.attributes('disabled')).toBeUndefined()
|
||||||
|
await createButton!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('新增库位')
|
||||||
|
expect(document.body.textContent).toContain('所属仓库')
|
||||||
|
expect(document.body.textContent).toContain('请选择所属仓库')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the operation column first and hides edit actions for occupied resources', async () => {
|
it('keeps the operation column first and hides edit actions for occupied resources', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await selectScope(wrapper)
|
await selectScope(wrapper)
|
||||||
@@ -267,7 +275,7 @@ describe('LocationsView', () => {
|
|||||||
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
|
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears customer and warehouse on reset and immediately restores the full query', async () => {
|
it('clears the warehouse on reset and immediately restores the full query', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await selectScope(wrapper)
|
await selectScope(wrapper)
|
||||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
@@ -277,7 +285,6 @@ describe('LocationsView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const model = wrapper.findComponent(QueryToolbar).props('modelValue') as QueryModel
|
const model = wrapper.findComponent(QueryToolbar).props('modelValue') as QueryModel
|
||||||
expect(model.customerId).toBe('')
|
|
||||||
expect(model.warehouseId).toBe('')
|
expect(model.warehouseId).toBe('')
|
||||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../..
|
|||||||
|
|
||||||
type DrawerMode = 'view' | 'create' | 'edit'
|
type DrawerMode = 'view' | 'create' | 'edit'
|
||||||
type LocationFilters = QueryModel & {
|
type LocationFilters = QueryModel & {
|
||||||
customerId: string
|
|
||||||
warehouseId: string
|
warehouseId: string
|
||||||
zoneCode: string
|
zoneCode: string
|
||||||
locationCode: string
|
locationCode: string
|
||||||
@@ -36,6 +35,7 @@ type LocationFilters = QueryModel & {
|
|||||||
status: PlatformLocationStatus | ''
|
status: PlatformLocationStatus | ''
|
||||||
}
|
}
|
||||||
type AppliedLocationFilters = {
|
type AppliedLocationFilters = {
|
||||||
|
warehouseId: string
|
||||||
zoneCode: string
|
zoneCode: string
|
||||||
locationCode: string
|
locationCode: string
|
||||||
locationType: PlatformLocationType | ''
|
locationType: PlatformLocationType | ''
|
||||||
@@ -46,7 +46,6 @@ const access = useAccessPreviewStore()
|
|||||||
const customers = ref<PlatformTenantResource[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
const customerLoading = ref(false)
|
const customerLoading = ref(false)
|
||||||
const customerError = ref<ApiErrorDetails | null>(null)
|
const customerError = ref<ApiErrorDetails | null>(null)
|
||||||
const loadedCustomerId = ref<string | null>(null)
|
|
||||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||||
const warehouseLoading = ref(false)
|
const warehouseLoading = ref(false)
|
||||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -64,39 +63,26 @@ const detail = ref<PlatformLocationDetail | null>(null)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const actionLoadingIds = ref(new Set<string>())
|
const actionLoadingIds = ref(new Set<string>())
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
let customerLoadSequence = 0
|
let warehouseLoadSequence = 0
|
||||||
let locationLoadSequence = 0
|
let locationLoadSequence = 0
|
||||||
|
|
||||||
const filters = ref<LocationFilters>({ customerId: '', warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
const filters = ref<LocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||||
const appliedFilters = ref<AppliedLocationFilters>({ zoneCode: '', locationCode: '', locationType: '', status: '' })
|
const appliedFilters = ref<AppliedLocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||||
const selectedCustomerId = computed(() => String(filters.value.customerId || ''))
|
|
||||||
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
|
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
const queryFields = computed<QueryField[]>(() => [
|
||||||
{
|
|
||||||
key: 'customerId',
|
|
||||||
type: 'select',
|
|
||||||
width: 260,
|
|
||||||
placeholder: customerError.value ? '客户列表加载失败' : '全部客户',
|
|
||||||
props: { 'aria-label': '所属企业', filterable: true, disabled: customerLoading.value || Boolean(customerError.value) || selectionDisabled.value },
|
|
||||||
options: customers.value.map((customer) => ({
|
|
||||||
value: String(customer.id),
|
|
||||||
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'warehouseId',
|
key: 'warehouseId',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
width: 240,
|
width: 300,
|
||||||
placeholder: warehouseLoading.value ? '仓库加载中' : '所属仓库',
|
placeholder: warehouseLoading.value ? '仓库加载中' : '全部仓库',
|
||||||
props: {
|
props: {
|
||||||
'aria-label': '所属仓库',
|
'aria-label': '所属仓库',
|
||||||
filterable: true,
|
filterable: true,
|
||||||
disabled: selectionDisabled.value || warehouseLoading.value || !customerReady.value || warehouses.value.length === 0,
|
disabled: selectionDisabled.value || warehouseLoading.value || Boolean(warehouseError.value),
|
||||||
},
|
},
|
||||||
options: warehouses.value.map((warehouse) => ({
|
options: warehouses.value.map((warehouse) => ({
|
||||||
value: String(warehouse.id),
|
value: String(warehouse.id),
|
||||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
label: `${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`,
|
||||||
disabled: warehouse.status !== 'ACTIVE',
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
|
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
|
||||||
@@ -123,8 +109,8 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
id: '',
|
id: '',
|
||||||
customerId: selectedCustomerId.value,
|
customerId: '',
|
||||||
warehouseId: selectedWarehouseId.value,
|
warehouseId: '',
|
||||||
zoneCode: '',
|
zoneCode: '',
|
||||||
zoneName: '',
|
zoneName: '',
|
||||||
locationCode: '',
|
locationCode: '',
|
||||||
@@ -137,6 +123,7 @@ const emptyForm = () => ({
|
|||||||
})
|
})
|
||||||
const form = reactive(emptyForm())
|
const form = reactive(emptyForm())
|
||||||
const rules: FormRules<typeof form> = {
|
const rules: FormRules<typeof form> = {
|
||||||
|
warehouseId: [{ required: true, message: '请选择所属仓库', trigger: 'change' }],
|
||||||
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
|
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
|
||||||
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
|
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
|
||||||
locationCode: [
|
locationCode: [
|
||||||
@@ -153,9 +140,7 @@ const rules: FormRules<typeof form> = {
|
|||||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) ?? null)
|
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) ?? null)
|
||||||
const customerReady = computed(() => Boolean(selectedCustomerId.value) && loadedCustomerId.value === selectedCustomerId.value && !warehouseLoading.value)
|
|
||||||
const createReady = computed(() => customerReady.value && Boolean(selectedWarehouse.value) && !loading.value)
|
|
||||||
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
|
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
|
||||||
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
|
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
|
||||||
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
|
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
|
||||||
@@ -236,11 +221,11 @@ function actionError(error: unknown, fallback: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requireCreateScope() {
|
function requireCreateScope() {
|
||||||
if (!createReady.value || !selectedCustomerId.value || !selectedWarehouseId.value) {
|
if (!selectedWarehouse.value || !form.warehouseId) {
|
||||||
ElMessage.warning('所属企业和仓库数据尚未加载完成,请稍后重试')
|
ElMessage.warning('请选择所属仓库')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return { customerId: apiId(selectedCustomerId.value), warehouseId: apiId(selectedWarehouseId.value) }
|
return { customerId: apiId(String(selectedWarehouse.value.tenantId)), warehouseId: apiId(form.warehouseId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordScope(row: PlatformLocationResource) {
|
function recordScope(row: PlatformLocationResource) {
|
||||||
@@ -266,24 +251,20 @@ async function loadCustomers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouses(customerId = selectedCustomerId.value) {
|
async function loadWarehouses() {
|
||||||
if (!customerId) return
|
const sequence = ++warehouseLoadSequence
|
||||||
const sequence = ++customerLoadSequence
|
|
||||||
warehouseLoading.value = true
|
warehouseLoading.value = true
|
||||||
warehouseError.value = null
|
warehouseError.value = null
|
||||||
filters.value = { ...filters.value, warehouseId: '' }
|
|
||||||
warehouses.value = []
|
warehouses.value = []
|
||||||
try {
|
try {
|
||||||
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
|
const result = await listPlatformWarehouses(undefined, 1, 200)
|
||||||
if (sequence !== customerLoadSequence) return
|
if (sequence !== warehouseLoadSequence) return
|
||||||
warehouses.value = result.records
|
warehouses.value = result.records
|
||||||
loadedCustomerId.value = customerId
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (sequence !== customerLoadSequence) return
|
if (sequence !== warehouseLoadSequence) return
|
||||||
loadedCustomerId.value = null
|
|
||||||
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
|
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
if (sequence === customerLoadSequence) warehouseLoading.value = false
|
if (sequence === warehouseLoadSequence) warehouseLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,8 +281,7 @@ async function loadLocations() {
|
|||||||
locationType: appliedFilters.value.locationType,
|
locationType: appliedFilters.value.locationType,
|
||||||
status: appliedFilters.value.status,
|
status: appliedFilters.value.status,
|
||||||
}
|
}
|
||||||
if (selectedCustomerId.value) query.tenantId = apiId(selectedCustomerId.value)
|
if (appliedFilters.value.warehouseId) query.warehouseId = apiId(appliedFilters.value.warehouseId)
|
||||||
if (selectedWarehouseId.value) query.warehouseId = apiId(selectedWarehouseId.value)
|
|
||||||
const result = await listPlatformLocations(query)
|
const result = await listPlatformLocations(query)
|
||||||
if (sequence !== locationLoadSequence) return
|
if (sequence !== locationLoadSequence) return
|
||||||
rows.value = result.records
|
rows.value = result.records
|
||||||
@@ -318,6 +298,7 @@ async function loadLocations() {
|
|||||||
|
|
||||||
function applyFilters(model: QueryModel) {
|
function applyFilters(model: QueryModel) {
|
||||||
appliedFilters.value = {
|
appliedFilters.value = {
|
||||||
|
warehouseId: String(model.warehouseId || ''),
|
||||||
zoneCode: String(model.zoneCode || ''),
|
zoneCode: String(model.zoneCode || ''),
|
||||||
locationCode: String(model.locationCode || ''),
|
locationCode: String(model.locationCode || ''),
|
||||||
locationType: (model.locationType || '') as PlatformLocationType | '',
|
locationType: (model.locationType || '') as PlatformLocationType | '',
|
||||||
@@ -345,10 +326,10 @@ function assignForm(resource: PlatformLocationResource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
if (!requireCreateScope()) return
|
Object.assign(form, emptyForm())
|
||||||
Object.assign(form, emptyForm(), {
|
form.warehouseId = selectedWarehouseId.value
|
||||||
status: selectedWarehouse.value?.status === 'ACTIVE' ? 'AVAILABLE' : 'DISABLED',
|
form.customerId = selectedWarehouse.value ? String(selectedWarehouse.value.tenantId) : ''
|
||||||
})
|
form.status = selectedWarehouse.value?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
|
||||||
detail.value = null
|
detail.value = null
|
||||||
drawerError.value = null
|
drawerError.value = null
|
||||||
drawerMode.value = 'create'
|
drawerMode.value = 'create'
|
||||||
@@ -395,12 +376,12 @@ function buildPayload(): SavePlatformLocationRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveLocation() {
|
async function saveLocation() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
const scope = form.id
|
const scope = form.id
|
||||||
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
|
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
|
||||||
: requireCreateScope()
|
: requireCreateScope()
|
||||||
if (!scope) return
|
if (!scope) return
|
||||||
const valid = await formRef.value?.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload()
|
const payload = buildPayload()
|
||||||
@@ -493,19 +474,16 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch([page, pageSize], () => { void loadLocations() })
|
watch([page, pageSize], () => { void loadLocations() })
|
||||||
watch(selectedCustomerId, (customerId) => {
|
watch(() => form.warehouseId, (warehouseId) => {
|
||||||
drawerOpen.value = false
|
if (drawerMode.value !== 'create') return
|
||||||
loadedCustomerId.value = null
|
const warehouse = warehouses.value.find((item) => String(item.id) === warehouseId)
|
||||||
warehouses.value = []
|
form.customerId = warehouse ? String(warehouse.tenantId) : ''
|
||||||
filters.value = { ...filters.value, warehouseId: '' }
|
form.status = warehouse?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
|
||||||
if (customerId) void loadWarehouses(customerId)
|
|
||||||
})
|
|
||||||
watch(selectedWarehouseId, () => {
|
|
||||||
drawerOpen.value = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCustomers()
|
void loadCustomers()
|
||||||
|
void loadWarehouses()
|
||||||
void loadLocations()
|
void loadLocations()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -522,7 +500,6 @@ onMounted(() => {
|
|||||||
<el-button
|
<el-button
|
||||||
v-if="access.can('platform:location:create')"
|
v-if="access.can('platform:location:create')"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!createReady"
|
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
><Plus :size="16" />新增库位</el-button>
|
><Plus :size="16" />新增库位</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -540,7 +517,7 @@ onMounted(() => {
|
|||||||
<ApiErrorMeta :details="warehouseError" />
|
<ApiErrorMeta :details="warehouseError" />
|
||||||
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
|
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-alert v-else-if="customerReady && warehouses.length === 0" type="info" :closable="false" show-icon title="当前所属企业暂无仓库">
|
<el-alert v-else-if="!warehouseLoading && warehouses.length === 0" type="info" :closable="false" show-icon title="暂无可选仓库">
|
||||||
请先在仓库管理中建立仓库,再维护库位。
|
请先在仓库管理中建立仓库,再维护库位。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
||||||
@@ -610,8 +587,8 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #warehouse="{ row }">
|
<template #warehouse="{ row }">
|
||||||
<div class="stacked-value">
|
<div class="stacked-value">
|
||||||
<strong>{{ (row as PlatformLocationResource).warehouseName || selectedWarehouse?.warehouseName || '—' }}</strong>
|
<strong>{{ (row as PlatformLocationResource).warehouseName || warehouses.find((item) => String(item.id) === String((row as PlatformLocationResource).warehouseId))?.warehouseName || '—' }}</strong>
|
||||||
<small>{{ (row as PlatformLocationResource).warehouseCode || selectedWarehouse?.warehouseCode || '—' }}</small>
|
<small>{{ (row as PlatformLocationResource).warehouseCode || warehouses.find((item) => String(item.id) === String((row as PlatformLocationResource).warehouseId))?.warehouseCode || '—' }}</small>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
|
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
|
||||||
@@ -625,7 +602,7 @@ onMounted(() => {
|
|||||||
<template #empty>
|
<template #empty>
|
||||||
<el-empty v-if="!loading" :description="emptyDescription">
|
<el-empty v-if="!loading" :description="emptyDescription">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="selectedWarehouseId && !filtered && access.can('platform:location:create')"
|
v-if="!filtered && access.can('platform:location:create')"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
><Plus :size="16" />新增第一个库位</el-button>
|
><Plus :size="16" />新增第一个库位</el-button>
|
||||||
@@ -698,10 +675,20 @@ onMounted(() => {
|
|||||||
class="drawer-form"
|
class="drawer-form"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
>
|
||||||
<div class="editor-scope">
|
<div v-if="drawerMode === 'edit'" class="editor-scope">
|
||||||
<MapPin :size="18" />
|
<MapPin :size="18" />
|
||||||
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
|
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<el-form-item v-if="drawerMode === 'create'" label="所属仓库" prop="warehouseId" required>
|
||||||
|
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
|
||||||
|
<el-option
|
||||||
|
v-for="warehouse in warehouses"
|
||||||
|
:key="String(warehouse.id)"
|
||||||
|
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`"
|
||||||
|
:value="String(warehouse.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3>库位标识</h3>
|
<h3>库位标识</h3>
|
||||||
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
|
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
|
||||||
|
|||||||
@@ -84,17 +84,8 @@ async function mountView(permissions = [
|
|||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setFilterContext(wrapper: VueWrapper, customerId = '10001', warehouseId = '') {
|
async function setWarehouseFilter(wrapper: VueWrapper, warehouseId = '') {
|
||||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||||
customerId,
|
|
||||||
warehouseId: '',
|
|
||||||
palletCode: '',
|
|
||||||
palletType: '',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
if (!warehouseId) return
|
|
||||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
|
||||||
customerId,
|
|
||||||
warehouseId,
|
warehouseId,
|
||||||
palletCode: '',
|
palletCode: '',
|
||||||
palletType: '',
|
palletType: '',
|
||||||
@@ -103,9 +94,8 @@ async function setFilterContext(wrapper: VueWrapper, customerId = '10001', wareh
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRows(wrapper: VueWrapper, warehouseId = '') {
|
async function loadRows(wrapper: VueWrapper, warehouseId = '') {
|
||||||
await setFilterContext(wrapper, '10001', warehouseId)
|
await setWarehouseFilter(wrapper, warehouseId)
|
||||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||||
customerId: '10001',
|
|
||||||
warehouseId,
|
warehouseId,
|
||||||
palletCode: '',
|
palletCode: '',
|
||||||
palletType: '',
|
palletType: '',
|
||||||
@@ -129,33 +119,33 @@ describe('PalletsView', () => {
|
|||||||
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
|
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts with all customers and uses compact semantic filter widths', async () => {
|
it('starts with all customers and all warehouses using compact filters', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
const fields = toolbar.props('fields')
|
const fields = toolbar.props('fields')
|
||||||
|
|
||||||
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
|
expect(toolbar.props('modelValue')).toMatchObject({ warehouseId: '' })
|
||||||
expect(fields.map((field) => [field.key, field.width])).toEqual([
|
expect(fields.map((field) => [field.key, field.width])).toEqual([
|
||||||
['customerId', 260],
|
['warehouseId', 300],
|
||||||
['warehouseId', 240],
|
|
||||||
['palletCode', 240],
|
['palletCode', 240],
|
||||||
['palletType', 180],
|
['palletType', 180],
|
||||||
])
|
])
|
||||||
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
|
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
|
||||||
expect(fields.find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
|
expect(fields.find((field) => field.key === 'warehouseId')?.placeholder).toBe('全部仓库')
|
||||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||||
page: 1, size: 20,
|
page: 1, size: 20,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads warehouses after customer selection and omits an empty warehouse id when searching', async () => {
|
it('loads all warehouses independently and omits an empty warehouse id when searching', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await loadRows(wrapper)
|
await loadRows(wrapper)
|
||||||
|
|
||||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
|
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||||
|
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||||
page: 1, size: 20, tenantId: 10001,
|
page: 1, size: 20,
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('TP-001')
|
expect(wrapper.text()).toContain('TP-001')
|
||||||
expect(wrapper.text()).toContain('木托盘')
|
expect(wrapper.text()).toContain('木托盘')
|
||||||
@@ -165,7 +155,7 @@ describe('PalletsView', () => {
|
|||||||
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('全部托盘类型')
|
||||||
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
|
expect(wrapper.find('input[aria-label="客户"]').exists()).toBe(false)
|
||||||
expect(wrapper.find('input[aria-label="所属仓库"]').exists()).toBe(true)
|
expect(wrapper.find('input[aria-label="所属仓库"]').exists()).toBe(true)
|
||||||
expect(wrapper.find('.platform-tenant-selector').exists()).toBe(false)
|
expect(wrapper.find('.platform-tenant-selector').exists()).toBe(false)
|
||||||
expect(wrapper.text()).not.toContain('租户')
|
expect(wrapper.text()).not.toContain('租户')
|
||||||
@@ -179,7 +169,7 @@ describe('PalletsView', () => {
|
|||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
|
|
||||||
toolbar.vm.$emit('search', { customerId: '', warehouseId: '', palletCode: '', palletType: '' })
|
toolbar.vm.$emit('search', { warehouseId: '', palletCode: '', palletType: '' })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||||
page: 1, size: 20,
|
page: 1, size: 20,
|
||||||
@@ -187,56 +177,49 @@ describe('PalletsView', () => {
|
|||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resets customer and dependent warehouse filters without sending an empty id', async () => {
|
it('resets the warehouse filter without sending an empty id', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await setFilterContext(wrapper, '10001', '19001')
|
await setWarehouseFilter(wrapper, '19001')
|
||||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
|
|
||||||
toolbar.vm.reset()
|
toolbar.vm.reset()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
|
expect(toolbar.props('modelValue')).toMatchObject({ warehouseId: '' })
|
||||||
expect(listPlatformPallets).toHaveBeenCalledTimes(2)
|
expect(listPlatformPallets).toHaveBeenCalledTimes(2)
|
||||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||||
page: 1, size: 20,
|
page: 1, size: 20,
|
||||||
})
|
})
|
||||||
expect(toolbar.props('fields').find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
|
expect(toolbar.props('fields').find((field) => field.key === 'warehouseId')?.placeholder).toBe('全部仓库')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves a snowflake customer id when switching the enterprise filter', async () => {
|
it('does not expose customer ownership in warehouse choices', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
const snowflakeId = '9223372036854775806'
|
const warehouseField = wrapper.findComponent(QueryToolbar).props('fields').find((field) => field.key === 'warehouseId')
|
||||||
|
|
||||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||||
customerId: snowflakeId,
|
expect(warehouseField?.options).toEqual([{ label: '义乌收货仓(CN-YW-01)', value: '19001' }])
|
||||||
warehouseId: '',
|
|
||||||
palletCode: '',
|
|
||||||
palletType: '',
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(snowflakeId, 1, 200)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies pallet filters only after search', async () => {
|
it('applies pallet filters only after search', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await setFilterContext(wrapper, '10001', '19001')
|
await setWarehouseFilter(wrapper, '19001')
|
||||||
await wrapper.find('input[placeholder="托盘编码(模糊查询)"]').setValue('TP-00')
|
await wrapper.find('input[placeholder="托盘编码(模糊查询)"]').setValue('TP-00')
|
||||||
expect(listPlatformPallets).toHaveBeenCalledTimes(1)
|
expect(listPlatformPallets).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||||
customerId: '10001', warehouseId: '19001', palletCode: 'TP-00', palletType: '木托盘',
|
warehouseId: '19001', palletCode: 'TP-00', palletType: '木托盘',
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||||
page: 1, size: 20, tenantId: 10001, warehouseId: 19001, palletCode: 'TP-00', palletType: '木托盘',
|
page: 1, size: 20, warehouseId: 19001, palletCode: 'TP-00', palletType: '木托盘',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates a pallet in the selected warehouse with the real request fields', async () => {
|
it('creates a pallet in the selected warehouse with the real request fields', async () => {
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
await setFilterContext(wrapper, '10001', '19001')
|
await setWarehouseFilter(wrapper, '19001')
|
||||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
|
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
|
||||||
await createButton?.trigger('click')
|
await createButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -276,13 +259,13 @@ describe('PalletsView', () => {
|
|||||||
expect(wrapper.text()).not.toContain('TP-001')
|
expect(wrapper.text()).not.toContain('TP-001')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stops dependent requests when the enterprise filter cannot load', async () => {
|
it('keeps platform warehouse and pallet queries available when customer names cannot load', async () => {
|
||||||
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||||
const wrapper = await mountView()
|
const wrapper = await mountView()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('所属企业列表暂时无法加载')
|
expect(wrapper.text()).toContain('客户列表暂时无法加载')
|
||||||
expect(wrapper.text()).toContain('重新加载')
|
expect(wrapper.text()).toContain('重新加载')
|
||||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||||
page: 1, size: 20,
|
page: 1, size: 20,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ const access = useAccessPreviewStore()
|
|||||||
const customers = ref<PlatformCustomerResource[]>([])
|
const customers = ref<PlatformCustomerResource[]>([])
|
||||||
const customerLoading = ref(false)
|
const customerLoading = ref(false)
|
||||||
const customerError = ref<ApiErrorDetails | null>(null)
|
const customerError = ref<ApiErrorDetails | null>(null)
|
||||||
const selectedCustomerId = ref('')
|
|
||||||
const selectedWarehouseId = ref('')
|
|
||||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||||
const warehouseLoading = ref(false)
|
const warehouseLoading = ref(false)
|
||||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -45,36 +43,24 @@ const formRef = ref<FormInstance>()
|
|||||||
let warehouseSequence = 0
|
let warehouseSequence = 0
|
||||||
let palletSequence = 0
|
let palletSequence = 0
|
||||||
|
|
||||||
type PalletFilters = QueryModel & { customerId: string; warehouseId: string; palletCode: string; palletType: string }
|
type PalletFilters = QueryModel & { warehouseId: string; palletCode: string; palletType: string }
|
||||||
type AppliedPalletFilters = Pick<PalletFilters, 'palletCode' | 'palletType'>
|
type AppliedPalletFilters = Pick<PalletFilters, 'warehouseId' | 'palletCode' | 'palletType'>
|
||||||
const filters = ref<PalletFilters>({ customerId: '', warehouseId: '', palletCode: '', palletType: '' })
|
const filters = ref<PalletFilters>({ warehouseId: '', palletCode: '', palletType: '' })
|
||||||
const appliedFilters = ref<AppliedPalletFilters>({ palletCode: '', palletType: '' })
|
const appliedFilters = ref<AppliedPalletFilters>({ warehouseId: '', palletCode: '', palletType: '' })
|
||||||
const palletTypeOptions = ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸托盘']
|
const palletTypeOptions = ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸托盘']
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
const queryFields = computed<QueryField[]>(() => [
|
||||||
{
|
|
||||||
key: 'customerId',
|
|
||||||
type: 'select',
|
|
||||||
width: 260,
|
|
||||||
placeholder: customerLoading.value ? '正在加载客户' : customerError.value ? '客户列表加载失败' : '全部客户',
|
|
||||||
options: customers.value.map((customer) => ({
|
|
||||||
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
|
|
||||||
value: String(customer.id),
|
|
||||||
})),
|
|
||||||
props: { filterable: true, disabled: contextLocked.value || customerLoading.value || Boolean(customerError.value), 'aria-label': '所属企业' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'warehouseId',
|
key: 'warehouseId',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
width: 240,
|
width: 300,
|
||||||
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
|
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
|
||||||
options: warehouses.value.map((warehouse) => ({
|
options: warehouses.value.map((warehouse) => ({
|
||||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||||||
value: String(warehouse.id),
|
value: String(warehouse.id),
|
||||||
disabled: warehouse.status !== 'ACTIVE',
|
|
||||||
})),
|
})),
|
||||||
props: {
|
props: {
|
||||||
filterable: true,
|
filterable: true,
|
||||||
disabled: contextLocked.value || !selectedCustomerId.value || warehouseLoading.value || Boolean(warehouseError.value),
|
disabled: contextLocked.value || warehouseLoading.value || Boolean(warehouseError.value),
|
||||||
'aria-label': '所属仓库',
|
'aria-label': '所属仓库',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -92,6 +78,7 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
const emptyForm = () => ({ id: '', customerId: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, remark: '' })
|
const emptyForm = () => ({ id: '', customerId: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, remark: '' })
|
||||||
const form = reactive(emptyForm())
|
const form = reactive(emptyForm())
|
||||||
const rules: FormRules<typeof form> = {
|
const rules: FormRules<typeof form> = {
|
||||||
|
warehouseId: [{ required: true, message: '请选择所属仓库', trigger: 'change' }],
|
||||||
palletCode: [
|
palletCode: [
|
||||||
{ required: true, message: '请输入托盘编码', trigger: 'blur' },
|
{ required: true, message: '请输入托盘编码', trigger: 'blur' },
|
||||||
{ max: 50, message: '托盘编码不能超过 50 个字符', trigger: 'blur' },
|
{ max: 50, message: '托盘编码不能超过 50 个字符', trigger: 'blur' },
|
||||||
@@ -110,10 +97,7 @@ const rules: FormRules<typeof form> = {
|
|||||||
remark: [{ max: 500, message: '备注不能超过 500 个字符', trigger: 'blur' }],
|
remark: [{ max: 500, message: '备注不能超过 500 个字符', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) || null)
|
|
||||||
const editorWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) || null)
|
const editorWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) || null)
|
||||||
const currentCustomer = computed(() => customers.value.find((item) => String(item.id) === selectedCustomerId.value) || null)
|
|
||||||
const contextReady = computed(() => Boolean(selectedCustomerId.value && selectedWarehouseId.value && currentWarehouse.value))
|
|
||||||
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
|
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
|
||||||
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
|
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
|
||||||
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
|
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
|
||||||
@@ -187,18 +171,13 @@ async function loadCustomers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouses(customerId = selectedCustomerId.value) {
|
async function loadWarehouses() {
|
||||||
const sequence = ++warehouseSequence
|
const sequence = ++warehouseSequence
|
||||||
selectedWarehouseId.value = ''
|
|
||||||
filters.value = { ...filters.value, warehouseId: '' }
|
|
||||||
warehouses.value = []
|
warehouses.value = []
|
||||||
rows.value = []
|
|
||||||
total.value = 0
|
|
||||||
warehouseError.value = null
|
warehouseError.value = null
|
||||||
if (!customerId) return
|
|
||||||
warehouseLoading.value = true
|
warehouseLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
|
const result = await listPlatformWarehouses(undefined, 1, 200)
|
||||||
if (sequence !== warehouseSequence) return
|
if (sequence !== warehouseSequence) return
|
||||||
warehouses.value = result.records
|
warehouses.value = result.records
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,8 +189,6 @@ async function loadWarehouses(customerId = selectedCustomerId.value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPallets() {
|
async function loadPallets() {
|
||||||
const customerId = selectedCustomerId.value
|
|
||||||
const warehouseId = selectedWarehouseId.value
|
|
||||||
const sequence = ++palletSequence
|
const sequence = ++palletSequence
|
||||||
rows.value = []
|
rows.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
@@ -221,8 +198,7 @@ async function loadPallets() {
|
|||||||
const result = await listPlatformPallets({
|
const result = await listPlatformPallets({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
size: pageSize.value,
|
size: pageSize.value,
|
||||||
...(customerId ? { tenantId: apiId(customerId) } : {}),
|
...(appliedFilters.value.warehouseId ? { warehouseId: apiId(appliedFilters.value.warehouseId) } : {}),
|
||||||
...(warehouseId ? { warehouseId: apiId(warehouseId) } : {}),
|
|
||||||
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
|
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
|
||||||
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
|
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
|
||||||
})
|
})
|
||||||
@@ -238,29 +214,25 @@ async function loadPallets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFilters(model: QueryModel) {
|
function applyFilters(model: QueryModel) {
|
||||||
selectedCustomerId.value = String(model.customerId || '')
|
appliedFilters.value = {
|
||||||
selectedWarehouseId.value = String(model.warehouseId || '')
|
warehouseId: String(model.warehouseId || ''),
|
||||||
appliedFilters.value = { palletCode: String(model.palletCode || ''), palletType: String(model.palletType || '') }
|
palletCode: String(model.palletCode || ''),
|
||||||
|
palletType: String(model.palletType || ''),
|
||||||
|
}
|
||||||
if (page.value === 1) void loadPallets()
|
if (page.value === 1) void loadPallets()
|
||||||
else page.value = 1
|
else page.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
selectedCustomerId.value = ''
|
appliedFilters.value = { warehouseId: '', palletCode: '', palletType: '' }
|
||||||
selectedWarehouseId.value = ''
|
|
||||||
appliedFilters.value = { palletCode: '', palletType: '' }
|
|
||||||
if (page.value === 1) void loadPallets()
|
if (page.value === 1) void loadPallets()
|
||||||
else page.value = 1
|
else page.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
if (!contextReady.value) {
|
|
||||||
ElMessage.warning('请先选择所属企业和仓库')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Object.assign(form, emptyForm())
|
Object.assign(form, emptyForm())
|
||||||
form.customerId = selectedCustomerId.value
|
form.warehouseId = filters.value.warehouseId
|
||||||
form.warehouseId = selectedWarehouseId.value
|
form.customerId = editorWarehouse.value ? String(editorWarehouse.value.tenantId) : ''
|
||||||
editorOpen.value = true
|
editorOpen.value = true
|
||||||
captureBaseline()
|
captureBaseline()
|
||||||
void formRef.value?.clearValidate()
|
void formRef.value?.clearValidate()
|
||||||
@@ -308,7 +280,13 @@ async function openEdit(row: PlatformPalletResource) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePallet() {
|
async function savePallet() {
|
||||||
if ((!form.id && !contextReady.value) || !form.customerId || !form.warehouseId || !await formRef.value?.validate().catch(() => false) || form.capacityCbm === null) return
|
if (!await formRef.value?.validate().catch(() => false) || form.capacityCbm === null) return
|
||||||
|
const warehouse = editorWarehouse.value
|
||||||
|
if (!warehouse) {
|
||||||
|
ElMessage.error('所选仓库已不存在,请重新选择')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.customerId = String(warehouse.tenantId)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const payload: SavePlatformPalletRequest = {
|
const payload: SavePlatformPalletRequest = {
|
||||||
warehouseId: apiId(form.warehouseId),
|
warehouseId: apiId(form.warehouseId),
|
||||||
@@ -357,18 +335,6 @@ async function removePallet(row: PlatformPalletResource) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => filters.value.customerId, (customerId) => {
|
|
||||||
selectedCustomerId.value = String(customerId || '')
|
|
||||||
page.value = 1
|
|
||||||
void loadWarehouses(selectedCustomerId.value)
|
|
||||||
})
|
|
||||||
watch(() => filters.value.warehouseId, (warehouseId) => {
|
|
||||||
selectedWarehouseId.value = String(warehouseId || '')
|
|
||||||
page.value = 1
|
|
||||||
rows.value = []
|
|
||||||
total.value = 0
|
|
||||||
loadError.value = null
|
|
||||||
})
|
|
||||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||||
if (nextPage === previousPage && nextSize === previousSize) return
|
if (nextPage === previousPage && nextSize === previousSize) return
|
||||||
void loadPallets()
|
void loadPallets()
|
||||||
@@ -378,6 +344,7 @@ watch(() => access.can(editorPermission.value), (canWrite) => {
|
|||||||
}, { flush: 'sync' })
|
}, { flush: 'sync' })
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadCustomers()
|
void loadCustomers()
|
||||||
|
void loadWarehouses()
|
||||||
void loadPallets()
|
void loadPallets()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -386,7 +353,7 @@ onMounted(() => {
|
|||||||
<section class="pallet-page">
|
<section class="pallet-page">
|
||||||
<LoadErrorState
|
<LoadErrorState
|
||||||
v-if="customerError"
|
v-if="customerError"
|
||||||
title="所属企业列表暂时无法加载"
|
title="客户列表暂时无法加载"
|
||||||
:message="customerError.message"
|
:message="customerError.message"
|
||||||
:loading="customerLoading"
|
:loading="customerLoading"
|
||||||
@retry="loadCustomers"
|
@retry="loadCustomers"
|
||||||
@@ -407,7 +374,7 @@ onMounted(() => {
|
|||||||
title="仓库列表暂时无法加载"
|
title="仓库列表暂时无法加载"
|
||||||
:message="warehouseError.message"
|
:message="warehouseError.message"
|
||||||
:loading="warehouseLoading"
|
:loading="warehouseLoading"
|
||||||
@retry="loadWarehouses()"
|
@retry="loadWarehouses"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LoadErrorState
|
<LoadErrorState
|
||||||
@@ -420,8 +387,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<section class="table-panel surface-panel" aria-label="托盘列表区域">
|
<section class="table-panel surface-panel" aria-label="托盘列表区域">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<div><h2>托盘列表</h2><p>{{ currentCustomer?.tenantName || '全部客户' }} · {{ currentWarehouse?.warehouseName || '全部仓库' }} · 共 {{ total }} 个托盘</p></div>
|
<div><h2>托盘列表</h2><p>共 {{ total }} 个托盘</p></div>
|
||||||
<el-button v-if="access.can('platform:pallet:create')" type="primary" :disabled="!contextReady" @click="openCreate"><Plus :size="16" />新增托盘</el-button>
|
<el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
@@ -431,7 +398,7 @@ onMounted(() => {
|
|||||||
:loading="loading || warehouseLoading"
|
:loading="loading || warehouseLoading"
|
||||||
:total="total"
|
:total="total"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
:empty-text="filtered ? '暂无符合筛选条件的托盘' : selectedCustomerId ? '当前客户暂无托盘' : '暂无托盘数据'"
|
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
|
||||||
aria-label="托盘列表"
|
aria-label="托盘列表"
|
||||||
>
|
>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
@@ -484,11 +451,21 @@ onMounted(() => {
|
|||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<el-drawer v-model="editorOpen" :title="editorTitle" size="520px" class="business-drawer" :close-on-click-modal="false">
|
<el-drawer v-model="editorOpen" :title="editorTitle" size="520px" class="business-drawer" :close-on-click-modal="false">
|
||||||
<div class="editor-context">
|
<div v-if="form.id" class="editor-context">
|
||||||
<WarehouseIcon :size="18" />
|
<WarehouseIcon :size="18" />
|
||||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}({{ editorWarehouse?.warehouseCode || form.warehouseId }})</strong><span>所属企业 {{ customerLabel(form.customerId) }} · 托盘保存后不可跨仓移动</span></div>
|
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}({{ editorWarehouse?.warehouseCode || form.warehouseId }})</strong><span>客户 {{ customerLabel(form.customerId) }} · 托盘保存后不可跨仓移动</span></div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
|
<el-form-item v-if="!form.id" label="所属仓库" prop="warehouseId" required>
|
||||||
|
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
|
||||||
|
<el-option
|
||||||
|
v-for="warehouse in warehouses"
|
||||||
|
:key="String(warehouse.id)"
|
||||||
|
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||||
|
:value="String(warehouse.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="托盘编码" prop="palletCode" required>
|
<el-form-item label="托盘编码" prop="palletCode" required>
|
||||||
<el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" />
|
<el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ describe('platform warehouse API', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('lists all platform warehouses without a customer filter', async () => {
|
||||||
|
const page = { records: [], page: 1, size: 200, total: 0, pages: 0 }
|
||||||
|
vi.mocked(http.get).mockResolvedValue({ code: '200', data: page })
|
||||||
|
|
||||||
|
await listPlatformWarehouses()
|
||||||
|
|
||||||
|
expect(http.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||||
|
params: { page: 1, size: 200 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the implemented create, update, and status routes', async () => {
|
it('uses the implemented create, update, and status routes', async () => {
|
||||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
|
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
|
||||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ export interface SavePlatformWarehouseRequest {
|
|||||||
|
|
||||||
const basePath = '/platform/v1/warehouse-admin'
|
const basePath = '/platform/v1/warehouse-admin'
|
||||||
|
|
||||||
export async function listPlatformWarehouses(tenantId: number | string, page = 1, size = 200) {
|
export async function listPlatformWarehouses(tenantId?: number | string, page = 1, size = 200) {
|
||||||
const response = await http.get<ApiEnvelope<PlatformWarehousePage>, ApiEnvelope<PlatformWarehousePage>>(basePath, {
|
const response = await http.get<ApiEnvelope<PlatformWarehousePage>, ApiEnvelope<PlatformWarehousePage>>(basePath, {
|
||||||
params: { tenantId, page, size },
|
params: { ...(tenantId === undefined || tenantId === '' ? {} : { tenantId }), page, size },
|
||||||
})
|
})
|
||||||
return unwrapApiResponse(response)
|
return unwrapApiResponse(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export const warehouseRoutes = [
|
|||||||
path: 'warehouses',
|
path: 'warehouses',
|
||||||
name: 'warehouses',
|
name: 'warehouses',
|
||||||
component: () => import('@/modules/warehouses/pages/WarehousesView.vue'),
|
component: () => import('@/modules/warehouses/pages/WarehousesView.vue'),
|
||||||
meta: { title: '仓库管理', description: '维护客户头程下单可选择的平台收货仓', permission: 'platform:warehouse:list' },
|
meta: { title: '仓库管理', description: '统一维护国内收货仓、FBA 仓和海外仓', permission: 'platform:warehouse:list' },
|
||||||
},
|
},
|
||||||
] satisfies RouteRecordRaw[]
|
] satisfies RouteRecordRaw[]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ type PrototypeRoute = readonly [path: string, name: string, title: string]
|
|||||||
type ModuleView = () => Promise<Component>
|
type ModuleView = () => Promise<Component>
|
||||||
|
|
||||||
const moduleViews = {
|
const moduleViews = {
|
||||||
dashboard: () => import('@/prototype/modules/dashboard/ModuleView.vue'),
|
|
||||||
customerManagement: () => import('@/prototype/modules/customer-management/ModuleView.vue'),
|
customerManagement: () => import('@/prototype/modules/customer-management/ModuleView.vue'),
|
||||||
domesticWarehouse: () => import('@/prototype/modules/domestic-warehouse/ModuleView.vue'),
|
domesticWarehouse: () => import('@/prototype/modules/domestic-warehouse/ModuleView.vue'),
|
||||||
overseasDelivery: () => import('@/prototype/modules/overseas-delivery/ModuleView.vue'),
|
overseasDelivery: () => import('@/prototype/modules/overseas-delivery/ModuleView.vue'),
|
||||||
@@ -36,7 +35,6 @@ function createRoutes(view: ModuleView, routes: PrototypeRoute[]): RouteRecordRa
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const prototypeModuleRoutes: RouteRecordRaw[] = [
|
export const prototypeModuleRoutes: RouteRecordRaw[] = [
|
||||||
...createRoutes(moduleViews.dashboard, [['dashboard', 'dashboard', '首页数据看板']]),
|
|
||||||
...createRoutes(moduleViews.customerManagement, [
|
...createRoutes(moduleViews.customerManagement, [
|
||||||
['prototype/customer-management/warehouse-tier-fee', 'warehouse-tier-fee', '海外仓阶梯费用'],
|
['prototype/customer-management/warehouse-tier-fee', 'warehouse-tier-fee', '海外仓阶梯费用'],
|
||||||
]),
|
]),
|
||||||
@@ -114,10 +112,8 @@ export const prototypeModuleRoutes: RouteRecordRaw[] = [
|
|||||||
...createRoutes(moduleViews.general, [
|
...createRoutes(moduleViews.general, [
|
||||||
['prototype/general/product-information', 'general-product', '产品信息'],
|
['prototype/general/product-information', 'general-product', '产品信息'],
|
||||||
['prototype/general/fee-items', 'general-fee', '费用项目'],
|
['prototype/general/fee-items', 'general-fee', '费用项目'],
|
||||||
['prototype/general/fba-address', 'general-fba', 'FBA编码地址'],
|
|
||||||
['prototype/general/pallet-management', 'general-pallet', '托盘管理'],
|
['prototype/general/pallet-management', 'general-pallet', '托盘管理'],
|
||||||
['prototype/general/location-management', 'general-location', '库位/货架管理'],
|
['prototype/general/location-management', 'general-location', '库位/货架管理'],
|
||||||
['prototype/general/voyage-price-list', 'general-voyage', '船期价格表'],
|
|
||||||
]),
|
]),
|
||||||
...createRoutes(moduleViews.sales, [
|
...createRoutes(moduleViews.sales, [
|
||||||
['prototype/sales/visit-records', 'sales-visits', '拜访记录'],
|
['prototype/sales/visit-records', 'sales-visits', '拜访记录'],
|
||||||
|
|||||||
Reference in New Issue
Block a user