diff --git a/AGENTS.md b/AGENTS.md index 4100859c..82088b0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,12 +33,16 @@ ## 业务与安全约束 +- 当前产品层级只有平台和客户(企业)两级;Admin 不把“租户”作为用户可见概念,不新增租户选择器、目标租户文案或依赖租户上下文才能使用的页面流程。多租户产品能力后续单独设计。 +- 平台仓库是公共主数据,不与客户绑定。托盘、库位等仓储资源页面必须能够独立查询全部客户和全部仓库,选择客户不得缩小、清空或禁用仓库候选。 +- 后端兼容字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。 + - 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。 - 认证统一经过 `src/core/auth/auth.ts` 和 `src/shared/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。 - 当前登录和部分数据仍可能是模拟实现。接入 `baiyue-firstleg` Gateway 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。 - 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。 - 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。 -- Admin 维护企业所属仓库、往来单位、快递公司等主数据时,必须通过平台企业列表显式选择目标企业;不得从本地存储推断租户。默认可优先选择历史租户 `10001`(不存在时选择列表第一项),企业雪花 ID 必须按字符串透传,切换企业时不得保留上一企业的编辑、详情或导入上下文。 +- Admin 维护客户专属数据时,必须通过平台客户列表显式选择目标客户,不得从本地存储推断客户。平台仓库等公共主数据不要求先选择客户;切换客户时不得保留上一客户的编辑、详情或导入上下文。 ## UI 与交互 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..8fb8fa52 --- /dev/null +++ b/DESIGN.md @@ -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** 把核心操作放到表格末列或右侧固定列。 diff --git a/README.md b/README.md index 74e52708..79fa4c41 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pnpm test 依赖和 `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` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 -仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。 +仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。 头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司已经接入 Warehouse Service 的真实分页查询、详情、新增、编辑、启停、标准模板下载和 XLSX 整批导入接口,按租户与平台权限访问;订单履约接口当前仍按公司名称和快递单号保存。平台往来单位已经接入 Warehouse Service 的真实分页、新增、编辑和启停接口,当前后端类型契约为客户、供应商及客户/供应商双重类型;批量导入接口尚未提供,不模拟导入成功。内部用户管理仅维护平台员工账号,已接入服务端分页查询、关键词与状态筛选、新增、编辑、平台角色调整、启停、最后登录展示和密码重置接口;账号凭据仍由 Auth Service 独占保存。 diff --git a/src/app/components/AdminSidebarMenu.test.ts b/src/app/components/AdminSidebarMenu.test.ts index 97b69c80..5abcf329 100644 --- a/src/app/components/AdminSidebarMenu.test.ts +++ b/src/app/components/AdminSidebarMenu.test.ts @@ -9,7 +9,6 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([ 'platform:firstleg:inbound:list', 'platform:location:list', 'platform:pallet:list', - 'firstleg:fba-pricing:rule:view', 'firstleg:fba-pricing:rate:view', 'platform:express:list', 'platform:counterparty:list', @@ -49,11 +48,11 @@ describe('AdminSidebarMenu', () => { expect(groupTitles).toContain('供应商中心') expect(groupTitles).toContain('系统管理') expect(groupTitles).not.toContain('财务管理') - expect(paths).not.toContain('/dashboard') + expect(paths).toContain('/dashboard') expect(paths).toContain('/inbounds') expect(paths).toContain('/master-data/express-companies') 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).not.toContain('/prototype/reports/overview') expect(paths).toContain('/system/permissions') @@ -76,7 +75,7 @@ describe('AdminSidebarMenu', () => { }) expect(wrapper.text()).toContain('菜单权限') - expect(wrapper.text()).not.toContain('首页数据看板') + expect(wrapper.text()).toContain('首页数据看板') expect(wrapper.text()).not.toContain('客户列表') }) diff --git a/src/app/components/WorkspaceTabs.test.ts b/src/app/components/WorkspaceTabs.test.ts new file mode 100644 index 00000000..5b663870 --- /dev/null +++ b/src/app/components/WorkspaceTabs.test.ts @@ -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() + 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: '

' }, meta: { requiresAuth: true, title: '客户管理' } }, + { path: '/warehouses', name: 'warehouses', component: { template: '

' }, 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('.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) + }) +}) diff --git a/src/app/components/WorkspaceTabs.vue b/src/app/components/WorkspaceTabs.vue new file mode 100644 index 00000000..8007dda5 --- /dev/null +++ b/src/app/components/WorkspaceTabs.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts index aca0ee0f..f7554da9 100644 --- a/src/app/layouts/AppLayout.test.ts +++ b/src/app/layouts/AppLayout.test.ts @@ -1,11 +1,20 @@ import { createPinia, setActivePinia } from 'pinia' import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, ref } from 'vue' import { createMemoryHistory, createRouter } from 'vue-router' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ElMessageBox } from 'element-plus' import { useAuthStore } from '@/core/auth/auth' import AppLayout from './AppLayout.vue' +const CachedPage = defineComponent({ + setup() { + const count = ref(0) + return { count } + }, + template: '', +}) + vi.mock('element-plus', async (importOriginal) => { const actual = await importOriginal() return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } } @@ -21,15 +30,16 @@ async function mountLayout(path = '/') { const router = createRouter({ history: createMemoryHistory(), routes: [ - { path: '/', component: { template: '

page

' } }, - { path: '/customers', component: { template: '

customers

' }, meta: { title: '客户管理', showPageHeading: false } }, + { path: '/', name: 'home', component: { template: '

page

' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } }, + { path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } }, + { path: '/warehouses', name: 'warehouses', component: { template: '

warehouses

' }, meta: { title: '仓库管理', requiresAuth: true } }, { path: '/login', component: { template: '

login

' } }, ], }) await router.push(path) await router.isReady() const wrapper = mount(AppLayout, { - global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '