This commit is contained in:
2026-08-05 15:54:59 +08:00
parent eede351038
commit 4b31eb7786
43 changed files with 2117 additions and 851 deletions
+5 -1
View File
@@ -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 与交互
+261
View File
@@ -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** 把核心操作放到表格末列或右侧固定列。
+2 -2
View File
@@ -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 独占保存。
+3 -4
View File
@@ -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('客户列表')
}) })
+84
View File
@@ -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)
})
})
+261
View File
@@ -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>
+27 -3
View File
@@ -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')
})
}) })
+58 -3
View File
@@ -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>
+12 -3
View File
@@ -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
View File
@@ -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 }
+4 -4
View File
@@ -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')
}) })
}) })
+9 -11
View File
@@ -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')
}) })
+5 -14
View File
@@ -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' },
], ],
}, },
+33
View File
@@ -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'])
})
}) })
+39 -4
View File
@@ -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
View File
@@ -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 {}
+37
View File
@@ -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,
}
}
+17
View File
@@ -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 })
})
})
+147
View File
@@ -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
View File
@@ -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')
+4 -4
View File
@@ -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>
+14
View File
@@ -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>
+14
View File
@@ -0,0 +1,14 @@
export {
createCustomerPricingRuleVersion,
disableCustomerPricingRule,
getCustomerPricingRule,
listCustomerPricingRules,
} from './customer-rules/api'
export type {
CreateCustomerRuleVersionRequest,
CustomerPricingRule,
CustomerRuleChargeMode,
CustomerRuleFormModel,
CustomerRuleStatus,
} from './customer-rules/types'
+2 -12
View File
@@ -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)
} }
+1 -1
View File
@@ -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[]
-4
View File
@@ -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', '拜访记录'],