信用代码:{{ displayText(qualification.certificateNo) }}
+审核意见:{{ qualification.reviewComment }}
+From dd0d365b0305547cf1fc78710a976995bd522446 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 30 Jul 2026 17:59:43 +0800 Subject: [PATCH] chore: initial commit --- .env.example | 2 + .gitignore | 19 + AGENTS.md | 59 + README.md | 60 + index.html | 14 + package.json | 34 + pnpm-lock.yaml | 2641 +++++++++++++++++ .../counterparties-import-template.xlsx | Bin 0 -> 7039 bytes .../express-companies-import-template.xlsx | Bin 0 -> 6141 bytes src/App.vue | 4 + src/api/http.test.ts | 43 + src/api/http.ts | 121 + src/api/platformIam.test.ts | 117 + src/api/platformIam.ts | 136 + src/auto-imports.d.ts | 10 + src/components.d.ts | 58 + src/components/AdminSidebarMenu.test.ts | 93 + src/components/AdminSidebarMenu.vue | 31 + src/components/AdminSidebarMenuNode.vue | 25 + src/components/ApiErrorMeta.vue | 38 + src/components/MetricStrip.vue | 16 + src/components/RolePreviewSwitcher.test.ts | 63 + src/components/RolePreviewSwitcher.vue | 52 + .../StaticBatchImportDialog.test.ts | 19 + src/components/StaticBatchImportDialog.vue | 118 + src/components/StatusBadge.vue | 18 + .../useUnsavedWorkRegistration.test.ts | 101 + src/composables/useUnsavedWorkRegistration.ts | 47 + src/env.d.ts | 15 + src/layouts/AppLayout.test.ts | 323 ++ src/layouts/AppLayout.vue | 265 ++ src/main.ts | 9 + src/navigation/adminNavigation.test.ts | 111 + src/navigation/adminNavigation.ts | 89 + src/router/accessDecision.test.ts | 109 + src/router/accessDecision.ts | 48 + src/router/index.test.ts | 50 + src/router/index.ts | 125 + src/router/meta.d.ts | 10 + src/stores/accessPreview.test.ts | 200 ++ src/stores/accessPreview.ts | 155 + src/stores/adminData.test.ts | 126 + src/stores/adminData.ts | 389 +++ src/stores/auth.ts | 150 + src/stores/unsavedWork.test.ts | 83 + src/stores/unsavedWork.ts | 40 + src/styles/index.css | 351 +++ src/types/admin.ts | 147 + src/utils/jwt.ts | 26 + src/utils/permissionTree.test.ts | 55 + src/utils/permissionTree.ts | 75 + src/views/AccessControlledActions.test.ts | 325 ++ src/views/CounterpartiesView.vue | 182 ++ src/views/CustomersView.vue | 190 ++ src/views/EnterpriseReviewsView.test.ts | 160 + src/views/EnterpriseReviewsView.vue | 360 +++ src/views/ExpressCompaniesView.test.ts | 78 + src/views/ExpressCompaniesView.vue | 153 + src/views/FirstLegOrdersView.vue | 241 ++ src/views/ForbiddenView.test.ts | 80 + src/views/ForbiddenView.vue | 37 + src/views/InternalUsersView.vue | 128 + src/views/LoginView.test.ts | 150 + src/views/LoginView.vue | 87 + src/views/ModuleView.vue | 29 + src/views/PermissionsView.test.ts | 80 + src/views/PermissionsView.vue | 180 ++ src/views/RolesView.test.ts | 98 + src/views/RolesView.vue | 207 ++ src/views/WarehousesView.vue | 165 + tsconfig.json | 23 + vite.config.ts | 38 + vitest.config.ts | 17 + 73 files changed, 9898 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 public/templates/counterparties-import-template.xlsx create mode 100644 public/templates/express-companies-import-template.xlsx create mode 100644 src/App.vue create mode 100644 src/api/http.test.ts create mode 100644 src/api/http.ts create mode 100644 src/api/platformIam.test.ts create mode 100644 src/api/platformIam.ts create mode 100644 src/auto-imports.d.ts create mode 100644 src/components.d.ts create mode 100644 src/components/AdminSidebarMenu.test.ts create mode 100644 src/components/AdminSidebarMenu.vue create mode 100644 src/components/AdminSidebarMenuNode.vue create mode 100644 src/components/ApiErrorMeta.vue create mode 100644 src/components/MetricStrip.vue create mode 100644 src/components/RolePreviewSwitcher.test.ts create mode 100644 src/components/RolePreviewSwitcher.vue create mode 100644 src/components/StaticBatchImportDialog.test.ts create mode 100644 src/components/StaticBatchImportDialog.vue create mode 100644 src/components/StatusBadge.vue create mode 100644 src/composables/useUnsavedWorkRegistration.test.ts create mode 100644 src/composables/useUnsavedWorkRegistration.ts create mode 100644 src/env.d.ts create mode 100644 src/layouts/AppLayout.test.ts create mode 100644 src/layouts/AppLayout.vue create mode 100644 src/main.ts create mode 100644 src/navigation/adminNavigation.test.ts create mode 100644 src/navigation/adminNavigation.ts create mode 100644 src/router/accessDecision.test.ts create mode 100644 src/router/accessDecision.ts create mode 100644 src/router/index.test.ts create mode 100644 src/router/index.ts create mode 100644 src/router/meta.d.ts create mode 100644 src/stores/accessPreview.test.ts create mode 100644 src/stores/accessPreview.ts create mode 100644 src/stores/adminData.test.ts create mode 100644 src/stores/adminData.ts create mode 100644 src/stores/auth.ts create mode 100644 src/stores/unsavedWork.test.ts create mode 100644 src/stores/unsavedWork.ts create mode 100644 src/styles/index.css create mode 100644 src/types/admin.ts create mode 100644 src/utils/jwt.ts create mode 100644 src/utils/permissionTree.test.ts create mode 100644 src/utils/permissionTree.ts create mode 100644 src/views/AccessControlledActions.test.ts create mode 100644 src/views/CounterpartiesView.vue create mode 100644 src/views/CustomersView.vue create mode 100644 src/views/EnterpriseReviewsView.test.ts create mode 100644 src/views/EnterpriseReviewsView.vue create mode 100644 src/views/ExpressCompaniesView.test.ts create mode 100644 src/views/ExpressCompaniesView.vue create mode 100644 src/views/FirstLegOrdersView.vue create mode 100644 src/views/ForbiddenView.test.ts create mode 100644 src/views/ForbiddenView.vue create mode 100644 src/views/InternalUsersView.vue create mode 100644 src/views/LoginView.test.ts create mode 100644 src/views/LoginView.vue create mode 100644 src/views/ModuleView.vue create mode 100644 src/views/PermissionsView.test.ts create mode 100644 src/views/PermissionsView.vue create mode 100644 src/views/RolesView.test.ts create mode 100644 src/views/RolesView.vue create mode 100644 src/views/WarehousesView.vue create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..a39d43e3 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=/api +API_PROXY_TARGET=http://localhost:9000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e2bc4c31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules/ +node_modules.broken-by-path/ +dist/ + +.env +.env.* +!.env.example + +.playwright-cli/ +.impeccable/ +.codex-temp/ +.codex-tmp/ +.logs/ +outputs/ + +*.log +.DS_Store +Thumbs.db + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..baddfbda --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Admin Web Instructions + +## 项目定位 + +- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。 +- 当前范围包括工作台、客户管理、头程订单、内部用户、角色管理和菜单权限。 +- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。 + +## 技术基线 + +- 使用 Vue 3、TypeScript、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue。 +- TypeScript 保持严格模式;不要使用 `any` 绕过业务模型,优先从 `@chongchong/shared` 复用跨项目类型。 +- 使用现有 `@` 别名引用 `src/`。保持 ES Module 和现有无分号代码风格。 +- Element Plus 组件使用现有自动导入配置;`ref`、`computed` 等 Vue API 必须显式导入。`src/auto-imports.d.ts` 与 `src/components.d.ts` 属于生成声明,不手工维护业务内容。 +- 不更换框架、路由、状态管理、组件库或构建工具,除非任务明确要求并完成影响评估。 + +## 目录与职责 + +- `src/views/`:路由级页面和业务编排;复杂、可复用的展示或交互下沉到组件。 +- `src/components/`:无路由依赖的复用组件,输入输出应有明确类型。 +- `src/layouts/`:应用壳层、导航和全局页面结构。 +- `src/router/`:路由、标题、登录保护和权限元数据;新增模块时同步维护导航与路由配置。 +- `src/stores/`:跨页面状态和业务动作;仅页面局部使用的状态留在组件内。 +- `src/api/`:HTTP 客户端和接口适配;页面与组件不得散落创建 Axios 实例。 +- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 +- `src/types/`:后台专属类型;跨应用共享的稳定契约放到 `packages/shared`。 +- `src/styles/`:设计令牌和全局样式;优先复用现有 `--cc-*` 变量和组件样式。 + +## 业务与安全约束 + +- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。 +- 认证统一经过 `src/stores/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。 +- 当前登录和部分数据仍可能是模拟实现。接入 `services/api` 时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。 +- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。 +- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。 + +## UI 与交互 + +- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。 +- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。 +- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。 +- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。 +- 新页面至少适配现有 320px 最小宽度策略,并检查侧栏、表格、抽屉和表单在窄屏下的退化方式。 + +## 本地命令与质量门禁 + +在 `apps/admin-web` 目录执行: + +```bash +pnpm install +pnpm dev +pnpm test +pnpm typecheck +pnpm build +``` + +- 日常变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`,后者也会执行类型检查。 +- 当前项目未声明 lint 脚本,不要声称 lint 已通过。若任务新增或变更质量脚本,应同时更新 `package.json`、`README.md` 和本文件。 +- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目的 `package.json` 与锁文件。 diff --git a/README.md b/README.md new file mode 100644 index 00000000..be666787 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# 虫虫出海管理后台 + +平台内部运营与管理应用,使用 Vue 3、TypeScript、Vite、Vue Router、Pinia 和 Element Plus。 + +## 本地运行 + +```bash +cd apps/admin-web +pnpm install +pnpm dev +``` + +生产构建: + +```bash +pnpm typecheck +pnpm build +``` + +自动化测试: + +```bash +pnpm test +``` + +依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 + +工作台已从一期路由与菜单移除;登录后按当前角色进入第一个有权限的业务页面。一期 Admin 路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限。 + +后台菜单按“业务管理”和“系统管理”分组。企业审核、平台角色、菜单及按钮权限资源已接入真实 IAM 接口;客户档案、基础资料和部分系统管理页面仍使用 Pinia 模拟数据,不请求尚未确定的 Java 业务接口。 + +## 本地 Gateway 联调 + +开发环境统一通过 Vite 的 `/api` 代理访问 Gateway,不在浏览器代码中拼接 Nacos 服务名或服务直连端口。复制 `.env.example` 为 `.env.local`,并按联调机器修改: + +```dotenv +VITE_API_BASE_URL=/api +API_PROXY_TARGET=http://192.168.110.149:9000 +``` + +认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。平台角色调用 `/roles`,权限资源调用 `/permissions`;接口未提供的编辑、删除、启停和已有授权回显不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 + +仓库管理当前维护义乌、深圳、青岛、上海、宁波、厦门等平台国内收货仓,支持筛选、新增、编辑和启停。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业,快照仍为 Pinia 模拟数据,服务端持久化待接入。 + +快递公司和往来单位一期静态维护已完成:支持筛选、详情、新增、编辑、启停、唯一校验、标准模板下载和导入文件基础检查;往来单位支持六类多选角色、联系人和完整地址。两类主数据只导入不导出,导入只新增,任一行错误整批失败。工作表内容、模板版本、重复数据和整批事务必须由后端解析校验;接口提供前,前端不模拟校验或导入成功。头程订单可维护一组快递公司和快递单号;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。上述业务数据仍使用 Pinia,等待后端新架构明确契约后接入。 + +真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 + +平台联调账号和密码只在 Vite 开发模式下预填,联调提示也只在开发模式显示。生产登录表单默认留空;生产构建产物不得包含本地联调账号、密码或开发提示文案。 + +## 静态权限预览 + +管理后台支持在顶栏切换本地角色,预览该角色可见的菜单、可访问页面和可用操作。预览角色保存在浏览器本地,刷新后继续保留;退出登录时清除,下次登录重新使用默认角色。 + +该功能仅用于静态页面和交互验收: + +- 预览角色和演示业务数据来自本地 Pinia;真实登录会切换到服务端权限,并让已接入页面调用 Java 业务 API。 +- 当前不模拟角色的 `dataScope` 数据范围。 +- 菜单隐藏、页面拦截和操作禁用不能替代服务端鉴权。 +- 后续接入 Java 服务时,接口仍须独立校验登录身份、角色权限和数据范围。 diff --git a/index.html b/index.html new file mode 100644 index 00000000..02879339 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + +
+ + + +ujkTQ+l*hmj{6Vx^F z210obo+eDP`wjfeykV7J<)gq&R2~1~sVg7pu@obAl5)2qe`-woty@(TVf$m_Ps@T! z?bmu+8Rz!0bwBBCf0Ubcw2|TE?*zN{Pj~DsX2cP$>>-BTDkbgi4uN-dk#>5bgM; zC0^pkx0OvfHkQZzp>}B`+7PXDC2{_#SY9U!8CI1)YmfT+o`_<~=dp3o0g8;?<1*|7 zE_Me<)%H@`_yZN|O408TX|HLNyY)F9bn7!mR@xJ=<#hWKnQQjEGh+tG-c#Ywh?!)s z)>cjNciuZfd=UNi_Wom2J~D+<^?tuL(;2#8jzO7tA{ny?E>{rf>Oh8Rh4SESlS3Bp zB8VV}Is#enCTr8(SBn)`U+Zq#42|y`b#x@#Cox=47#A`uPB4yWOauRP#5XtII%%XA z8IauRcJx|e^j;F46^n6Su>-ArxzSA;e(lit!K-^&m8aj9+%4mAC#&bX)ZyNNWTATK zZlyQ`EnMQ;09ec0EjTZOXdk(_uWPQFn&Z9pU@z4a01u5;XzBSv*>~ zillgXE|3_5TJKO^!I0AMl#{0;`_LaZ8s<4AjE)?v#oQ+p820iwB7|UfDg%ItQHVQ5 z?WeLvEZzvgp0|3vNCl;Fac8^q+!V=@iA36+-PkSQYE&(nbxv!uv12rs-S>*RR6{_d zC+Esi=9(V5+`bt)oy9l@)wkZ>DOIhPVo-r!y;B4ZIfRL?W~{EPO&u e^}Q^%V{6@$G6_|0)9rLgQ5gMtij-T}occ)EW;1qEIo ~9*4w3%eLxm=IAeCnu4dYO8Ch=eA(|$2{f`6HXqpOVyZKv**M`>Ov-3JK3Cp?i zJ6N`(WU@sEqF)J#4d}8EJnw$Sp D&CVb fKHK{>&-!E`HN$gT zh-ssqYP^sv*r;8AU-6{v_W?K0XVVQ?dsa2$w#0|l^-WQuCu*gW6Ev~zQ= gjt#^&ldgV| DgD1g+t@n9%q z=rs5X86pQ2p(cn2|DHGAWVjB$mOn6k^aZu^p40iL)kNLiU%!XvxT>T&QwPspKVXoq z<$G(`0$iQcc$Q`}K)k+YzryVWS&W*e0J=Cmq&*fI-F!%kzfm!OURuT^oHJPEx^IQ2 z{rA3}kZV9^rT$FXS?|LYlo7qQ7G6usq0RTT%c^U(+h6yJ7jmJ+?Gh$rrMuEYD(UpI zlf|S6KeZbkQ}3J68#@O&ccL;-F?-Vam!)qtcAe5U9f6v18uWIw&8FxMduk%|yzX|` zLf1|Zq%Du*G74jA(5GL&AG}vH$*dY)?dn1xettx4Y&Wu79tmxsxNqeC1d SXh z?`);spQaW$Oj-{auZaLUkw_oxkhNz7`0Q?%7BI=Gh_yMN&b98^*G|7w8KKr) xU%NpXkllMcV<(IgB0QKfnJLGm?b&0J*>7nTNSeX znhasm!5|H>G4uCVqVQjd^>y1ciBR7UuVK=)OJ3-+uHAik+$-gOr~U^(
MkeCb(t!M zLohUD@QQbv3=Qnz6KI)=ZxkhySr(XFNlu~gGD!}*Tj+BTd1psD>Q-_jH1hL}lB o2>^ZC>q2YrR;&SRV z1dAVf(YihFYLrk{#xCRLuxW=r8zeW7yT5iB$ILM6`uwB-Dk~pNun`&zrr?oNwIR5b zbK`qx5a@Z h8vF+5;)q}*sB-slT)?O0l{g>jVGm{OR2J4kV(5L@R3 zk2c&6d<%K^M @vW14x!O2N z#ZS#%jA>ltDGAq^r&u``c)ILA8BRiQA)zUDGH1?^5M1QUXy8yWIfpQ__C<_hd|_5` zkhzdiyd(^x6M^9Wqo4V-;4?;X @Qs1Y=eP_qiWRBMrF6s;uGtPL;KS&SM*+X*!r!b|lQiN>r%vQa{-(hL}q{=F|R z`u9_~F`$g$5J|&f$M83Ujfp%u@_#FZ86N1@49$)a+tUklp6GstX7`Bg*@X;G^cF+2 zU&QwO0-YCz!Vb; 0NDSdzJeynYIWj zmOd; >O!k1m-lX(p z>A&Y`%4I^MQP~Z*{VS7n<~B(2X8TbUudFO`CzS`N%RgAvNQgdmO?vlU4{U@6{^Lsx z@JlECnz$0ih0#dQP9jV>X5tEQfC2f=uG yRzZx=-}mPFSf-u8J2N!pX#*!M6bEUNQI9 F z?qvT-lR7E++10++?|t?bXnX=I3;ep6$N|ex$J(Jm|1fpb}Z$l>StPt6tfeq)lkR z->T@YPgW{5w#V>SYI7t8)aWqO&S0oLQvn~{g*v%m#@Xa7JYYs=1w~b&?3u2wP)hMQ z%Dl#iIW)lo%K_^$pQ%{lJEUawoy3IaaNPT!2@Cm<02T$EX}I-m|LxNb-{t`A;*SmH zIFSx-4SJVVC<8eJsT&N4XHrDWt4fS(t`yQV-9;)4&_k11heO8M?p~sAvcLQ7nPT`z zQDL8fXEt@kWR(Ec^*H4@{&(f|5xQhCQ4#T2`%@n!G-fvUJ&*=#G+9E=9A&CnS7P&s z9qma3TZtLt*gG!O=P(8~B|cUvy=4q}b2y&<_M^+twyr=>kHpCI1N@F*Jk4?v$^mgy z?jD#(NL-=OUFKUa$!#~KK1r{Mmfb`g%U;V#V@?}mQT?y|a|fJ0Jl&5TY#haFGrrtE zWmtJDE!_S2v#fUW=<+qNG8Q%^&fo8lU rpanvVY%UOdtKF?>w@>-B##?VQIspH+WwMy_3$ XV_;=myt9WcV-7{k&JpwVv%CKQjpTJ; literal 0 HcmV?d00001 diff --git a/public/templates/express-companies-import-template.xlsx b/public/templates/express-companies-import-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5b2d267ff8884768774f528c45cfe57e875726e0 GIT binary patch literal 6141 zcmbtY2UJr{w+$d7O#$f$1f+vB5u^s`O*%p8B>@727J3JjA|OpbM34@GQVmT)5s@an z7eS> G- P`G4=fCNfI wP*H*>8v=Nv21g#* zQ*B_EG=`kJigTF^YBu9WwoaXoi&Wy_vCliq?$?;=q(s5ma1YW`q5R6)K${$$F 1)N_+oicztd*lw&+1yH$lXwou#3 zi+2wcwvfT=z4z|uvgRK;vDcNo@WuuJk|+QG;vYI8T)pg}h*OnjpQt)!@sqUF6Kwc3 zaThD(=*8i}_)xDyQDhcjIi*pN{j7(J`oiawkX}8kOoelAJ!kg!yCYXdB&6oaBRzNo z-zJAH2R$JAz+A`1v)%)KKb5<%zbNn=>VmiDcCS>(g@7kzmSIdOxl8SOgz%G)(ZIYX zv*Z@MHXP+xRN=`v*r7{m548B{J7`mcSco_+J|usL40BB0>dALmZ1ksFWAbdRh&AWJ zBjn^Y>d*wl8Xb0uS)3SApt&2W&dFV=;+HGi(9}FWrhuOi!!;(FN8k}sozJ>L3GKWo zs^NlKw8;$j0lqKku{67d9+MGLk^r(^Pq}mMkW3Tlzbo4uO_#ebK2C#t)NwP$Era_m z180mE$rS0-b38DAH3R5Ea5zY+laJW=1qGEIPl3TGWdTZll+f^Qpe1bK!75WVXVPM$ z?_DWh#)D=x!HDC6h#6Q4f13{aC0^0BTXDC!iN(?_NuPJfwm1&F5SEXL>lcode@DSJ zSp8Tvux?2M{YmE2Xtk?SKVnfGBBYt{LL~SCPA%6+chw}ZkYF@%MGjU1Xo#UKCc}Ln zr3_FhmrjVXXkc>z$=zG6dfq(mEZHRwn5PD+>5QQ47YQPypQ{e2QcVVyTSWi{UOfm| z+)>8g(T`0ZM*#7&s?tP1PiCmc#L0bKCNXC#9npxDs4EF}&{#yQ>IeIY 2V1A9qRNCqJT4SR)oeY~F{EwKy>L!SdQJ zwF0H1NEnnKrPi+j9k3&(h1{3%R4uwk<>yP@4p$yNvndX9HtHdHqJO@JB _$ zWEKiQ8)U?DfiQ>(B;D^=v@QHFEZmIMi4}C)jGnaqj$lmAl5pQ0gX0g&)GWba0#1ub zQ@mMn4IDDB6SIPEx7_e8#Ny7k^#uBSO5OoVo0qf@1^GC39$=B_5nE5w8RK@~ m9BTYso_@dZ( #ibvRqbcjrY8|5Uc833%qG!E;A#ifXo?z5Wb~- z%fJsUq1zPGYe(L0lr;-%GwQp=Pcis(>4t{nvv~M%dDv2}h@p5O;-b7qqZ9da8=4uO z*@6% I6%1EY;N@70clNfVeYSg_q%lY|(?VDLr77kuQF#AHn zHV^AXD;*DrpSzs~hd{Sk d@jKsBuyvkkaEuw zTZimxogP$@rY4&bL=*? urYUmLUdeRU=G&b z<2!Dn7C{q`2+&igbZk8#^*&}SO@8b(0UKpJadiD|BHRT5qBVHK;&}jg1X)u|;BFl5 zYQd{;OV%2km|>%}4z_Stl>12N44!g~Yg<{qSN>vcS_XwTD@z#b+@ycOamEPuN>RX{ zuKeU`g-jC0m02zT0MtLGb-FJjY@kqA1ZIYq=DR;XNSJaNQy_nD3bkH~I`;$wjl6#Q zN@Xg{N)TmJOmRp0ayXP_)G2)2qT#{2Z8Hgm`1dL+Z188FAQE@ZX*1-G$s4T`q#DyO zUrsQAbypsA?WbOaA>H$`xy AnPcv%bRkm60D*u1wxF af5g0H2l3lRl!`mk4jPrdQSK*xR zJ*xI@#FdL)#lJ_o%FU{M=W_@-IFPHnh)FYtte$fAb*$=J%?rWLo~dU^@Fk7)5x+eC zw2BMLabC%w^RaP=es|K%>-+Z81YI0csdSTk`g#hr%T1x{-6@~SRZD&9h-A{)&8e}k z;=9RCi;pisw!F2afC+>f1DV^$*$0nZL$h7fH43w9R85n&M#B=O+?nPUSG)T05i4oY z6`!*{?)D0!o;HYY3Q_NDk d1nhcbP ?Dk 2XnR$)Z)6Uu<1hkkO39elFP{wu1q&*WJq)5g_w #~g?xFOz3v}nf z)>;>pVcDe|M7PdcS~656%z=)!1J1;l$|!*XqVoViE62Y&v*79GoH}M2eOd3y`}In} zWYw$VpmFykV&hCim)7O*cZ6H+HrADw#ZtwtcMOTw0yAZ#EyLJINSU5;6!miCN7T`b z@#@#%B64#)Z%z&2P~Bh2tDpK%u9L_ nP2K` zKJcCv9Wvb99zr}rZEUe}m|iEP% w|dxO-ucTjJ4SsuKw;d&0h++sal4M)audrRQI3}pvkm}Dnazo zaMrz)*X)U_egf6Uhckt56!9waE`jK$YNf|r+IF_4r`V|W#C1E=J?ER+_@-#+5;mTm z%=Zn?C)JgH?Hlfx9xm*@Vp`{O@FXU0@@-)q#M*m4S~0*+@c8hsg-R*~h3&bRWG_-M zXgN6goXg~Z)#u>km2aVTxPrOhvZ7t8P@`Qv#Lsu7FDq1pL9!}TKVa6wduw|(;3A*K zs^4{~S~01cS4?Zgj~Ci|8`0}IhSBx4 S9$xSvYirY^Cy7+g`GMs3<1}e78DbR# zt_gk{s7azCGk7N%xfq93zKf3|bBhCC4*?xYR2`y(vJzdu&0x`em2g+YsLbK#Xk@A2 zQm*RI?RFJv5&{|Jd|`bn$^+n?HxQ=v%$gGk*J(Q~a83AeSa
O+5fo^z#8uF?*GC3`{b;LR!TQ8GKXQfp=CpQl$I7Itvc;zBz zSma8`f7q6FoxKT|E!#4F!f&LW!rgwsALL=+V1{4twNrR!=nwcYy!{1w>oGme4QYye zGQDJ5MR!|}ByMaeS`)WGPA_*X_M< E(rT!l-tTP?bjJo zzJ|zqvMslKED7tswd&S~YGkh1&(^O6MUMG{x(KH_raCg|(c|buDHOvH 1Rw9|bL~yN1PHTv0NtT32$-XTB(nf7KQj>OVCe|KB(RdqZmw3>ZZdTwR^S`1lZ(m@r_D;B|C@I$-{>a&&>4yJEU7)_hLp zmUiaWP(DF^eqlb$8-H^!|H%o|^>Tv#Ul<*&tYDT ;7S4pBP2{T5_ zSWJ#h`y;>pq3J)XU*GlZ>{tC>0{c`bH+fmy-6TEtV~M$;cTl*bSH8bo1>xf89VeEe zK#mKnVYn$O_EziBTT7ct{yttGm#wnm*|*GHE12%KD+PnaZj9A2<&AtCfK+~5){?!F zO5Akw0YrE#B!oyn-Q)3j(e$908vGS|*ZJBYYVCO+u^ Cf2w^De=-`U^%%-Q-fiOPJbCB`3P9>EuCi9B0s5A!Nk8{Ve$HM0|6ODgY zAXy<1AD0$|tN4W=pSJ+OB(FCfWqsX$?WEbaE ^@wrhDu9EvAPU-yC?K>4ItsxFJEry)(y^5X{usj zljHn*eHJ6=_g{9 -OK&VtVtEq_1(fb5{tIC6T@{Z+y|3q4y(`VH;Gd{+$gY+>mv z!P#^5Zvrg5zn`+tGMqgE|7LjdgW<0S; PSpMQ;nyR>%69)i*kGYgFh3hm-;Tiz= EAL;Sn`Tzg` literal 0 HcmV?d00001 diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 00000000..4be89653 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,4 @@ + + + + diff --git a/src/api/http.test.ts b/src/api/http.test.ts new file mode 100644 index 00000000..cbee3a30 --- /dev/null +++ b/src/api/http.test.ts @@ -0,0 +1,43 @@ +import axios, { AxiosHeaders } from 'axios' +import { describe, expect, it } from 'vitest' +import { ApiResponseError, getApiErrorDetails, unwrapApiResponse } from './http' + +describe('API error diagnostics', () => { + it('keeps business request and trace identifiers when unwrapping fails', () => { + expect(() => unwrapApiResponse({ + code: 'A0403', + message: '无权限', + data: null, + requestId: 'request-1', + traceId: 'trace-1', + })).toThrow(ApiResponseError) + + try { + unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' }) + } catch (error) { + expect(getApiErrorDetails(error)).toEqual({ + message: '无权限', + code: 'A0403', + requestId: 'request-1', + traceId: 'trace-1', + }) + } + }) + + it('extracts identifiers from an HTTP error envelope', () => { + const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, { + status: 503, + statusText: 'Service Unavailable', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: { code: 'S0503', message: '服务不可用', requestId: 'request-2', traceId: 'trace-2' }, + }) + + expect(getApiErrorDetails(error)).toEqual({ + message: '服务不可用', + code: 'S0503', + requestId: 'request-2', + traceId: 'trace-2', + }) + }) +}) diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 00000000..87552097 --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,121 @@ +import axios, { type InternalAxiosRequestConfig } from 'axios' + +export interface ApiEnvelope { + code: string + message: string + data: T + requestId?: string | null + traceId?: string | null + timestamp?: string +} + +export class ApiResponseError extends Error { + readonly code: string + readonly status?: number + readonly details?: unknown + readonly requestId?: string + readonly traceId?: string + + constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) { + super(message) + this.name = 'ApiResponseError' + this.code = code + this.status = status + this.details = details + this.requestId = requestId || undefined + this.traceId = traceId || undefined + } +} + +export interface ApiErrorDetails { + message: string + code?: string + requestId?: string + traceId?: string +} + +export const http = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 15_000, +}) + +type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean } +type AuthRefreshHandler = () => Promise + +let authRefreshHandler: AuthRefreshHandler | null = null +let activeRefresh: Promise | null = null + +export function registerAuthRefreshHandler(handler: AuthRefreshHandler) { + authRefreshHandler = handler +} + +http.interceptors.request.use((config) => { + const token = localStorage.getItem('cc-admin-token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +http.interceptors.response.use( + (response) => response.data, + async (error) => { + const config = error.config as RetryableRequestConfig | undefined + const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/') + if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) { + return Promise.reject(error) + } + + config._authRetry = true + try { + if (!activeRefresh) { + activeRefresh = authRefreshHandler().finally(() => { activeRefresh = null }) + } + const nextToken = await activeRefresh + config.headers.Authorization = `Bearer ${nextToken}` + return http.request(config) + } catch { + return Promise.reject(error) + } + }, +) + +export function unwrapApiResponse (response: ApiEnvelope ): T { + if (response?.code !== '0') { + throw new ApiResponseError( + response?.message || '接口请求失败', + response?.code || 'UNKNOWN', + undefined, + response?.data, + response?.requestId, + response?.traceId, + ) + } + return response.data +} + +export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails { + if (error instanceof ApiResponseError) { + return { message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId } + } + if (axios.isAxiosError(error)) { + const response = error.response?.data as { + code?: string + message?: string + error?: string + requestId?: string | null + traceId?: string | null + } | undefined + return { + message: response?.message || response?.error || fallback, + code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined), + requestId: response?.requestId || undefined, + traceId: response?.traceId || undefined, + } + } + return { message: error instanceof Error ? error.message : fallback } +} + +export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string { + return getApiErrorDetails(error, fallback).message +} diff --git a/src/api/platformIam.test.ts b/src/api/platformIam.test.ts new file mode 100644 index 00000000..f76601d8 --- /dev/null +++ b/src/api/platformIam.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/api/http' +import { + createPlatformPermission, + createPlatformRole, + downloadTenantQualification, + listTenantQualifications, + listPlatformPermissions, + listPlatformRoles, + qualificationFileName, + type PlatformPermissionResource, + type PlatformRoleResource, +} from './platformIam' + +vi.mock('@/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const role: PlatformRoleResource = { + id: 4000002, + tenantId: 0, + appCode: 'PLATFORM', + roleCode: 'CUSTOMER_REVIEWER', + roleName: '企业审核员', + roleType: 'SYSTEM', + status: 'ACTIVE', + description: '企业资料与入驻审核', +} + +const permission: PlatformPermissionResource = { + id: 5100010, + tenantId: 0, + appCode: 'PLATFORM', + permissionType: 'MENU', + parentId: 5100000, + permissionCode: 'platform:customer:list', + permissionName: '企业客户', + routePath: '/customers', + httpMethod: null, + sortNo: 10, + status: 'ACTIVE', +} + +describe('platform IAM roles', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + }) + + it('loads roles from the platform IAM prefix', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [role] }) + + await expect(listPlatformRoles()).resolves.toEqual([role]) + expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/roles') + }) + + it('creates a platform role with the documented payload', async () => { + const payload = { + roleCode: 'CUSTOMER_REVIEWER', + roleName: '企业审核员', + description: '企业资料与入驻审核', + } + vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: role }) + + await expect(createPlatformRole(payload)).resolves.toEqual(role) + expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/roles', payload) + }) + + it('loads and creates permission resources under the platform IAM prefix', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [permission] }) + vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: permission }) + const payload = { + type: 'MENU' as const, + code: permission.permissionCode, + name: permission.permissionName, + parentId: permission.parentId, + routePath: permission.routePath, + httpMethod: null, + sortNo: permission.sortNo, + } + + await expect(listPlatformPermissions()).resolves.toEqual([permission]) + await expect(createPlatformPermission(payload)).resolves.toEqual(permission) + expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/permissions') + expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/permissions', payload) + }) + + it('loads qualifications for the selected tenant', async () => { + const qualification = { + id: 12, + tenantId: 8, + qualificationType: 'BUSINESS_LICENSE', + certificateNo: '91440300XXXXXXXXXX', + documentUrl: '/api/merchant/v1/files/qualifications/license.png', + status: 'PENDING' as const, + reviewComment: null, + } + vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [qualification] }) + + await expect(listTenantQualifications(8)).resolves.toEqual([qualification]) + expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants/8/qualifications') + }) + + it('downloads a qualification with the platform file endpoint and a safe filename', async () => { + const blob = new Blob(['image'], { type: 'image/png' }) + vi.mocked(http.get).mockResolvedValue(blob) + + expect(qualificationFileName('/api/merchant/v1/files/qualifications/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png?token=ignored')).toBe('营业执照.png') + await expect(downloadTenantQualification(8, '/api/merchant/v1/files/qualifications/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png')).resolves.toBe(blob) + expect(http.get).toHaveBeenCalledWith( + '/platform/v1/files/qualifications/8/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png', + { responseType: 'blob' }, + ) + expect(() => qualificationFileName('/api/merchant/v1/files/qualifications/%2Fetc%2Fpasswd')).toThrow('资质文件地址无效') + }) +}) diff --git a/src/api/platformIam.ts b/src/api/platformIam.ts new file mode 100644 index 00000000..96fcaeeb --- /dev/null +++ b/src/api/platformIam.ts @@ -0,0 +1,136 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' + +export interface PlatformPermissionResource { + id: number + tenantId: number + appCode: 'PLATFORM' + permissionType: 'MENU' | 'BUTTON' + parentId: number + permissionCode: string + permissionName: string + routePath: string | null + httpMethod: string | null + sortNo: number + status: 'ACTIVE' | 'INACTIVE' +} + +export interface EnterpriseReview { + id: number + tenantId: number + status: 'PENDING' | 'APPROVED' | 'REJECTED' + tenantName?: string | null + tenantCode?: string | null + qualificationId?: number | null + qualificationType?: string | null + certificateNo?: string | null + documentUrl?: string | null + contactName?: string | null + contactPhone?: string | null + contactEmail?: string | null + submittedAt?: string | null + createdAt?: string | null + reviewComment?: string | null + [key: string]: unknown +} + +export interface PlatformTenantQualification { + id: number | string + tenantId: number | string + qualificationType: 'BUSINESS_LICENSE' | string + certificateNo: string | null + documentUrl: string + status: 'PENDING' | 'APPROVED' | 'REJECTED' + reviewComment: string | null +} + +export interface PlatformRoleResource { + id: number + tenantId: number + appCode: 'PLATFORM' + roleCode: string + roleName: string + roleType: 'SYSTEM' | 'CUSTOM' | string + status: 'ACTIVE' | 'INACTIVE' | string + description: string | null +} + +export interface CreatePlatformRoleRequest { + roleCode: string + roleName: string + description: string +} + +export interface CreatePlatformPermissionRequest { + type: 'MENU' | 'BUTTON' + code: string + name: string + parentId: number + routePath: string | null + httpMethod: null + sortNo: number +} + +async function getData (path: string): Promise { + const response = await http.get , ApiEnvelope >(path) + return unwrapApiResponse(response) +} + +async function postData (path: string, body: unknown): Promise { + const response = await http.post , ApiEnvelope >(path, body) + return unwrapApiResponse(response) +} + +export function listPlatformPermissions() { + return getData ('/platform/v1/iam/permissions') +} + +export function listEnterpriseReviews() { + return getData ('/platform/v1/iam/reviews') +} + +export function listTenantQualifications(tenantId: number | string) { + return getData (`/platform/v1/iam/tenants/${tenantId}/qualifications`) +} + +export function qualificationFileName(documentUrl: string) { + const path = documentUrl.split(/[?#]/, 1)[0] + const encodedName = path.split('/').filter(Boolean).at(-1) + if (!encodedName) throw new Error('资质文件地址无效') + let fileName: string + try { + fileName = decodeURIComponent(encodedName) + } catch { + throw new Error('资质文件地址无效') + } + if (!fileName || fileName === '.' || fileName === '..' || /[\\/]/.test(fileName)) { + throw new Error('资质文件地址无效') + } + return fileName +} + +export function downloadTenantQualification(tenantId: number | string, documentUrl: string) { + const fileName = qualificationFileName(documentUrl) + return http.get (`/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`, { + responseType: 'blob', + }) +} + +export function listPlatformRoles() { + return getData ('/platform/v1/iam/roles') +} + +export function createPlatformRole(payload: CreatePlatformRoleRequest) { + return postData ('/platform/v1/iam/roles', payload) +} + +export function createPlatformPermission(payload: CreatePlatformPermissionRequest) { + return postData ('/platform/v1/iam/permissions', payload) +} + +export function approveEnterpriseReview(reviewId: number, comment: string) { + return postData (`/platform/v1/iam/reviews/${reviewId}/approve`, { comment }) +} + +export function rejectEnterpriseReview(reviewId: number, comment: string) { + return postData (`/platform/v1/iam/reviews/${reviewId}/reject`, { comment }) +} diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts new file mode 100644 index 00000000..9d240079 --- /dev/null +++ b/src/auto-imports.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/src/components.d.ts b/src/components.d.ts new file mode 100644 index 00000000..c9d95df8 --- /dev/null +++ b/src/components.d.ts @@ -0,0 +1,58 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AdminSidebarMenu: typeof import('./components/AdminSidebarMenu.vue')['default'] + AdminSidebarMenuNode: typeof import('./components/AdminSidebarMenuNode.vue')['default'] + ApiErrorMeta: typeof import('./components/ApiErrorMeta.vue')['default'] + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDrawer: typeof import('element-plus/es')['ElDrawer'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElStep: typeof import('element-plus/es')['ElStep'] + ElSteps: typeof import('element-plus/es')['ElSteps'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTimeline: typeof import('element-plus/es')['ElTimeline'] + ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] + ElTree: typeof import('element-plus/es')['ElTree'] + MetricStrip: typeof import('./components/MetricStrip.vue')['default'] + RolePreviewSwitcher: typeof import('./components/RolePreviewSwitcher.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + StaticBatchImportDialog: typeof import('./components/StaticBatchImportDialog.vue')['default'] + StatusBadge: typeof import('./components/StatusBadge.vue')['default'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/src/components/AdminSidebarMenu.test.ts b/src/components/AdminSidebarMenu.test.ts new file mode 100644 index 00000000..9bab1fc2 --- /dev/null +++ b/src/components/AdminSidebarMenu.test.ts @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/navigation/adminNavigation' +import AdminSidebarMenu from './AdminSidebarMenu.vue' + +const stubs = { + 'el-menu': { + props: ['defaultActive', 'collapse'], + template: '', + }, + 'el-sub-menu': { + props: ['index'], + template: ' ', + }, + 'el-menu-item': { + props: ['index'], + template: ' ', + }, +} + +describe('AdminSidebarMenu', () => { + it('renders grouped page entries and forwards the active route', () => { + const wrapper = mount(AdminSidebarMenu, { + props: { items: adminNavigation, activePath: '/system/users', collapsed: false }, + global: { stubs }, + }) + + expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users') + expect(wrapper.findAll('[data-kind="group"] header').map((node) => node.text())).toEqual([ + '业务管理', + '基础资料', + '系统管理', + ]) + expect(wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))).toEqual([ + '/customers', + '/customers/reviews', + '/warehouses', + '/orders/first-leg', + '/master-data/express-companies', + '/master-data/counterparties', + '/system/users', + '/system/roles', + '/system/permissions', + ]) + }) + + it('forwards desktop collapse state to Element Plus', () => { + const wrapper = mount(AdminSidebarMenu, { + props: { items: adminNavigation, activePath: '/', collapsed: true }, + global: { stubs }, + }) + + expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true') + }) + + it('renders only entries included in the filtered navigation input', () => { + const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list'])) + const wrapper = mount(AdminSidebarMenu, { + props: { items, activePath: '/', collapsed: false }, + global: { stubs }, + }) + + expect(wrapper.text()).toContain('菜单权限') + expect(wrapper.text()).not.toContain('客户管理') + }) + + it('recursively renders three navigation levels', () => { + const items: AdminNavigationNode[] = [{ + key: 'operations', + title: '运营管理', + icon: adminNavigation[0].icon, + children: [{ + key: 'shipment', + title: '运输管理', + icon: adminNavigation[0].icon, + children: [{ + key: 'tracking', + title: '轨迹管理', + icon: adminNavigation[0].icon, + path: '/tracking', + permission: 'tracking:list', + }], + }], + }] + const wrapper = mount(AdminSidebarMenu, { + props: { items, activePath: '/tracking', collapsed: false }, + global: { stubs }, + }) + + expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2) + expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking') + }) +}) diff --git a/src/components/AdminSidebarMenu.vue b/src/components/AdminSidebarMenu.vue new file mode 100644 index 00000000..86d121b0 --- /dev/null +++ b/src/components/AdminSidebarMenu.vue @@ -0,0 +1,31 @@ + + + + + + diff --git a/src/components/AdminSidebarMenuNode.vue b/src/components/AdminSidebarMenuNode.vue new file mode 100644 index 00000000..548c7044 --- /dev/null +++ b/src/components/AdminSidebarMenuNode.vue @@ -0,0 +1,25 @@ + + + ++ + + + ++ {{ node.title }} + + + + + + diff --git a/src/components/ApiErrorMeta.vue b/src/components/ApiErrorMeta.vue new file mode 100644 index 00000000..296fc001 --- /dev/null +++ b/src/components/ApiErrorMeta.vue @@ -0,0 +1,38 @@ + + + + + + + diff --git a/src/components/MetricStrip.vue b/src/components/MetricStrip.vue new file mode 100644 index 00000000..2167bdba --- /dev/null +++ b/src/components/MetricStrip.vue @@ -0,0 +1,16 @@ + + + ++ {{ node.title }} + + + + diff --git a/src/components/RolePreviewSwitcher.test.ts b/src/components/RolePreviewSwitcher.test.ts new file mode 100644 index 00000000..6a221d8a --- /dev/null +++ b/src/components/RolePreviewSwitcher.test.ts @@ -0,0 +1,63 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import type { Role } from '@/types/admin' +import RolePreviewSwitcher from './RolePreviewSwitcher.vue' + +const roles: Role[] = [ + { id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: [] }, + { id: 'R003', name: '操作员', code: 'OPERATOR', description: '', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: [] }, +] + +const stubs = { + 'el-dropdown': { + name: 'ElDropdownStub', + emits: ['command'], + template: '+ {{ item.label }} + {{ item.value }} + {{ item.note }} ++', + }, + 'el-dropdown-menu': { template: ' ' }, + 'el-dropdown-item': { + props: ['command', 'disabled'], + template: '', + }, +} + +describe('RolePreviewSwitcher', () => { + it('announces the active role and explains the static preview boundary', () => { + const wrapper = mount(RolePreviewSwitcher, { + props: { roles, currentRole: roles[0] }, + global: { stubs }, + }) + + const trigger = wrapper.get('button[aria-label]') + expect(trigger.attributes('aria-label')).toBe('权限预览:超级管理员,数据范围未模拟') + expect(trigger.attributes('title')).toBe('权限预览:超级管理员,数据范围未模拟') + expect(wrapper.text()).toContain('权限预览:超级管理员') + expect(wrapper.text()).toContain('仅预览菜单、页面和操作;数据范围未模拟') + expect(wrapper.find('svg.lucide-user-round-cog').exists()).toBe(true) + }) + + it('disables the current role and emits a string role id for another selection', async () => { + const wrapper = mount(RolePreviewSwitcher, { + props: { roles, currentRole: roles[0] }, + global: { stubs }, + }) + + const items = wrapper.findAll('[data-role]') + expect(items[0].attributes('disabled')).toBeDefined() + expect(items[1].attributes('disabled')).toBeUndefined() + wrapper.getComponent({ name: 'ElDropdownStub' }).vm.$emit('command', 3) + + expect(wrapper.emitted('select')).toEqual([['3']]) + }) + + it('labels the trigger when no enabled role is available', () => { + const wrapper = mount(RolePreviewSwitcher, { + props: { roles: [], currentRole: null }, + global: { stubs }, + }) + + expect(wrapper.get('button[aria-label]').attributes('aria-label')) + .toBe('权限预览:无可用角色,数据范围未模拟') + }) +}) + diff --git a/src/components/RolePreviewSwitcher.vue b/src/components/RolePreviewSwitcher.vue new file mode 100644 index 00000000..b0f677cd --- /dev/null +++ b/src/components/RolePreviewSwitcher.vue @@ -0,0 +1,52 @@ + + + ++ + + + + diff --git a/src/components/StaticBatchImportDialog.test.ts b/src/components/StaticBatchImportDialog.test.ts new file mode 100644 index 00000000..b56a95dc --- /dev/null +++ b/src/components/StaticBatchImportDialog.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync('src/components/StaticBatchImportDialog.vue', 'utf8') + +describe('StaticBatchImportDialog source contract', () => { + it('only performs safe client-side file checks before the backend endpoint exists', () => { + expect(source).toContain("fileName.endsWith('.xlsx')") + expect(source).toContain('10 * 1024 * 1024') + expect(source).toContain('前端不会解析工作表内容') + expect(source).toContain('等待后端导入接口') + }) + + it('does not simulate server validation or a successful import', () => { + expect(source).not.toContain('demonstratesFailure') + expect(source).not.toContain('静态演示') + expect(source).not.toContain("emit('imported'") + }) +}) diff --git a/src/components/StaticBatchImportDialog.vue b/src/components/StaticBatchImportDialog.vue new file mode 100644 index 00000000..2e14e890 --- /dev/null +++ b/src/components/StaticBatchImportDialog.vue @@ -0,0 +1,118 @@ + + + ++ + ++ {{ role.name }} + ++ + + + + + + + + diff --git a/src/components/StatusBadge.vue b/src/components/StatusBadge.vue new file mode 100644 index 00000000..1fa0a4e1 --- /dev/null +++ b/src/components/StatusBadge.vue @@ -0,0 +1,18 @@ + + + + {{ status }} + + diff --git a/src/composables/useUnsavedWorkRegistration.test.ts b/src/composables/useUnsavedWorkRegistration.test.ts new file mode 100644 index 00000000..acdfb15d --- /dev/null +++ b/src/composables/useUnsavedWorkRegistration.test.ts @@ -0,0 +1,101 @@ +import { computed, defineComponent, nextTick, reactive, ref } from 'vue' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration' +import { useUnsavedWorkStore } from '@/stores/unsavedWork' + +interface HarnessModel { + name: string +} + +function mountHarness() { + const isOpen = ref(false) + const label = ref('客户资料') + const requiredPermission = ref++文件已选择,等待基础检查点击“检查文件”确认扩展名和文件大小;前端不会解析工作表内容。+++文件基础检查通过{{ validationMessage }}。后端导入接口尚未提供,文件没有上传,也没有写入任何业务数据。+++ +文件基础检查未通过{{ validationMessage }};当前文件没有上传。+后端接口接入后,此处将展示上传进度、整批校验结果、逐行错误和错误报告下载;任一行错误时后端必须保证 0 行写入。
+ + +取消 +检查文件 +等待后端导入接口 + +('customer:create') + const model = reactive ({ name: '' }) + let captureBaseline: () => void = () => undefined + + const wrapper = mount(defineComponent({ + setup() { + const registration = useUnsavedWorkRegistration({ + key: 'customer-form', + label: computed(() => label.value), + isOpen, + model, + requiredPermission: computed(() => requiredPermission.value), + }) + captureBaseline = registration.captureBaseline + return () => null + }, + })) + + return { wrapper, isOpen, label, requiredPermission, model, captureBaseline } +} + +describe('useUnsavedWorkRegistration', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('tracks open model changes against a captured baseline and removes closed work', async () => { + const store = useUnsavedWorkStore() + const harness = mountHarness() + + harness.captureBaseline() + expect(store.entries).toEqual([]) + + harness.isOpen.value = true + await nextTick() + expect(store.entries[0]?.dirty).toBe(false) + + harness.model.name = '柏悦物流' + await nextTick() + expect(store.entries[0]?.dirty).toBe(true) + + harness.isOpen.value = false + await nextTick() + expect(store.entries).toEqual([]) + + harness.wrapper.unmount() + }) + + it('updates label and permission reactively and becomes clean after restoring the baseline', async () => { + const store = useUnsavedWorkStore() + const harness = mountHarness() + harness.captureBaseline() + harness.isOpen.value = true + await nextTick() + + harness.label.value = '新增客户' + harness.requiredPermission.value = 'customer:update' + harness.model.name = '柏悦物流' + await nextTick() + expect(store.entries[0]).toEqual({ + key: 'customer-form', + label: '新增客户', + dirty: true, + requiredPermission: 'customer:update', + }) + + harness.model.name = '' + await nextTick() + expect(store.entries[0]?.dirty).toBe(false) + + harness.wrapper.unmount() + }) + + it('removes registered work when its component scope is disposed', async () => { + const store = useUnsavedWorkStore() + const harness = mountHarness() + harness.captureBaseline() + harness.isOpen.value = true + harness.model.name = '柏悦物流' + await nextTick() + expect(store.entries).toHaveLength(1) + + harness.wrapper.unmount() + + expect(store.entries).toEqual([]) + }) +}) diff --git a/src/composables/useUnsavedWorkRegistration.ts b/src/composables/useUnsavedWorkRegistration.ts new file mode 100644 index 00000000..ccd7c389 --- /dev/null +++ b/src/composables/useUnsavedWorkRegistration.ts @@ -0,0 +1,47 @@ +import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue' +import { useUnsavedWorkStore } from '@/stores/unsavedWork' + +interface UnsavedWorkRegistrationOptions { + key: string + label: ComputedRef + isOpen: Ref + model: T + requiredPermission: ComputedRef +} + +export function useUnsavedWorkRegistration ( + options: UnsavedWorkRegistrationOptions , +) { + const store = useUnsavedWorkStore() + const snapshot = computed(() => JSON.stringify(options.model)) + const baseline = ref(snapshot.value) + + function sync() { + if (!options.isOpen.value) { + store.remove(options.key) + return + } + + store.upsert({ + key: options.key, + label: options.label.value, + dirty: snapshot.value !== baseline.value, + requiredPermission: options.requiredPermission.value, + }) + } + + function captureBaseline() { + baseline.value = snapshot.value + sync() + } + + watch( + [options.isOpen, snapshot, options.label, options.requiredPermission], + sync, + { flush: 'sync' }, + ) + sync() + onScopeDispose(() => store.remove(options.key)) + + return { captureBaseline } +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 00000000..f5c051ec --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,15 @@ +/// + +import 'vue-router' + +export {} + +declare module 'vue-router' { + interface RouteMeta { + title?: string + description?: string + requiresAuth?: boolean + permission?: string + } +} + diff --git a/src/layouts/AppLayout.test.ts b/src/layouts/AppLayout.test.ts new file mode 100644 index 00000000..cfa83d1f --- /dev/null +++ b/src/layouts/AppLayout.test.ts @@ -0,0 +1,323 @@ +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { nextTick } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ElMessage, ElMessageBox } from 'element-plus' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAdminDataStore } from '@/stores/adminData' +import { useAuthStore } from '@/stores/auth' +import { useUnsavedWorkStore } from '@/stores/unsavedWork' +import AppLayout from './AppLayout.vue' + +vi.mock('element-plus', async (importOriginal) => { + const actual = await importOriginal () + return { + ...actual, + ElMessage: { success: vi.fn(), error: vi.fn() }, + ElMessageBox: { confirm: vi.fn() }, + } +}) + +const page = { template: ' page
' } + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +async function mountLayout(path: string) { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'test-access-token' + auth.user = { id: '1', name: '平台管理员', account: 'platform_admin', roles: ['PLATFORM_ADMIN'] } + localStorage.setItem('cc-admin-token', auth.token) + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: page, meta: { title: '管理后台' } }, + { path: '/customers', component: page, meta: { title: '客户管理', permission: 'platform:customer:list' } }, + { path: '/warehouses', component: page, meta: { title: '仓库管理', permission: 'platform:warehouse:list' } }, + { path: '/orders/first-leg', component: page, meta: { title: '头程订单', permission: 'platform:order:list' } }, + { path: '/system/users', component: page, meta: { title: '内部用户', permission: 'platform:user:list' } }, + { path: '/403', component: page, meta: { title: '权限不足' } }, + { path: '/login', component: page, meta: { title: '登录' } }, + ], + }) + await router.push(path) + await router.isReady() + + const wrapper = mount(AppLayout, { + attachTo: document.body, + global: { + plugins: [pinia, router], + stubs: { + AdminSidebarMenu: { props: ['items'], template: '' }, + RolePreviewSwitcher: { + props: ['roles', 'currentRole'], + emits: ['select'], + template: ' {{ currentRole?.name }}', + }, + 'el-button': { emits: ['click'], template: '' }, + }, + }, + }) + await nextTick() + return { + wrapper, + router, + auth, + access: useAccessPreviewStore(), + data: useAdminDataStore(), + unsaved: useUnsavedWorkStore(), + } +} + +describe('AppLayout role preview coordination', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()) + vi.mocked(ElMessageBox.confirm).mockReset() + vi.mocked(ElMessage.success).mockReset() + vi.mocked(ElMessage.error).mockReset() + document.body.innerHTML = '' + }) + + it('keeps the login identity visibly separate from the preview role', async () => { + const { wrapper } = await mountLayout('/') + + expect(wrapper.get('[aria-label="登录身份:平台管理员(platform_admin)"]').text()) + .toBe('登录身份:平台管理员(platform_admin)') + expect(wrapper.get('[data-preview]').text()).toBe('超级管理员') + }) + + it('commits a role and replaces an inaccessible system route with its first leaf', async () => { + const { wrapper, router, access } = await mountLayout('/system/users') + const replace = vi.spyOn(router, 'replace') + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + + expect(access.currentRole?.id).toBe('R003') + expect(replace).toHaveBeenCalledWith('/warehouses') + expect(router.currentRoute.value.path).toBe('/warehouses') + expect(wrapper.get('[aria-live="polite"]').text()).toContain('操作员') + expect(wrapper.get('[aria-live="polite"]').text()).toContain('仓库管理') + expect(ElMessage.success).toHaveBeenCalledWith('已切换至“操作员”,当前仅为静态权限预览') + expect(document.activeElement).toBe(wrapper.get('h1').element) + expect(wrapper.get('[data-sidebar]').attributes('data-count')) + .toBe(String(access.filteredNavigation.length)) + expect(access.filteredNavigation).toHaveLength(1) + }) + + it('recovers from 403 to the first accessible leaf after a role change', async () => { + const { wrapper, router, access } = await mountLayout('/403') + const replace = vi.spyOn(router, 'replace') + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + + expect(access.currentRole?.id).toBe('R003') + expect(replace).toHaveBeenCalledWith('/warehouses') + }) + + it('keeps a queried 403 route when the candidate role has no accessible page', async () => { + const { wrapper, router, access, data } = await mountLayout('/403?from=/system/users') + data.roles.find((role) => role.id === 'R003')!.permissionIds = [] + const replace = vi.spyOn(router, 'replace') + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + + expect(access.currentRole?.id).toBe('R003') + expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users') + expect(replace).not.toHaveBeenCalled() + expect(wrapper.get('[aria-live="polite"]').text()).toBe('权限预览已切换为操作员') + }) + + it('keeps role, route and dirty entry unchanged when role switching is cancelled', async () => { + vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') + const { wrapper, router, access, unsaved } = await mountLayout('/system/users') + unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' }) + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + + expect(ElMessageBox.confirm).toHaveBeenCalledWith( + expect.stringMatching(/切换到操作员.*将离开内部用户.*内部用户编辑表单/), + expect.any(String), + expect.any(Object), + ) + expect(access.currentRole?.id).toBe('R001') + expect(router.currentRoute.value.path).toBe('/system/users') + expect(unsaved.entries).toHaveLength(1) + }) + + it('warns that same-page forms will lose save permission without claiming navigation', async () => { + vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel') + const { wrapper, router, access, unsaved } = await mountLayout('/orders/first-leg') + unsaved.upsert({ key: 'restricted-form', label: '受限订单表单', dirty: true, requiredPermission: 'system:user:write' }) + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + + const message = String(vi.mocked(ElMessageBox.confirm).mock.calls[0]?.[0]) + expect(message).toContain('切换到操作员') + expect(message).toContain('头程订单') + expect(message).toContain('将失去保存权限') + expect(message).toContain('受限订单表单') + expect(message).not.toContain('将离开头程订单') + expect(access.currentRole?.id).toBe('R001') + expect(router.currentRoute.value.path).toBe('/orders/first-leg') + }) + + it('commits only after dirty-work confirmation succeeds', async () => { + let resolveConfirmation: (() => void) | undefined + vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => { + resolveConfirmation = () => resolve({} as never) + })) + const { wrapper, access, unsaved } = await mountLayout('/system/users') + unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' }) + + await wrapper.get('[data-switch-r003]').trigger('click') + await nextTick() + expect(access.currentRole?.id).toBe('R001') + + resolveConfirmation?.() + await flushPromises() + expect(access.currentRole?.id).toBe('R003') + }) + + it('ignores another role selection while the first confirmation is pending', async () => { + let resolveConfirmation: (() => void) | undefined + vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => { + resolveConfirmation = () => resolve({} as never) + })) + const { wrapper, access, unsaved } = await mountLayout('/system/users') + unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' }) + + await wrapper.get('[data-switch-r003]').trigger('click') + await wrapper.get('[data-switch-r005]').trigger('click') + await nextTick() + + expect(ElMessageBox.confirm).toHaveBeenCalledTimes(1) + expect(access.currentRole?.id).toBe('R001') + resolveConfirmation?.() + await flushPromises() + expect(access.currentRole?.id).toBe('R003') + }) + + it('reconfirms when a new blocker appears while the first confirmation is pending', async () => { + let resolveFirst: (() => void) | undefined + let resolveSecond: (() => void) | undefined + vi.mocked(ElMessageBox.confirm) + .mockReturnValueOnce(new Promise((resolve) => { resolveFirst = () => resolve({} as never) })) + .mockReturnValueOnce(new Promise((resolve) => { resolveSecond = () => resolve({} as never) })) + const { wrapper, access, unsaved } = await mountLayout('/orders/first-leg') + unsaved.upsert({ key: 'first-form', label: '第一张表单', dirty: true, requiredPermission: 'system:user:write' }) + + await wrapper.get('[data-switch-r003]').trigger('click') + await nextTick() + unsaved.upsert({ key: 'second-form', label: '新增表单', dirty: true, requiredPermission: 'customer:create' }) + resolveFirst?.() + await flushPromises() + + expect(ElMessageBox.confirm).toHaveBeenCalledTimes(2) + expect(String(vi.mocked(ElMessageBox.confirm).mock.calls[1]?.[0])).toContain('新增表单') + expect(access.currentRole?.id).toBe('R001') + resolveSecond?.() + await flushPromises() + expect(access.currentRole?.id).toBe('R003') + }) + + it('replaces the route when live permission changes make the current page inaccessible', async () => { + const { router, access, data } = await mountLayout('/system/users') + const replace = vi.spyOn(router, 'replace') + const permission = data.permissions.find((item) => item.code === 'platform:user:list') + if (!permission) throw new Error('fixture permission missing') + + permission.status = '停用' + await nextTick() + await flushPromises() + + expect(access.currentRole?.id).toBe('R001') + expect(replace).toHaveBeenCalledWith('/customers') + expect(document.activeElement).toBe(document.querySelector('h1')) + expect(document.querySelector('[aria-live="polite"]')?.textContent) + .toBe('当前页面权限已变化,已前往客户管理') + }) + + it('does not replace or announce navigation when live access remains on queried 403', async () => { + const { router, data } = await mountLayout('/403?from=/system/users') + const replace = vi.spyOn(router, 'replace') + + data.permissions.forEach((permission) => { permission.status = '停用' }) + await nextTick() + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users') + expect(replace).not.toHaveBeenCalled() + expect(document.querySelector('[aria-live="polite"]')?.textContent).toBe('') + }) + + it('rolls back the role and reports an error when correction navigation fails', async () => { + const { wrapper, router, access } = await mountLayout('/system/users') + vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed')) + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + + expect(access.currentRole?.id).toBe('R001') + expect(router.currentRoute.value.path).toBe('/system/users') + expect(ElMessage.error).toHaveBeenCalledWith('权限预览切换失败,已恢复原角色') + expect(ElMessage.success).not.toHaveBeenCalled() + + await wrapper.get('[data-switch-r003]').trigger('click') + await flushPromises() + expect(access.currentRole?.id).toBe('R003') + }) + + it('clears preview and unsaved state before logging out and replacing with login', async () => { + vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never) + const { wrapper, router, auth, access, unsaved } = await mountLayout('/') + unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null }) + const calls: string[] = [] + const resetAccess = access.reset + const clearUnsaved = unsaved.clear + const logout = auth.logout + vi.spyOn(access, 'reset').mockImplementation(() => { calls.push('access'); resetAccess() }) + vi.spyOn(unsaved, 'clear').mockImplementation(() => { calls.push('unsaved'); clearUnsaved() }) + vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); logout() }) + const replace = vi.spyOn(router, 'replace') + + await wrapper.get('[aria-label="退出登录"]').trigger('click') + await flushPromises() + + expect(calls).toEqual(['access', 'unsaved', 'auth']) + expect(replace).toHaveBeenCalledWith('/login') + }) + + it('keeps cleared logout state and reports a login navigation failure', async () => { + vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never) + const { wrapper, router, auth, access, unsaved } = await mountLayout('/') + unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null }) + vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed')) + + await wrapper.get('[aria-label="退出登录"]').trigger('click') + await flushPromises() + + expect(auth.isAuthenticated).toBe(false) + expect(access.currentRole).toBeNull() + expect(unsaved.entries).toHaveLength(0) + expect(ElMessage.error).toHaveBeenCalledWith('已退出登录,但登录页跳转失败,请刷新页面') + expect(router.currentRoute.value.path).toBe('/') + expect(router.replace).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue new file mode 100644 index 00000000..6e11027e --- /dev/null +++ b/src/layouts/AppLayout.vue @@ -0,0 +1,265 @@ + + + ++ + + ++ diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..fc69d59c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import 'element-plus/es/components/message/style/css' +import 'element-plus/es/components/message-box/style/css' +import App from './App.vue' +import router from './router' +import './styles/index.css' + +createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/src/navigation/adminNavigation.test.ts b/src/navigation/adminNavigation.test.ts new file mode 100644 index 00000000..8d3f0825 --- /dev/null +++ b/src/navigation/adminNavigation.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + adminNavigation, + filterAdminNavigation, + getFirstNavigationLeaf, + getNavigationLeaves, + type AdminNavigationNode, +} from './adminNavigation' + +describe('adminNavigation', () => { + it('keeps phase-one pages grouped without exposing the deferred dashboard', () => { + expect(adminNavigation.map((item) => item.title)).toEqual([ + '业务管理', + '基础资料', + '系统管理', + ]) + expect(adminNavigation[0].children?.map((item) => item.title)).toEqual([ + '客户管理', + '企业审核', + '仓库管理', + '头程订单', + ]) + expect(adminNavigation[1].children?.map((item) => item.title)).toEqual(['快递公司', '往来单位']) + expect(adminNavigation[2].children?.map((item) => item.title)).toEqual([ + '内部用户', + '角色管理', + '菜单权限', + ]) + }) + + it('assigns every page a unique path and permission code', () => { + const leaves = getNavigationLeaves(adminNavigation) + const paths = leaves.map((item) => item.path) + const permissions = leaves.map((item) => item.permission) + + expect(paths).toEqual([ + '/customers', + '/customers/reviews', + '/warehouses', + '/orders/first-leg', + '/master-data/express-companies', + '/master-data/counterparties', + '/system/users', + '/system/roles', + '/system/permissions', + ]) + expect(new Set(paths).size).toBe(paths.length) + expect(new Set(permissions).size).toBe(permissions.length) + expect(leaves.every((item) => Boolean(item.icon))).toBe(true) + }) + + it('recursively filters leaves without mutating the source tree', () => { + const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value) + const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list'])) + + expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/system/permissions']) + expect(filtered).not.toBe(adminNavigation) + expect(filtered[0]).not.toBe(adminNavigation[2]) + expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot) + }) + + it('keeps directories based on accessible descendants instead of a directory permission', () => { + const nodes: AdminNavigationNode[] = [{ + key: 'operations', + title: '运营管理', + icon: adminNavigation[0].icon, + permission: 'directory:denied', + children: [{ + key: 'tracking', + title: '轨迹管理', + icon: adminNavigation[0].icon, + path: '/tracking', + permission: 'tracking:list', + }], + }] + + const filtered = filterAdminNavigation(nodes, new Set(['tracking:list'])) + + expect(filtered).toHaveLength(1) + expect(filtered[0].children?.[0].path).toBe('/tracking') + }) + + it('selects the first fallback leaf in strict depth-first configuration order', () => { + const nodes: AdminNavigationNode[] = [{ + key: 'level-one', + title: '一级目录', + icon: adminNavigation[0].icon, + children: [{ + key: 'level-two', + title: '二级目录', + icon: adminNavigation[0].icon, + children: [{ + key: 'tracking', + title: '轨迹管理', + icon: adminNavigation[0].icon, + path: '/tracking', + permission: 'tracking:list', + }], + }, { + key: 'reports', + title: '报表管理', + icon: adminNavigation[0].icon, + path: '/reports', + permission: 'reports:list', + }], + }] + + expect(getNavigationLeaves(nodes).map((item) => item.path)).toEqual(['/tracking', '/reports']) + expect(getFirstNavigationLeaf(nodes)?.path).toBe('/tracking') + }) +}) diff --git a/src/navigation/adminNavigation.ts b/src/navigation/adminNavigation.ts new file mode 100644 index 00000000..dd1f2daf --- /dev/null +++ b/src/navigation/adminNavigation.ts @@ -0,0 +1,89 @@ +import type { Component } from 'vue' +import { + BriefcaseBusiness, + Building2, + ContactRound, + ClipboardList, + Database, + FileCheck2, + KeyRound, + Settings, + ShieldCheck, + Truck, + Users, + Warehouse as WarehouseIcon, +} from '@lucide/vue' + +export interface AdminNavigationNode { + key: string + title: string + icon: Component + path?: string + permission?: string + children?: AdminNavigationNode[] +} + +export interface AdminNavigationLeaf extends AdminNavigationNode { + path: string + permission: string + children?: never +} + +export const adminNavigation: AdminNavigationNode[] = [ + { + key: 'business', + title: '业务管理', + icon: BriefcaseBusiness, + children: [ + { key: 'customers', title: '客户管理', icon: Building2, path: '/customers', permission: 'platform:customer:list' }, + { key: 'enterprise-reviews', title: '企业审核', icon: FileCheck2, path: '/customers/reviews', permission: 'platform:customer:review' }, + { key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' }, + { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list' }, + ], + }, + { + key: 'master-data', + title: '基础资料', + icon: Database, + children: [ + { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' }, + { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' }, + ], + }, + { + key: 'system', + title: '系统管理', + icon: Settings, + children: [ + { key: 'internal-users', title: '内部用户', icon: Users, path: '/system/users', permission: 'platform:user:list' }, + { key: 'roles', title: '角色管理', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' }, + { key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' }, + ], + }, +] + +export function getNavigationLeaves(nodes: AdminNavigationNode[]): AdminNavigationLeaf[] { + return nodes.flatMap((node) => { + if (node.children?.length) return getNavigationLeaves(node.children) + if (!node.path || !node.permission) return [] + return [node as AdminNavigationLeaf] + }) +} + +export function filterAdminNavigation( + nodes: AdminNavigationNode[], + permissionCodes: ReadonlySet+++ + + +++{{ pageTitle }}
+{{ pageDescription }}
++ ++{{ auth.user?.name.slice(0, 1) }}++ 登录身份:{{ auth.user?.name }}({{ auth.user?.account }}) +++ ++ {{ announcement }}
++ ++ , +): AdminNavigationNode[] { + return nodes.flatMap((node) => { + if (node.children?.length) { + const children = filterAdminNavigation(node.children, permissionCodes) + return children.length ? [{ ...node, children }] : [] + } + if (!node.path || !node.permission || !permissionCodes.has(node.permission)) return [] + return [{ ...node }] + }) +} + +export function getFirstNavigationLeaf(nodes: AdminNavigationNode[]): AdminNavigationLeaf | null { + return getNavigationLeaves(nodes)[0] ?? null +} diff --git a/src/router/accessDecision.test.ts b/src/router/accessDecision.test.ts new file mode 100644 index 00000000..67c20daf --- /dev/null +++ b/src/router/accessDecision.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + decideRouteAccess, + resolvePreviewTarget, + sanitizeInternalPath, +} from './accessDecision' + +describe('sanitizeInternalPath', () => { + it.each([ + [undefined, '/'], + [null, '/'], + [42, '/'], + ['', '/'], + ['customers', '/'], + ['//example.com', '/'], + ['/\\example.com', '/'], + ['/\n/example.com', '/'], + ['/\r/example.com', '/'], + ['/\t/example.com', '/'], + ['/\u0000/example.com', '/'], + ['/\u007f/example.com', '/'], + ['https://example.com', '/'], + ])('sanitizes %s to %s', (value, expected) => { + expect(sanitizeInternalPath(value)).toBe(expected) + }) + + it('accepts an internal path with its query string', () => { + expect(sanitizeInternalPath('/customers?status=pending')).toBe('/customers?status=pending') + }) +}) + +describe('decideRouteAccess', () => { + it('checks authentication before route permission', () => { + expect(decideRouteAccess({ + requiresAuth: true, + isAuthenticated: false, + fullPath: '/system/roles?tab=enabled', + permission: 'system:role:list', + hasPermission: false, + })).toEqual({ type: 'login', redirect: '/system/roles?tab=enabled' }) + }) + + it('sanitizes an unsafe login redirect', () => { + expect(decideRouteAccess({ + requiresAuth: true, + isAuthenticated: false, + fullPath: '//example.com', + permission: 'system:role:list', + hasPermission: false, + })).toEqual({ type: 'login', redirect: '/' }) + }) + + it('sends an authenticated user without permission to forbidden and preserves the internal source', () => { + expect(decideRouteAccess({ + requiresAuth: true, + isAuthenticated: true, + fullPath: '/customers?status=pending', + permission: 'customer:list', + hasPermission: false, + })).toEqual({ type: 'forbidden', from: '/customers?status=pending' }) + }) + + it('allows the authenticated forbidden route because it has no permission metadata', () => { + expect(decideRouteAccess({ + requiresAuth: true, + isAuthenticated: true, + fullPath: '/403?from=%2Fcustomers', + hasPermission: false, + })).toEqual({ type: 'allow' }) + }) +}) + +describe('resolvePreviewTarget', () => { + it('recovers from forbidden to the first accessible route', () => { + expect(resolvePreviewTarget({ + currentPath: '/403', + currentPermission: undefined, + candidateCodes: new Set(['customer:list']), + firstPath: '/customers', + })).toBe('/customers') + }) + + it('stays on forbidden when the candidate has no accessible route', () => { + expect(resolvePreviewTarget({ + currentPath: '/403', + currentPermission: undefined, + candidateCodes: new Set (), + firstPath: null, + })).toBe('/403') + }) + + it('falls back when the current route permission is lost', () => { + expect(resolvePreviewTarget({ + currentPath: '/system/roles', + currentPermission: 'system:role:list', + candidateCodes: new Set(['customer:list']), + firstPath: '/customers', + })).toBe('/customers') + }) + + it('keeps the current route when its permission remains available', () => { + expect(resolvePreviewTarget({ + currentPath: '/customers', + currentPermission: 'customer:list', + candidateCodes: new Set(['customer:list']), + firstPath: '/customers', + })).toBeNull() + }) +}) diff --git a/src/router/accessDecision.ts b/src/router/accessDecision.ts new file mode 100644 index 00000000..e21ce3e5 --- /dev/null +++ b/src/router/accessDecision.ts @@ -0,0 +1,48 @@ +export type RouteAccessDecision = + | { type: 'allow' } + | { type: 'login'; redirect: string } + | { type: 'forbidden'; from: string } + +export interface RouteAccessInput { + requiresAuth?: boolean + isAuthenticated: boolean + fullPath: string + permission?: string + hasPermission: boolean +} + +export interface PreviewTargetInput { + currentPath: string + currentPermission?: string + candidateCodes: ReadonlySet + firstPath: string | null +} + +export function sanitizeInternalPath(value: unknown): string { + if ( + typeof value !== 'string' + || !value.startsWith('/') + || value[1] === '/' + || value[1] === '\\' + || /[\u0000-\u001F\u007F]/.test(value) + ) return '/' + return value +} + +export function decideRouteAccess(input: RouteAccessInput): RouteAccessDecision { + if (input.requiresAuth && !input.isAuthenticated) { + return { type: 'login', redirect: sanitizeInternalPath(input.fullPath) } + } + if (input.permission && !input.hasPermission) { + return { type: 'forbidden', from: sanitizeInternalPath(input.fullPath) } + } + return { type: 'allow' } +} + +export function resolvePreviewTarget(input: PreviewTargetInput): string | null { + if (input.currentPath === '/403') return input.firstPath ?? '/403' + if (input.currentPermission && !input.candidateCodes.has(input.currentPermission)) { + return input.firstPath ?? '/403' + } + return null +} diff --git a/src/router/index.test.ts b/src/router/index.test.ts new file mode 100644 index 00000000..8b961028 --- /dev/null +++ b/src/router/index.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import router from './index' + +describe('admin router access metadata', () => { + it('uses the root route only as a permission-aware entry point', () => { + const home = router.getRoutes().find((route) => route.name === 'home') + + expect(home?.path).toBe('/') + expect(home?.meta.permission).toBeUndefined() + expect(router.getRoutes().some((route) => route.name === 'dashboard')).toBe(false) + }) + + it('protects warehouse management with its stable list permission', () => { + const warehouseRoute = router.getRoutes().find((route) => route.name === 'warehouses') + + expect(warehouseRoute?.path).toBe('/warehouses') + expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list') + }) + + it('protects express company records with a stable master-data permission', () => { + const route = router.getRoutes().find((item) => item.name === 'express-companies') + + expect(route?.path).toBe('/master-data/express-companies') + expect(route?.meta.permission).toBe('platform:express:list') + }) + + it('protects counterparty records with a stable master-data permission', () => { + const route = router.getRoutes().find((item) => item.name === 'counterparties') + expect(route?.path).toBe('/master-data/counterparties') + expect(route?.meta.permission).toBe('platform:counterparty:list') + }) + + it('protects enterprise reviews with the backend review menu permission', () => { + const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews') + + expect(route?.path).toBe('/customers/reviews') + expect(route?.meta.permission).toBe('platform:customer:review') + }) + + it('keeps forbidden inside the authenticated layout without a business permission', () => { + const authenticatedLayout = router.options.routes.find((route) => route.path === '/') + const forbidden = authenticatedLayout?.children?.find((route) => route.name === 'forbidden') + const resolved = router.resolve('/403') + + expect(authenticatedLayout?.meta?.requiresAuth).toBe(true) + expect(forbidden?.path).toBe('403') + expect(forbidden?.meta?.permission).toBeUndefined() + expect(resolved.matched.some((record) => record.meta.requiresAuth)).toBe(true) + }) +}) diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 00000000..517b84ba --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,125 @@ +import { createRouter, createWebHistory } from 'vue-router' +import AppLayout from '@/layouts/AppLayout.vue' +import { decideRouteAccess } from '@/router/accessDecision' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { title: '登录' }, + }, + { + path: '/', + component: AppLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'home', + component: () => import('@/views/ModuleView.vue'), + meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' }, + }, + { + path: 'customers', + name: 'customers', + component: () => import('@/views/CustomersView.vue'), + meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list' }, + }, + { + path: 'customers/reviews', + name: 'enterprise-reviews', + component: () => import('@/views/EnterpriseReviewsView.vue'), + meta: { title: '企业审核', description: '核验企业资质并维护客户下单资格', permission: 'platform:customer:review' }, + }, + { + path: 'warehouses', + name: 'warehouses', + component: () => import('@/views/WarehousesView.vue'), + meta: { title: '仓库管理', description: '维护客户头程下单可选择的平台收货仓', permission: 'platform:warehouse:list' }, + }, + { + path: 'orders/first-leg', + name: 'first-leg-orders', + component: () => import('@/views/FirstLegOrdersView.vue'), + meta: { title: '头程订单', description: '创建、受理和跟进客户提交的头程运输订单', permission: 'platform:order:list' }, + }, + { + path: 'master-data/express-companies', + name: 'express-companies', + component: () => import('@/views/ExpressCompaniesView.vue'), + meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' }, + }, + { + path: 'master-data/counterparties', + name: 'counterparties', + component: () => import('@/views/CounterpartiesView.vue'), + meta: { title: '往来单位', description: '维护供应商、客户、承运商、报关行及收发货单位档案', permission: 'platform:counterparty:list' }, + }, + { + path: 'system/users', + name: 'users', + component: () => import('@/views/InternalUsersView.vue'), + meta: { title: '内部用户', description: '管理平台内部员工账号、状态和角色', permission: 'platform:user:list' }, + }, + { + path: 'system/roles', + name: 'roles', + component: () => import('@/views/RolesView.vue'), + meta: { title: '角色管理', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' }, + }, + { + path: 'system/permissions', + name: 'permissions', + component: () => import('@/views/PermissionsView.vue'), + meta: { title: '菜单权限', description: '维护菜单、页面、按钮和接口权限编码', permission: 'platform:permission:list' }, + }, + { + path: '403', + name: 'forbidden', + component: () => import('@/views/ForbiddenView.vue'), + meta: { title: '权限不足', description: '当前权限预览角色无法访问此页面' }, + }, + ], + }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) + +router.beforeEach((to) => { + document.title = to.meta.title ? `${to.meta.title} - 虫虫出海管理后台` : '虫虫出海管理后台' + const auth = useAuthStore() + if (to.name === 'login' && auth.isAuthenticated) { + return { name: 'home' } + } + if (!auth.isAuthenticated && !to.meta.requiresAuth) return true + + const access = useAccessPreviewStore() + if (auth.isAuthenticated) access.initializeAfterLogin() + const decision = decideRouteAccess({ + requiresAuth: to.meta.requiresAuth, + isAuthenticated: auth.isAuthenticated, + fullPath: to.fullPath, + permission: to.meta.permission, + hasPermission: to.meta.permission ? access.can(to.meta.permission) : true, + }) + + if (decision.type === 'login') { + return { name: 'login', query: { redirect: decision.redirect } } + } + if (decision.type === 'forbidden') { + return { name: 'forbidden', query: { from: decision.from }, replace: true } + } + if (to.name === 'home') { + return access.firstAccessiblePath + ? { path: access.firstAccessiblePath, replace: true } + : { name: 'forbidden', replace: true } + } + return true +}) + +export default router diff --git a/src/router/meta.d.ts b/src/router/meta.d.ts new file mode 100644 index 00000000..af6661fd --- /dev/null +++ b/src/router/meta.d.ts @@ -0,0 +1,10 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + title?: string + description?: string + requiresAuth?: boolean + permission?: string + } +} diff --git a/src/stores/accessPreview.test.ts b/src/stores/accessPreview.test.ts new file mode 100644 index 00000000..80f26f8e --- /dev/null +++ b/src/stores/accessPreview.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import { getNavigationLeaves } from '@/navigation/adminNavigation' +import { useAdminDataStore } from '@/stores/adminData' +import { PREVIEW_ROLE_KEY, useAccessPreviewStore } from '@/stores/accessPreview' + +function createMemoryStorage(): Storage { + const values = new Map () + + return { + get length() { + return values.size + }, + clear() { + values.clear() + }, + getItem(key) { + return values.get(key) ?? null + }, + key(index) { + return [...values.keys()][index] ?? null + }, + removeItem(key) { + values.delete(key) + }, + setItem(key, value) { + values.set(key, value) + }, + } +} + +describe('access preview store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.stubGlobal('localStorage', createMemoryStorage()) + }) + + it('defaults to the enabled super admin with all enabled permissions', () => { + const preview = useAccessPreviewStore() + + preview.initializeAfterLogin() + + expect(preview.enabledRoles).toHaveLength(5) + expect(preview.currentRole?.code).toBe('SUPER_ADMIN') + expect(preview.can('system:permission:write')).toBe(true) + expect(preview.firstAccessiblePath).toBe('/customers') + expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R001') + }) + + it('ignores disabled permission resources even when the selected role still holds their ids', () => { + const data = useAdminDataStore() + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + const customerList = data.permissions.find((item) => item.code === 'platform:customer:list') + expect(customerList).toBeDefined() + + if (customerList) customerList.status = '停用' + + expect(preview.can('platform:customer:list')).toBe(false) + expect(getNavigationLeaves(preview.filteredNavigation).map((item) => item.path)).not.toContain('/customers') + expect(preview.firstAccessiblePath).toBe('/customers/reviews') + }) + + it('does not cascade a disabled directory to enabled descendant leaves', () => { + const data = useAdminDataStore() + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + const businessDirectory = data.permissions.find((item) => item.code === 'business') + expect(businessDirectory).toBeDefined() + + if (businessDirectory) businessDirectory.status = '停用' + + expect(preview.can('business')).toBe(false) + expect(preview.can('platform:customer:list')).toBe(true) + expect(getNavigationLeaves(preview.filteredNavigation).map((item) => item.path)).toContain('/customers') + }) + + it('reacts immediately to permission id changes and falls back when the current role is disabled', async () => { + const data = useAdminDataStore() + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + expect(preview.commitRole('R003')).toBe(true) + const operator = data.roles.find((item) => item.id === 'R003') + expect(operator).toBeDefined() + + if (operator) operator.permissionIds = ['P100'] + expect(preview.can('first-leg:list')).toBe(false) + expect(preview.can('platform:customer:list')).toBe(true) + expect(preview.permissionSignature).toBe('platform:customer:list') + + if (operator) operator.status = '停用' + await nextTick() + + expect(preview.currentRole?.code).toBe('SUPER_ADMIN') + expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R001') + }) + + it('falls back from an invalid persisted role and reset clears selection and persistence', () => { + localStorage.setItem(PREVIEW_ROLE_KEY, 'missing-role') + const preview = useAccessPreviewStore() + + preview.initializeAfterLogin() + expect(preview.currentRole?.code).toBe('SUPER_ADMIN') + + preview.reset() + expect(preview.initialized).toBe(false) + expect(preview.currentRole).toBeNull() + expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull() + }) + + it('restores an enabled persisted role', () => { + localStorage.setItem(PREVIEW_ROLE_KEY, 'R003') + const preview = useAccessPreviewStore() + + preview.initializeAfterLogin() + + expect(preview.currentRole?.id).toBe('R003') + expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R003') + }) + + it('does not reload an initialized role during guarded navigation', () => { + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + expect(preview.commitRole('R003')).toBe(true) + + localStorage.setItem(PREVIEW_ROLE_KEY, 'R001') + preview.initializeAfterLogin() + + expect(preview.currentRole?.id).toBe('R003') + }) + + it('has no role, permissions, or route when every role is disabled', () => { + const data = useAdminDataStore() + data.roles.forEach((role) => { + role.status = '停用' + }) + const preview = useAccessPreviewStore() + + preview.initializeAfterLogin() + + expect(preview.currentRole).toBeNull() + expect([...preview.permissionCodes]).toEqual([]) + expect(preview.filteredNavigation).toEqual([]) + expect(preview.firstAccessiblePath).toBeNull() + expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull() + }) + + it('does not recreate a default preview after reset when role data changes', async () => { + const data = useAdminDataStore() + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + preview.reset() + + data.roles[0].status = '停用' + data.roles[1].status = '停用' + await nextTick() + + expect(preview.initialized).toBe(false) + expect(preview.selectedRoleId).toBeNull() + expect(preview.currentRole).toBeNull() + expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull() + }) + + it('previews candidates without committing and rejects disabled roles', () => { + const data = useAdminDataStore() + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + const candidate = preview.previewRole('R003') + + expect(candidate.codes.has('first-leg:create')).toBe(true) + expect(candidate.firstPath).toBe('/warehouses') + expect(getNavigationLeaves(candidate.navigation).map((item) => item.path)).toContain('/orders/first-leg') + expectTypeOf(candidate.codes).toEqualTypeOf >() + expectTypeOf(preview.permissionCodes).toEqualTypeOf >() + expect(preview.currentRole?.id).toBe('R001') + + const finance = data.roles.find((item) => item.id === 'R004') + if (finance) finance.status = '停用' + expect(preview.commitRole('R004')).toBe(false) + expect(preview.previewRole('R004').firstPath).toBeNull() + expect(preview.currentRole?.id).toBe('R001') + }) + + it('does not filter or mutate mock business data when switching data scopes', () => { + const data = useAdminDataStore() + const preview = useAccessPreviewStore() + preview.initializeAfterLogin() + const customerSnapshot = JSON.parse(JSON.stringify(data.customers)) as typeof data.customers + const orderSnapshot = JSON.parse(JSON.stringify(data.orders)) as typeof data.orders + + expect(preview.commitRole('R003')).toBe(true) + expect(preview.currentRole?.dataScope).toBe('本部门数据') + expect(preview.commitRole('R005')).toBe(true) + expect(preview.currentRole?.dataScope).toBe('本人数据') + + expect(data.customers).toEqual(customerSnapshot) + expect(data.orders).toEqual(orderSnapshot) + }) +}) diff --git a/src/stores/accessPreview.ts b/src/stores/accessPreview.ts new file mode 100644 index 00000000..ba5cd025 --- /dev/null +++ b/src/stores/accessPreview.ts @@ -0,0 +1,155 @@ +import { computed, ref, watch } from 'vue' +import { defineStore } from 'pinia' +import { + adminNavigation, + filterAdminNavigation, + getFirstNavigationLeaf, + type AdminNavigationNode, +} from '@/navigation/adminNavigation' +import { useAdminDataStore } from '@/stores/adminData' +import { useAuthStore } from '@/stores/auth' + +export const PREVIEW_ROLE_KEY = 'cc-admin-preview-role' + +export interface RoleAccessPreview { + codes: ReadonlySet + navigation: AdminNavigationNode[] + firstPath: string | null +} + +const serverPermissionAliases: Record = { + 'customer:list': ['platform:customer:list'], + 'warehouse:list': ['platform:warehouse:list'], + 'first-leg:list': ['platform:order:list'], + 'express-company:list': ['platform:express:list'], + 'counterparty:list': ['platform:counterparty:list'], + 'system:user:list': ['platform:user:list'], + 'system:role:list': ['platform:role:list'], + 'system:role:grant': ['platform:role:create', 'platform:role:grant'], + 'system:permission:list': ['platform:permission:list'], + 'system:permission:write': ['platform:permission:create'], +} + +function withServerAliases(codes: ReadonlySet ): ReadonlySet { + const result = new Set(codes) + codes.forEach((code) => serverPermissionAliases[code]?.forEach((alias) => result.add(alias))) + return result +} + +export const useAccessPreviewStore = defineStore('access-preview', () => { + const data = useAdminDataStore() + const auth = useAuthStore() + const selectedRoleId = ref (null) + const initialized = ref(false) + + const enabledRoles = computed(() => data.roles.filter((role) => role.status === '启用')) + const enabledRoleIdsSignature = computed(() => enabledRoles.value + .map((role) => role.id) + .sort() + .join('|')) + const currentRole = computed(() => enabledRoles.value + .find((role) => role.id === selectedRoleId.value) ?? null) + + function permissionCodesFor(roleId: string | null): ReadonlySet { + const role = enabledRoles.value.find((item) => item.id === roleId) + if (!role) return new Set () + + const permissionIds = new Set(role.permissionIds) + return new Set(data.permissions + .filter((permission) => permission.status === '启用' && permissionIds.has(permission.id)) + .map((permission) => permission.code)) + } + + const usesServerPermissions = computed(() => auth.isServerSession) + const permissionCodes = computed >(() => usesServerPermissions.value + ? auth.permissionCodes + : withServerAliases(permissionCodesFor(selectedRoleId.value))) + const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|')) + const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value)) + const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null) + + function getDefaultRoleId(): string | null { + return enabledRoles.value.find((role) => role.code === 'SUPER_ADMIN')?.id + ?? enabledRoles.value[0]?.id + ?? null + } + + function persistRole(roleId: string | null) { + if (roleId) { + localStorage.setItem(PREVIEW_ROLE_KEY, roleId) + return + } + localStorage.removeItem(PREVIEW_ROLE_KEY) + } + + function initializeAfterLogin() { + if (usesServerPermissions.value) { + initialized.value = true + selectedRoleId.value = null + localStorage.removeItem(PREVIEW_ROLE_KEY) + return + } + if (initialized.value && currentRole.value) return + initialized.value = true + const persistedRoleId = localStorage.getItem(PREVIEW_ROLE_KEY) + const roleId = enabledRoles.value.some((role) => role.id === persistedRoleId) + ? persistedRoleId + : getDefaultRoleId() + selectedRoleId.value = roleId + persistRole(roleId) + } + + function commitRole(roleId: string): boolean { + if (usesServerPermissions.value) return false + if (!enabledRoles.value.some((role) => role.id === roleId)) return false + selectedRoleId.value = roleId + persistRole(roleId) + return true + } + + function previewRole(roleId: string): RoleAccessPreview { + const candidateCodes = withServerAliases(permissionCodesFor(roleId)) + const candidateNavigation = filterAdminNavigation(adminNavigation, candidateCodes) + + return { + codes: candidateCodes, + navigation: candidateNavigation, + firstPath: getFirstNavigationLeaf(candidateNavigation)?.path ?? null, + } + } + + function can(permissionCode: string): boolean { + return permissionCodes.value.has(permissionCode) + } + + function reset() { + initialized.value = false + selectedRoleId.value = null + localStorage.removeItem(PREVIEW_ROLE_KEY) + } + + watch(enabledRoleIdsSignature, () => { + if (!initialized.value || currentRole.value) return + const fallbackRoleId = getDefaultRoleId() + selectedRoleId.value = fallbackRoleId + persistRole(fallbackRoleId) + }) + + return { + selectedRoleId, + initialized, + usesServerPermissions, + enabledRoles, + currentRole, + permissionCodes, + permissionSignature, + filteredNavigation, + firstAccessiblePath, + permissionCodesFor, + initializeAfterLogin, + commitRole, + previewRole, + can, + reset, + } +}) diff --git a/src/stores/adminData.test.ts b/src/stores/adminData.test.ts new file mode 100644 index 00000000..a0bed3e0 --- /dev/null +++ b/src/stores/adminData.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { adminNavigation, getNavigationLeaves } from '@/navigation/adminNavigation' +import { useAdminDataStore } from '@/stores/adminData' +import { buildPermissionTree } from '@/utils/permissionTree' + +describe('admin permission fixtures', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('groups page permissions under business and system directories', () => { + const tree = buildPermissionTree(useAdminDataStore().permissions) + + expect(tree.map((node) => node.name)).toEqual(['业务管理', '基础资料', '系统管理']) + expect(tree[0]?.children?.map((node) => node.name)).toEqual(['客户管理', '企业审核', '仓库管理', '头程订单']) + expect(tree[1]?.children?.map((node) => node.name)).toEqual(['快递公司', '往来单位']) + expect(tree[2]?.children?.map((node) => node.name)).toEqual(['内部用户', '角色管理', '菜单权限']) + }) + + it('keeps menu permission codes aligned with navigation leaves', () => { + const store = useAdminDataStore() + const menuCodes = store.permissions + .filter((item) => item.type === '菜单') + .map((item) => item.code) + .sort() + const navigationCodes = getNavigationLeaves(adminNavigation) + .map((item) => item.permission) + .sort() + + expect(menuCodes).toEqual(navigationCodes) + }) + + it('does not guess API paths for static button permissions', () => { + const buttonPermissions = useAdminDataStore().permissions.filter((item) => item.type === '按钮') + + expect(buttonPermissions.every((item) => item.path === '' && item.method === '-')).toBe(true) + expect(useAdminDataStore().permissions.some((item) => item.type === '接口')).toBe(false) + }) + + it('keeps every role permission id valid after regrouping', () => { + const store = useAdminDataStore() + const permissionIds = new Set(store.permissions.map((item) => item.id)) + + store.roles.forEach((role) => { + expect(role.permissionIds.every((id) => permissionIds.has(id))).toBe(true) + }) + }) + + it('maintains platform receiving warehouses with unique normalized codes', () => { + const store = useAdminDataStore() + expect(store.warehouses).toHaveLength(6) + expect(store.activeWarehouses).toBe(5) + + const id = store.saveWarehouse({ + code: 'cn-hz-01', name: '杭州仓', province: '浙江省', city: '杭州市', address: '演示物流园 7 号库', + contactName: '仓库客服', contactPhone: '0571-****007', businessHours: '周一至周五 09:00-18:00', + status: 'ACTIVE', remark: '', + }) + expect(store.warehouses.find((item) => item.id === id)?.code).toBe('CN-HZ-01') + expect(store.activeWarehouses).toBe(6) + expect(() => store.saveWarehouse({ + code: 'CN-HZ-01', name: '重复仓', province: '', city: '杭州市', address: '重复地址', + contactName: '', contactPhone: '', businessHours: '', status: 'ACTIVE', remark: '', + })).toThrow('仓库编码已存在') + + store.setWarehouseStatus(id, 'INACTIVE') + expect(store.activeWarehouses).toBe(5) + }) + + it('provides the express company records defined by the phase-one prototype', () => { + const store = useAdminDataStore() + + expect(store.expressCompanies).toHaveLength(8) + expect(store.activeExpressCompanies).toBe(7) + expect(store.expressCompanies.find((item) => item.code === 'YTO')?.status).toBe('已终止') + expect(new Set(store.expressCompanies.map((item) => item.code)).size).toBe(store.expressCompanies.length) + }) + + it('validates express company uniqueness and counterparty multi-types', () => { + const store = useAdminDataStore() + expect(() => store.saveExpressCompany({ name: '顺丰速运', code: 'NEW-SF', website: '', phone: '', status: '合作中', remark: '' })).toThrow('快递公司名称已存在') + expect(() => store.saveExpressCompany({ name: '新快递', code: 'sf', website: '', phone: '', status: '合作中', remark: '' })).toThrow('快递公司编码已存在') + const id = store.saveCounterparty({ code: 'cp-test', name: '测试多角色单位', types: ['供应商', '发货单位'], contactName: '联系人', contactPhone: '13800000000', email: '', country: '中国', province: '浙江省', city: '义乌市', postalCode: '', address: '测试地址', status: '启用', remark: '' }) + expect(store.counterparties.find((item) => item.id === id)?.types).toEqual(['供应商', '发货单位']) + expect(() => store.saveCounterparty({ code: 'CP-TEST', name: '重复单位', types: ['客户'], contactName: '联系人', contactPhone: '13800000000', email: '', country: '中国', province: '', city: '深圳市', postalCode: '', address: '测试地址', status: '启用', remark: '' })).toThrow('往来单位编码已存在') + }) + + it('keeps pending orders pending after admin edits and audits delivery maintenance', () => { + const store = useAdminDataStore() + const order = store.orders.find((item) => item.status === '待审核')! + const before = order.timeline.length + store.saveOrder({ ...order, id: order.id, remark: '后台补充资料' }) + const updated = store.orders.find((item) => item.id === order.id)! + expect(updated.status).toBe('待审核') + expect(updated.timeline).toHaveLength(before + 1) + expect(updated.timeline.at(-1)?.title).toBe('后台修改待审核订单') + store.saveOrderDeliveryInfo(order.id, 'E001', 'DHL-20260730-001', '张伟') + expect(updated.deliveryInfo?.trackingNo).toBe('DHL-20260730-001') + expect(updated.timeline.at(-1)?.title).toBe('更新履约快递信息') + }) + + it('keeps the order warehouse snapshot stable when the warehouse master changes', () => { + const store = useAdminDataStore() + const order = store.orders[0] + const warehouse = store.warehouses.find((item) => item.id === order.receivingWarehouseId) + expect(warehouse).toBeDefined() + const originalSnapshot = order.receivingWarehouseSnapshot ? { ...order.receivingWarehouseSnapshot } : null + + store.saveWarehouse({ ...warehouse!, name: '宁波仓(新名称)' }) + + expect(store.warehouses.find((item) => item.id === warehouse!.id)?.name).toBe('宁波仓(新名称)') + expect(order.receivingWarehouseSnapshot).toEqual(originalSnapshot) + }) + + it('rejects new orders that select an inactive receiving warehouse', () => { + const store = useAdminDataStore() + const template = store.orders[0] + + expect(() => store.saveOrder({ + ...template, + id: undefined, + receivingWarehouseId: 'WH006', + })).toThrow('所选收货仓当前不可用于新订单') + }) +}) diff --git a/src/stores/adminData.ts b/src/stores/adminData.ts new file mode 100644 index 00000000..4661cea5 --- /dev/null +++ b/src/stores/adminData.ts @@ -0,0 +1,389 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { + createPlatformReceivingWarehouseSnapshot, + DEMO_PLATFORM_RECEIVING_WAREHOUSES, +} from '@chongchong/shared' +import type { + Customer, + Counterparty, + ExpressCompany, + FirstLegOrder, + InternalUser, + OrderStatus, + Permission, + Role, + Warehouse, +} from '@/types/admin' + +const initialExpressCompanies: ExpressCompany[] = [ + { id: 'E001', name: 'DHL', code: 'DHL', website: 'www.dhl.com', phone: '95380', status: '合作中', remark: '国际快递渠道。', updatedAt: '2026-07-28 09:10' }, + { id: 'E002', name: 'FedEx', code: 'FDX', website: 'www.fedex.com', phone: '400-886-1888', status: '合作中', remark: '国际快递渠道。', updatedAt: '2026-07-28 09:12' }, + { id: 'E003', name: 'UPS', code: 'UPS', website: 'www.ups.com', phone: '400-820-8388', status: '合作中', remark: '', updatedAt: '2026-07-28 09:15' }, + { id: 'E004', name: '顺丰速运', code: 'SF', website: 'www.sf-express.com', phone: '95338', status: '合作中', remark: '国内收货常用渠道。', updatedAt: '2026-07-28 09:20' }, + { id: 'E005', name: '中通快递', code: 'ZTO', website: 'www.zto.com', phone: '95311', status: '合作中', remark: '', updatedAt: '2026-07-28 09:22' }, + { id: 'E006', name: '圆通速递', code: 'YTO', website: 'www.yto.net.cn', phone: '95554', status: '已终止', remark: '暂停新增引用,历史订单保留。', updatedAt: '2026-07-28 09:25' }, + { id: 'E007', name: 'EMS', code: 'EMS', website: 'www.ems.com.cn', phone: '11183', status: '合作中', remark: '', updatedAt: '2026-07-28 09:28' }, + { id: 'E008', name: '德邦快递', code: 'DPK', website: 'www.deppon.com', phone: '95353', status: '合作中', remark: '大件货物备选渠道。', updatedAt: '2026-07-28 09:30' }, +] + +const initialCounterparties: Counterparty[] = [ + { id: 'CP001', code: 'CP-HY-001', name: '华远贸易有限公司', types: ['客户', '发货单位'], contactName: '张伟', contactPhone: '138****6789', email: 'zhangwei@huayuan.cn', country: '中国', province: '浙江省', city: '杭州市', postalCode: '310000', address: '滨江区江南大道 588 号', status: '启用', remark: '美线核心客户。', updatedAt: '2026-07-28 10:10' }, + { id: 'CP002', code: 'CP-SD-002', name: '深圳速达供应链有限公司', types: ['供应商', '承运商', '发货单位'], contactName: '李明', contactPhone: '139****8901', email: 'liming@suda.cn', country: '中国', province: '广东省', city: '深圳市', postalCode: '518000', address: '盐田区深盐路 2028 号', status: '启用', remark: '欧洲线拼箱合作方。', updatedAt: '2026-07-28 10:15' }, + { id: 'CP003', code: 'CP-CUS-003', name: 'HY GLOBAL LLC', types: ['客户', '收货单位'], contactName: 'Alice Chen', contactPhone: '+1 213 *** 8899', email: 'ops@hyglobal.example', country: '美国', province: 'California', city: 'Los Angeles', postalCode: '90001', address: '1200 S Alameda St', status: '启用', remark: '华远贸易美国收货方。', updatedAt: '2026-07-28 10:20' }, + { id: 'CP004', code: 'CP-CB-004', name: '宁波海诚报关有限公司', types: ['供应商', '报关行'], contactName: '周海', contactPhone: '0574-****2211', email: 'service@haicheng.example', country: '中国', province: '浙江省', city: '宁波市', postalCode: '315800', address: '北仑区港兴路 36 号', status: '启用', remark: '宁波口岸报关服务。', updatedAt: '2026-07-28 10:25' }, + { id: 'CP005', code: 'CP-OLD-005', name: '厦门远港运输有限公司', types: ['承运商'], contactName: '刘洋', contactPhone: '0592-****9812', email: 'dispatch@xm-old.example', country: '中国', province: '福建省', city: '厦门市', postalCode: '361000', address: '湖里区东港北路 31 号', status: '停用', remark: '合同到期,停止新增引用。', updatedAt: '2026-07-28 10:30' }, +] + +const initialCustomers: Customer[] = [ + { id: 'T001', name: '华远贸易有限公司', creditCode: '91330100MA15XXXX91', contact: '张伟', phone: '138****6789', email: 'zhangwei@huayuan.cn', sales: '王建国', packageName: '企业版', period: 30, status: '正常', orders: 286, joinedAt: '2025-03-15', address: '浙江省杭州市滨江区江南大道 588 号', remark: '美线核心客户,优先处理订舱需求。' }, + { id: 'T002', name: '深圳速达供应链有限公司', creditCode: '91440300MA95XXXX13', contact: '李明', phone: '139****8901', email: 'liming@suda.cn', sales: '李秀英', packageName: '专业版', period: 45, status: '正常', orders: 152, joinedAt: '2025-06-20', address: '广东省深圳市盐田区深盐路 2028 号', remark: '欧洲线拼箱客户。' }, + { id: 'T003', name: '广州恒通物流有限公司', creditCode: '91440100MA32XXXX45', contact: '王芳', phone: '137****2345', email: 'wangfang@hengtong.cn', sales: '张志强', packageName: '专业版', period: 60, status: '正常', orders: 98, joinedAt: '2025-09-01', address: '广东省广州市白云区机场路 1688 号', remark: '空运及敏感货渠道客户。' }, + { id: 'T004', name: '宁波远洋货运代理有限公司', creditCode: '91330200MA18XXXX38', contact: '陈强', phone: '136****4567', email: 'chenqiang@ocean-nb.cn', sales: '刘德海', packageName: '基础版', period: 90, status: '正常', orders: 45, joinedAt: '2026-01-10', address: '浙江省宁波市北仑区港兴路 88 号', remark: '' }, + { id: 'T005', name: '北京国贸跨境供应链有限公司', creditCode: '91110100MA87XXXX23', contact: '赵雪', phone: '135****7890', email: 'zhaoxue@bjgmt.cn', sales: '陈美玲', packageName: '基础版', period: 30, status: '待审核', orders: 0, joinedAt: '2026-07-26', address: '北京市朝阳区建国路 93 号', remark: '营业执照已上传,等待审核。' }, + { id: 'T006', name: '厦门港务货运有限公司', creditCode: '91350200MA12XXXX79', contact: '刘洋', phone: '133****0123', email: 'liuyang@xmgw.cn', sales: '赵晓东', packageName: '企业版', period: 45, status: '停用', orders: 73, joinedAt: '2024-11-01', address: '福建省厦门市湖里区东港北路 31 号', remark: '合同到期,暂时停用。' }, +] + +const initialUsers: InternalUser[] = [ + { id: 'U001', account: 'admin', name: '系统管理员', department: '平台管理', phone: '13800000001', email: 'admin@ccglobal.cn', roleIds: ['R001'], status: '正常', lastLogin: '2026-07-27 17:56', createdAt: '2026-01-01' }, + { id: 'U002', account: 'zhangwei', name: '张伟', department: '运营部', phone: '13800000002', email: 'zhangwei@ccglobal.cn', roleIds: ['R002'], status: '正常', lastLogin: '2026-07-27 16:30', createdAt: '2026-02-08' }, + { id: 'U003', account: 'liming', name: '李明', department: '操作部', phone: '13800000003', email: 'liming@ccglobal.cn', roleIds: ['R003'], status: '正常', lastLogin: '2026-07-27 15:42', createdAt: '2026-02-18' }, + { id: 'U004', account: 'wangfang', name: '王芳', department: '财务部', phone: '13800000004', email: 'wangfang@ccglobal.cn', roleIds: ['R004'], status: '正常', lastLogin: '2026-07-27 14:18', createdAt: '2026-03-02' }, + { id: 'U005', account: 'chenqiang', name: '陈强', department: '销售部', phone: '13800000005', email: 'chenqiang@ccglobal.cn', roleIds: ['R005'], status: '正常', lastLogin: '2026-07-26 18:02', createdAt: '2026-04-15' }, + { id: 'U006', account: 'zhaoting', name: '赵婷', department: '操作部', phone: '13800000006', email: 'zhaoting@ccglobal.cn', roleIds: ['R003'], status: '停用', lastLogin: '2026-07-18 10:10', createdAt: '2026-05-10' }, +] + +const initialPermissions: Permission[] = [ + { id: 'P010', parentId: null, name: '业务管理', code: 'business', type: '目录', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'P100', parentId: 'P010', name: '客户管理', code: 'platform:customer:list', type: '菜单', path: '/customers', method: '-', status: '启用', sort: 10 }, + { id: 'P101', parentId: 'P010', name: '企业审核', code: 'platform:customer:review', type: '菜单', path: '/customers/reviews', method: '-', status: '启用', sort: 15 }, + { id: 'P102', parentId: 'P100', name: '新增客户', code: 'customer:create', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P103', parentId: 'P101', name: '审核通过', code: 'platform:customer:approve', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P104', parentId: 'P101', name: '审核驳回', code: 'platform:customer:reject', type: '按钮', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'P150', parentId: 'P010', name: '仓库管理', code: 'platform:warehouse:list', type: '菜单', path: '/warehouses', method: '-', status: '启用', sort: 20 }, + { id: 'P152', parentId: 'P150', name: '维护仓库', code: 'warehouse:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P200', parentId: 'P010', name: '头程订单', code: 'platform:order:list', type: '菜单', path: '/orders/first-leg', method: '-', status: '启用', sort: 30 }, + { id: 'P202', parentId: 'P200', name: '创建订单', code: 'first-leg:create', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P203', parentId: 'P200', name: '更新状态', code: 'first-leg:status', type: '按钮', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'P204', parentId: 'P200', name: '维护履约快递', code: 'first-leg:delivery', type: '按钮', path: '', method: '-', status: '启用', sort: 30 }, + { id: 'P015', parentId: null, name: '基础资料', code: 'master-data', type: '目录', path: '', method: '-', status: '启用', sort: 25 }, + { id: 'P160', parentId: 'P015', name: '快递公司', code: 'platform:express:list', type: '菜单', path: '/master-data/express-companies', method: '-', status: '启用', sort: 10 }, + { id: 'P161', parentId: 'P160', name: '维护快递公司', code: 'express-company:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P162', parentId: 'P160', name: '导入快递公司', code: 'express-company:import', type: '按钮', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'P165', parentId: 'P015', name: '往来单位', code: 'platform:counterparty:list', type: '菜单', path: '/master-data/counterparties', method: '-', status: '启用', sort: 20 }, + { id: 'P166', parentId: 'P165', name: '维护往来单位', code: 'counterparty:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P167', parentId: 'P165', name: '导入往来单位', code: 'counterparty:import', type: '按钮', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'P020', parentId: null, name: '系统管理', code: 'system', type: '目录', path: '', method: '-', status: '启用', sort: 30 }, + { id: 'P300', parentId: 'P020', name: '内部用户', code: 'platform:user:list', type: '菜单', path: '/system/users', method: '-', status: '启用', sort: 10 }, + { id: 'P302', parentId: 'P300', name: '维护用户', code: 'system:user:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P400', parentId: 'P020', name: '角色管理', code: 'platform:role:list', type: '菜单', path: '/system/roles', method: '-', status: '启用', sort: 20 }, + { id: 'P402', parentId: 'P400', name: '角色授权', code: 'system:role:grant', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'P500', parentId: 'P020', name: '菜单权限', code: 'platform:permission:list', type: '菜单', path: '/system/permissions', method: '-', status: '启用', sort: 30 }, + { id: 'P502', parentId: 'P500', name: '维护权限', code: 'system:permission:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, +] + +const allPermissionIds = initialPermissions.map((item) => item.id) +const initialRoles: Role[] = [ + { id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '平台最高权限,拥有全部菜单和数据权限。', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: allPermissionIds, builtIn: true }, + { id: 'R002', name: '运营主管', code: 'OPS_MANAGER', description: '负责客户审核、仓库维护、基础资料、订单分派和运营监控。', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: ['P010', 'P100', 'P103', 'P150', 'P152', 'P200', 'P203', 'P204', 'P015', 'P160', 'P161', 'P162', 'P165', 'P166', 'P167'] }, + { id: 'R003', name: '操作员', code: 'OPERATOR', description: '查看收货仓并处理头程订单和物流节点更新。', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: ['P010', 'P150', 'P200', 'P202', 'P203', 'P204'] }, + { id: 'R004', name: '财务', code: 'FINANCE', description: '查看订单应收并处理对账结算。', userCount: 1, dataScope: '本部门数据', status: '启用', permissionIds: ['P010', 'P200'] }, + { id: 'R005', name: '销售', code: 'SALES', description: '维护客户、查看收货仓并跟进本人负责的订单。', userCount: 1, dataScope: '本人数据', status: '启用', permissionIds: ['P010', 'P100', 'P102', 'P150', 'P200'] }, +] + +function demoWarehouseSnapshot(id: string) { + const warehouse = DEMO_PLATFORM_RECEIVING_WAREHOUSES.find((item) => item.id === id) + if (!warehouse) throw new Error(`演示仓库不存在:${id}`) + return createPlatformReceivingWarehouseSnapshot(warehouse) +} + +const initialOrders: FirstLegOrder[] = [ + { id: 'O001', no: 'BY20260727001', customerId: 'T001', customerName: '华远贸易有限公司', referenceNo: 'HY-0727-01', transportMode: '海运整箱 FCL', businessType: '出口', origin: '宁波', destination: '洛杉矶', receivingWarehouseId: 'WH005', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH005'), container: '40HQ x 2', etd: '2026-08-02', eta: '2026-08-18', amount: 48500, currency: 'CNY', status: '已受理', priority: '紧急', operator: '张伟', shipper: '华远贸易有限公司', consignee: 'HY GLOBAL LLC', cargoName: '家居用品', pieces: 1260, weight: 27800, volume: 62.5, createdAt: '2026-07-27 09:18', remark: '需要在截关日前完成报关资料确认。', timeline: [{ time: '2026-07-27 09:18', title: '客户提交订单', description: '客户完成货物与平台收货仓信息提交。', operator: '客户管理员' }, { time: '2026-07-27 10:02', title: '订单已受理', description: '运营审核资料完整,进入订舱准备。', operator: '张伟' }] }, + { id: 'O002', no: 'BY20260726008', customerId: 'T002', customerName: '深圳速达供应链有限公司', referenceNo: 'SD-EU-558', transportMode: '海运拼箱 LCL', businessType: '出口', origin: '深圳', destination: '汉堡', receivingWarehouseId: 'WH002', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH002'), container: 'LCL 8.6 CBM', etd: '2026-08-05', eta: '2026-09-06', amount: 12800, currency: 'CNY', status: '已订舱', priority: '普通', operator: '李明', shipper: '深圳速达供应链有限公司', consignee: 'SUDA EUROPE GMBH', cargoName: '电子配件', pieces: 286, weight: 5200, volume: 8.6, createdAt: '2026-07-26 15:42', remark: '', timeline: [{ time: '2026-07-26 15:42', title: '客户提交订单', description: '订单进入平台审核。', operator: '客户管理员' }, { time: '2026-07-27 08:55', title: '订舱完成', description: '已获得船公司舱位确认。', operator: '李明' }] }, + { id: 'O003', no: 'BY20260726005', customerId: 'T003', customerName: '广州恒通物流有限公司', referenceNo: 'HT-AIR-0719', transportMode: '空运', businessType: '出口', origin: '广州', destination: '纽约', receivingWarehouseId: 'WH002', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH002'), container: '500 KG', etd: '2026-07-30', eta: '2026-08-01', amount: 35600, currency: 'CNY', status: '运输中', priority: '加急', operator: '赵婷', shipper: '广州恒通物流有限公司', consignee: 'HENGTONG US INC.', cargoName: '服装样品', pieces: 36, weight: 500, volume: 2.8, createdAt: '2026-07-26 10:20', remark: '航班起飞后第一时间同步轨迹。', timeline: [{ time: '2026-07-26 10:20', title: '客户提交订单', description: '加急空运订单。', operator: '客户管理员' }, { time: '2026-07-27 16:30', title: '货物已起飞', description: '航班 CZ699 离港。', operator: '赵婷' }] }, + { id: 'O004', no: 'BY20260725012', customerId: 'T004', customerName: '宁波远洋货运代理有限公司', referenceNo: 'NB-ROAD-201', transportMode: '陆运', businessType: '出口', origin: '宁波', destination: '霍尔果斯', receivingWarehouseId: 'WH005', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH005'), container: '整车 x 3', etd: '2026-07-28', eta: '2026-08-08', amount: 18200, currency: 'CNY', status: '待审核', priority: '普通', operator: '未分配', shipper: '宁波远洋货运代理有限公司', consignee: 'ALATAU TRADING LLP', cargoName: '机械配件', pieces: 480, weight: 18500, volume: 46, createdAt: '2026-07-25 14:08', remark: '', timeline: [{ time: '2026-07-25 14:08', title: '客户提交订单', description: '等待平台审核与人员分派。', operator: '客户管理员' }] }, + { id: 'O005', no: 'BY20260724009', customerId: 'T001', customerName: '华远贸易有限公司', referenceNo: 'HY-0724-06', transportMode: '海运整箱 FCL', businessType: '出口', origin: '上海', destination: '长滩', receivingWarehouseId: 'WH004', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH004'), container: '20GP x 5', etd: '2026-07-28', eta: '2026-08-12', amount: 56000, currency: 'CNY', status: '已完成', priority: '普通', operator: '张伟', shipper: '华远贸易有限公司', consignee: 'HY GLOBAL LLC', cargoName: '户外用品', pieces: 3200, weight: 55000, volume: 135, createdAt: '2026-07-24 08:45', remark: '', timeline: [{ time: '2026-07-24 08:45', title: '客户提交订单', description: '订单资料提交。', operator: '客户管理员' }, { time: '2026-07-27 12:20', title: '订单已完成', description: '全部业务节点已确认。', operator: '张伟' }] }, + { id: 'O006', no: 'BY20260723007', customerId: 'T002', customerName: '深圳速达供应链有限公司', referenceNo: 'SD-EU-552', transportMode: '海运拼箱 LCL', businessType: '出口', origin: '青岛', destination: '鹿特丹', receivingWarehouseId: 'WH003', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH003'), container: 'LCL 12 CBM', etd: '2026-08-10', eta: '2026-09-15', amount: 15300, currency: 'CNY', status: '草稿', priority: '普通', operator: '未分配', shipper: '深圳速达供应链有限公司', consignee: 'SUDA EUROPE GMBH', cargoName: '厨房用品', pieces: 520, weight: 8500, volume: 12, createdAt: '2026-07-23 17:10', remark: '等待客户补充收货人税号。', timeline: [{ time: '2026-07-23 17:10', title: '保存草稿', description: '订单尚未提交审核。', operator: '客户管理员' }] }, +] + +export const useAdminDataStore = defineStore('admin-data', () => { + const customers = ref (structuredClone(initialCustomers)) + const users = ref (structuredClone(initialUsers)) + const roles = ref (structuredClone(initialRoles)) + const permissions = ref (structuredClone(initialPermissions)) + const warehouses = ref (DEMO_PLATFORM_RECEIVING_WAREHOUSES.map((item) => ({ ...item }))) + const expressCompanies = ref (structuredClone(initialExpressCompanies)) + const counterparties = ref (structuredClone(initialCounterparties)) + const orders = ref (structuredClone(initialOrders)) + + const activeUsers = computed(() => users.value.filter((item) => item.status === '正常').length) + const pendingCustomers = computed(() => customers.value.filter((item) => item.status === '待审核').length) + const activeWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE').length) + const activeExpressCompanies = computed(() => expressCompanies.value.filter((item) => item.status === '合作中').length) + const activeCounterparties = computed(() => counterparties.value.filter((item) => item.status === '启用').length) + const pendingOrders = computed(() => orders.value.filter((item) => ['待审核', '已受理'].includes(item.status)).length) + + function nextId(prefix: string, rows: Array<{ id: string }>) { + const max = rows.reduce((value, item) => Math.max(value, Number(item.id.replace(/\D/g, '')) || 0), 0) + return `${prefix}${String(max + 1).padStart(3, '0')}` + } + + function saveCustomer(payload: Omit & { id?: string }) { + if (payload.id) { + const index = customers.value.findIndex((item) => item.id === payload.id) + if (index >= 0) customers.value[index] = { ...customers.value[index], ...payload } as Customer + return payload.id + } + const id = nextId('T', customers.value) + customers.value.unshift({ ...payload, id, orders: 0, joinedAt: new Date().toISOString().slice(0, 10) }) + return id + } + + function setCustomerStatus(id: string, status: Customer['status']) { + const customer = customers.value.find((item) => item.id === id) + if (customer) customer.status = status + } + + function saveWarehouse(payload: Omit & { id?: string }) { + const duplicate = warehouses.value.find((item) => item.code.toLowerCase() === payload.code.trim().toLowerCase() && item.id !== payload.id) + if (duplicate) throw new Error('仓库编码已存在') + const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ') + if (payload.id) { + const index = warehouses.value.findIndex((item) => item.id === payload.id) + if (index >= 0) warehouses.value[index] = { ...warehouses.value[index], ...payload, code: payload.code.trim().toUpperCase(), updatedAt } as Warehouse + return payload.id + } + const id = nextId('WH', warehouses.value) + warehouses.value.unshift({ ...payload, id, code: payload.code.trim().toUpperCase(), updatedAt }) + return id + } + + function setWarehouseStatus(id: string, status: Warehouse['status']) { + const warehouse = warehouses.value.find((item) => item.id === id) + if (warehouse) { + warehouse.status = status + warehouse.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ') + } + } + + function saveExpressCompany(payload: Omit & { id?: string }) { + const normalizedCode = payload.code.trim().toUpperCase() + const duplicateCode = expressCompanies.value.find((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== payload.id) + if (duplicateCode) throw new Error('快递公司编码已存在') + const duplicateName = expressCompanies.value.find((item) => item.name.trim().toLowerCase() === payload.name.trim().toLowerCase() && item.id !== payload.id) + if (duplicateName) throw new Error('快递公司名称已存在') + const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ') + if (payload.id) { + const index = expressCompanies.value.findIndex((item) => item.id === payload.id) + if (index >= 0) expressCompanies.value[index] = { ...expressCompanies.value[index], ...payload, code: normalizedCode, updatedAt } as ExpressCompany + return payload.id + } + const id = nextId('E', expressCompanies.value) + expressCompanies.value.unshift({ ...payload, id, code: normalizedCode, updatedAt }) + return id + } + + function setExpressCompanyStatus(id: string, status: ExpressCompany['status']) { + const company = expressCompanies.value.find((item) => item.id === id) + if (!company) return + company.status = status + company.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ') + } + + function saveCounterparty(payload: Omit & { id?: string }) { + const normalizedCode = payload.code.trim().toUpperCase() + const duplicate = counterparties.value.find((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== payload.id) + if (duplicate) throw new Error('往来单位编码已存在') + const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ') + if (payload.id) { + const index = counterparties.value.findIndex((item) => item.id === payload.id) + if (index >= 0) counterparties.value[index] = { ...counterparties.value[index], ...payload, code: normalizedCode, types: [...payload.types], updatedAt } as Counterparty + return payload.id + } + const id = nextId('CP', counterparties.value) + counterparties.value.unshift({ ...payload, id, code: normalizedCode, types: [...payload.types], updatedAt }) + return id + } + + function setCounterpartyStatus(id: string, status: Counterparty['status']) { + const counterparty = counterparties.value.find((item) => item.id === id) + if (!counterparty) return + counterparty.status = status + counterparty.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ') + } + + function saveUser(payload: Omit & { id?: string }) { + if (payload.id) { + const index = users.value.findIndex((item) => item.id === payload.id) + if (index >= 0) users.value[index] = { ...users.value[index], ...payload } as InternalUser + return payload.id + } + const id = nextId('U', users.value) + users.value.unshift({ ...payload, id, lastLogin: '-', createdAt: new Date().toISOString().slice(0, 10) }) + syncRoleCounts() + return id + } + + function setUserStatus(id: string, status: InternalUser['status']) { + const user = users.value.find((item) => item.id === id) + if (user && user.account !== 'admin') user.status = status + } + + function saveRole(payload: Omit & { id?: string }) { + if (payload.id) { + const index = roles.value.findIndex((item) => item.id === payload.id) + if (index >= 0) roles.value[index] = { ...roles.value[index], ...payload } as Role + syncRoleCounts() + return payload.id + } + const id = nextId('R', roles.value) + roles.value.push({ ...payload, id, userCount: 0 }) + return id + } + + function removeRole(id: string) { + const role = roles.value.find((item) => item.id === id) + if (!role || role.builtIn || users.value.some((item) => item.roleIds.includes(id))) return false + roles.value = roles.value.filter((item) => item.id !== id) + return true + } + + function syncRoleCounts() { + roles.value.forEach((role) => { + role.userCount = users.value.filter((user) => user.roleIds.includes(role.id)).length + }) + } + + function savePermission(payload: Omit & { id?: string }) { + if (payload.id) { + const index = permissions.value.findIndex((item) => item.id === payload.id) + if (index >= 0) permissions.value[index] = { ...permissions.value[index], ...payload } as Permission + return payload.id + } + const id = nextId('P', permissions.value) + permissions.value.push({ ...payload, id }) + return id + } + + function saveOrder(payload: Omit & { id?: string }) { + const existing = payload.id ? orders.value.find((item) => item.id === payload.id) : undefined + const warehouse = warehouses.value.find((item) => item.id === payload.receivingWarehouseId) + const keepsExistingWarehouse = existing?.receivingWarehouseId === payload.receivingWarehouseId + if (!warehouse || (warehouse.status !== 'ACTIVE' && !keepsExistingWarehouse)) throw new Error('所选收货仓当前不可用于新订单') + const receivingWarehouseSnapshot = keepsExistingWarehouse && existing?.receivingWarehouseSnapshot + ? existing.receivingWarehouseSnapshot + : createPlatformReceivingWarehouseSnapshot(warehouse) + if (payload.id) { + const index = orders.value.findIndex((item) => item.id === payload.id) + if (index >= 0) { + const previous = orders.value[index] + const keepsPendingReview = previous.status === '待审核' + const time = new Date().toISOString().slice(0, 16).replace('T', ' ') + const timeline = [...previous.timeline] + if (keepsPendingReview) timeline.push({ + time, + title: '后台修改待审核订单', + description: '平台已修正订单资料,订单继续保持待审核;本次修改已记录审计。', + operator: payload.operator === '未分配' ? '平台管理员' : payload.operator, + }) + orders.value[index] = { + ...previous, + ...payload, + status: keepsPendingReview ? '待审核' : payload.status, + receivingWarehouseSnapshot, + timeline, + } as FirstLegOrder + } + return payload.id + } + const id = nextId('O', orders.value) + const no = `BY${new Date().toISOString().slice(0, 10).replace(/-/g, '')}${String(orders.value.length + 1).padStart(3, '0')}` + orders.value.unshift({ + ...payload, + receivingWarehouseSnapshot, + id, + no, + createdAt: new Date().toISOString().slice(0, 16).replace('T', ' '), + timeline: [{ time: new Date().toISOString().slice(0, 16).replace('T', ' '), title: '后台创建订单', description: '平台工作人员创建头程订单。', operator: '平台管理员' }], + }) + return id + } + + function updateOrderStatus(id: string, status: OrderStatus, operator: string, note: string) { + const order = orders.value.find((item) => item.id === id) + if (!order) return + const transitions: Record = { 草稿: ['待审核', '已取消'], 待审核: ['已受理', '已取消'], 已受理: ['已订舱', '已取消'], 已订舱: ['运输中', '已取消'], 运输中: ['已完成', '已取消'], 已完成: [], 已取消: [] } + if (status !== order.status && !transitions[order.status].includes(status)) throw new Error(`订单不能从“${order.status}”直接变更为“${status}”`) + order.status = status + order.operator = operator || order.operator + order.timeline.push({ + time: new Date().toISOString().slice(0, 16).replace('T', ' '), + title: `状态更新为${status}`, + description: note || '平台更新订单处理状态。', + operator: operator || '平台管理员', + }) + } + + function recordCustomerModificationContact(id: string, operator: string) { + const order = orders.value.find((item) => item.id === id) + if (!order || order.status !== '待审核') throw new Error('仅待审核订单可记录联系客户修改') + order.timeline.push({ time: new Date().toISOString().slice(0, 16).replace('T', ' '), title: '联系客户修改订单', description: '已联系客户补充或修正订单资料,订单继续保持待审核。', operator }) + } + + function saveOrderDeliveryInfo(id: string, expressCompanyId: string, trackingNo: string, operator: string) { + const order = orders.value.find((item) => item.id === id) + const company = expressCompanies.value.find((item) => item.id === expressCompanyId) + if (!order) throw new Error('订单不存在') + if (!company) throw new Error('快递公司不存在') + if (company.status !== '合作中' && order.deliveryInfo?.expressCompanyId !== company.id) throw new Error('停用快递公司不能新增到履约信息') + const time = new Date().toISOString().slice(0, 16).replace('T', ' ') + order.deliveryInfo = { + expressCompanyId: company.id, + expressCompanyCode: company.code, + expressCompanyName: company.name, + trackingNo: trackingNo.trim(), + updatedAt: time, + operator, + } + order.timeline.push({ + time, + title: '更新履约快递信息', + description: `${company.name} · ${trackingNo.trim()}`, + operator, + }) + } + + return { + customers, + users, + roles, + permissions, + warehouses, + expressCompanies, + counterparties, + orders, + activeUsers, + pendingCustomers, + activeWarehouses, + activeExpressCompanies, + activeCounterparties, + pendingOrders, + saveCustomer, + setCustomerStatus, + saveWarehouse, + setWarehouseStatus, + saveExpressCompany, + setExpressCompanyStatus, + saveCounterparty, + setCounterpartyStatus, + saveUser, + setUserStatus, + saveRole, + removeRole, + savePermission, + saveOrder, + updateOrderStatus, + recordCustomerModificationContact, + saveOrderDeliveryInfo, + } +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 00000000..c126bff6 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,150 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import type { CurrentUser } from '@chongchong/shared' +import { http, registerAuthRefreshHandler, unwrapApiResponse, type ApiEnvelope } from '@/api/http' +import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam' +import { decodeJwtClaims } from '@/utils/jwt' + +const TOKEN_KEY = 'cc-admin-token' +const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token' +const PERMISSION_CODES_KEY = 'cc-admin-permission-codes' + +interface LoginResponse { + tokenType: string + accessToken: string + expiresIn: number + refreshToken: string + refreshExpiresIn: number +} + +function sessionFromToken(token: string) { + const claims = decodeJwtClaims(token) + if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期,请重新登录') + return { + user: { + id: String(claims.user_id ?? ''), + name: claims.username || '平台用户', + account: claims.username || '', + roles: claims.roles ?? [], + } satisfies CurrentUser, + permissions: claims.permissions ?? [], + } +} + +export const useAuthStore = defineStore('admin-auth', () => { + const token = ref(localStorage.getItem(TOKEN_KEY) || '') + const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '') + const user = ref (null) + const permissions = ref ([]) + const permissionResources = ref ([]) + const isServerSession = ref(false) + + if (token.value) { + try { + const session = sessionFromToken(token.value) + const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown + if (!Array.isArray(cachedPermissions)) throw new Error('权限缓存格式无效') + user.value = session.user + permissions.value = session.permissions.filter((code) => cachedPermissions.includes(code)) + isServerSession.value = true + } catch { + token.value = '' + refreshToken.value = '' + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(PERMISSION_CODES_KEY) + } + } + + const isAuthenticated = computed(() => Boolean(token.value)) + const permissionCodes = computed >(() => new Set(permissions.value)) + + async function applySession(result: LoginResponse) { + const session = sessionFromToken(result.accessToken) + token.value = result.accessToken + refreshToken.value = result.refreshToken + user.value = session.user + isServerSession.value = true + localStorage.setItem(TOKEN_KEY, token.value) + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value) + try { + permissionResources.value = await listPlatformPermissions() + const activeResourceCodes = new Set(permissionResources.value + .filter((resource) => resource.status === 'ACTIVE') + .map((resource) => resource.permissionCode)) + permissions.value = session.permissions.filter((code) => activeResourceCodes.has(code)) + localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(permissions.value)) + } catch (error) { + clearSession() + throw error + } + } + + function clearSession() { + token.value = '' + refreshToken.value = '' + user.value = null + permissions.value = [] + permissionResources.value = [] + isServerSession.value = false + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(PERMISSION_CODES_KEY) + } + + async function login(account: string, password: string) { + const response = await http.post , ApiEnvelope >( + '/auth/login', + { username: account, password }, + ) + const result = unwrapApiResponse (response) + await applySession(result) + } + + async function refreshSession() { + if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录') + try { + const response = await http.post , ApiEnvelope >( + '/auth/refresh', + { refreshToken: refreshToken.value }, + ) + const result = unwrapApiResponse (response) + await applySession(result) + } catch (error) { + clearSession() + throw error + } + } + + registerAuthRefreshHandler(async () => { + await refreshSession() + if (!token.value) throw new Error('登录状态刷新失败') + return token.value + }) + + function logout() { + const accessToken = token.value + const tokenToRevoke = refreshToken.value + clearSession() + if (accessToken && tokenToRevoke) { + void http.post('/auth/logout', { refreshToken: tokenToRevoke }, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).catch(() => undefined) + } + } + + return { + token, + refreshToken, + user, + permissions, + permissionResources, + isServerSession, + permissionCodes, + isAuthenticated, + login, + refreshSession, + logout, + } +}) diff --git a/src/stores/unsavedWork.test.ts b/src/stores/unsavedWork.test.ts new file mode 100644 index 00000000..a68c7343 --- /dev/null +++ b/src/stores/unsavedWork.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useUnsavedWorkStore } from '@/stores/unsavedWork' + +describe('unsaved work store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('blocks dirty work only when a role switch removes permission or leaves the page', () => { + const store = useUnsavedWorkStore() + store.upsert({ + key: 'customer-form', + label: '客户资料', + dirty: true, + requiredPermission: 'customer:create', + }) + store.upsert({ + key: 'order-form', + label: '头程订单', + dirty: true, + requiredPermission: 'first-leg:create', + }) + + expect(store.findBlocking(new Set(['customer:create', 'first-leg:create']), false)).toEqual([]) + expect(store.findBlocking(new Set(), false).map((entry) => entry.key)).toEqual([ + 'customer-form', + 'order-form', + ]) + expect(store.findBlocking(new Set(['customer:create', 'first-leg:create']), true) + .map((entry) => entry.key)).toEqual(['customer-form', 'order-form']) + }) + + it('does not block clean work', () => { + const store = useUnsavedWorkStore() + store.upsert({ + key: 'customer-form', + label: '客户资料', + dirty: false, + requiredPermission: 'customer:create', + }) + + expect(store.findBlocking(new Set(), true)).toEqual([]) + }) + + it('removes an entry by key', () => { + const store = useUnsavedWorkStore() + store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null }) + + store.remove('customer-form') + + expect(store.entries).toEqual([]) + }) + + it('replaces an existing entry with the same key', () => { + const store = useUnsavedWorkStore() + store.upsert({ key: 'customer-form', label: '旧名称', dirty: false, requiredPermission: null }) + + store.upsert({ + key: 'customer-form', + label: '客户资料', + dirty: true, + requiredPermission: 'customer:create', + }) + + expect(store.entries).toEqual([{ + key: 'customer-form', + label: '客户资料', + dirty: true, + requiredPermission: 'customer:create', + }]) + }) + + it('clears every registered entry', () => { + const store = useUnsavedWorkStore() + store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null }) + store.upsert({ key: 'order-form', label: '头程订单', dirty: true, requiredPermission: null }) + + store.clear() + + expect(store.entries).toEqual([]) + }) +}) diff --git a/src/stores/unsavedWork.ts b/src/stores/unsavedWork.ts new file mode 100644 index 00000000..256637e7 --- /dev/null +++ b/src/stores/unsavedWork.ts @@ -0,0 +1,40 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export interface UnsavedWorkEntry { + key: string + label: string + dirty: boolean + requiredPermission: string | null +} + +export const useUnsavedWorkStore = defineStore('unsaved-work', () => { + const entries = ref ([]) + + function upsert(entry: UnsavedWorkEntry) { + const index = entries.value.findIndex((item) => item.key === entry.key) + if (index === -1) { + entries.value.push(entry) + return + } + entries.value[index] = entry + } + + function remove(key: string) { + const index = entries.value.findIndex((item) => item.key === key) + if (index !== -1) entries.value.splice(index, 1) + } + + function findBlocking(candidatePermissions: ReadonlySet , willLeave: boolean) { + return entries.value.filter((entry) => entry.dirty && ( + willLeave + || (entry.requiredPermission !== null && !candidatePermissions.has(entry.requiredPermission)) + )) + } + + function clear() { + entries.value = [] + } + + return { entries, upsert, remove, findBlocking, clear } +}) diff --git a/src/styles/index.css b/src/styles/index.css new file mode 100644 index 00000000..8f3f225a --- /dev/null +++ b/src/styles/index.css @@ -0,0 +1,351 @@ +:root { + font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + color: #172033; + background: #f4f6f8; + font-synthesis: none; + --cc-primary: #155eef; + --cc-navy: #172a46; + --cc-border: #e3e8ef; + --cc-muted: #667085; +} + +* { box-sizing: border-box; } +html, body, #app { width: 100%; min-width: 0; min-height: 100%; margin: 0; } +body { min-height: 100vh; background: #f4f6f8; } +button, input, textarea, select { font: inherit; } +button { letter-spacing: 0; } +:focus-visible { outline: 3px solid rgba(21, 94, 239, .28); outline-offset: 2px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +.app-shell { min-width: 0; min-height: 100vh; } +.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; background: var(--cc-navy); color: #fff; } +.is-collapsed .app-sidebar { width: 76px; } +.brand-block { display: flex; height: 72px; align-items: center; gap: 12px; padding: 0 18px; border-bottom: 1px solid rgba(255,255,255,.1); } +.brand-mark { display: grid; width: 40px; height: 40px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: #fff; color: var(--cc-navy); font-weight: 800; letter-spacing: 0; } +.brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.brand-copy strong { font-size: 16px; } +.brand-copy span { color: #aebbd0; font-size: 12px; } +.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; } +.side-nav-menu { + width: 100%; + border-right: 0 !important; + background: transparent !important; + --el-menu-bg-color: transparent; + --el-menu-text-color: #bdc7d7; + --el-menu-active-color: var(--cc-navy); + --el-menu-hover-bg-color: rgba(255,255,255,.08); +} +.side-nav-menu.el-menu--collapse { width: 56px; } +.side-nav-menu .el-menu-item, +.side-nav-menu .el-sub-menu__title { height: 44px; margin: 3px 0; border-radius: 8px; line-height: 44px; } +.side-nav-menu .el-menu-item:hover, +.side-nav-menu .el-sub-menu__title:hover { color: #fff; } +.side-nav-menu .el-menu-item.is-active { background: #fff; color: var(--cc-navy); font-weight: 650; } +.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 48px !important; } +.side-nav-menu .el-menu-item svg, +.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; } +.sidebar-toggle { display: flex; height: 52px; align-items: center; justify-content: center; gap: 8px; border: 0; border-top: 1px solid rgba(255,255,255,.1); background: transparent; color: #aebbd0; cursor: pointer; } +.app-main { min-width: 0; min-height: 100vh; margin-left: 240px; } +.is-collapsed .app-main { margin-left: 76px; } +.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 72px; align-items: center; gap: 16px; padding: 10px 28px; border-bottom: 1px solid var(--cc-border); background: rgba(255,255,255,.96); } +.page-heading { min-width: 0; flex: 1; } +.page-heading h1 { margin: 0; overflow: hidden; font-size: 20px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; } +.page-heading p { margin: 4px 0 0; overflow: hidden; color: var(--cc-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.header-user { display: flex; min-width: 0; flex: 0 0 auto; align-items: center; gap: 10px; } +.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #eaf1ff; color: var(--cc-primary); font-weight: 700; } +.user-copy { display: flex; flex-direction: column; } +.user-copy strong { font-size: 13px; } +.user-copy span { color: var(--cc-muted); font-size: 11px; } +.mobile-menu-button { display: none; width: 44px; height: 44px; padding: 0; border: 0; background: none; cursor: pointer; } +.header-user .el-button { width: 44px; height: 44px; } +.page-container { padding: 24px 28px 40px; } + +/* Static permission preview */ +.app-header > .el-dropdown { min-width: 0; max-width: 320px; flex: 0 1 320px; } +.role-preview-trigger { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: #172033; text-align: left; cursor: pointer; } +.role-preview-trigger:hover { border-color: #b8c6d9; background: #f8fafc; } +.role-preview-trigger:focus-visible { border-color: var(--cc-primary); outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 1px; } +.role-preview-trigger:disabled, .role-preview-trigger[aria-disabled="true"] { cursor: not-allowed; opacity: .58; } +.role-preview-trigger > svg { flex: 0 0 auto; } +.role-preview-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; } +.role-preview-copy strong, .role-preview-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.role-preview-copy strong { font-size: 12px; font-weight: 700; } +.role-preview-copy small { color: var(--cc-muted); font-size: 10px; line-height: 1.35; } + +.forbidden-page { display: flex; min-height: min(560px, calc(100vh - 136px)); align-items: center; justify-content: center; flex-direction: column; padding: clamp(28px, 7vw, 64px) clamp(18px, 6vw, 48px); border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; text-align: center; } +.forbidden-icon { display: grid; width: 56px; height: 56px; place-items: center; margin-bottom: 18px; border-radius: 8px; background: #fef3f2; color: #b42318; } +.forbidden-page .section-kicker { margin: 0 0 8px; } +.forbidden-page h2 { max-width: 560px; margin: 0 0 10px; font-size: 20px; line-height: 1.35; } +.forbidden-page > p:not(.section-kicker) { max-width: 560px; margin: 0 0 10px; color: var(--cc-muted); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; } +.forbidden-page .forbidden-source { margin-bottom: 20px; color: #475467; font-size: 12px; } +.forbidden-page .el-button { min-height: 44px; } + +.login-page { display: grid; min-height: 100vh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: #fff; } +.login-intro { display: flex; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--cc-navy); color: #fff; } +.login-brand { display: flex; align-items: center; gap: 12px; font-weight: 700; } +.login-brand span { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 10px; background: #fff; color: var(--cc-navy); } +.eyebrow, .section-kicker { color: #3975e9; font-size: 11px; font-weight: 750; letter-spacing: 0; } +.login-intro h1 { max-width: 680px; margin: 16px 0 22px; font-size: 52px; line-height: 1.16; letter-spacing: 0; } +.intro-copy { max-width: 560px; color: #b9c6d8; font-size: 16px; line-height: 1.8; } +.security-note { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 13px; } +.login-panel { display: grid; place-items: center; padding: 48px; } +.login-card { width: min(410px, 100%); } +.login-card-icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 12px; background: #eaf1ff; color: var(--cc-primary); } +.login-card h2 { margin: 22px 0 8px; font-size: 28px; } +.login-card > p { margin: 0 0 30px; color: var(--cc-muted); } +.login-submit { width: 100%; } +.login-submit span { display: flex; align-items: center; gap: 8px; } +.login-error { margin: 14px 0 0; color: #b42318; font-size: 13px; line-height: 1.5; } +.login-error > p { margin: 0; } +.api-error-alert-title { min-width: 0; } +.demo-tip { margin-top: 18px; padding: 10px 12px; border: 1px solid #dce6f8; background: #f7faff; color: #64748b; font-size: 12px; } + +.dashboard-stack { display: flex; flex-direction: column; gap: 20px; } +.welcome-panel, .content-card, .module-card { border: 1px solid var(--cc-border); background: #fff; } +.welcome-panel { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 28px 30px; } +.welcome-panel h2 { margin: 8px 0 7px; font-size: 23px; } +.welcome-panel p { margin: 0; color: var(--cc-muted); } +.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 16px; } +.stat-card { padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease; } +.stat-card:hover { border-color: #b8c6d9; box-shadow: 0 6px 16px rgba(23,42,70,.08); transform: translateY(-1px); } +.stat-icon { display: grid; width: 38px; height: 38px; place-items: center; margin-bottom: 20px; background: #eaf1ff; color: var(--cc-primary); } +.stat-icon.is-amber { background: #fff7e6; color: #b76e00; } +.stat-icon.is-teal { background: #e8f7f4; color: #087d70; } +.stat-icon.is-red { background: #fff0f0; color: #c2413b; } +.stat-label { color: var(--cc-muted); font-size: 13px; } +.stat-card > strong { display: block; margin: 4px 0; font-size: 30px; } +.stat-card > span { color: #98a2b3; font-size: 11px; } +.content-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 20px; } +.content-card { padding: 24px; } +.card-heading h3 { margin: 5px 0 18px; font-size: 18px; } +.milestone-list { border-top: 1px solid var(--cc-border); } +.milestone-row { display: grid; grid-template-columns: 52px 1fr auto; align-items: center; gap: 12px; padding: 15px 0; border-bottom: 1px solid var(--cc-border); } +.milestone-code { color: var(--cc-primary); font-size: 12px; font-weight: 750; } +.next-list { display: flex; flex-direction: column; gap: 16px; margin: 0; padding: 0; list-style: none; } +.next-list li { display: flex; gap: 12px; } +.next-list li > span { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; border: 1px solid #cddaf0; color: var(--cc-primary); font-size: 12px; font-weight: 700; } +.next-list strong { font-size: 14px; } +.next-list p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } +.module-page { display: flex; flex-direction: column; gap: 16px; } +.module-toolbar { display: flex; justify-content: flex-end; gap: 10px; } +.module-toolbar .el-input { width: 280px; } +.module-card { min-height: 430px; } +.empty-state { display: flex; min-height: 430px; align-items: center; justify-content: center; flex-direction: column; padding: 40px; text-align: center; } +.empty-icon { display: grid; width: 58px; height: 58px; place-items: center; margin-bottom: 18px; background: #eef3fa; color: #55708f; } +.empty-state h2 { margin: 0 0 8px; font-size: 20px; } +.empty-state p { max-width: 540px; margin: 0 0 18px; color: var(--cc-muted); } + +/* Business modules */ +.business-page { display: flex; min-width: 0; flex-direction: column; gap: 16px; } +.surface-panel { min-width: 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; } +.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; } +.metric-item { min-width: 0; padding: 18px 20px; border-right: 1px solid var(--cc-border); } +.metric-item:last-child { border-right: 0; } +.metric-item span, .metric-item small { display: block; color: var(--cc-muted); } +.metric-item span { margin-bottom: 7px; font-size: 12px; } +.metric-item strong { display: block; overflow-wrap: anywhere; font-size: 25px; line-height: 1.15; } +.metric-item small { margin-top: 5px; font-size: 11px; } +.filter-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; } +.filter-fields, .filter-actions { display: flex; align-items: center; gap: 10px; } +.filter-fields { min-width: 0; flex: 1; } +.filter-fields > .el-input { width: min(360px, 100%); } +.filter-fields > .el-select { width: 150px; flex: 0 0 auto; } +.filter-actions { flex: 0 0 auto; } +.filter-actions .el-button + .el-button { margin-left: 0; } +.filter-actions .el-button span, .table-actions .el-button span { gap: 5px; } +.table-panel { overflow: hidden; } +.panel-heading { display: flex; min-height: 76px; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 20px; border-bottom: 1px solid var(--cc-border); } +.panel-heading h2 { margin: 0; font-size: 16px; } +.panel-heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } +.table-panel .el-table { --el-table-border-color: #edf0f4; --el-table-header-bg-color: #f8fafc; --el-table-row-hover-bg-color: #f8fbff; } +.table-panel .el-table th.el-table__cell { height: 44px; color: #475467; font-size: 12px; font-weight: 650; } +.table-panel .el-table td.el-table__cell { padding: 12px 0; } +.primary-cell, .user-cell { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.primary-cell strong, .user-cell strong { overflow: hidden; color: #172033; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.primary-cell span, .user-cell span { overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.user-cell { flex-direction: row; align-items: center; gap: 10px; } +.user-cell > span:first-child, .detail-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: #eaf1ff; color: var(--cc-primary); font-weight: 700; } +.user-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.table-actions { display: flex; align-items: center; white-space: nowrap; } +.table-actions .el-button + .el-button { margin-left: 10px; } +.table-footer { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; } +.status-badge { display: inline-flex; min-height: 24px; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 999px; background: #f2f4f7; color: #475467; font-size: 11px; font-weight: 650; white-space: nowrap; } +.status-badge i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.status-badge.is-success { background: #ecfdf3; color: #067647; } +.status-badge.is-warning { background: #fffaeb; color: #b54708; } +.status-badge.is-danger { background: #fef3f2; color: #b42318; } +.status-badge.is-info { background: #eff4ff; color: #3538cd; } +.tag-list { display: flex; flex-wrap: wrap; gap: 5px; } +.tag-list .el-tag + .el-tag { margin-left: 0; } +.muted-value { color: #98a2b3; } + +/* Drawers and forms */ +.business-drawer { max-width: 100vw; } +.business-drawer .el-drawer__header { margin-bottom: 0; padding: 20px 24px 16px; border-bottom: 1px solid var(--cc-border); color: #172033; font-weight: 700; } +.business-drawer .el-drawer__body { padding: 0; } +.editor-drawer { top: 72px; height: calc(100% - 72px); } +.business-drawer .el-drawer__footer { padding: 14px 24px; border-top: 1px solid var(--cc-border); } +.drawer-form { padding: 4px 24px 24px; } +.form-section { padding: 22px 0; border-bottom: 1px solid var(--cc-border); } +.form-section:last-child { border-bottom: 0; } +.form-section h3, .detail-section h3 { margin: 0 0 16px; font-size: 14px; } +.form-grid { display: grid; gap: 0 16px; } +.form-grid.two-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.form-grid.three-cols { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.drawer-form .el-form-item { min-width: 0; margin-bottom: 18px; } +.drawer-form .el-select, .drawer-form .el-input-number, .drawer-form .el-date-editor { width: 100%; } +.field-suffix { margin-left: 8px; color: var(--cc-muted); font-size: 12px; } +.form-help { margin: -7px 0 18px; color: var(--cc-muted); font-size: 11px; line-height: 1.6; } +.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; } +.drawer-footer .el-button + .el-button { margin-left: 0; } +.detail-identity { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 24px; border-bottom: 1px solid var(--cc-border); } +.detail-identity > span { width: 44px; height: 44px; } +.detail-identity h2, .order-detail-head h2 { margin: 0; font-size: 17px; } +.detail-identity p, .order-detail-head p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; } +.business-drawer .metric-strip { margin: 20px 24px 0; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.detail-section { padding: 22px 24px 0; } +.detail-note { margin: 12px 0 0; padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; color: #475467; font-size: 12px; line-height: 1.65; } + +/* Role and permission management */ +.role-cell { display: flex; align-items: center; gap: 8px; } +.section-heading-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.section-heading-row h3 { margin-bottom: 4px; } +.section-heading-row p { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } +.section-heading-row > span { color: var(--cc-muted); font-size: 11px; white-space: nowrap; } +.permission-tree { margin-top: 16px; padding: 8px 0; border-top: 1px solid var(--cc-border); } +.permission-tree .el-tree-node__content { min-height: 36px; } +.permission-node { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: space-between; gap: 16px; padding-right: 12px; } +.permission-node code, .code-value, .endpoint-cell code { color: #475467; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; } +.legend-list { display: flex; flex-wrap: wrap; gap: 14px; color: var(--cc-muted); font-size: 11px; } +.legend-list span { display: inline-flex; align-items: center; gap: 5px; } +.legend-list i { width: 8px; height: 8px; border-radius: 2px; background: #155eef; } +.legend-list i.is-action { background: #087d70; } +.legend-list i.is-api { background: #8b5cf6; } +.permission-name { display: flex; align-items: center; gap: 8px; } +.permission-name > span { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; border-radius: 6px; background: #eef4ff; color: #155eef; } +.permission-name > span.is-action { background: #e8f7f4; color: #087d70; } +.permission-name > span.is-api { background: #f3efff; color: #7047b8; } +.endpoint-cell { display: flex; align-items: center; gap: 8px; } +.endpoint-cell > span { padding: 2px 5px; border-radius: 3px; background: #edf2f7; color: #344054; font-size: 10px; font-weight: 700; } +.form-callout { display: flex; gap: 10px; margin-top: 18px; padding: 13px 14px; border: 1px solid #dbe5f5; border-radius: 6px; background: #f7faff; color: #344054; } +.form-callout > svg { flex: 0 0 auto; color: var(--cc-primary); } +.form-callout strong { font-size: 12px; } +.form-callout p { margin: 3px 0 0; color: #5c6f87; font-size: 11px; line-height: 1.6; } + +/* First-leg orders */ +.order-filter .filter-fields > .el-input { width: 280px; } +.order-no { color: var(--cc-primary); font-weight: 700; } +.route-cell { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 6px; font-size: 12px; } +.route-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.route-cell i { width: 18px; height: 1px; background: #98a2b3; } +.selection-note { color: var(--cc-primary); font-size: 12px; font-weight: 650; } +.order-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 24px; border-bottom: 1px solid var(--cc-border); } +.order-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; } +.party-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.party-grid > div { padding: 14px; border: 1px solid var(--cc-border); border-radius: 6px; } +.party-grid span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; } +.party-grid strong { font-size: 13px; line-height: 1.5; } +.order-progress { margin: 4px 0 0; padding-left: 5px; } +.order-progress .el-timeline-item__timestamp { color: #667085; font-size: 11px; } +.order-progress h4 { margin: 0 0 4px; font-size: 13px; } +.order-progress p { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; } +.amount-field { display: flex; align-items: center; gap: 8px; } +.amount-field .el-input-number { flex: 1; } +.amount-field .el-select { width: 92px; flex: 0 0 auto; } +.status-dialog .el-form-item { margin-bottom: 18px; } +.status-dialog .el-select { width: 100%; } + +/* Dashboard tasks */ +.task-card { padding-bottom: 10px; } +.task-row { display: grid; width: 100%; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 0; border: 0; border-top: 1px solid var(--cc-border); background: transparent; color: inherit; text-align: left; cursor: pointer; } +.task-row:hover strong { color: var(--cc-primary); } +.task-row > span:nth-child(2) { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.task-row small { color: var(--cc-muted); font-size: 11px; } +.task-count { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 6px; background: #eef4ff; color: var(--cc-primary); font-size: 12px; font-weight: 700; } +.milestone-row { grid-template-columns: 20px 1fr auto; } +.milestone-state { width: 8px; height: 8px; border-radius: 50%; background: #12b76a; } +.sidebar-scrim { display: none; } + +@media (max-width: 960px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .content-grid { grid-template-columns: 1fr; } + .metric-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric-item:nth-child(2) { border-right: 0; } + .metric-item:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } + .filter-panel { align-items: stretch; flex-direction: column; } + .filter-actions { justify-content: flex-end; } + .form-grid.three-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .login-page { grid-template-columns: 1fr; } + .login-intro { display: none; } +} + +@media (max-width: 720px) { + .app-sidebar { display: flex; width: min(280px, 86vw); transform: translateX(-100%); transition: transform .2s ease; } + .is-mobile-open .app-sidebar { transform: translateX(0); } + .is-collapsed .app-sidebar { width: min(280px, 86vw); } + .is-collapsed .brand-copy, .is-collapsed .sidebar-toggle span { display: flex; } + .side-nav-menu, .side-nav-menu.el-menu--collapse { width: 100%; } + .sidebar-toggle { display: none; } + .sidebar-scrim { position: fixed; inset: 0 0 0 min(280px, 86vw); z-index: 19; display: block; border: 0; background: rgba(15, 23, 42, .46); } + .app-main, .is-collapsed .app-main { margin-left: 0; } + .mobile-menu-button { display: inline-grid; place-items: center; } + .app-header > .el-dropdown { width: 44px; max-width: 44px; flex: 0 0 44px; } + .role-preview-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } + .role-preview-copy, .role-preview-trigger > svg:last-child { display: none; } + .user-copy { display: none; } + .page-heading p { display: none; } + .page-container { padding: 18px 14px 30px; } + .app-header { padding: 10px 14px; } + .stats-grid { grid-template-columns: 1fr; } + .welcome-panel { align-items: flex-start; flex-direction: column; } + .metric-strip { grid-template-columns: 1fr 1fr; } + .metric-item { padding: 15px; } + .metric-item strong { font-size: 22px; } + .filter-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .filter-fields > .el-input, .filter-fields > .el-select, .order-filter .filter-fields > .el-input { width: 100%; } + .filter-fields > .el-input { grid-column: 1 / -1; } + .filter-fields > .el-date-editor { width: 100%; min-width: 0; max-width: 100%; } + .order-filter .filter-fields > .el-date-editor { grid-column: 1 / -1; } + .filter-actions { overflow-x: auto; justify-content: flex-start; padding-bottom: 2px; } + .filter-actions .el-button, .table-actions .el-button { min-height: 44px; } + .panel-heading { align-items: flex-start; flex-direction: column; padding: 15px; } + .table-footer { align-items: flex-start; flex-direction: column; } + .table-footer .el-pagination { max-width: 100%; overflow-x: auto; } + .business-drawer { width: 100% !important; } + .drawer-form { padding: 4px 16px 20px; } + .business-drawer .el-drawer__header { padding: 18px 16px 14px; } + .business-drawer .el-drawer__footer { padding: 12px 16px; } + .form-grid.two-cols, .form-grid.three-cols, .party-grid { grid-template-columns: 1fr; } + .business-drawer .metric-strip { margin: 16px 16px 0; grid-template-columns: 1fr; } + .business-drawer .metric-item { border-right: 0; border-bottom: 1px solid var(--cc-border); } + .business-drawer .metric-item:last-child { border-bottom: 0; } + .detail-section, .detail-identity, .order-detail-head { padding-right: 16px; padding-left: 16px; } + .detail-identity { grid-template-columns: auto minmax(0, 1fr); } + .detail-identity .status-badge { grid-column: 2; justify-self: start; } + .section-heading-row { flex-direction: column; } + .legend-list { display: none; } + .login-panel { padding: 30px 20px; } +} + +@media (max-width: 420px) { + .metric-strip { grid-template-columns: 1fr; } + .metric-item { border-right: 0; border-bottom: 1px solid var(--cc-border); } + .metric-item:last-child { border-bottom: 0; } + .filter-fields { grid-template-columns: minmax(0, 1fr); } + .filter-fields > .el-input { grid-column: auto; } + .header-user .user-avatar { display: none; } + .forbidden-page { min-height: calc(100vh - 120px); padding: 28px 16px; } + .forbidden-page h2 { font-size: 18px; } +} + +@media (max-width: 360px) { + .app-header { gap: 6px; padding: 8px; } + .page-heading h1 { max-width: 100%; font-size: 17px; } + .mobile-menu-button, .role-preview-trigger, .header-user .el-button { min-width: 44px; min-height: 44px; } + .page-container { padding: 14px 8px 24px; } + .forbidden-page { padding-right: 14px; padding-left: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + .app-sidebar, .side-nav-item, .stat-card { transition: none !important; } +} diff --git a/src/types/admin.ts b/src/types/admin.ts new file mode 100644 index 00000000..28f6eb1f --- /dev/null +++ b/src/types/admin.ts @@ -0,0 +1,147 @@ +import type { + FirstLegOrderStatusLabel, + PlatformReceivingWarehouse, + PlatformReceivingWarehouseSnapshot, +} from '@chongchong/shared' + +export type RecordStatus = '正常' | '停用' +export type CustomerStatus = '正常' | '待审核' | '停用' +export type RoleStatus = '启用' | '停用' +export type PermissionType = '目录' | '菜单' | '按钮' | '接口' +export type DataScope = '全部数据' | '本部门数据' | '本人数据' | '自定义' +export type OrderStatus = FirstLegOrderStatusLabel +export type Warehouse = PlatformReceivingWarehouse +export type ExpressCompanyStatus = '合作中' | '已终止' +export type CounterpartyStatus = '启用' | '停用' +export type CounterpartyType = '供应商' | '客户' | '承运商' | '报关行' | '发货单位' | '收货单位' + +export interface ExpressCompany { + id: string + name: string + code: string + website: string + phone: string + status: ExpressCompanyStatus + remark: string + updatedAt: string +} + +export interface Counterparty { + id: string + code: string + name: string + types: CounterpartyType[] + contactName: string + contactPhone: string + email: string + country: string + province: string + city: string + postalCode: string + address: string + status: CounterpartyStatus + remark: string + updatedAt: string +} + +export interface FirstLegDeliveryInfo { + expressCompanyId: string + expressCompanyCode: string + expressCompanyName: string + trackingNo: string + updatedAt: string + operator: string +} + +export interface Customer { + id: string + name: string + creditCode: string + contact: string + phone: string + email: string + sales: string + packageName: '基础版' | '专业版' | '企业版' + period: number + status: CustomerStatus + orders: number + joinedAt: string + address: string + remark: string +} + +export interface InternalUser { + id: string + account: string + name: string + department: string + phone: string + email: string + roleIds: string[] + status: RecordStatus + lastLogin: string + createdAt: string +} + +export interface Role { + id: string + name: string + code: string + description: string + userCount: number + dataScope: DataScope + status: RoleStatus + permissionIds: string[] + builtIn?: boolean +} + +export interface Permission { + id: string + parentId: string | null + name: string + code: string + type: PermissionType + path: string + method: string + status: RoleStatus + sort: number +} + +export interface OrderTimelineItem { + time: string + title: string + description: string + operator: string +} + +export interface FirstLegOrder { + id: string + no: string + customerId: string + customerName: string + referenceNo: string + transportMode: '海运整箱 FCL' | '海运拼箱 LCL' | '空运' | '陆运' + businessType: '出口' | '进口' + origin: string + destination: string + receivingWarehouseId: string + receivingWarehouseSnapshot: PlatformReceivingWarehouseSnapshot | null + container: string + etd: string + eta: string + amount: number + currency: 'CNY' | 'USD' + status: OrderStatus + priority: '普通' | '紧急' | '加急' + operator: string + shipper: string + consignee: string + cargoName: string + pieces: number + weight: number + volume: number + createdAt: string + remark: string + deliveryInfo?: FirstLegDeliveryInfo | null + timeline: OrderTimelineItem[] +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 00000000..87465ebf --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,26 @@ +export interface PlatformJwtClaims { + user_id?: string | number + username?: string + app_code?: string + roles?: string[] + permissions?: string[] + tenant_id?: string | number + membership_id?: string | number + exp?: number +} + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + return atob(padded) +} + +export function decodeJwtClaims(token: string): PlatformJwtClaims { + const payload = token.split('.')[1] + if (!payload) throw new Error('登录响应中的 accessToken 格式无效') + try { + return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims + } catch { + throw new Error('登录响应中的 accessToken 无法解析') + } +} diff --git a/src/utils/permissionTree.test.ts b/src/utils/permissionTree.test.ts new file mode 100644 index 00000000..5bb5c978 --- /dev/null +++ b/src/utils/permissionTree.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { Permission } from '@/types/admin' +import { buildPermissionTree, getDescendantIds, summarizePermissionSelection } from '@/utils/permissionTree' + +const permissions: Permission[] = [ + { id: 'action-b', parentId: 'menu', name: '操作 B', code: 'demo:b', type: '按钮', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'menu', parentId: 'directory', name: '页面', code: 'demo:list', type: '菜单', path: '/demo', method: '-', status: '启用', sort: 10 }, + { id: 'directory', parentId: null, name: '目录', code: 'demo', type: '目录', path: '', method: '-', status: '启用', sort: 10 }, + { id: 'action-a', parentId: 'menu', name: '操作 A', code: 'demo:a', type: '按钮', path: '', method: '-', status: '启用', sort: 10 }, +] + +describe('permissionTree', () => { + it('builds arbitrary-depth trees and sorts siblings', () => { + const tree = buildPermissionTree(permissions) + + expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a', 'action-b']) + }) + + it('keeps the ancestor chain when a deep descendant matches', () => { + const tree = buildPermissionTree(permissions, (item) => item.code === 'demo:a') + + expect(tree.map((node) => node.id)).toEqual(['directory']) + expect(tree[0]?.children?.map((node) => node.id)).toEqual(['menu']) + expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a']) + }) + + it('stops building a branch when malformed data repeats an ancestor id', () => { + const malformed = [ + ...permissions, + { id: 'loop', parentId: null, name: '循环目录', code: 'loop', type: '目录', path: '', method: '-', status: '启用', sort: 20 }, + { id: 'loop', parentId: 'loop', name: '循环下级', code: 'loop:child', type: '目录', path: '', method: '-', status: '启用', sort: 10 }, + ] satisfies Permission[] + + const tree = buildPermissionTree(malformed) + + expect(tree.find((node) => node.code === 'loop')).not.toHaveProperty('children') + }) + + it('collects descendants once when malformed data contains a cycle', () => { + const malformed = [ + ...permissions, + { id: 'cycle-a', parentId: 'cycle-b', name: 'A', code: 'cycle:a', type: '目录', path: '', method: '-', status: '启用', sort: 1 }, + { id: 'cycle-b', parentId: 'cycle-a', name: 'B', code: 'cycle:b', type: '目录', path: '', method: '-', status: '启用', sort: 2 }, + ] satisfies Permission[] + + expect(getDescendantIds(malformed, 'cycle-a')).toEqual(['cycle-b']) + }) + + it('counts page menus and operations without treating directories as menus', () => { + expect(summarizePermissionSelection(permissions, ['directory', 'menu', 'action-a'])).toEqual({ + menuCount: 1, + actionCount: 1, + }) + }) +}) diff --git a/src/utils/permissionTree.ts b/src/utils/permissionTree.ts new file mode 100644 index 00000000..eeeb42a5 --- /dev/null +++ b/src/utils/permissionTree.ts @@ -0,0 +1,75 @@ +import type { Permission } from '@/types/admin' + +export type PermissionTreeNode = Permission & { children?: PermissionTreeNode[] } +export type PermissionMatcher = (permission: Permission) => boolean + +function groupByParent(permissions: Permission[]) { + const childrenByParent = new Map () + + permissions.forEach((permission) => { + const siblings = childrenByParent.get(permission.parentId) ?? [] + siblings.push(permission) + childrenByParent.set(permission.parentId, siblings) + }) + + childrenByParent.forEach((siblings) => { + siblings.sort((left, right) => left.sort - right.sort) + }) + + return childrenByParent +} + +export function buildPermissionTree( + permissions: Permission[], + matches: PermissionMatcher = () => true, +): PermissionTreeNode[] { + const childrenByParent = groupByParent(permissions) + + function buildNode(permission: Permission, ancestors: Set ): PermissionTreeNode | null { + if (ancestors.has(permission.id)) return null + + const nextAncestors = new Set(ancestors).add(permission.id) + const children = (childrenByParent.get(permission.id) ?? []) + .map((child) => buildNode(child, nextAncestors)) + .filter((child): child is PermissionTreeNode => child !== null) + + if (!matches(permission) && children.length === 0) return null + + return { + ...permission, + ...(children.length ? { children } : {}), + } + } + + return (childrenByParent.get(null) ?? []) + .map((permission) => buildNode(permission, new Set())) + .filter((node): node is PermissionTreeNode => node !== null) +} + +export function getDescendantIds(permissions: Permission[], permissionId: string): string[] { + const childrenByParent = groupByParent(permissions) + const visited = new Set([permissionId]) + const descendants: string[] = [] + const queue = [...(childrenByParent.get(permissionId) ?? [])] + + while (queue.length) { + const current = queue.shift() + if (!current || visited.has(current.id)) continue + + visited.add(current.id) + descendants.push(current.id) + queue.push(...(childrenByParent.get(current.id) ?? [])) + } + + return descendants +} + +export function summarizePermissionSelection(permissions: Permission[], selectedIds: string[]) { + const selected = new Set(selectedIds) + const selectedPermissions = permissions.filter((permission) => selected.has(permission.id)) + + return { + menuCount: selectedPermissions.filter((permission) => permission.type === '菜单').length, + actionCount: selectedPermissions.filter((permission) => ['按钮', '接口'].includes(permission.type)).length, + } +} diff --git a/src/views/AccessControlledActions.test.ts b/src/views/AccessControlledActions.test.ts new file mode 100644 index 00000000..b09738be --- /dev/null +++ b/src/views/AccessControlledActions.test.ts @@ -0,0 +1,325 @@ +import ElementPlus, { type MessageBoxData } from 'element-plus' +import { createPinia, setActivePinia, type Pinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useUnsavedWorkStore } from '@/stores/unsavedWork' +import CustomersView from './CustomersView.vue' +import FirstLegOrdersView from './FirstLegOrdersView.vue' +import InternalUsersView from './InternalUsersView.vue' +import PermissionsView from './PermissionsView.vue' +import RolesView from './RolesView.vue' +import WarehousesView from './WarehousesView.vue' +import { useAdminDataStore } from '@/stores/adminData' +import { ElMessageBox } from 'element-plus' + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function createMemoryStorage(): Storage { + const values = new Map () + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +async function mountForRole( + component: typeof CustomersView | typeof FirstLegOrdersView | typeof InternalUsersView | typeof RolesView | typeof PermissionsView | typeof WarehousesView, + roleId: string, +) { + const pinia: Pinia = createPinia() + setActivePinia(pinia) + const access = useAccessPreviewStore() + access.initializeAfterLogin() + expect(access.commitRole(roleId)).toBe(true) + const wrapper = mount(component, { + attachTo: document.body, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + return { wrapper, access, unsaved: useUnsavedWorkStore() } +} + +function buttonTexts(wrapper: VueWrapper) { + return wrapper.findAll('button').map((button) => button.text().trim()) +} + +async function clickButton(wrapper: VueWrapper, text: string) { + const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text) + if (!button) throw new Error(`Button not found: ${text}`) + await button.trigger('click') + await flushPromises() +} + +async function fillNativeInput(placeholder: string, value: string) { + const input = document.querySelector (`input[placeholder="${placeholder}"]`) + if (!input) throw new Error(`Input not found: ${placeholder}`) + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('business action permissions', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()) + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + document.body.innerHTML = '' + }) + + it('keeps review actions out of customer records now that reviews use a dedicated route', async () => { + const { wrapper } = await mountForRole(CustomersView, 'R002') + const texts = buttonTexts(wrapper) + + expect(texts).not.toContain('新增客户') + expect(texts).not.toContain('审核') + expect(texts).toContain('编辑') + }) + + it('shows no audit or status action for a pending customer without audit permission', async () => { + const { wrapper } = await mountForRole(CustomersView, 'R005') + const pendingRow = wrapper.findAll('tr') + .find((row) => row.text().includes('北京国贸跨境供应链有限公司')) + expect(pendingRow).toBeDefined() + + const actions = pendingRow?.findAll('button').map((button) => button.text().trim()) ?? [] + expect(actions).not.toContain('审核') + expect(actions).not.toContain('停用') + expect(actions).not.toContain('启用') + }) + + it('lets sales create and edit customers while finance cannot update orders', async () => { + const sales = await mountForRole(CustomersView, 'R005') + expect(buttonTexts(sales.wrapper)).toContain('新增客户') + sales.wrapper.unmount() + document.body.innerHTML = '' + + const finance = await mountForRole(FirstLegOrdersView, 'R004') + const texts = buttonTexts(finance.wrapper) + expect(texts).not.toContain('创建订单') + expect(texts).not.toContain('批量更新') + expect(texts).not.toContain('处理') + expect(texts).toContain('详情') + expect(texts).not.toContain('编辑') + }) + + it('shows create actions to the super administrator but no order status actions to sales', async () => { + const superAdmin = await mountForRole(FirstLegOrdersView, 'R001') + expect(buttonTexts(superAdmin.wrapper)).toContain('创建订单') + expect(buttonTexts(superAdmin.wrapper)).toContain('批量更新') + expect(buttonTexts(superAdmin.wrapper)).toContain('处理') + superAdmin.wrapper.unmount() + document.body.innerHTML = '' + + const sales = await mountForRole(FirstLegOrdersView, 'R005') + const texts = buttonTexts(sales.wrapper) + expect(texts).not.toContain('批量更新') + expect(texts).not.toContain('处理') + expect(texts).not.toContain('编辑') + }) + + it('lets operations maintain warehouses while operators can only view them', async () => { + const operations = await mountForRole(WarehousesView, 'R002') + expect(buttonTexts(operations.wrapper)).toContain('新增仓库') + expect(buttonTexts(operations.wrapper)).toContain('编辑') + expect(buttonTexts(operations.wrapper)).toContain('停用') + operations.wrapper.unmount() + document.body.innerHTML = '' + + const operator = await mountForRole(WarehousesView, 'R003') + expect(buttonTexts(operator.wrapper)).not.toContain('新增仓库') + expect(buttonTexts(operator.wrapper)).not.toContain('编辑') + expect(operator.wrapper.text()).toContain('仅查看') + }) + + it('registers a dirty customer draft and discards it when create permission is lost', async () => { + const { wrapper, access, unsaved } = await mountForRole(CustomersView, 'R001') + await clickButton(wrapper, '新增客户') + await fillNativeInput('请输入营业执照上的企业名称', '测试客户') + + expect(unsaved.entries).toContainEqual({ + key: 'customer-editor', + label: '新增客户', + dirty: true, + requiredPermission: 'customer:create', + }) + + expect(access.commitRole('R002')).toBe(true) + await flushPromises() + + expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toBeUndefined() + expect(document.body.textContent).not.toContain('请输入营业执照上的企业名称') + }) + + it.each([ + [CustomersView, '新增客户'], + [FirstLegOrdersView, '创建订单'], + [InternalUsersView, '新增用户'], + [RolesView, '新增角色'], + [PermissionsView, '新增权限'], + ] as const)('keeps the role preview reachable while the %s editor is open', async (component, openText) => { + const { wrapper } = await mountForRole(component, 'R001') + await clickButton(wrapper, openText) + + const editor = wrapper.findComponent({ name: 'ElDrawer' }) + expect(editor.exists()).toBe(true) + expect(document.querySelector('.editor-drawer')).not.toBeNull() + expect(editor.props('modal')).toBe(false) + expect(editor.props('closeOnClickModal')).toBe(false) + }) + + it('keeps an existing customer editor open when create permission is lost', async () => { + const { wrapper, access, unsaved } = await mountForRole(CustomersView, 'R001') + await clickButton(wrapper, '编辑') + + expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toMatchObject({ + label: '编辑客户', + requiredPermission: null, + }) + + expect(access.commitRole('R002')).toBe(true) + await flushPromises() + + expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toBeDefined() + expect(document.body.textContent).toContain('编辑客户') + }) + + it('discards only a new order editor when create permission is lost', async () => { + const { wrapper, access, unsaved } = await mountForRole(FirstLegOrdersView, 'R001') + await clickButton(wrapper, '创建订单') + await fillNativeInput('如 宁波', '上海') + + expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toMatchObject({ + label: '创建头程订单', + dirty: true, + requiredPermission: 'first-leg:create', + }) + + expect(access.commitRole('R004')).toBe(true) + await flushPromises() + expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toBeUndefined() + + access.commitRole('R001') + await nextTick() + await clickButton(wrapper, '编辑') + expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toMatchObject({ + label: '编辑头程订单', + requiredPermission: null, + }) + + expect(access.commitRole('R004')).toBe(true) + await flushPromises() + expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toBeDefined() + expect(document.body.textContent).toContain('编辑头程订单') + }) + + it.each([ + [InternalUsersView, '系统管理员', ['编辑', '重置密码', '停用']], + [RolesView, '超级管理员', ['配置', '停用', '删除']], + [PermissionsView, '系统管理', ['编辑', '添加下级', '停用']], + ] as const)('hides every maintenance action on %s rows for the operations manager', async (component, rowText, actions) => { + const { wrapper } = await mountForRole(component, 'R002') + const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes(rowText)) + expect(row).toBeDefined() + const rowActions = row?.findAll('button').map((button) => button.text().trim()) ?? [] + for (const action of actions) expect(rowActions).not.toContain(action) + }) + + it.each([ + [InternalUsersView, '新增用户', '系统管理员', '编辑'], + [RolesView, '新增角色', '超级管理员', '配置'], + [PermissionsView, '新增权限', '系统管理', '添加下级'], + ] as const)('shows toolbar and row maintenance actions on %s for the super administrator', async (component, toolbarAction, rowText, rowAction) => { + const { wrapper } = await mountForRole(component, 'R001') + expect(buttonTexts(wrapper)).toContain(toolbarAction) + const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes(rowText)) + expect(row?.findAll('button').map((button) => button.text().trim())).toContain(rowAction) + }) + + it.each([ + [InternalUsersView, '新增用户', null, null, 'internal-user-editor', '新增内部用户', 'system:user:write'], + [RolesView, '新增角色', null, null, 'role-editor', '新增角色', 'system:role:grant'], + [PermissionsView, '新增权限', '用户可理解的中文名称', '测试权限', 'permission-editor', '新增权限', 'system:permission:write'], + ] as const)('registers system editor dirty work with the maintenance permission on %s', async (component, openText, placeholder, value, key, label, requiredPermission) => { + const { wrapper, unsaved } = await mountForRole(component, 'R001') + await clickButton(wrapper, openText) + if (placeholder && value) await fillNativeInput(placeholder, value) + if (!placeholder) { + const nameInput = document.querySelector ('.el-drawer input') + expect(nameInput).not.toBeNull() + if (nameInput) { + nameInput.value = `${label}测试内容` + nameInput.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + } + } + + expect(unsaved.entries.find((entry) => entry.key === key)).toMatchObject({ + label, + dirty: true, + requiredPermission, + }) + expect(buttonTexts(wrapper)).toContain(component === InternalUsersView ? '保存用户' : component === RolesView ? '保存角色' : '保存权限') + }) + + it('marks the role editor dirty when only a permission checkbox changes', async () => { + const { wrapper, unsaved } = await mountForRole(RolesView, 'R001') + await clickButton(wrapper, '配置') + const permissionCheckbox = document.querySelector ('.el-drawer .el-tree .el-checkbox') + expect(permissionCheckbox).not.toBeNull() + permissionCheckbox?.click() + await flushPromises() + + expect(unsaved.entries.find((entry) => entry.key === 'role-editor')).toMatchObject({ + dirty: true, + requiredPermission: 'system:role:grant', + }) + }) + + it.each([ + [InternalUsersView, '保存用户'], + [RolesView, '保存角色'], + [PermissionsView, '保存权限'], + ] as const)('does not render a save entry on %s without maintenance permission', async (component, saveText) => { + const { wrapper } = await mountForRole(component, 'R002') + expect(buttonTexts(wrapper)).not.toContain(saveText) + }) + + it('warns that disabling a directory is non-cascading and preserves descendant statuses', async () => { + const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue({ + value: '', + action: 'confirm', + } as unknown as MessageBoxData) + const { wrapper } = await mountForRole(PermissionsView, 'R001') + const data = useAdminDataStore() + const directory = data.permissions.find((permission) => permission.id === 'P020') + const descendant = data.permissions.find((permission) => permission.id === 'P300') + expect(directory?.status).toBe('启用') + expect(descendant?.status).toBe('启用') + + const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes('系统管理')) + const stop = row?.findAll('button').find((button) => button.text().trim() === '停用') + expect(stop).toBeDefined() + await stop?.trigger('click') + await flushPromises() + + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining('目录仅用于组织结构,停用不会级联影响已启用的下级菜单和操作。'), + '权限状态', + expect.any(Object), + ) + expect(data.permissions.find((permission) => permission.id === 'P020')?.status).toBe('停用') + expect(data.permissions.find((permission) => permission.id === 'P300')?.status).toBe('启用') + }) +}) diff --git a/src/views/CounterpartiesView.vue b/src/views/CounterpartiesView.vue new file mode 100644 index 00000000..49d32e32 --- /dev/null +++ b/src/views/CounterpartiesView.vue @@ -0,0 +1,182 @@ + + + + + ++ + + diff --git a/src/views/CustomersView.vue b/src/views/CustomersView.vue new file mode 100644 index 00000000..a411cbc5 --- /dev/null +++ b/src/views/CustomersView.vue @@ -0,0 +1,190 @@ + + + ++ + + + ++++ + + +++ 重置 + 批量导入 + 新增单位 + + ++ 往来单位档案
平台维护完整主数据;企业客户端仅可只读选择已启用的发货单位和收货单位。
+ + ++ {{ (row as Counterparty).name }}{{ (row as Counterparty).code }}+ + {{ (row as Counterparty).contactName }}{{ (row as Counterparty).contactPhone }}+ {{ (row as Counterparty).country }} · {{ (row as Counterparty).city }}{{ (row as Counterparty).address }}+ + 详情 编辑 {{ (row as Counterparty).status === '启用' ? '停用' : '启用' }} + + ++ + ++单位信息
++ + + + +联系信息
++ + + +主要地址
++ + + + + + + + ++{{ selectedCounterparty.name }}
{{ selectedCounterparty.code }} · {{ selectedCounterparty.id }}
+单位角色
+联系与地址
{{ selectedCounterparty.contactName }} · {{ selectedCounterparty.contactPhone }} {{ selectedCounterparty.email || '—' }} {{ addressText(selectedCounterparty) }} {{ selectedCounterparty.updatedAt }} + + +内部备注
{{ selectedCounterparty.remark || '暂无内部备注。' }}
+ ++ diff --git a/src/views/EnterpriseReviewsView.test.ts b/src/views/EnterpriseReviewsView.test.ts new file mode 100644 index 00000000..3e764ccd --- /dev/null +++ b/src/views/EnterpriseReviewsView.test.ts @@ -0,0 +1,160 @@ +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 { + approveEnterpriseReview, + downloadTenantQualification, + listEnterpriseReviews, + listTenantQualifications, + rejectEnterpriseReview, + type EnterpriseReview, + type PlatformTenantQualification, +} from '@/api/platformIam' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import EnterpriseReviewsView from './EnterpriseReviewsView.vue' + +vi.mock('@/api/platformIam', () => ({ + listEnterpriseReviews: vi.fn(), + listTenantQualifications: vi.fn(), + downloadTenantQualification: vi.fn(), + qualificationFileName: vi.fn(() => 'test.png'), + approveEnterpriseReview: vi.fn(), + rejectEnterpriseReview: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +const pendingReview: EnterpriseReview = { + id: 202607290005, + tenantId: 202607290002, + tenantName: '深圳测试跨境科技有限公司', + tenantCode: 'TEN202607290002', + qualificationId: 202607290004, + qualificationType: 'BUSINESS_LICENSE', + certificateNo: '91440300XXXXXXXXXX', + documentUrl: '/api/merchant/v1/files/qualifications/test.png', + contactName: '张三', + contactPhone: '13800138000', + contactEmail: 'test01@example.com', + submittedAt: '2026-07-29T10:00:00Z', + status: 'PENDING', +} + +const pendingQualification: PlatformTenantQualification = { + id: 202607290004, + tenantId: pendingReview.tenantId, + qualificationType: 'BUSINESS_LICENSE', + certificateNo: '91440300XXXXXXXXXX', + documentUrl: '/api/merchant/v1/files/qualifications/test.png', + status: 'PENDING', + reviewComment: null, +} + +async function mountView() { + const pinia = createPinia() + setActivePinia(pinia) + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(EnterpriseReviewsView, { + attachTo: document.body, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + return wrapper +} + +function buttonByText(text: string) { + return [...document.querySelectorAll+ + + + ++++ ++ + ++ + ++ +++ 重置 + 导出 + 新增客户 + + ++ ++客户档案
共 {{ filteredRows.length }} 家企业,客户资料与注册审核统一管理。
+ + ++ + +{{ row.name }}{{ row.creditCode }}++ {{ row.contact }}{{ row.phone }}+ + {{ row.packageName }}{{ row.period }} 天账期+ + + + +++ ++ 详情 + 编辑 {{ row.status === '停用' ? '启用' : '停用' }} ++ + ++ + ++企业信息
++ + + + 天 + + +联系资料
++ + + + + + +{{ currentCustomer.name.slice(0, 1) }}+{{ currentCustomer.name }}
{{ currentCustomer.id }} · {{ currentCustomer.creditCode }}
+ +基本资料
{{ currentCustomer.packageName }} {{ currentCustomer.sales }} {{ currentCustomer.contact }} · {{ currentCustomer.phone }} {{ currentCustomer.email }} {{ currentCustomer.address }} {{ currentCustomer.joinedAt }} + + +内部备注
{{ currentCustomer.remark || '暂无内部备注。' }}
('button')] + .find((button) => button.textContent?.trim() === text) +} + +enableAutoUnmount(afterEach) + +describe('EnterpriseReviewsView', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.mocked(listEnterpriseReviews).mockReset() + vi.mocked(listTenantQualifications).mockReset() + vi.mocked(downloadTenantQualification).mockReset() + vi.mocked(approveEnterpriseReview).mockReset() + vi.mocked(rejectEnterpriseReview).mockReset() + document.body.innerHTML = '' + }) + + it('renders an honest empty state from the real review endpoint', async () => { + vi.mocked(listEnterpriseReviews).mockResolvedValue([]) + + const wrapper = await mountView() + + expect(listEnterpriseReviews).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('暂无待审核企业') + expect(wrapper.text()).toContain('共 0 条待审记录') + }) + + it('shows review identity and submits approval with the review id', async () => { + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + vi.mocked(approveEnterpriseReview).mockResolvedValue(undefined) + + const wrapper = await mountView() + expect(wrapper.text()).toContain('深圳测试跨境科技有限公司') + expect(wrapper.text()).toContain('91440300XXXXXXXXXX') + + const approve = buttonByText('通过') + expect(approve).toBeDefined() + approve?.click() + await flushPromises() + + const confirm = buttonByText('确认通过') + expect(confirm).toBeDefined() + confirm?.click() + await flushPromises() + + expect(approveEnterpriseReview).toHaveBeenCalledWith( + pendingReview.id, + '营业执照清晰,企业资料审核通过', + ) + expect(wrapper.text()).toContain('暂无待审核企业') + expect(wrapper.text()).toContain('共 0 条待审记录') + }) + + it('loads tenant qualifications when the reviewer opens the detail drawer', async () => { + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + vi.mocked(listTenantQualifications).mockResolvedValue([pendingQualification]) + + const wrapper = await mountView() + buttonByText('详情')?.click() + await flushPromises() + + expect(listTenantQualifications).toHaveBeenCalledWith(pendingReview.tenantId) + expect(wrapper.text()).toContain('信用代码:91440300XXXXXXXXXX') + expect(buttonByText('查看文件')).toBeDefined() + }) + + it('keeps qualification loading recoverable without using a direct file link', async () => { + vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview]) + vi.mocked(listTenantQualifications).mockRejectedValue(new Error('qualification unavailable')) + + const wrapper = await mountView() + buttonByText('详情')?.click() + await flushPromises() + + expect(wrapper.text()).toContain('资质暂时无法加载') + expect(wrapper.text()).toContain('qualification unavailable') + expect(wrapper.find('a[href*="qualifications"]').exists()).toBe(false) + expect(buttonByText('重新加载')).toBeDefined() + }) + + it('keeps the page recoverable when the endpoint fails', async () => { + vi.mocked(listEnterpriseReviews).mockRejectedValue(new Error('IAM unavailable')) + + const wrapper = await mountView() + + expect(wrapper.get('[role="alert"]').text()).toContain('审核列表暂时无法加载') + expect(wrapper.get('[role="alert"]').text()).toContain('IAM unavailable') + expect(buttonByText('重新加载')).toBeDefined() + }) +}) diff --git a/src/views/EnterpriseReviewsView.vue b/src/views/EnterpriseReviewsView.vue new file mode 100644 index 00000000..7364860b --- /dev/null +++ b/src/views/EnterpriseReviewsView.vue @@ -0,0 +1,360 @@ + + + + + ++ + + diff --git a/src/views/ExpressCompaniesView.test.ts b/src/views/ExpressCompaniesView.test.ts new file mode 100644 index 00000000..e8bd925a --- /dev/null +++ b/src/views/ExpressCompaniesView.test.ts @@ -0,0 +1,78 @@ +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 { nextTick } from 'vue' +import ExpressCompaniesView from './ExpressCompaniesView.vue' + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +async function mountView() { + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(ExpressCompaniesView, { + attachTo: document.body, + global: { plugins: [pinia, ElementPlus] }, + }) + await flushPromises() + return wrapper +} + +async function filterByKeyword(value: string) { + const input = document.querySelector+ + + + + + ++ 审核列表暂时无法加载+{{ loadError.message }}
重新加载 ++ + ++ ++企业资料审核
仅展示后端返回的待审核记录,提交审核时使用审核单 ID。
+ + ++ + +{{ displayText((row as EnterpriseReview).tenantName) }}{{ displayText((row as EnterpriseReview).tenantCode) }} · 租户 {{ (row as EnterpriseReview).tenantId }}+ ++ + +{{ qualificationName((row as EnterpriseReview).qualificationType) }}{{ displayText((row as EnterpriseReview).certificateNo) }}+ ++ + +{{ displayText((row as EnterpriseReview).contactName) }}{{ displayText((row as EnterpriseReview).contactPhone) }}+ +{{ formatDate((row as EnterpriseReview).submittedAt ?? (row as EnterpriseReview).createdAt) }} +待审核 ++ + +++ ++ 详情 + 通过 + 驳回 + + + ++++ + 待审核 +{{ displayText(selectedReview.tenantName) }}
审核单 {{ selectedReview.id }} · 租户 {{ selectedReview.tenantId }}
+ +企业与联系人
++ +{{ displayText(selectedReview.tenantCode) }} +{{ displayText(selectedReview.contactName) }} +{{ displayText(selectedReview.contactPhone) }} +{{ displayText(selectedReview.contactEmail) }} ++ + + + + +资质文件
+++正在读取该企业资质…+++资质暂时无法加载+{{ qualificationsError.message }}
重新加载 ++ +++ +++{{ qualificationName(qualification.qualificationType) }}{{ qualificationStatus(qualification.status).label }}+信用代码:{{ displayText(qualification.certificateNo) }}
+审核意见:{{ qualification.reviewComment }}
++ 查看文件 请核对企业名称、统一社会信用代码和营业执照内容。通过后企业获得下单资格;驳回时必须说明原因。
++ + ++++
关闭预览 ++ ++ {{ displayText(selectedReview.tenantName) }} + 审核单 {{ selectedReview.id }} +++ + + + ++ ++ ('input[placeholder="公司名称、编码、官网或电话"]') + expect(input).not.toBeNull() + if (!input) return + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() +} + +enableAutoUnmount(afterEach) + +describe('ExpressCompaniesView', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + document.body.innerHTML = '' + }) + + it('renders the prototype-backed company records and public fields', async () => { + const wrapper = await mountView() + + expect(wrapper.text()).toContain('DHL') + expect(wrapper.text()).toContain('www.dhl.com') + expect(wrapper.text()).toContain('95380') + expect(wrapper.text()).toContain('已终止') + const actions = wrapper.findAll('button').map((button) => button.text().trim()) + expect(actions).not.toContain('新增') + expect(actions).not.toContain('编辑') + }) + + it('filters by company name or code and renders an actionable empty state', async () => { + const wrapper = await mountView() + + await filterByKeyword('YTO') + expect(wrapper.text()).toContain('圆通速递') + expect(wrapper.text()).not.toContain('顺丰速运') + + await filterByKeyword('不存在的公司') + expect(wrapper.text()).toContain('没有符合条件的快递公司') + expect(wrapper.text()).toContain('共 0 条') + }) + + it('opens a detail drawer without exposing maintenance actions to an unauthorized role', async () => { + const wrapper = await mountView() + const detail = wrapper.findAll('button').find((button) => button.text().trim() === '详情') + expect(detail).toBeDefined() + + await detail?.trigger('click') + await flushPromises() + + expect(document.body.textContent).toContain('快递公司详情') + expect(document.body.textContent).toContain('公开联系信息') + expect(document.body.textContent).not.toContain('保存快递公司') + }) +}) diff --git a/src/views/ExpressCompaniesView.vue b/src/views/ExpressCompaniesView.vue new file mode 100644 index 00000000..70d355ed --- /dev/null +++ b/src/views/ExpressCompaniesView.vue @@ -0,0 +1,153 @@ + + + + + ++ + + diff --git a/src/views/FirstLegOrdersView.vue b/src/views/FirstLegOrdersView.vue new file mode 100644 index 00000000..7263990e --- /dev/null +++ b/src/views/FirstLegOrdersView.vue @@ -0,0 +1,241 @@ + + + ++ + + + ++++ + +++ 重置 + 批量导入 + 新增快递公司 + + ++快递公司名录
编码和名称必须唯一;停用后不允许新增引用,历史订单快照不受影响。
+ + ++ {{ (row as ExpressCompany).name }}{{ (row as ExpressCompany).id }} · {{ (row as ExpressCompany).updatedAt }}+ {{ (row as ExpressCompany).code }}{{ (row as ExpressCompany).website }} +— + {{ (row as ExpressCompany).phone || '—' }} + + 详情 编辑 {{ (row as ExpressCompany).status === '合作中' ? '停用' : '启用' }} + + ++ + 档案信息
++ + + + + + + +{{ selectedCompany.name }}
{{ selectedCompany.code }} · {{ selectedCompany.id }}
+ + 公开联系信息
- 客服电话
- {{ selectedCompany.phone || '—' }}
- 更新时间
- {{ selectedCompany.updatedAt }}
{{ selectedCompany.remark || '暂无内部备注。' }}
+ ++ + + diff --git a/src/views/ForbiddenView.test.ts b/src/views/ForbiddenView.test.ts new file mode 100644 index 00000000..4ae368b9 --- /dev/null +++ b/src/views/ForbiddenView.test.ts @@ -0,0 +1,80 @@ +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAdminDataStore } from '@/stores/adminData' +import ForbiddenView from './ForbiddenView.vue' + +let pinia: ReturnType+ + + ++++ + + + + +重置 批量更新 导出 创建订单 + + ++ 已选择 {{ selectedRows.length }} 笔头程订单
从审核、受理、订舱到运输完成,统一跟进订单状态与负责人。
+ + ++ + {{ row.no }}{{ row.referenceNo || '无客户参考号' }} · {{ row.createdAt }}+ {{ row.customerName }}{{ row.businessType }} · {{ row.priority }}+ {{ row.transportMode }}{{ row.container }}+ {{ row.origin }}→{{ row.destination }}+ {{ row.receivingWarehouseSnapshot?.name || '-' }}{{ row.receivingWarehouseSnapshot?.code || '-' }}+ {{ row.deliveryInfo?.expressCompanyName || '未维护' }}{{ row.deliveryInfo?.trackingNo || '—' }}{{ row.currency === 'CNY' ? '¥' : '$' }}{{ row.amount.toLocaleString() }} +{{ row.operator }} ++ + 详情 编辑 快递 处理 + + ++ + ++业务信息
+运输信息
+货物与收发货方
+ + + ++{{ currentOrder.transportMode }}{{ currentOrder.no }}
{{ currentOrder.customerName }} · {{ currentOrder.referenceNo }}
+ +订单概要
{{ currentOrder.origin }} → {{ currentOrder.destination }} {{ currentOrder.businessType }} {{ currentOrder.receivingWarehouseSnapshot?.name || '-' }}{{ currentOrder.receivingWarehouseSnapshot.code }} · {{ currentOrder.receivingWarehouseSnapshot.province }}{{ currentOrder.receivingWarehouseSnapshot.city }}{{ currentOrder.receivingWarehouseSnapshot.address }} {{ currentOrder.container }} {{ currentOrder.operator }} {{ currentOrder.etd || '-' }} / {{ currentOrder.eta || '-' }} {{ currentOrder.currency === 'CNY' ? '¥' : '$' }}{{ currentOrder.amount.toLocaleString() }} +货物信息
发货方{{ currentOrder.shipper }}收货方{{ currentOrder.consignee }}+履约快递
{{ currentOrder.deliveryInfo.expressCompanyName }}({{ currentOrder.deliveryInfo.expressCompanyCode }}) {{ currentOrder.deliveryInfo.trackingNo }} {{ currentOrder.deliveryInfo.operator }} {{ currentOrder.deliveryInfo.updatedAt }} + + +处理记录
{{ item.title }} {{ item.description }}
+ ++ 取消 确认更新 ++ ++ + 取消 保存快递信息 ++ +function createMemoryStorage(): Storage { + const values = new Map () + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +async function mountForbidden(from: string) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/customers', component: { template: ' customers
' } }, + { path: '/403', component: ForbiddenView }, + ], + }) + await router.push({ path: '/403', query: { from } }) + await router.isReady() + + const wrapper = mount(ForbiddenView, { + global: { + plugins: [pinia, router], + stubs: { + 'el-button': { + template: '', + }, + }, + }, + }) + return { wrapper, router } +} + +describe('ForbiddenView', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()) + pinia = createPinia() + setActivePinia(pinia) + }) + + it('shows the preview role and replaces the route with the first accessible page', async () => { + const access = useAccessPreviewStore() + access.initializeAfterLogin() + const { wrapper, router } = await mountForbidden('/customers?status=pending') + + expect(wrapper.text()).toContain('权限预览:超级管理员') + expect(wrapper.text()).toContain('来源页面:/customers?status=pending') + await wrapper.get('button').trigger('click') + await flushPromises() + + expect(router.currentRoute.value.fullPath).toBe('/customers') + }) + + it('sanitizes an external-looking source and guides role switching when no role is available', async () => { + const data = useAdminDataStore() + data.roles.forEach((role) => { role.status = '停用' }) + const access = useAccessPreviewStore() + access.initializeAfterLogin() + const { wrapper } = await mountForbidden('//example.com') + + expect(wrapper.text()).toContain('来源页面:/') + expect(wrapper.text()).not.toContain('//example.com') + expect(wrapper.text()).toContain('当前无可用角色') + expect(wrapper.text()).toContain('请使用顶栏切换角色') + expect(wrapper.find('button').exists()).toBe(false) + }) +}) diff --git a/src/views/ForbiddenView.vue b/src/views/ForbiddenView.vue new file mode 100644 index 00000000..ea0858fc --- /dev/null +++ b/src/views/ForbiddenView.vue @@ -0,0 +1,37 @@ + + + ++ + + diff --git a/src/views/InternalUsersView.vue b/src/views/InternalUsersView.vue new file mode 100644 index 00000000..0c8ef0e9 --- /dev/null +++ b/src/views/InternalUsersView.vue @@ -0,0 +1,128 @@ + + + +403 · 权限不足
+当前预览角色无法访问此页面
+{{ previewRoleLabel }}
+来源页面:{{ sourcePath }}
++ 返回可访问页面 + +请使用顶栏切换角色,选择具有可访问菜单的角色。
+++ diff --git a/src/views/LoginView.test.ts b/src/views/LoginView.test.ts new file mode 100644 index 00000000..c2e78049 --- /dev/null +++ b/src/views/LoginView.test.ts @@ -0,0 +1,150 @@ +import { createPinia, setActivePinia } from 'pinia' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAuthStore } from '@/stores/auth' +import LoginView from './LoginView.vue' + +const loginSource = readFileSync(resolve(process.cwd(), 'src/views/LoginView.vue'), 'utf8') + +function createMemoryStorage(): Storage { + const values = new Map+ + + ++++ + + + +重置 新增用户 + + ++ 内部用户
维护平台员工账号,并通过角色控制菜单和操作权限。
+ + ++ {{ row.name.slice(0,1) }}{{ row.name }}{{ row.account }}+ + + {{ row.phone }}{{ row.email }}+ + + 编辑 重置密码 {{ row.status === '正常' ? '停用' : '启用' }} + ++ + ++账号信息
++ + + + +联系方式
+角色分配
用户最终权限为所分配角色权限的合集。 () + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +describe('LoginView access preview lifecycle', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()) + }) + + it('only exposes the integration account hint through the development environment branch', () => { + expect(loginSource).toContain('const isDevelopment = import.meta.env.DEV') + expect(loginSource).toContain('v-if="developmentHint" class="demo-tip"') + expect(loginSource).toContain("account: isDevelopment ? 'platform_admin' : ''") + expect(loginSource).toContain("password: isDevelopment ? 'ChangeMe123!' : ''") + }) + + it('initializes preview access after login and before redirecting', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + const access = useAccessPreviewStore() + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/login', component: LoginView }, + { path: '/', component: { template: ' home
' } }, + ], + }) + await router.push('/login') + await router.isReady() + + const calls: string[] = [] + vi.spyOn(auth, 'login').mockImplementation(async () => { calls.push('login') }) + vi.spyOn(access, 'initializeAfterLogin').mockImplementation(() => { calls.push('access') }) + vi.spyOn(router, 'replace').mockImplementation(async () => { calls.push('replace') }) + const wrapper = mount(LoginView, { + global: { + plugins: [pinia, router], + stubs: { + 'el-form': { template: '' }, + 'el-form-item': { template: '' }, + 'el-input': { template: '' }, + 'el-button': { emits: ['click'], template: '' }, + }, + }, + }) + + await wrapper.get('.login-submit').trigger('click') + await flushPromises() + + expect(calls).toEqual(['login', 'access', 'replace']) + }) + + it.each([ + ['/customers?page=2', '/customers?page=2'], + ['/customers/reviews', '/customers/reviews'], + ['//example.com', '/customers'], + ['https://example.com', '/customers'], + [['/customers', '/orders/first-leg'], '/customers'], + [null, '/customers'], + ])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/login', component: LoginView }, + { path: '/', component: { template: 'home
' } }, + ], + }) + await router.push({ path: '/login', query: { redirect } }) + await router.isReady() + vi.spyOn(auth, 'login').mockResolvedValue() + const replace = vi.spyOn(router, 'replace').mockResolvedValue() + const wrapper = mount(LoginView, { + global: { + plugins: [pinia, router], + stubs: { + 'el-form': { template: '' }, + 'el-form-item': { template: '' }, + 'el-input': { template: '' }, + 'el-button': { emits: ['click'], template: '' }, + }, + }, + }) + + await wrapper.get('.login-submit').trigger('click') + await flushPromises() + + expect(replace).toHaveBeenCalledWith(expected) + }) + + it('preserves the form and reports a recoverable error when login fails', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + const access = useAccessPreviewStore() + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/login', component: LoginView }, + { path: '/customers', component: { template: 'customers
' } }, + ], + }) + await router.push('/login') + await router.isReady() + vi.spyOn(auth, 'login').mockRejectedValue(new Error('network unavailable')) + const initialize = vi.spyOn(access, 'initializeAfterLogin') + const replace = vi.spyOn(router, 'replace') + const wrapper = mount(LoginView, { + global: { + plugins: [pinia, router], + stubs: { + 'el-form': { template: '' }, + 'el-form-item': { template: '' }, + 'el-input': { template: '' }, + 'el-button': { emits: ['click'], template: '' }, + }, + }, + }) + + await wrapper.get('.login-submit').trigger('click') + await flushPromises() + + expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable') + expect(initialize).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() + }) +}) diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 00000000..4508862d --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,87 @@ + + + +++ diff --git a/src/views/ModuleView.vue b/src/views/ModuleView.vue new file mode 100644 index 00000000..45e63c98 --- /dev/null +++ b/src/views/ModuleView.vue @@ -0,0 +1,29 @@ + + + ++ + +CC 虫虫出海+++CHONGCHONG GLOBAL
+让跨境物流管理
+
清晰、可靠、可追踪统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。
++++ 平台内部系统,请使用授权账号登录 + + ++ ++登录管理后台
+使用平台内部员工账号登录
++ ++ ++ + ++ + 登录 ++ +{{ loginError.message }}
{{ developmentHint }}++ + + + diff --git a/src/views/PermissionsView.test.ts b/src/views/PermissionsView.test.ts new file mode 100644 index 00000000..4aa8bdd5 --- /dev/null +++ b/src/views/PermissionsView.test.ts @@ -0,0 +1,80 @@ +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam' +import { useAuthStore } from '@/stores/auth' +import PermissionsView from './PermissionsView.vue' + +vi.mock('@/api/platformIam', () => ({ + createPlatformPermission: vi.fn(), + listPlatformPermissions: vi.fn(), +})) + +const permission: PlatformPermissionResource = { + id: 5100010, + tenantId: 0, + appCode: 'PLATFORM', + permissionType: 'MENU', + parentId: 5100000, + permissionCode: 'platform:customer:list', + permissionName: '企业客户', + routePath: '/customers', + httpMethod: null, + sortNo: 10, + status: 'ACTIVE', +} + +function mountServerView() { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.isServerSession = true + auth.permissions = ['platform:permission:list', 'platform:permission:create'] + return shallowMount(PermissionsView, { + global: { + plugins: [pinia], + directives: { loading: () => undefined }, + stubs: { + 'el-alert': { props: ['title'], template: '+++ ++{{ title }}
+{{ description }}
+页面骨架已完成,业务接口待接入 +{{ title }}' }, + 'el-input': true, + 'el-option': true, + 'el-select': true, + 'el-button': true, + 'el-table': true, + 'el-table-column': true, + 'el-input-number': true, + 'el-radio-group': true, + 'el-radio-button': true, + 'el-drawer': { template: '' }, + 'el-form': { template: '' }, + 'el-form-item': { template: '' }, + }, + }, + }) +} + +describe('PermissionsView IAM mode', () => { + beforeEach(() => { + vi.mocked(listPlatformPermissions).mockReset() + }) + + it('loads the platform permission resource tree from IAM', async () => { + vi.mocked(listPlatformPermissions).mockResolvedValue([permission]) + + const wrapper = mountServerView() + await flushPromises() + + expect(listPlatformPermissions).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('权限资源列表和新增已接入 IAM') + expect(wrapper.text()).toContain('一期真实资源仅使用菜单和按钮类型') + }) + + it('shows a recoverable state when the permission list fails', async () => { + vi.mocked(listPlatformPermissions).mockRejectedValue(new Error('IAM unavailable')) + + const wrapper = mountServerView() + await flushPromises() + + expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable') + }) +}) diff --git a/src/views/PermissionsView.vue b/src/views/PermissionsView.vue new file mode 100644 index 00000000..0807e21e --- /dev/null +++ b/src/views/PermissionsView.vue @@ -0,0 +1,180 @@ + + + + ++ diff --git a/src/views/RolesView.test.ts b/src/views/RolesView.test.ts new file mode 100644 index 00000000..296cb1c3 --- /dev/null +++ b/src/views/RolesView.test.ts @@ -0,0 +1,98 @@ +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, shallowMount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPlatformRole, listPlatformRoles, type PlatformRoleResource } from '@/api/platformIam' +import { useAuthStore } from '@/stores/auth' +import RolesView from './RolesView.vue' + +vi.mock('@/api/platformIam', () => ({ + createPlatformRole: vi.fn(), + listPlatformRoles: vi.fn(), +})) + +const platformRole: PlatformRoleResource = { + id: 4000002, + tenantId: 0, + appCode: 'PLATFORM', + roleCode: 'CUSTOMER_REVIEWER', + roleName: '企业审核员', + roleType: 'SYSTEM', + status: 'ACTIVE', + description: '企业资料与入驻审核', +} + +function mountView(pinia = createPinia()) { + setActivePinia(pinia) + return shallowMount(RolesView, { + global: { + plugins: [pinia], + directives: { loading: () => undefined }, + stubs: { + 'el-alert': { props: ['title'], template: '+ + + {{ loadError.message }}重新加载 + + ++ +新增权限 + + ++ 权限资源树
目录组织菜单,按钮控制页面操作;接口权限将在服务端契约确定后补充。
目录 / 菜单操作接口预留+ ++ + {{ row.code }}+ + {{ row.method }}{{ row.path || '-' }}+ + + 编辑接口待补齐 添加下级 编辑 添加下级 {{ row.status === '启用' ? '停用' : '启用' }} + ++ + +++资源定义
++++ + + + + + + + +权限编码约定使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。
{{ title }}' }, + 'el-input': true, + 'el-option': true, + 'el-select': true, + 'el-button': true, + 'el-table': true, + 'el-table-column': true, + 'el-tag': true, + 'el-radio-group': true, + 'el-radio-button': true, + 'el-drawer': { + props: ['modelValue'], + template: '', + }, + 'el-form': { template: '' }, + 'el-form-item': { template: '' }, + 'el-tree': { + props: { checkStrictly: Boolean }, + template: '', + }, + }, + }, + }) +} + +describe('RolesView', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(listPlatformRoles).mockReset() + vi.mocked(createPlatformRole).mockReset() + }) + + it('keeps directory, menu, and action permissions independently checkable', () => { + const wrapper = mountView() + + expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('true') + }) + + it('loads platform roles from IAM for a server session', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.isServerSession = true + auth.permissions = ['platform:role:list', 'platform:role:create'] + vi.mocked(listPlatformRoles).mockResolvedValue([platformRole]) + + const wrapper = mountView(pinia) + await flushPromises() + + expect(listPlatformRoles).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('角色列表和新增角色已接入 IAM') + expect(wrapper.find('[data-testid="permission-tree"]').exists()).toBe(false) + }) + + it('keeps a failed IAM role list recoverable', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.isServerSession = true + auth.permissions = ['platform:role:list'] + vi.mocked(listPlatformRoles).mockRejectedValue(new Error('IAM unavailable')) + + const wrapper = mountView(pinia) + await flushPromises() + + expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable') + }) +}) diff --git a/src/views/RolesView.vue b/src/views/RolesView.vue new file mode 100644 index 00000000..94f71382 --- /dev/null +++ b/src/views/RolesView.vue @@ -0,0 +1,207 @@ + + + + ++ diff --git a/src/views/WarehousesView.vue b/src/views/WarehousesView.vue new file mode 100644 index 00000000..fe4492bc --- /dev/null +++ b/src/views/WarehousesView.vue @@ -0,0 +1,165 @@ + + + ++ + + {{ loadError.message }}重新加载 + + ++ +重置 新增角色 + + ++ 角色与授权
角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。
+ ++ {{ row.name }}{{ row.code }}+ + {{ permissionSummary(row as Role) }}{{ access.usesServerPermissions ? '数据范围待接口提供' : row.dataScope }}{{ access.usesServerPermissions ? '—' : row.userCount }} ++ {{ row.builtIn ? '系统内置' : '自定义' }} + 接口待补齐 +配置 {{ row.status === '启用' ? '停用' : '启用' }} 删除 + ++ + ++ +基本信息
++ + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..dc5c2049 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "jsx": "preserve", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..e5d2d0ce --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,38 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const proxyTarget = env.API_PROXY_TARGET || 'http://localhost:8080' + + return { + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + dts: 'src/auto-imports.d.ts', + }), + Components({ + resolvers: [ElementPlusResolver()], + dts: 'src/components.d.ts', + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + }, + }, + }, + } +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..512197bc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + clearMocks: true, + }, +})+ + + + ++++ ++ + ++ +++ 重置 + 新增仓库 + + ++平台收货仓
仅启用仓库会进入客户头程下单选项
+ ++ ++{{ (row as Warehouse).name }}{{ (row as Warehouse).code }}{{ (row as Warehouse).province }} · {{ (row as Warehouse).city }} ++ {{ (row as Warehouse).address }} {{ (row as Warehouse).contactName || '-' }}{{ (row as Warehouse).contactPhone || '-' }} ++ + {{ (row as Warehouse).status === 'ACTIVE' ? '启用' : '停用' }} + +仅查看 +编辑 {{ (row as Warehouse).status === 'ACTIVE' ? '停用' : '启用' }} + + + ++ + ++仓库标识
启用 停用 +地址与收货
+备注