信用代码:{{ displayText(qualification.certificateNo) }}
+审核意见:{{ qualification.reviewComment }}
+commit dd0d365b0305547cf1fc78710a976995bd522446 Author: ouyangxq <775153912@qq.com> Date: Thu Jul 30 17:59:43 2026 +0800 chore: initial commit 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 @@ + + +
+ + + +后端接口接入后,此处将展示上传进度、整批校验结果、逐行错误和错误报告下载;任一行错误时后端必须保证 0 行写入。
+ + +page
' } + +function createMemoryStorage(): Storage { + const values = new Map{{ pageDescription }}
+{{ announcement }}
+平台维护完整主数据;企业客户端仅可只读选择已启用的发货单位和收货单位。
{{ selectedCounterparty.code }} · {{ selectedCounterparty.id }}
{{ selectedCounterparty.remark || '暂无内部备注。' }}
共 {{ filteredRows.length }} 家企业,客户资料与注册审核统一管理。
{{ currentCustomer.id }} · {{ currentCustomer.creditCode }}
{{ currentCustomer.remark || '暂无内部备注。' }}
{{ loadError.message }}
仅展示后端返回的待审核记录,提交审核时使用审核单 ID。
审核单 {{ selectedReview.id }} · 租户 {{ selectedReview.tenantId }}
{{ qualificationsError.message }}
信用代码:{{ displayText(qualification.certificateNo) }}
+审核意见:{{ qualification.reviewComment }}
+请核对企业名称、统一社会信用代码和营业执照内容。通过后企业获得下单资格;驳回时必须说明原因。
+编码和名称必须唯一;停用后不允许新增引用,历史订单快照不受影响。
{{ (row as ExpressCompany).code }}{{ selectedCompany.code }} · {{ selectedCompany.id }}
{{ selectedCompany.remark || '暂无内部备注。' }}
从审核、受理、订舱到运输完成,统一跟进订单状态与负责人。
{{ currentOrder.customerName }} · {{ currentOrder.referenceNo }}
{{ item.description }}
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 @@ + + + +403 · 权限不足
+{{ previewRoleLabel }}
+来源页面:{{ sourcePath }}
+请使用顶栏切换角色,选择具有可访问菜单的角色。
+维护平台员工账号,并通过角色控制菜单和操作权限。
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 @@ + + + +CHONGCHONG GLOBAL
+统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。
+使用平台内部员工账号登录
+{{ loginError.message }}
{{ description }}
+目录组织菜单,按钮控制页面操作;接口权限将在服务端契约确定后补充。
{{ row.code }}{{ row.path || '-' }}使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。
角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。
仅启用仓库会进入客户头程下单选项