diff --git a/.gitignore b/.gitignore index 1b95f355..0af18c53 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ dist-ssr dist.zip dist.tar dist.war +# 双品牌构建产物:build:brm / build:wl +dist-brm +dist-wl +dist-brm.zip +dist-wl.zip .nitro .output *-dist.zip diff --git a/README.md b/README.md index e1c9f29c..3be0b09a 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,67 @@ -
- - VbenAdmin Logo - -
-
+# lgp-admin-plus -[![license](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](LICENSE) +LGP 管理后台前端(Vben Admin 5.7 / `apps/web-antd`)。 +两个家具品牌共用一套代码,靠构建模式区分。 -

Vue Vben Admin

-
+| 品牌 | 命令后缀 | AppID | 产出目录 | +|---|---|---|---| +| 佛山铂尔曼 | `brm` | `wx679d36842570cea7` | `apps/web-antd/dist-brm` | +| 维伦家具 | `wl` | `wx57b54060a579c31a` | `apps/web-antd/dist-wl` | -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vbenjs_vue-vben-admin&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin) [![codeql](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml) [![build](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml) [![ci](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml) [![deploy](https://github.com/vbenjs/vue-vben-admin/actions/workflows/deploy.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/deploy.yml) +包管理器:**pnpm**(不要用 npm / yarn)。 -**English** | [中文](./README.zh-CN.md) | [日本語](./README.ja-JP.md) - -## Introduction - -Vue Vben Admin is a free and open source middle and back-end template. Using the latest `vue3`, `vite`, `TypeScript` and other mainstream technology development, the out-of-the-box middle and back-end front-end solutions can also be used for learning reference. - -## Upgrade Notice - -This is the latest version, 5.0, and it is not compatible with previous versions. If you are starting a new project, it is recommended to use the latest version. If you wish to view the old version, please use the [v2 branch](https://github.com/vbenjs/vue-vben-admin/tree/v2). - -## Features - -- **Latest Technology Stack**: Developed with cutting-edge front-end technologies like Vue 3 and Vite -- **TypeScript**: A language for application-scale JavaScript -- **Themes**: Multiple theme colors available with customizable options -- **Internationalization**: Comprehensive built-in internationalization support -- **Permissions**: Built-in solution for dynamic route-based permission generation - -## Preview - -- [Vben Admin](https://vben.pro/) - Full version Chinese site - -Test Account: vben/123456 - -
- VbenAdmin Logo - VbenAdmin Logo - VbenAdmin Logo -
- -### Use Gitpod - -Open the project in Gitpod (free online dev environment for GitHub) and start coding immediately. - -[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/vbenjs/vue-vben-admin) - -## Documentation - -[Document](https://doc.vben.pro/) - -## Install and Use - -1. Get the project code +## 准备 ```bash -git clone https://github.com/vbenjs/vue-vben-admin.git -``` - -2. Install dependencies - -```bash -cd vue-vben-admin -npm i -g corepack pnpm install ``` -3. Run +## 启动(两种) + +在仓库根目录执行: ```bash -pnpm dev +# 1)佛山铂尔曼 —— 读 .env.brm(标题 / Logo / 品牌色) +pnpm dev:brm + +# 2)维伦家具 —— 读 .env.wl +pnpm dev:wl ``` -4. Build +默认开发端口见 `apps/web-antd/vite.config.ts`(当前 **14002**)。 +接口代理:`/api` → 本地后端 `http://127.0.0.1:18004/api/`。 + +> 仅调试、不区分品牌时可用 `pnpm dev`(`development` 模式,默认品牌仍是铂尔曼)。 + +## 打包(两种) ```bash -pnpm build +# 1)佛山铂尔曼 → apps/web-antd/dist-brm(并可能产出 zip) +pnpm build:brm + +# 2)维伦家具 → apps/web-antd/dist-wl +pnpm build:wl ``` -## Change Log +一次打两个品牌: -[CHANGELOG](https://github.com/vbenjs/vue-vben-admin/releases) +```bash +pnpm build:brands +``` -## How to Contribute +两个品牌 **outDir 必须分开**,否则会互相覆盖。上线前按品牌分别回归。 -You are very welcome to join! [Raise an issue](https://github.com/anncwb/vue-vben-admin/issues/new/choose) or submit a Pull Request. +## 环境文件 -**Pull Request Process:** +| 文件 | 用途 | +|---|---| +| `apps/web-antd/.env.brm` | 铂尔曼生产构建 / `dev:brm` | +| `apps/web-antd/.env.wl` | 维伦生产构建 / `dev:wl` | +| `apps/web-antd/.env.development` | 通用本地开发(`pnpm dev`) | -1. Fork the code -2. Create your branch: `git checkout -b feat/xxxx` -3. Submit your changes: `git commit -am 'feat(function): add xxxxx'` -4. Push your branch: `git push origin feat/xxxx` -5. Submit `pull request` +品牌相关变量:`VITE_APP_BRAND`、`VITE_APP_TITLE`、`VITE_APP_FAVICON`、`VITE_GLOB_API_URL`。 -## Git Contribution Submission Specification +## 后端 -Reference [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) specification ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular)) - -- `feat` Add new features -- `fix` Fix the problem/BUG -- `style` The code style is related and does not affect the running result -- `perf` Optimization/performance improvement -- `refactor` Refactor -- `revert` Undo edit -- `test` Test related -- `docs` Documentation/notes -- `chore` Dependency update/scaffolding configuration modification etc. -- `ci` Continuous integration -- `types` Type definition file changes - -## Browser Support - -Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+ - -Support modern browsers, not IE - -| [Edge](http://godban.github.io/browsers-support-badges/)
Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | -| :-: | :-: | :-: | :-: | -| last 2 versions | last 2 versions | last 2 versions | last 2 versions | - -## Maintainer - -[@Vben](https://github.com/anncwb) - -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.com/#vbenjs/vue-vben-admin&Date) - -## Donate - -If you think this project is helpful to you, you can help the author buy a cup of coffee to show your support! - -![donate](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png) - -Paypal Me - -## Contributors - - - Contribution Leaderboard - - - - Contributors - - -## Discord - -- [Github Discussions](https://github.com/anncwb/vue-vben-admin/discussions) - -## License - -[MIT © Vben-2020](./LICENSE) +配套 API:`lgp-admin-plus-api`(Laravel 13)。 +库表变更走 `database/sql//01_名字.sql`,不要新建 PHP Migration。 diff --git a/apps/web-antd/.env b/apps/web-antd/.env index 5af824df..7961c7b3 100644 --- a/apps/web-antd/.env +++ b/apps/web-antd/.env @@ -1,5 +1,11 @@ # 应用标题 -VITE_APP_TITLE=Nl Admin +VITE_APP_TITLE=佛山铂尔曼 + +# 品牌:brm 佛山铂尔曼 / wl 维伦家具,由 build:brm、build:wl 覆盖 +VITE_APP_BRAND=brm + +# 浏览器标签图标,index.html 里按此变量注入 +VITE_APP_FAVICON=/brand/brm-favicon.svg # 应用命名空间,用于缓存、store等功能的前缀,确保隔离 VITE_APP_NAMESPACE=vben-web-antd diff --git a/apps/web-antd/.env.brm b/apps/web-antd/.env.brm new file mode 100644 index 00000000..dcc76b5f --- /dev/null +++ b/apps/web-antd/.env.brm @@ -0,0 +1,27 @@ +# 佛山铂尔曼(小程序 AppID wx679d36842570cea7) +# +# --mode brm 不会再去加载 .env.production,所以生产变量必须在这份文件里写全。 +# VITE_ARCHIVER 会产出 dist.zip,两个品牌必须分开 outDir,否则互相覆盖。 +VITE_APP_TITLE=佛山铂尔曼 +VITE_APP_BRAND=brm +VITE_APP_FAVICON=/brand/brm-favicon.svg + +VITE_BASE=/ + +# 接口地址 +VITE_GLOB_API_URL=https://api.borman.top/api + +# 是否开启压缩,可以设置为 none, brotli, gzip +VITE_COMPRESS=gzip + +# 是否开启 PWA +VITE_PWA=false + +# vue-router 的模式 +VITE_ROUTER_HISTORY=hash + +# 是否注入全局loading +VITE_INJECT_APP_LOADING=true + +# 打包后是否生成dist.zip +VITE_ARCHIVER=true diff --git a/apps/web-antd/.env.wl b/apps/web-antd/.env.wl new file mode 100644 index 00000000..d6793ea0 --- /dev/null +++ b/apps/web-antd/.env.wl @@ -0,0 +1,27 @@ +# 维伦家具(小程序 AppID wx57b54060a579c31a) +# +# --mode wl 不会再去加载 .env.production,所以生产变量必须在这份文件里写全。 +# VITE_ARCHIVER 会产出 dist.zip,两个品牌必须分开 outDir,否则互相覆盖。 +VITE_APP_TITLE=维伦家具 +VITE_APP_BRAND=wl +VITE_APP_FAVICON=/brand/wl-favicon.svg + +VITE_BASE=/ + +# 接口地址 +VITE_GLOB_API_URL=https://api.weilun.top/api + +# 是否开启压缩,可以设置为 none, brotli, gzip +VITE_COMPRESS=gzip + +# 是否开启 PWA +VITE_PWA=false + +# vue-router 的模式 +VITE_ROUTER_HISTORY=hash + +# 是否注入全局loading +VITE_INJECT_APP_LOADING=true + +# 打包后是否生成dist.zip +VITE_ARCHIVER=true diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index e3b9ae5c..f50cb452 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -13,7 +13,9 @@ /> %VITE_APP_TITLE% - + + + + + diff --git a/apps/web-antd/src/components/crud/crud-api.ts b/apps/web-antd/src/components/crud/crud-api.ts new file mode 100644 index 00000000..e1295a86 --- /dev/null +++ b/apps/web-antd/src/components/crud/crud-api.ts @@ -0,0 +1,30 @@ +import { requestClient } from '#/api/request'; + +export interface CrudApi { + create: (data: Record) => Promise; + delete: (data: Record) => Promise; + detail: (id: number | string) => Promise; + list: (params?: Record) => Promise; + option: (params?: Record) => Promise; + status: (data: Record) => Promise; + update: (data: Record) => Promise; +} + +/** + * 后端每个业务模块的 CRUD 路径完全一致(BaseController 自动注册), + * 逐个模块手写六个请求函数只是把同一段代码抄十二遍。 + * + * @param prefix 模块路由前缀,如 'catalogue' + */ +export function createCrudApi(prefix: string): CrudApi { + const base = prefix.endsWith('/') ? prefix : `${prefix}/`; + return { + list: (params = {}) => requestClient.get(`${base}list`, { params }), + option: (params = {}) => requestClient.get(`${base}option`, { params }), + detail: (id) => requestClient.get(`${base}detail`, { params: { id } }), + create: (data) => requestClient.post(`${base}create`, data), + update: (data) => requestClient.post(`${base}update`, data), + delete: (data) => requestClient.post(`${base}delete`, data), + status: (data) => requestClient.post(`${base}status`, data), + }; +} diff --git a/apps/web-antd/src/components/crud/grid-options.ts b/apps/web-antd/src/components/crud/grid-options.ts new file mode 100644 index 00000000..b0ac9cf7 --- /dev/null +++ b/apps/web-antd/src/components/crud/grid-options.ts @@ -0,0 +1,98 @@ +import type { VxeGridProps } from '#/adapter/vxe-table'; + +interface CreateGridOptions { + /** 除勾选列外的业务列 */ + columns: NonNullable['columns']>; + /** 追加或覆盖任意原生配置 */ + extra?: Partial>; + /** 列表请求,收到已合并的分页参数与搜索条件 */ + query: (params: Record) => Promise; + /** 关闭分页;树表必须关闭,否则父节点被分到别页会建树失败 */ + pager?: boolean; + /** 是否显示勾选列 */ + selectable?: boolean; + /** 树表配置,传入即启用 */ + tree?: { expandAll?: boolean; parentField?: string; rowField?: string }; +} + +/** + * 列表页表格配置的公共部分。 + * 各模块的 table.ts 只写自己的列与请求,其余(勾选、工具栏、高度、代理)统一在这里。 + */ +export function createGridOptions(options: CreateGridOptions): VxeGridProps { + const { + columns, + extra = {}, + pager = true, + query, + selectable = true, + tree, + } = options; + + const fullColumns = selectable + ? [{ type: 'checkbox' as const, width: 60 }, ...columns] + : columns; + + const base: VxeGridProps = { + border: false, + checkboxConfig: { highlight: true, labelField: '' }, + columnConfig: { useKey: true }, + columns: fullColumns, + height: 'auto', + keepSource: true, + pagerConfig: pager ? {} : { enabled: false }, + proxyConfig: { + ajax: { + query: async ({ page }: any, formValues: Record) => { + return await query({ + page: pager ? page.currentPage : 1, + pageSize: pager ? page.pageSize : 1000, + ...formValues, + }); + }, + }, + }, + rowConfig: { useKey: true }, + showOverflow: false, + toolbarConfig: { + custom: { icon: 'vxe-icon-menu' }, + export: false, + print: false, + refresh: true, + // @ts-ignore 适配器类型未声明 search + search: true, + slots: { buttons: 'toolbar-buttons' }, + zoom: true, + }, + }; + + if (tree) { + base.treeConfig = { + expandAll: tree.expandAll ?? true, + parentField: tree.parentField ?? 'pid', + rowField: tree.rowField ?? 'id', + transform: true, + }; + } + + return { ...base, ...extra }; +} + +/** 启用 / 禁用列,配合 index.vue 里的 status 插槽 */ +export const statusColumn = { + field: 'status', + title: '状态', + width: 100, + slots: { default: 'status' }, +}; + +/** 右侧固定操作列 */ +export function actionColumn(width = 180) { + return { + align: 'right' as const, + slots: { default: 'action' }, + title: '操作', + type: 'html' as const, + width, + }; +} diff --git a/apps/web-antd/src/components/crud/index.ts b/apps/web-antd/src/components/crud/index.ts new file mode 100644 index 00000000..983e3314 --- /dev/null +++ b/apps/web-antd/src/components/crud/index.ts @@ -0,0 +1,20 @@ +export type { CrudApi } from './crud-api'; +export { createCrudApi } from './crud-api'; +export { actionColumn, createGridOptions, statusColumn } from './grid-options'; +export { popupData } from './popup-data'; +export { default as SimpleNamePage } from './simple-name-page.vue'; +export { useCrudModal } from './use-crud-modal'; +export { useCrudTable } from './use-crud-table'; + +/** UploadImage 组件用 string[],后端单图字段是 string,两边转换 */ +export function toImageList(value?: null | string): string[] { + return value ? [value] : []; +} + +/** 取图片数组第一项,空数组转空串 */ +export function firstImage(value?: null | string | string[]): string { + if (Array.isArray(value)) { + return value[0] ?? ''; + } + return value ?? ''; +} diff --git a/apps/web-antd/src/components/crud/popup-data.ts b/apps/web-antd/src/components/crud/popup-data.ts new file mode 100644 index 00000000..72637e08 --- /dev/null +++ b/apps/web-antd/src/components/crud/popup-data.ts @@ -0,0 +1,12 @@ +/** + * 弹窗 / 抽屉传参的类型化读取 + * + * `modalApi.getData()` 的返回类型是 `{}`,直接 `.record` 会报 TS2339, + * 而它的泛型重载在当前 vben 版本已被移除(传泛型报 TS2558)。 + * 脚手架里几十处都在硬吃这两个错误,新代码统一走这里,别再复制那个写法。 + */ +export function popupData = Record>( + api: { getData: () => unknown }, +): Partial { + return (api.getData() ?? {}) as Partial; +} diff --git a/apps/web-antd/src/components/crud/simple-name-modal.vue b/apps/web-antd/src/components/crud/simple-name-modal.vue new file mode 100644 index 00000000..f6af6476 --- /dev/null +++ b/apps/web-antd/src/components/crud/simple-name-modal.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/web-antd/src/components/crud/simple-name-page.vue b/apps/web-antd/src/components/crud/simple-name-page.vue new file mode 100644 index 00000000..4ee4112b --- /dev/null +++ b/apps/web-antd/src/components/crud/simple-name-page.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/web-antd/src/components/crud/use-crud-modal.ts b/apps/web-antd/src/components/crud/use-crud-modal.ts new file mode 100644 index 00000000..1dd82ca8 --- /dev/null +++ b/apps/web-antd/src/components/crud/use-crud-modal.ts @@ -0,0 +1,79 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { ref } from 'vue'; + +import { useVbenModal } from '@vben/common-ui'; + +import { message } from 'ant-design-vue'; + +import { useVbenForm } from '#/adapter/form'; + +import { popupData } from './popup-data'; + +interface UseCrudModalOptions { + api: { + create: (data: Record) => Promise; + update: (data: Record) => Promise; + }; + formProps: VbenFormProps; + /** 提交前处理表单值,如图片数组转字符串 */ + transform?: ( + values: Record, + isUpdate: boolean, + ) => Record; +} + +/** + * 表单弹窗的公共装配:打开时回填、提交时按 update 走不同接口、成功后刷新列表 + */ +export function useCrudModal(options: UseCrudModalOptions) { + const { api, formProps, transform } = options; + + const isUpdate = ref(false); + const gridApi = ref(); + + const [Form, formApi] = useVbenForm(formProps); + + const [Modal, modalApi] = useVbenModal({ + draggable: true, + fullscreenButton: false, + onCancel() { + modalApi.close(); + }, + async onConfirm() { + const { valid } = await formApi.validate(); + if (!valid) { + return; + } + const values = await formApi.getValues(); + const payload = transform + ? transform(values, isUpdate.value) + : (values as Record); + modalApi.setState({ confirmLoading: true, loading: true }); + const submit = isUpdate.value ? api.update : api.create; + submit(payload) + .then(() => { + message.success('保存成功'); + gridApi.value?.reload(); + modalApi.close(); + }) + .finally(() => { + modalApi.setState({ confirmLoading: false, loading: false }); + }); + }, + onOpenChange(isOpen: boolean) { + gridApi.value = isOpen ? popupData(modalApi).gridApi : null; + if (!isOpen) { + return; + } + const { update, values } = popupData(modalApi); + isUpdate.value = !!update; + formApi.resetForm(); + if (values) { + formApi.setValues(values); + } + }, + }); + + return { Form, formApi, isUpdate, Modal, modalApi }; +} diff --git a/apps/web-antd/src/components/crud/use-crud-table.ts b/apps/web-antd/src/components/crud/use-crud-table.ts new file mode 100644 index 00000000..6214093b --- /dev/null +++ b/apps/web-antd/src/components/crud/use-crud-table.ts @@ -0,0 +1,119 @@ +import type { Component } from 'vue'; + +import type { VbenFormProps } from '#/adapter/form'; +import type { VxeGridListeners, VxeGridProps } from '#/adapter/vxe-table'; + +import { ref } from 'vue'; + +import { useVbenModal } from '@vben/common-ui'; + +import { message } from 'ant-design-vue'; + +import { useVbenVxeGrid } from '#/adapter/vxe-table'; + +interface UseCrudTableOptions { + /** 删除与状态接口,通常来自 createCrudApi */ + api: { + delete: (data: Record) => Promise; + status?: (data: Record) => Promise; + }; + /** 打开新增弹窗时的初始值 */ + defaultValues?: Record; + formOptions?: VbenFormProps; + gridOptions: VxeGridProps; + /** 表单弹窗组件 */ + modalComponent: Component; + /** 行数据 → 表单值,处理图片字符串转数组这类差异 */ + toFormValues?: (row: Record) => Record; +} + +/** + * 列表页的公共装配:表格 + 勾选状态 + 表单弹窗 + 删除 + 状态开关 + * + * 每个模块的 index.vue 只剩下自己的列插槽与特有操作, + * 不必再各抄一份 gridEvents / deleteApi / handleStatus。 + */ +export function useCrudTable(options: UseCrudTableOptions) { + const { + api, + defaultValues = {}, + formOptions, + gridOptions, + modalComponent, + toFormValues, + } = options; + + /** 是否有勾选行,控制工具栏的批量操作是否可用 */ + const hasSelection = ref(false); + + const gridEvents: VxeGridListeners = { + checkboxAll() { + hasSelection.value = gridApi.grid.getCheckboxRecords().length > 0; + }, + checkboxChange() { + hasSelection.value = gridApi.grid.getCheckboxRecords().length > 0; + }, + }; + + const [Grid, gridApi] = useVbenVxeGrid({ + formOptions, + gridOptions, + gridEvents, + }); + + const [FormModal, formModalApi] = useVbenModal({ + connectedComponent: modalComponent, + }); + + /** 打开表单弹窗;不传 row 即为新增 */ + function showModal(row?: Record, isUpdate = false) { + const values = row + ? (toFormValues?.(row) ?? { ...row }) + : { ...defaultValues }; + formModalApi.setData({ values, update: isUpdate, gridApi }); + formModalApi.open(); + } + + /** 传 id 删单行,不传则删勾选行 */ + function removeRows(id?: number | string) { + const ids = + id === undefined + ? gridApi.grid.getCheckboxRecords().map((item: any) => item.id) + : [id]; + if (ids.length === 0) { + message.warning('请先勾选要删除的数据'); + return; + } + return api.delete({ ids }).then(() => { + message.success('删除成功'); + hasSelection.value = false; + gridApi.reload(); + }); + } + + /** 状态开关:打开代表启用,对应后端 status=0 */ + function toggleStatus(row: Record, enabled: boolean) { + if (!api.status) { + return; + } + return api + .status({ id: row.id, status: enabled ? 0 : 1 }) + .then(() => { + message.success('操作成功'); + }) + .finally(() => { + gridApi.reload(); + }); + } + + return { + FormModal, + formModalApi, + Grid, + gridApi, + hasSelection, + removeRows, + showModal, + toggleStatus, + }; +} diff --git a/apps/web-antd/src/components/customer/user-detail-modal.vue b/apps/web-antd/src/components/customer/user-detail-modal.vue new file mode 100644 index 00000000..c43ba503 --- /dev/null +++ b/apps/web-antd/src/components/customer/user-detail-modal.vue @@ -0,0 +1,186 @@ + + + diff --git a/apps/web-antd/src/components/form/components/upload-doc.vue b/apps/web-antd/src/components/form/components/upload-doc.vue new file mode 100644 index 00000000..4a4c9762 --- /dev/null +++ b/apps/web-antd/src/components/form/components/upload-doc.vue @@ -0,0 +1,89 @@ + + + diff --git a/apps/web-antd/src/components/product/brand-album-loading.vue b/apps/web-antd/src/components/product/brand-album-loading.vue new file mode 100644 index 00000000..0fb42cee --- /dev/null +++ b/apps/web-antd/src/components/product/brand-album-loading.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/apps/web-antd/src/components/product/index.ts b/apps/web-antd/src/components/product/index.ts new file mode 100644 index 00000000..27053d9d --- /dev/null +++ b/apps/web-antd/src/components/product/index.ts @@ -0,0 +1,11 @@ +export type { + ProductCardProps, + ProductFetchParams, + ProductFetchResult, + ProductLayout, + ProductListProps, + ProductRecord, +} from './types'; +export { default as BrandAlbumLoading } from './brand-album-loading.vue'; +export { default as ProductCard } from './product-card.vue'; +export { default as ProductList } from './product-list.vue'; diff --git a/apps/web-antd/src/components/product/product-card.vue b/apps/web-antd/src/components/product/product-card.vue new file mode 100644 index 00000000..3ebeefb8 --- /dev/null +++ b/apps/web-antd/src/components/product/product-card.vue @@ -0,0 +1,127 @@ + + + diff --git a/apps/web-antd/src/components/product/product-list.vue b/apps/web-antd/src/components/product/product-list.vue new file mode 100644 index 00000000..a1d4766a --- /dev/null +++ b/apps/web-antd/src/components/product/product-list.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/apps/web-antd/src/components/product/types.ts b/apps/web-antd/src/components/product/types.ts new file mode 100644 index 00000000..637ee67a --- /dev/null +++ b/apps/web-antd/src/components/product/types.ts @@ -0,0 +1,64 @@ +import type { ActionItem } from '#/components/table-action'; + +/** 卡片排布:网格 / 列表行 / 真瀑布流(绝对定位落最短列) */ +export type ProductLayout = 'grid' | 'list' | 'waterfall'; + +/** + * 商品卡片读取的字段 + * + * 刻意用宽松的字段名而不是绑定某个模块的模型:商品图册、报价单、 + * 以后的选品弹窗传下来的行结构并不完全一致,组件只按这里列出的键取值, + * 取不到就不渲染那一块,不会因为少一个字段整张卡片报错。 + */ +export interface ProductRecord { + [key: string]: any; + alias?: string; + category_name?: string; + cover?: string; + id?: number | string; + identifier?: string; + name?: string; + price?: number | string; + /** 规格行,长度即规格数量 */ + price_sheet?: any[]; + /** 后端直接给了规格数量时优先用它,省得把整张规格表带下来 */ + spec_count?: number; + title?: string; +} + +export interface ProductCardProps { + /** 操作区按钮;同名插槽存在时以插槽为准 */ + actions?: ActionItem[]; + layout?: ProductLayout; + /** 骨架态,首屏占位用 */ + loading?: boolean; + record: ProductRecord; + selectable?: boolean; + selected?: boolean; +} + +export interface ProductFetchParams { + [key: string]: any; + page: number; + pageSize: number; +} + +export interface ProductFetchResult { + items: ProductRecord[]; + total: number; +} + +export interface ProductListProps { + /** 每张卡片的操作按钮;同名插槽存在时以插槽为准 */ + cardActions?: (record: ProductRecord) => ActionItem[]; + defaultLayout?: ProductLayout; + /** 数据源;分页参数由组件合并后传入 */ + fetch: (params: ProductFetchParams) => Promise; + pageSize?: number; + /** 筛选条件由外部持有(配合 filter 插槽),变化即回到第一页重查 */ + params?: Record; + /** 取哪个字段当选中标识 */ + rowKey?: string; + selectable?: boolean; + selected?: (number | string)[]; +} diff --git a/apps/web-antd/src/components/search-select/index.ts b/apps/web-antd/src/components/search-select/index.ts new file mode 100644 index 00000000..82d585aa --- /dev/null +++ b/apps/web-antd/src/components/search-select/index.ts @@ -0,0 +1,2 @@ +export type { SearchSelectOption } from './types'; +export { default as SearchSelect } from './search-select.vue'; diff --git a/apps/web-antd/src/components/search-select/search-select.vue b/apps/web-antd/src/components/search-select/search-select.vue new file mode 100644 index 00000000..f9c5de18 --- /dev/null +++ b/apps/web-antd/src/components/search-select/search-select.vue @@ -0,0 +1,306 @@ + + + diff --git a/apps/web-antd/src/components/search-select/types.ts b/apps/web-antd/src/components/search-select/types.ts new file mode 100644 index 00000000..3d8d3c12 --- /dev/null +++ b/apps/web-antd/src/components/search-select/types.ts @@ -0,0 +1,7 @@ +export interface SearchSelectOption { + [key: string]: unknown; + id?: number | string; + name?: string; + /** 企业默认价格倍率,命中时在候选卡片上显示 */ + price_number?: string; +} diff --git a/apps/web-antd/src/layouts/auth.vue b/apps/web-antd/src/layouts/auth.vue index 8ba66e85..1a1c685e 100644 --- a/apps/web-antd/src/layouts/auth.vue +++ b/apps/web-antd/src/layouts/auth.vue @@ -4,6 +4,8 @@ import { computed } from 'vue'; import { AuthPageLayout } from '@vben/layouts'; import { preferences } from '@vben/preferences'; +import { currentBrand } from '#/brand'; +import BrandLogo from '#/components/brand-logo.vue'; import { $t } from '#/locales'; const appName = computed(() => preferences.app.name); @@ -17,9 +19,15 @@ const logoDark = computed(() => preferences.logo.sourceDark); :logo="logo" :logo-dark="logoDark" :page-description="$t('authentication.pageDesc')" - :page-title="$t('authentication.pageTitle')" + :page-title="currentBrand.slogan" > - - + + diff --git a/apps/web-antd/src/main.ts b/apps/web-antd/src/main.ts index 110acbd7..7f8d5516 100644 --- a/apps/web-antd/src/main.ts +++ b/apps/web-antd/src/main.ts @@ -1,6 +1,7 @@ -import { initPreferences } from '@vben/preferences'; +import { initPreferences, updatePreferences } from '@vben/preferences'; import { unmountGlobalLoading } from '@vben/utils'; +import { currentBrand } from './brand'; import { overridesPreferences, preferencesExtension } from './preferences'; /** @@ -20,6 +21,17 @@ async function initApplication() { overrides: overridesPreferences, }); + // 品牌 Logo 必须跟构建品牌走,不能被 localStorage 里旧的 source 盖掉 + // (缓存优先合并时,坏掉的路径会一直显示裂图) + updatePreferences({ + logo: { + source: currentBrand.logo, + sourceDark: currentBrand.logoDark, + fit: 'contain', + size: 44, + }, + }); + // 启动应用并挂载 // vue应用主要逻辑及视图 const { bootstrap } = await import('./bootstrap'); diff --git a/apps/web-antd/src/preferences.ts b/apps/web-antd/src/preferences.ts index 8ee7659a..880cf9a5 100644 --- a/apps/web-antd/src/preferences.ts +++ b/apps/web-antd/src/preferences.ts @@ -4,6 +4,8 @@ import { definePreferencesExtension, } from '@vben/preferences'; +import { currentBrand } from './brand'; + interface WebAntdPreferencesExtension { defaultTableSize: number; enableFormFullscreen: boolean; @@ -21,7 +23,8 @@ export const overridesPreferences = defineOverridesPreferences({ app: { // 权限模式 frontend 默认前端控制 backend 后端控制 accessMode: 'backend', - name: import.meta.env.VITE_APP_TITLE, + // 品牌名优先于 env 标题,避免两处配置各写一份 + name: currentBrand.name, layout: 'sidebar-mixed-nav', // 是否开启检查更新 enableCheckUpdates: true, @@ -32,15 +35,20 @@ export const overridesPreferences = defineOverridesPreferences({ }, logo: { enable: true, - // 品牌 logo(年糕猫+奶酪):源文件在 public/logo.png,登录页/侧边栏/顶栏共用 - source: '/logo.png', + // 按品牌切换:源文件在 public/brand/;亮暗各一套,跟后台主题而非系统配色 + source: currentBrand.logo, + sourceDark: currentBrand.logoDark, + // 侧栏方形标默认偏小(框架 32),这里放大到 44,仅本应用覆盖 + size: 44, + // 线框 SVG 必须 contain,cover 会裁切并易触发 Avatar 回退怪相 + fit: 'contain', }, sidebar: { width: 220, }, theme: { - builtinType: 'violet', - colorPrimary: 'hsl(245 82% 67%)', + builtinType: currentBrand.builtinType, + colorPrimary: currentBrand.colorPrimary, mode: 'auto', }, copyright: appCopyrightPreferences, diff --git a/apps/web-antd/src/util/media.ts b/apps/web-antd/src/util/media.ts new file mode 100644 index 00000000..4494f5ea --- /dev/null +++ b/apps/web-antd/src/util/media.ts @@ -0,0 +1,45 @@ +/** + * 图片 URL 处理 + * + * 水印不是后端接口,是拼 OSS 的处理参数(七牛 / COS 同语法)。 + * 小程序详情页预览大图时就是这么拼的,后台预览要对齐, + * 否则运营在后台看到的是无水印图、客户看到的是带水印图。 + */ + +/** 七牛 URL 安全 base64:+ → -,/ → _ */ +export function urlSafeBase64Encode(text: string): string { + const bytes = new TextEncoder().encode(text); + let binary = ''; + bytes.forEach((byte) => { + binary += String.fromCodePoint(byte); + }); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_'); +} + +/** 给图片加文字水印,text 为空则原样返回 */ +export function watermarkUrl(url: string, text: string, fontSize = 800) { + if (!url || !text) { + return url; + } + const encoded = urlSafeBase64Encode(text); + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}watermark/2/text/${encoded}/fontsize/${fontSize}`; +} + +/** 缩略图,列表页用,避免直接加载原图 */ +export function thumbUrl(url: string, width = 200) { + if (!url) { + return url; + } + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}imageView2/2/w/${width}`; +} + +/** 去掉处理参数,拿到干净的对象地址,素材库按它做引用匹配 */ +export function stripProcessParams(url: string) { + if (!url) { + return ''; + } + const index = url.indexOf('?'); + return index === -1 ? url : url.slice(0, index); +} diff --git a/apps/web-antd/src/views/color/card-class/api/index.ts b/apps/web-antd/src/views/color/card-class/api/index.ts new file mode 100644 index 00000000..6d20d19c --- /dev/null +++ b/apps/web-antd/src/views/color/card-class/api/index.ts @@ -0,0 +1,8 @@ +import { createCrudApi } from '#/components/crud'; + +export const cardClassApi = createCrudApi('card-class'); + +/** 色卡分类下拉 */ +export async function getCardClassOption() { + return (await cardClassApi.option()) ?? []; +} diff --git a/apps/web-antd/src/views/color/card-class/index.vue b/apps/web-antd/src/views/color/card-class/index.vue new file mode 100644 index 00000000..55242891 --- /dev/null +++ b/apps/web-antd/src/views/color/card-class/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/apps/web-antd/src/views/color/card/api/index.ts b/apps/web-antd/src/views/color/card/api/index.ts new file mode 100644 index 00000000..fd504028 --- /dev/null +++ b/apps/web-antd/src/views/color/card/api/index.ts @@ -0,0 +1,3 @@ +import { createCrudApi } from '#/components/crud'; + +export const colorcardApi = createCrudApi('colorcard'); diff --git a/apps/web-antd/src/views/color/card/components/modal.vue b/apps/web-antd/src/views/color/card/components/modal.vue new file mode 100644 index 00000000..29a704f5 --- /dev/null +++ b/apps/web-antd/src/views/color/card/components/modal.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/web-antd/src/views/color/card/config/form.ts b/apps/web-antd/src/views/color/card/config/form.ts new file mode 100644 index 00000000..8b04dd9e --- /dev/null +++ b/apps/web-antd/src/views/color/card/config/form.ts @@ -0,0 +1,81 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCardClassOption } from '#/views/color/card-class/api'; +import { getCompanyOption } from '#/views/color/company/api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-6', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'ApiSelect', + componentProps: { + api: getCardClassOption, + labelField: 'name', + placeholder: '请选择分类', + valueField: 'id', + }, + fieldName: 'card_class', + label: '色卡分类', + rules: 'selectRequired', + }, + { + component: 'ApiSelect', + componentProps: { + api: getCompanyOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择公司', + showSearch: true, + valueField: 'id', + }, + fieldName: 'company', + label: '所属公司', + rules: 'selectRequired', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 280' }, + fieldName: 'price', + label: '价格', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + { + component: 'Textarea', + componentProps: { placeholder: '色卡说明,如颜色名称、编号', rows: 2 }, + fieldName: 'description', + formItemClass: 'col-span-12', + label: '色卡说明', + }, + { + component: 'UploadImage', + componentProps: { maxCount: 1, multiple: false }, + fieldName: 'cover', + formItemClass: 'col-span-12', + label: '色卡图', + rules: 'required', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/color/card/config/search.ts b/apps/web-antd/src/views/color/card/config/search.ts new file mode 100644 index 00000000..428b609a --- /dev/null +++ b/apps/web-antd/src/views/color/card/config/search.ts @@ -0,0 +1,59 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCardClassOption } from '#/views/color/card-class/api'; +import { getCompanyOption } from '#/views/color/company/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getCardClassOption, + labelField: 'name', + placeholder: '请选择分类', + valueField: 'id', + }, + fieldName: 'card_class', + label: '色卡分类', + }, + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getCompanyOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择公司', + showSearch: true, + valueField: 'id', + }, + fieldName: 'company', + label: '所属公司', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入色卡说明' }, + fieldName: 'description', + label: '色卡说明', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/color/card/config/table.ts b/apps/web-antd/src/views/color/card/config/table.ts new file mode 100644 index 00000000..092a5737 --- /dev/null +++ b/apps/web-antd/src/views/color/card/config/table.ts @@ -0,0 +1,22 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { colorcardApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'cover', title: '色卡图', width: 90, slots: { default: 'cover' } }, + { field: 'description', align: 'left', title: '色卡说明', minWidth: 200 }, + { field: 'card_class_name', title: '所属分类', width: 140 }, + { field: 'company_name', title: '所属公司', width: 160 }, + { field: 'price', title: '价格', width: 110 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(160), + ], + query: (params) => colorcardApi.list(params), +}); diff --git a/apps/web-antd/src/views/color/card/index.vue b/apps/web-antd/src/views/color/card/index.vue new file mode 100644 index 00000000..97380b48 --- /dev/null +++ b/apps/web-antd/src/views/color/card/index.vue @@ -0,0 +1,97 @@ + + + diff --git a/apps/web-antd/src/views/color/company/api/index.ts b/apps/web-antd/src/views/color/company/api/index.ts new file mode 100644 index 00000000..340f4513 --- /dev/null +++ b/apps/web-antd/src/views/color/company/api/index.ts @@ -0,0 +1,8 @@ +import { createCrudApi } from '#/components/crud'; + +export const companyApi = createCrudApi('company'); + +/** 公司下拉 */ +export async function getCompanyOption() { + return (await companyApi.option()) ?? []; +} diff --git a/apps/web-antd/src/views/color/company/components/modal.vue b/apps/web-antd/src/views/color/company/components/modal.vue new file mode 100644 index 00000000..60b82da9 --- /dev/null +++ b/apps/web-antd/src/views/color/company/components/modal.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/web-antd/src/views/color/company/config/form.ts b/apps/web-antd/src/views/color/company/config/form.ts new file mode 100644 index 00000000..f09336b6 --- /dev/null +++ b/apps/web-antd/src/views/color/company/config/form.ts @@ -0,0 +1,50 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入公司名称' }, + fieldName: 'name', + label: '公司名称', + rules: 'required', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入联系电话' }, + fieldName: 'phone', + label: '联系电话', + }, + { + component: 'Textarea', + componentProps: { placeholder: '请输入公司地址', rows: 2 }, + fieldName: 'address', + label: '公司地址', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/color/company/config/search.ts b/apps/web-antd/src/views/color/company/config/search.ts new file mode 100644 index 00000000..6eadf762 --- /dev/null +++ b/apps/web-antd/src/views/color/company/config/search.ts @@ -0,0 +1,37 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入公司名称' }, + defaultValue: '', + fieldName: 'name', + label: '公司名称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入联系电话' }, + fieldName: 'phone', + label: '联系电话', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/color/company/config/table.ts b/apps/web-antd/src/views/color/company/config/table.ts new file mode 100644 index 00000000..cd068fa4 --- /dev/null +++ b/apps/web-antd/src/views/color/company/config/table.ts @@ -0,0 +1,20 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { companyApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 90 }, + { field: 'name', align: 'left', title: '公司名称', minWidth: 200 }, + { field: 'phone', title: '联系电话', width: 150 }, + { field: 'address', align: 'left', title: '地址', minWidth: 220 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(160), + ], + query: (params) => companyApi.list(params), +}); diff --git a/apps/web-antd/src/views/color/company/index.vue b/apps/web-antd/src/views/color/company/index.vue new file mode 100644 index 00000000..82cebbce --- /dev/null +++ b/apps/web-antd/src/views/color/company/index.vue @@ -0,0 +1,91 @@ + + + diff --git a/apps/web-antd/src/views/customer/dealer/config/search.ts b/apps/web-antd/src/views/customer/dealer/config/search.ts new file mode 100644 index 00000000..ca81d959 --- /dev/null +++ b/apps/web-antd/src/views/customer/dealer/config/search.ts @@ -0,0 +1,23 @@ +import type { VbenFormProps } from '#/adapter/form'; + +/** 经销商页搜索:昵称 / 手机号 */ +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'Input', + fieldName: 'nick_name', + label: '昵称', + componentProps: { allowClear: true, placeholder: '昵称' }, + }, + { + component: 'Input', + fieldName: 'phone', + label: '手机号', + componentProps: { allowClear: true, placeholder: '手机号' }, + }, + ], + showCollapseButton: false, + submitButtonOptions: { content: '查询' }, + wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', +}; diff --git a/apps/web-antd/src/views/customer/dealer/config/table.ts b/apps/web-antd/src/views/customer/dealer/config/table.ts new file mode 100644 index 00000000..c7d96bb4 --- /dev/null +++ b/apps/web-antd/src/views/customer/dealer/config/table.ts @@ -0,0 +1,40 @@ +import { actionColumn, createGridOptions } from '#/components/crud'; + +import { wxUserApi } from '#/views/customer/wx-user/api'; + +/** 经销商列表:固定 is_p=1 */ +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'avatar', title: '头像', width: 80, slots: { default: 'avatar' } }, + { field: 'nick_name', align: 'left', title: '昵称', minWidth: 140 }, + { field: 'phone', title: '手机号', width: 140 }, + { + field: 'enterprise_name', + title: '所属企业', + minWidth: 140, + slots: { default: 'enterprise' }, + }, + { + field: 'show_price', + title: '可见价格', + width: 110, + slots: { default: 'showPrice' }, + }, + { + field: 'price_number', + title: '价格倍率', + width: 110, + slots: { default: 'multiplier' }, + }, + { + field: 'template_code', + title: '专属模板', + minWidth: 220, + slots: { default: 'template' }, + }, + { field: 'created_at', title: '注册时间', width: 180 }, + actionColumn(320), + ], + query: (params) => wxUserApi.list({ ...params, is_p: 1 }), +}); diff --git a/apps/web-antd/src/views/customer/dealer/index.vue b/apps/web-antd/src/views/customer/dealer/index.vue new file mode 100644 index 00000000..792b3b60 --- /dev/null +++ b/apps/web-antd/src/views/customer/dealer/index.vue @@ -0,0 +1,218 @@ + + + diff --git a/apps/web-antd/src/views/customer/enterprise/api/index.ts b/apps/web-antd/src/views/customer/enterprise/api/index.ts new file mode 100644 index 00000000..1aef99cd --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/api/index.ts @@ -0,0 +1,22 @@ +import { createCrudApi } from '#/components/crud'; + +export const enterpriseApi = createCrudApi('enterprise'); + +/** 企业下拉;SearchSelect 会带 keyword 做模糊搜索 */ +export async function searchEnterprise(params: { + keyword: string; + limit: number; +}) { + return (await enterpriseApi.option(params)) ?? []; +} + +/** 结算方式字典,列表与表单共用 */ +export const SETTLE_TYPES = [ + { label: '款到发货', value: 0 }, + { label: '月结', value: 1 }, + { label: '账期', value: 2 }, +]; + +export function settleTypeText(value?: number) { + return SETTLE_TYPES.find((item) => item.value === value)?.label ?? '-'; +} diff --git a/apps/web-antd/src/views/customer/enterprise/components/detail-drawer.vue b/apps/web-antd/src/views/customer/enterprise/components/detail-drawer.vue new file mode 100644 index 00000000..4fd22b05 --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/components/detail-drawer.vue @@ -0,0 +1,94 @@ + + + diff --git a/apps/web-antd/src/views/customer/enterprise/components/modal.vue b/apps/web-antd/src/views/customer/enterprise/components/modal.vue new file mode 100644 index 00000000..47376152 --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/components/modal.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/web-antd/src/views/customer/enterprise/config/form.ts b/apps/web-antd/src/views/customer/enterprise/config/form.ts new file mode 100644 index 00000000..9ebf51de --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/config/form.ts @@ -0,0 +1,93 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { SETTLE_TYPES } from '../api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-6', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入企业名称' }, + fieldName: 'name', + label: '企业名称', + rules: 'required', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入联系人' }, + fieldName: 'contact_name', + label: '联系人', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入联系电话' }, + fieldName: 'phone', + label: '联系电话', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '开票税号' }, + fieldName: 'tax_no', + label: '税号', + }, + { + component: 'Select', + componentProps: { options: SETTLE_TYPES }, + defaultValue: 0, + fieldName: 'settle_type', + label: '结算方式', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 1.2,用于该企业下用户的默认报价倍率' }, + defaultValue: '1', + fieldName: 'price_number', + label: '默认倍率', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '正常', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + { + component: 'Textarea', + componentProps: { placeholder: '请输入企业地址', rows: 2 }, + fieldName: 'address', + formItemClass: 'col-span-12', + label: '企业地址', + }, + { + component: 'Textarea', + componentProps: { placeholder: '备注', rows: 2 }, + fieldName: 'remark', + formItemClass: 'col-span-12', + label: '备注', + }, + { + component: 'UploadImage', + componentProps: { maxCount: 1, multiple: false }, + fieldName: 'logo', + formItemClass: 'col-span-12', + label: '企业 LOGO', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/customer/enterprise/config/search.ts b/apps/web-antd/src/views/customer/enterprise/config/search.ts new file mode 100644 index 00000000..917d2074 --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/config/search.ts @@ -0,0 +1,43 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入企业名称' }, + defaultValue: '', + fieldName: 'name', + label: '企业名称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入联系人' }, + fieldName: 'contact_name', + label: '联系人', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入联系电话' }, + fieldName: 'phone', + label: '联系电话', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '正常', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/customer/enterprise/config/table.ts b/apps/web-antd/src/views/customer/enterprise/config/table.ts new file mode 100644 index 00000000..180cc4e4 --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/config/table.ts @@ -0,0 +1,34 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { enterpriseApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'logo', title: 'LOGO', width: 90, slots: { default: 'logo' } }, + { field: 'name', align: 'left', title: '企业名称', minWidth: 200 }, + { field: 'contact_name', title: '联系人', width: 110 }, + { field: 'phone', title: '联系电话', width: 140 }, + { + field: 'settle_type', + title: '结算方式', + width: 110, + slots: { default: 'settle' }, + }, + { field: 'price_number', title: '默认倍率', width: 100 }, + { + field: 'user_count', + title: '关联用户', + width: 100, + slots: { default: 'users' }, + }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(220), + ], + query: (params) => enterpriseApi.list(params), +}); diff --git a/apps/web-antd/src/views/customer/enterprise/index.vue b/apps/web-antd/src/views/customer/enterprise/index.vue new file mode 100644 index 00000000..ea050ca5 --- /dev/null +++ b/apps/web-antd/src/views/customer/enterprise/index.vue @@ -0,0 +1,127 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/api/index.ts b/apps/web-antd/src/views/customer/list/api/index.ts new file mode 100644 index 00000000..9d4b5933 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/api/index.ts @@ -0,0 +1,50 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +/** + * 清单(cc_list)的 API 封装。 + * 后端 ListController 走反射注册,前缀为 `list`,所以基础 CRUD 直接复用 createCrudApi。 + * 清单的状态、明细、转订单都由后端 ListService 单独处理,前端各自声明一个函数。 + */ +export const listApi = createCrudApi('list'); + +/** + * 某用户的全部清单:用户详情模态框里用,列「该用户的所有清单」tab。 + * 后端按 deleted_at 过滤并聚合了 item_count。 + */ +export async function getListByUser(userId: number): Promise { + return ( + (await requestClient.get('list/by-user', { + params: { user_id: userId }, + })) ?? [] + ); +} + +/** + * 清单生成订单:后台代客下单。 + * 后端走 OrderCoreService::createFromList,金额按该用户倍率算。 + * params 里收货信息是可选的,缺省时后端用用户的昵称/手机兜底。 + */ +export async function listToOrder(data: { + id: number; + delivery_type?: number; + receiver_name?: string; + receiver_phone?: string; + receiver_address?: string; + remark?: string; +}) { + return requestClient.post('list/to-order', data); +} + +/** + * 保存清单明细:后台帮客户补规格/数量/单价。 + * 必须传 id,其余字段可选。 + */ +export async function saveListItem(data: Record) { + return requestClient.post('list/save-item', data); +} + +/** 删除清单明细:软删,后端置 deleted_at。 */ +export async function deleteListItem(ids: number[]) { + return requestClient.post('list/delete-item', { ids }); +} diff --git a/apps/web-antd/src/views/customer/list/components/detail-drawer.vue b/apps/web-antd/src/views/customer/list/components/detail-drawer.vue new file mode 100644 index 00000000..e5cc1da8 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/detail-drawer.vue @@ -0,0 +1,204 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/components/modal.vue b/apps/web-antd/src/views/customer/list/components/modal.vue new file mode 100644 index 00000000..2bab8908 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/components/modal.vue @@ -0,0 +1,64 @@ + + + diff --git a/apps/web-antd/src/views/customer/list/config/constants.ts b/apps/web-antd/src/views/customer/list/config/constants.ts new file mode 100644 index 00000000..9f9e1449 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/config/constants.ts @@ -0,0 +1,28 @@ +/** + * 清单状态枚举。 + * cc_list.status:0=待处理(还没转订单),1=已转订单。 + * 后端 toOrder 时会把 status 置 1,清单页据此展示「已下单/未下单」。 + */ +export const LIST_STATUS_OPTIONS = [ + { label: '待处理', value: 0 }, + { label: '已转订单', value: 1 }, +]; + +/** 状态值 → 中文标签,列表里把数字转文字时用。 */ +export const LIST_STATUS_TEXT: Record = LIST_STATUS_OPTIONS.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, +); + +/** + * 配送方式枚举(与 cc_order.delivery_type 对齐,订单详情抽屉里下单时用)。 + * 1=物流快递,2=上门自提,3=公司配送。值与后端 OrderModel::DELIVERY_* 常量一致。 + */ +export const DELIVERY_TYPE_OPTIONS = [ + { label: '物流快递', value: 1 }, + { label: '上门自提', value: 2 }, + { label: '公司配送', value: 3 }, +]; diff --git a/apps/web-antd/src/views/customer/list/config/form.ts b/apps/web-antd/src/views/customer/list/config/form.ts new file mode 100644 index 00000000..cd554502 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/config/form.ts @@ -0,0 +1,58 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { searchEnterprise } from '#/views/customer/enterprise/api'; + +/** + * 清单新增/编辑表单。 + * + * 后端约定: + * - create 必填 name + user_id(insertField) + * - update 仅允许改 name(updateField 限制了 id + name) + * - notRequest 里的 remark / enterprise_id 在 create/update 都可选 + * + * user_id 由弹窗里的 SearchSelect 选出来,不放进 schema(避免双向同步 bug)。 + * 清单号由后端 SerialNoService 生成,前端不提供。 + */ +export const editFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '给清单起个名字' }, + fieldName: 'name', + label: '清单名称', + rules: 'required', + }, + { + component: 'ApiSelect', + componentProps: { + api: () => searchEnterprise({ keyword: '', limit: 100 }), + filterOption: true, + labelField: 'name', + placeholder: '请选择企业', + showSearch: true, + valueField: 'id', + }, + fieldName: 'enterprise_id', + label: '所属企业', + }, + { + component: 'VbenTextarea', + componentProps: { placeholder: '备注(可选)', rows: 3 }, + fieldName: 'remark', + label: '备注', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/customer/list/config/search.ts b/apps/web-antd/src/views/customer/list/config/search.ts new file mode 100644 index 00000000..55b0e062 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/config/search.ts @@ -0,0 +1,66 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { searchEnterprise } from '#/views/customer/enterprise/api'; + +import { LIST_STATUS_OPTIONS } from './constants'; + +/** + * 清单列表的顶部搜索表单。 + * 后端支持 list_no / name / user_id / enterprise_id / status 五个查询字段, + * 以及一个特殊的 user_keyword(按客户昵称或手机号模糊搜对应 user_id 集合)。 + * 这里给客户搜索框用 user_keyword(按用户昵称/手机),企业用 ApiSelect 直接出 enterprise_id。 + */ +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入清单号' }, + defaultValue: '', + fieldName: 'list_no', + label: '清单号', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入清单名称' }, + defaultValue: '', + fieldName: 'name', + label: '名称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '昵称或手机号' }, + defaultValue: '', + fieldName: 'user_keyword', + label: '客户', + }, + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: () => searchEnterprise({ keyword: '', limit: 100 }), + filterOption: true, + labelField: 'name', + placeholder: '请选择企业', + showSearch: true, + valueField: 'id', + }, + fieldName: 'enterprise_id', + label: '所属企业', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: LIST_STATUS_OPTIONS, + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/customer/list/config/table.ts b/apps/web-antd/src/views/customer/list/config/table.ts new file mode 100644 index 00000000..5618e27c --- /dev/null +++ b/apps/web-antd/src/views/customer/list/config/table.ts @@ -0,0 +1,41 @@ +import { actionColumn, createGridOptions } from '#/components/crud'; + +import { listApi } from '../api'; + +/** + * 清单列表的表格列定义。 + * 后端 ListService::list 额外返回了 item_count / quantity / order_count / user_name / user_phone / enterprise_name, + * 这些是聚合字段(明细数、商品数、订单数)和关联字段(客户昵称、手机、企业名), + * 必须展示出来,否则后台看不到清单的实际内容量。 + */ +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'list_no', align: 'left', title: '清单号', width: 180 }, + { field: 'name', align: 'left', title: '清单名称', minWidth: 180 }, + { + field: 'user_name', + title: '客户', + width: 160, + slots: { default: 'user' }, + }, + { field: 'user_phone', title: '客户手机', width: 140 }, + { + field: 'enterprise_name', + title: '所属企业', + minWidth: 160, + }, + { field: 'item_count', title: '商品种类', width: 100 }, + { field: 'quantity', title: '商品件数', width: 100 }, + { field: 'order_count', title: '已下订单', width: 100 }, + { + field: 'status', + title: '状态', + width: 110, + slots: { default: 'status' }, + }, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(220), + ], + query: (params) => listApi.list(params), +}); diff --git a/apps/web-antd/src/views/customer/list/index.vue b/apps/web-antd/src/views/customer/list/index.vue new file mode 100644 index 00000000..090be909 --- /dev/null +++ b/apps/web-antd/src/views/customer/list/index.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/web-antd/src/views/customer/order/api/index.ts b/apps/web-antd/src/views/customer/order/api/index.ts new file mode 100644 index 00000000..7f908012 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/api/index.ts @@ -0,0 +1,62 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +/** + * 订单(cc_order)的 API 封装。 + * 后端 OrderController 走反射注册,前缀 `order`,基础 CRUD 走 createCrudApi。 + * 订单的核心是状态机与支付/发货流程,单独声明: + * - stat: 概览统计(顶部统计条) + * - byUser: 某用户订单(用户详情模态框里用) + * - auditPayment: 审核转账凭证 + * - ship: 发货 + * - cancel: 取消 + * - complete: 完成(确认收货) + */ +export const orderApi = createCrudApi('order'); + +/** 概览统计:各状态数量与金额,订单页顶部统计条用 */ +export async function getOrderStat() { + return requestClient.get('order/stat'); +} + +/** 某用户的订单(用户详情模态框) */ +export async function getListByOrder(userId: number): Promise { + return ( + (await requestClient.get('order/by-user', { + params: { user_id: userId }, + })) ?? [] + ); +} + +/** + * 审核转账凭证。 + * @param paymentId 支付记录 ID(不是订单 ID) + * @param pass true=通过(确认收款),false=拒绝 + * @param remark 审核备注 + */ +export async function auditPayment(data: { + payment_id: number; + pass: boolean; + remark?: string; +}) { + return requestClient.post('order/audit-payment', { + payment_id: data.payment_id, + status: data.pass ? 1 : 0, + remark: data.remark ?? '', + }); +} + +/** 发货:物流/自提/公司配送 */ +export async function shipOrder(data: Record) { + return requestClient.post('order/ship', data); +} + +/** 取消订单(仅未付款或已付款未发货可取消) */ +export async function cancelOrder(id: number) { + return requestClient.post('order/cancel', { id }); +} + +/** 完成订单(确认收货,已发货状态可用) */ +export async function completeOrder(id: number) { + return requestClient.post('order/complete', { id }); +} diff --git a/apps/web-antd/src/views/customer/order/components/detail-drawer.vue b/apps/web-antd/src/views/customer/order/components/detail-drawer.vue new file mode 100644 index 00000000..9ae0c35d --- /dev/null +++ b/apps/web-antd/src/views/customer/order/components/detail-drawer.vue @@ -0,0 +1,378 @@ + + + diff --git a/apps/web-antd/src/views/customer/order/components/modal.vue b/apps/web-antd/src/views/customer/order/components/modal.vue new file mode 100644 index 00000000..7e1c4a83 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/components/modal.vue @@ -0,0 +1,26 @@ + + + diff --git a/apps/web-antd/src/views/customer/order/components/ship-modal.vue b/apps/web-antd/src/views/customer/order/components/ship-modal.vue new file mode 100644 index 00000000..bd27d956 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/components/ship-modal.vue @@ -0,0 +1,113 @@ + + + diff --git a/apps/web-antd/src/views/customer/order/config/constants.ts b/apps/web-antd/src/views/customer/order/config/constants.ts new file mode 100644 index 00000000..d4cf6e62 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/config/constants.ts @@ -0,0 +1,96 @@ +/** + * 订单状态、支付状态、支付方式、配送方式枚举。 + * 值必须与后端 OrderModel 的常量严格一致: + * STATUS_UNPAID=0, STATUS_PAID=1, STATUS_SHIPPED=2, STATUS_DONE=3, STATUS_CANCELLED=4 + * PAY_STATUS_UNPAID=0, PAY_STATUS_AUDITING=1, PAY_STATUS_PAID=2, PAY_STATUS_REJECTED=3 + * PAY_TYPE_VOUCHER=1, PAY_TYPE_WECHAT=2 + * DELIVERY_EXPRESS=1, DELIVERY_PICKUP=2, DELIVERY_COMPANY=3 + * 后端 OrderService 的 queryField 支持按这些字段过滤。 + */ + +export const ORDER_STATUS_OPTIONS = [ + { label: '待付款', value: 0 }, + { label: '已付款', value: 1 }, + { label: '已发货', value: 2 }, + { label: '已完成', value: 3 }, + { label: '已取消', value: 4 }, +]; + +export const ORDER_STATUS_TEXT: Record = + ORDER_STATUS_OPTIONS.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, + ); + +/** 状态 → Tag 颜色,列表里展示状态时用 */ +export const ORDER_STATUS_COLOR: Record = { + 0: 'warning', + 1: 'processing', + 2: 'blue', + 3: 'success', + 4: 'default', +}; + +export const ORDER_PAY_STATUS_OPTIONS = [ + { label: '未支付', value: 0 }, + { label: '待审核', value: 1 }, + { label: '已支付', value: 2 }, + { label: '已拒绝', value: 3 }, +]; + +export const ORDER_PAY_STATUS_TEXT: Record = + ORDER_PAY_STATUS_OPTIONS.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, + ); + +/** + * 单笔支付记录(cc_order_payment)的状态枚举。 + * + * 注意与上面的 ORDER_PAY_STATUS(订单级 pay_status)区分: + * - 订单级 pay_status:0=未支付/1=待审核/2=已支付/3=已拒绝 + * - 支付记录级 status:0=待审核/1=已确认/2=已拒绝 + * 两套值空间不同,详情抽屉里渲染单笔 payment 记录必须用本枚举, + * 否则会出现「待审核显示成未支付、已确认显示成待审核」的全错位。 + * 对应后端 OrderPaymentModel::STATUS_AUDITING/CONFIRMED/REJECTED。 + */ +export const PAYMENT_STATUS_OPTIONS = [ + { label: '待审核', value: 0 }, + { label: '已确认', value: 1 }, + { label: '已拒绝', value: 2 }, +]; + +export const PAYMENT_STATUS_TEXT: Record = + PAYMENT_STATUS_OPTIONS.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, + ); + +export const ORDER_PAY_TYPE_TEXT: Record = { + 1: '转账凭证', + 2: '微信支付', +}; + +export const DELIVERY_TYPE_OPTIONS = [ + { label: '物流快递', value: 1 }, + { label: '上门自提', value: 2 }, + { label: '公司配送', value: 3 }, +]; + +export const DELIVERY_TYPE_TEXT: Record = + DELIVERY_TYPE_OPTIONS.reduce( + (acc, item) => { + acc[item.value] = item.label; + return acc; + }, + {} as Record, + ); diff --git a/apps/web-antd/src/views/customer/order/config/form.ts b/apps/web-antd/src/views/customer/order/config/form.ts new file mode 100644 index 00000000..d7822e0d --- /dev/null +++ b/apps/web-antd/src/views/customer/order/config/form.ts @@ -0,0 +1,62 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { DELIVERY_TYPE_OPTIONS } from './constants'; + +/** + * 订单编辑表单。 + * + * 后端 OrderService::update 仅允许改收件信息与备注(金额与状态走各自业务入口): + * receiver_name / receiver_phone / receiver_address / remark / delivery_type + * 订单号、金额、状态、用户都不能在此改。 + * 新建走 list/to-order,不在此弹窗。 + */ +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '收件人姓名' }, + fieldName: 'receiver_name', + label: '收件人', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '联系电话' }, + fieldName: 'receiver_phone', + label: '联系电话', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '收货地址' }, + fieldName: 'receiver_address', + label: '收货地址', + }, + { + component: 'Select', + componentProps: { + options: DELIVERY_TYPE_OPTIONS, + placeholder: '请选择配送方式', + }, + fieldName: 'delivery_type', + label: '配送方式', + }, + { + component: 'VbenTextarea', + componentProps: { placeholder: '备注', rows: 3 }, + fieldName: 'remark', + label: '备注', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/customer/order/config/search.ts b/apps/web-antd/src/views/customer/order/config/search.ts new file mode 100644 index 00000000..cb0a9fb1 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/config/search.ts @@ -0,0 +1,72 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { searchEnterprise } from '#/views/customer/enterprise/api'; + +import { + ORDER_PAY_STATUS_OPTIONS, + ORDER_STATUS_OPTIONS, +} from './constants'; + +/** + * 订单列表的顶部搜索表单。 + * 后端 OrderService::queryField 支持: + * order_no(like), user_id(=), enterprise_id(=), status(=), pay_status(=), pay_type(=), delivery_type(=), receiver_phone(like) + * 另有 user_keyword 走 ListService 那种聚合(按昵称/手机搜出 user_id 集合)。 + */ +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入订单号' }, + defaultValue: '', + fieldName: 'order_no', + label: '订单号', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '昵称或手机号' }, + defaultValue: '', + fieldName: 'user_keyword', + label: '客户', + }, + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: () => searchEnterprise({ keyword: '', limit: 100 }), + filterOption: true, + labelField: 'name', + placeholder: '请选择企业', + showSearch: true, + valueField: 'id', + }, + fieldName: 'enterprise_id', + label: '所属企业', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: ORDER_STATUS_OPTIONS, + placeholder: '请选择订单状态', + }, + fieldName: 'status', + label: '订单状态', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: ORDER_PAY_STATUS_OPTIONS, + placeholder: '请选择支付状态', + }, + fieldName: 'pay_status', + label: '支付状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/customer/order/config/table.ts b/apps/web-antd/src/views/customer/order/config/table.ts new file mode 100644 index 00000000..aa559786 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/config/table.ts @@ -0,0 +1,39 @@ +import { actionColumn, createGridOptions } from '#/components/crud'; + +import { orderApi } from '../api'; + +/** + * 订单列表的表格列定义。 + * 后端 OrderService::list 额外返回 user_name / user_phone / enterprise_name / total_amount_text / paid_amount_text。 + * total_amount 是整数分,展示必须用 _text 后缀(已转元),不要直接展示分。 + */ +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'order_no', align: 'left', title: '订单号', width: 200 }, + { + field: 'user_name', + title: '客户', + width: 160, + slots: { default: 'user' }, + }, + { field: 'enterprise_name', title: '企业', minWidth: 140 }, + { field: 'total_amount_text', title: '总金额', width: 110 }, + { field: 'paid_amount_text', title: '已付金额', width: 110 }, + { + field: 'pay_status', + title: '支付状态', + width: 110, + slots: { default: 'payStatus' }, + }, + { + field: 'status', + title: '订单状态', + width: 110, + slots: { default: 'status' }, + }, + { field: 'created_at', title: '下单时间', width: 180 }, + actionColumn(260), + ], + query: (params) => orderApi.list(params), +}); diff --git a/apps/web-antd/src/views/customer/order/index.vue b/apps/web-antd/src/views/customer/order/index.vue new file mode 100644 index 00000000..537256a9 --- /dev/null +++ b/apps/web-antd/src/views/customer/order/index.vue @@ -0,0 +1,185 @@ + + + diff --git a/apps/web-antd/src/views/customer/wx-user/api/index.ts b/apps/web-antd/src/views/customer/wx-user/api/index.ts new file mode 100644 index 00000000..0f2be681 --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/api/index.ts @@ -0,0 +1,71 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +export const wxUserApi = createCrudApi('wx-user'); + +/** + * 认定 / 取消代理商 + * + * 老接口是翻转语义(不传值、后端读当前值取反),开关点快了会和后端打反, + * 所以这里显式传目标值。 + */ +export async function setAgent(data: { id: number; is_p: number }) { + return requestClient.post('wx-user/update-user-is-p', data); +} + +/** 设置价格倍率(后端会同时打开价格可见) */ +export async function setPriceNumber(data: { id: number; number: string }) { + return requestClient.post('wx-user/update-show-price', data); +} + +/** 单独控制价格可见性,不动倍率 */ +export async function setShowPrice(data: { id: number; show_price: number }) { + return requestClient.post('wx-user/update-show-price-status', data); +} + +/** 绑定企业,enterprise_id 传 0 表示解绑 */ +export async function bindEnterprise(data: { + enterprise_id: number; + id: number; +}) { + return requestClient.post('wx-user/bind-user', data); +} + +/** 经销商绑定专属装修模板;空字符串=跟随品牌默认 */ +export async function bindTemplate(data: { + id: number; + template_code: string; +}) { + return requestClient.post('wx-user/bind-template', data); +} + +/** 该代理商名下的下级用户 */ +export async function getChildren(id: number): Promise { + return ( + (await requestClient.get('wx-user/children', { params: { id } })) ?? [] + ); +} + +/** + * 用户搜索:给清单新增、订单代客等场景挑客户用。 + * 后端 queryField 支持 nick_name / phone 模糊,这里只把第一个非空字段当 keyword。 + * 返回与 SearchSelect 约定一致的 {id, name, phone, ...} 列表。 + */ +export async function searchWxUser(params: { + keyword: string; + limit: number; +}): Promise { + const keyword = (params.keyword ?? '').trim(); + const query: Record = { page: 1, pageSize: params.limit ?? 20 }; + if (keyword) { + // 后端按 nick_name like 过滤;手机号匹配走另一条 queryField,这里两个都试太啰嗦, + // 简单起见统一按昵称搜,需要精确手机号的走 wx-user 列表页。 + query.nick_name = keyword; + } + const res = await wxUserApi.list(query); + // SearchSelect 期望 { id, name, ... },把 nick_name 复制到 name + return (res?.items ?? []).map((item: any) => ({ + ...item, + name: item.nick_name ?? '', + })); +} diff --git a/apps/web-antd/src/views/customer/wx-user/components/bind-modal.vue b/apps/web-antd/src/views/customer/wx-user/components/bind-modal.vue new file mode 100644 index 00000000..a501c59b --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/components/bind-modal.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/web-antd/src/views/customer/wx-user/components/children-drawer.vue b/apps/web-antd/src/views/customer/wx-user/components/children-drawer.vue new file mode 100644 index 00000000..e2149740 --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/components/children-drawer.vue @@ -0,0 +1,76 @@ + + + diff --git a/apps/web-antd/src/views/customer/wx-user/components/modal.vue b/apps/web-antd/src/views/customer/wx-user/components/modal.vue new file mode 100644 index 00000000..d6cf4f22 --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/components/modal.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/web-antd/src/views/customer/wx-user/config/form.ts b/apps/web-antd/src/views/customer/wx-user/config/form.ts new file mode 100644 index 00000000..97cdec8a --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/config/form.ts @@ -0,0 +1,48 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '用户昵称' }, + fieldName: 'nick_name', + label: '昵称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '手机号' }, + fieldName: 'phone', + label: '手机号', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '可见', value: 1 }, + { label: '不可见', value: 0 }, + ], + }, + fieldName: 'show_price', + label: '价格可见', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 1.2,小程序按此倍率显示价格' }, + fieldName: 'price_number', + label: '价格倍率', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/customer/wx-user/config/search.ts b/apps/web-antd/src/views/customer/wx-user/config/search.ts new file mode 100644 index 00000000..440695da --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/config/search.ts @@ -0,0 +1,66 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { searchEnterprise } from '#/views/customer/enterprise/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入昵称' }, + defaultValue: '', + fieldName: 'nick_name', + label: '昵称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入手机号' }, + fieldName: 'phone', + label: '手机号', + }, + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: () => searchEnterprise({ keyword: '', limit: 100 }), + filterOption: true, + labelField: 'name', + placeholder: '请选择企业', + showSearch: true, + valueField: 'id', + }, + fieldName: 'enterprise_id', + label: '所属企业', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '代理商', value: 1 }, + { label: '普通用户', value: 0 }, + ], + placeholder: '请选择身份', + }, + fieldName: 'is_p', + label: '用户身份', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '可见', value: 1 }, + { label: '不可见', value: 0 }, + ], + placeholder: '价格可见性', + }, + fieldName: 'show_price', + label: '价格可见', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/customer/wx-user/config/table.ts b/apps/web-antd/src/views/customer/wx-user/config/table.ts new file mode 100644 index 00000000..599376fc --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/config/table.ts @@ -0,0 +1,34 @@ +import { actionColumn, createGridOptions } from '#/components/crud'; + +import { wxUserApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'avatar', title: '头像', width: 80, slots: { default: 'avatar' } }, + { field: 'nick_name', align: 'left', title: '昵称', minWidth: 160 }, + { field: 'phone', title: '手机号', width: 140 }, + { + field: 'enterprise_name', + title: '所属企业', + minWidth: 160, + slots: { default: 'enterprise' }, + }, + { field: 'is_p', title: '代理商', width: 110, slots: { default: 'agent' } }, + { + field: 'show_price', + title: '可见价格', + width: 110, + slots: { default: 'showPrice' }, + }, + { + field: 'price_number', + title: '价格倍率', + width: 110, + slots: { default: 'multiplier' }, + }, + { field: 'created_at', title: '注册时间', width: 180 }, + actionColumn(280), + ], + query: (params) => wxUserApi.list(params), +}); diff --git a/apps/web-antd/src/views/customer/wx-user/index.vue b/apps/web-antd/src/views/customer/wx-user/index.vue new file mode 100644 index 00000000..07304163 --- /dev/null +++ b/apps/web-antd/src/views/customer/wx-user/index.vue @@ -0,0 +1,167 @@ + + + diff --git a/apps/web-antd/src/views/factory/class/api/index.ts b/apps/web-antd/src/views/factory/class/api/index.ts new file mode 100644 index 00000000..9d3232f8 --- /dev/null +++ b/apps/web-antd/src/views/factory/class/api/index.ts @@ -0,0 +1,8 @@ +import { createCrudApi } from '#/components/crud'; + +export const factoryClassApi = createCrudApi('factory-classification'); + +/** 工厂分类下拉 */ +export async function getFactoryClassOption() { + return (await factoryClassApi.option()) ?? []; +} diff --git a/apps/web-antd/src/views/factory/class/index.vue b/apps/web-antd/src/views/factory/class/index.vue new file mode 100644 index 00000000..58f7a309 --- /dev/null +++ b/apps/web-antd/src/views/factory/class/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/apps/web-antd/src/views/factory/image/api/index.ts b/apps/web-antd/src/views/factory/image/api/index.ts new file mode 100644 index 00000000..e8f8472f --- /dev/null +++ b/apps/web-antd/src/views/factory/image/api/index.ts @@ -0,0 +1,13 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +export const factoryImageApi = createCrudApi('factory-image'); + +/** 某工厂的全部产品图 */ +export async function getFactoryImages(factoryId: number): Promise { + return ( + (await requestClient.get('factory-image/image-list', { + params: { factory: factoryId }, + })) ?? [] + ); +} diff --git a/apps/web-antd/src/views/factory/image/components/modal.vue b/apps/web-antd/src/views/factory/image/components/modal.vue new file mode 100644 index 00000000..62346b19 --- /dev/null +++ b/apps/web-antd/src/views/factory/image/components/modal.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/web-antd/src/views/factory/image/config/form.ts b/apps/web-antd/src/views/factory/image/config/form.ts new file mode 100644 index 00000000..3b7f5984 --- /dev/null +++ b/apps/web-antd/src/views/factory/image/config/form.ts @@ -0,0 +1,54 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getFactoryOption } from '#/views/factory/info/api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'ApiSelect', + componentProps: { + api: getFactoryOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择工厂', + showSearch: true, + valueField: 'id', + }, + fieldName: 'factory', + label: '所属工厂', + rules: 'selectRequired', + }, + { + component: 'UploadImage', + componentProps: { maxCount: 9, multiple: true }, + fieldName: 'url', + label: '产品图', + rules: 'required', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/factory/image/config/search.ts b/apps/web-antd/src/views/factory/image/config/search.ts new file mode 100644 index 00000000..736e6775 --- /dev/null +++ b/apps/web-antd/src/views/factory/image/config/search.ts @@ -0,0 +1,40 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getFactoryOption } from '#/views/factory/info/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getFactoryOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择工厂', + showSearch: true, + valueField: 'id', + }, + fieldName: 'factory', + label: '所属工厂', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/factory/image/config/table.ts b/apps/web-antd/src/views/factory/image/config/table.ts new file mode 100644 index 00000000..a99b12d3 --- /dev/null +++ b/apps/web-antd/src/views/factory/image/config/table.ts @@ -0,0 +1,19 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { factoryImageApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'url', title: '产品图', width: 100, slots: { default: 'image' } }, + { field: 'factory_name', align: 'left', title: '所属工厂', minWidth: 200 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(160), + ], + query: (params) => factoryImageApi.list(params), +}); diff --git a/apps/web-antd/src/views/factory/image/index.vue b/apps/web-antd/src/views/factory/image/index.vue new file mode 100644 index 00000000..b862b2ec --- /dev/null +++ b/apps/web-antd/src/views/factory/image/index.vue @@ -0,0 +1,100 @@ + + + diff --git a/apps/web-antd/src/views/factory/info/api/index.ts b/apps/web-antd/src/views/factory/info/api/index.ts new file mode 100644 index 00000000..5edb9418 --- /dev/null +++ b/apps/web-antd/src/views/factory/info/api/index.ts @@ -0,0 +1,8 @@ +import { createCrudApi } from '#/components/crud'; + +export const factoryInfoApi = createCrudApi('factory-info'); + +/** 工厂下拉 */ +export async function getFactoryOption() { + return (await factoryInfoApi.option()) ?? []; +} diff --git a/apps/web-antd/src/views/factory/info/components/album-drawer.vue b/apps/web-antd/src/views/factory/info/components/album-drawer.vue new file mode 100644 index 00000000..1612a7da --- /dev/null +++ b/apps/web-antd/src/views/factory/info/components/album-drawer.vue @@ -0,0 +1,94 @@ + + + diff --git a/apps/web-antd/src/views/factory/info/components/modal.vue b/apps/web-antd/src/views/factory/info/components/modal.vue new file mode 100644 index 00000000..0a4be800 --- /dev/null +++ b/apps/web-antd/src/views/factory/info/components/modal.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/web-antd/src/views/factory/info/config/form.ts b/apps/web-antd/src/views/factory/info/config/form.ts new file mode 100644 index 00000000..1f31e0a6 --- /dev/null +++ b/apps/web-antd/src/views/factory/info/config/form.ts @@ -0,0 +1,72 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getFactoryClassOption } from '#/views/factory/class/api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-6', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入工厂名称' }, + fieldName: 'name', + label: '工厂名称', + rules: 'required', + }, + { + component: 'ApiSelect', + componentProps: { + api: getFactoryClassOption, + labelField: 'name', + placeholder: '请选择分类', + valueField: 'id', + }, + fieldName: 'classification', + label: '工厂分类', + rules: 'selectRequired', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入联系电话' }, + fieldName: 'phone', + label: '联系电话', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + { + component: 'Textarea', + componentProps: { placeholder: '请输入工厂地址', rows: 2 }, + fieldName: 'address', + formItemClass: 'col-span-12', + label: '工厂地址', + }, + { + component: 'UploadImage', + componentProps: { maxCount: 1, multiple: false }, + fieldName: 'cover', + formItemClass: 'col-span-12', + label: '工厂封面', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/factory/info/config/search.ts b/apps/web-antd/src/views/factory/info/config/search.ts new file mode 100644 index 00000000..ccc2801b --- /dev/null +++ b/apps/web-antd/src/views/factory/info/config/search.ts @@ -0,0 +1,51 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getFactoryClassOption } from '#/views/factory/class/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入工厂名称' }, + defaultValue: '', + fieldName: 'name', + label: '工厂名称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入联系电话' }, + fieldName: 'phone', + label: '联系电话', + }, + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getFactoryClassOption, + labelField: 'name', + placeholder: '请选择分类', + valueField: 'id', + }, + fieldName: 'classification', + label: '工厂分类', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/factory/info/config/table.ts b/apps/web-antd/src/views/factory/info/config/table.ts new file mode 100644 index 00000000..1341da76 --- /dev/null +++ b/apps/web-antd/src/views/factory/info/config/table.ts @@ -0,0 +1,22 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { factoryInfoApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'cover', title: '封面', width: 90, slots: { default: 'cover' } }, + { field: 'name', align: 'left', title: '工厂名称', minWidth: 200 }, + { field: 'classification_name', title: '所属分类', width: 140 }, + { field: 'phone', title: '联系电话', width: 140 }, + { field: 'address', align: 'left', title: '地址', minWidth: 200 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(240), + ], + query: (params) => factoryInfoApi.list(params), +}); diff --git a/apps/web-antd/src/views/factory/info/index.vue b/apps/web-antd/src/views/factory/info/index.vue new file mode 100644 index 00000000..bfb979e6 --- /dev/null +++ b/apps/web-antd/src/views/factory/info/index.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/web-antd/src/views/goods/carousel/api/index.ts b/apps/web-antd/src/views/goods/carousel/api/index.ts new file mode 100644 index 00000000..0e2635e1 --- /dev/null +++ b/apps/web-antd/src/views/goods/carousel/api/index.ts @@ -0,0 +1,3 @@ +import { createCrudApi } from '#/components/crud'; + +export const carouselApi = createCrudApi('carousel'); diff --git a/apps/web-antd/src/views/goods/carousel/components/modal.vue b/apps/web-antd/src/views/goods/carousel/components/modal.vue new file mode 100644 index 00000000..0028d1b6 --- /dev/null +++ b/apps/web-antd/src/views/goods/carousel/components/modal.vue @@ -0,0 +1,23 @@ + + + diff --git a/apps/web-antd/src/views/goods/carousel/config/form.ts b/apps/web-antd/src/views/goods/carousel/config/form.ts new file mode 100644 index 00000000..027e1d51 --- /dev/null +++ b/apps/web-antd/src/views/goods/carousel/config/form.ts @@ -0,0 +1,54 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'UploadImage', + // 新增时可一次选多张,后端按顺序生成多条轮播 + componentProps: { maxCount: 9, multiple: true }, + fieldName: 'url', + label: '轮播图', + rules: 'required', + }, + { + component: 'VbenInput', + componentProps: { + placeholder: '如 /pages/product/detail?id=12,留空则不跳转', + }, + fieldName: 'to_path', + label: '跳转路径', + }, + { + component: 'InputNumber', + componentProps: { min: 0, placeholder: '数字越小越靠前' }, + defaultValue: 0, + fieldName: 'sort', + label: '排序', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '显示', value: 0 }, + { label: '隐藏', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/goods/carousel/config/search.ts b/apps/web-antd/src/views/goods/carousel/config/search.ts new file mode 100644 index 00000000..6978d40d --- /dev/null +++ b/apps/web-antd/src/views/goods/carousel/config/search.ts @@ -0,0 +1,31 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入跳转路径' }, + defaultValue: '', + fieldName: 'to_path', + label: '跳转路径', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '显示', value: 0 }, + { label: '隐藏', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/goods/carousel/config/table.ts b/apps/web-antd/src/views/goods/carousel/config/table.ts new file mode 100644 index 00000000..69e21718 --- /dev/null +++ b/apps/web-antd/src/views/goods/carousel/config/table.ts @@ -0,0 +1,20 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { carouselApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'url', title: '轮播图', width: 140, slots: { default: 'image' } }, + { field: 'to_path', align: 'left', title: '跳转路径', minWidth: 260 }, + { field: 'sort', title: '排序', width: 90 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(160), + ], + query: (params) => carouselApi.list(params), +}); diff --git a/apps/web-antd/src/views/goods/carousel/index.vue b/apps/web-antd/src/views/goods/carousel/index.vue new file mode 100644 index 00000000..2e7dd569 --- /dev/null +++ b/apps/web-antd/src/views/goods/carousel/index.vue @@ -0,0 +1,95 @@ + + + diff --git a/apps/web-antd/src/views/goods/catalogue/api/index.ts b/apps/web-antd/src/views/goods/catalogue/api/index.ts new file mode 100644 index 00000000..ccb77f1c --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/api/index.ts @@ -0,0 +1,8 @@ +import { createCrudApi } from '#/components/crud'; + +export const catalogueApi = createCrudApi('catalogue'); + +/** 商品下拉(规格、相册等模块选商品用),返回 {id, name, identifier} */ +export async function getCatalogueOption(params?: Record) { + return (await catalogueApi.option(params)) ?? []; +} diff --git a/apps/web-antd/src/views/goods/catalogue/components/album-drawer.vue b/apps/web-antd/src/views/goods/catalogue/components/album-drawer.vue new file mode 100644 index 00000000..0b3b2a8e --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/components/album-drawer.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/web-antd/src/views/goods/catalogue/components/modal.vue b/apps/web-antd/src/views/goods/catalogue/components/modal.vue new file mode 100644 index 00000000..94624333 --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/components/modal.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/web-antd/src/views/goods/catalogue/components/spec-drawer.vue b/apps/web-antd/src/views/goods/catalogue/components/spec-drawer.vue new file mode 100644 index 00000000..d031bac1 --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/components/spec-drawer.vue @@ -0,0 +1,143 @@ + + + diff --git a/apps/web-antd/src/views/goods/catalogue/config/form.ts b/apps/web-antd/src/views/goods/catalogue/config/form.ts new file mode 100644 index 00000000..41261d0d --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/config/form.ts @@ -0,0 +1,87 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCategoryTree } from '#/views/goods/category/api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-6', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入商品名称' }, + fieldName: 'title', + label: '商品名称', + rules: 'required', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '线下对单与小程序搜索都靠它' }, + fieldName: 'identifier', + label: '商品编号', + rules: 'required', + }, + { + component: 'ApiTreeSelect', + componentProps: { + api: getCategoryTree, + childrenField: 'children', + labelField: 'name', + placeholder: '请选择分类', + valueField: 'id', + }, + fieldName: 'category_id', + label: '商品分类', + rules: 'selectRequired', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '别名,选填' }, + fieldName: 'alias', + label: '别名', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 3800 或 3800-5200' }, + fieldName: 'price', + label: '参考价', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '上架', value: 0 }, + { label: '下架', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + { + component: 'UploadImage', + componentProps: { maxCount: 1, multiple: false }, + fieldName: 'cover', + formItemClass: 'col-span-12', + label: '商品封面', + rules: 'required', + }, + { + component: 'UploadDoc', + componentProps: { accept: '.pdf' }, + fieldName: 'pdf', + formItemClass: 'col-span-12', + label: '图册 PDF', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/goods/catalogue/config/search.ts b/apps/web-antd/src/views/goods/catalogue/config/search.ts new file mode 100644 index 00000000..76fb1dce --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/config/search.ts @@ -0,0 +1,52 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCategoryTree } from '#/views/goods/category/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入商品名称' }, + defaultValue: '', + fieldName: 'title', + label: '商品名称', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入商品编号' }, + fieldName: 'identifier', + label: '商品编号', + }, + { + component: 'ApiTreeSelect', + componentProps: { + allowClear: true, + api: getCategoryTree, + childrenField: 'children', + labelField: 'name', + placeholder: '请选择分类', + valueField: 'id', + }, + fieldName: 'category_id', + label: '商品分类', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '上架', value: 0 }, + { label: '下架', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/goods/catalogue/config/table.ts b/apps/web-antd/src/views/goods/catalogue/config/table.ts new file mode 100644 index 00000000..e8dac891 --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/config/table.ts @@ -0,0 +1,24 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { catalogueApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'cover', title: '封面', width: 90, slots: { default: 'cover' } }, + { field: 'title', align: 'left', title: '商品名称', minWidth: 200 }, + { field: 'identifier', title: '商品编号', width: 140 }, + { field: 'category_name', title: '所属分类', width: 140 }, + { field: 'alias', title: '别名', width: 140 }, + { field: 'price', title: '参考价', width: 110 }, + { field: 'pdf', title: 'PDF', width: 80, slots: { default: 'pdf' } }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(300), + ], + query: (params) => catalogueApi.list(params), +}); diff --git a/apps/web-antd/src/views/goods/catalogue/index.vue b/apps/web-antd/src/views/goods/catalogue/index.vue new file mode 100644 index 00000000..0bd5f2db --- /dev/null +++ b/apps/web-antd/src/views/goods/catalogue/index.vue @@ -0,0 +1,743 @@ + + + + + diff --git a/apps/web-antd/src/views/goods/category/api/index.ts b/apps/web-antd/src/views/goods/category/api/index.ts new file mode 100644 index 00000000..21b50768 --- /dev/null +++ b/apps/web-antd/src/views/goods/category/api/index.ts @@ -0,0 +1,13 @@ +import { createCrudApi } from '#/components/crud'; + +export const categoryApi = createCrudApi('category'); + +/** 分类树(后端已建好层级) */ +export async function getCategoryTree(): Promise { + return (await categoryApi.option()) ?? []; +} + +/** 表单选上级用:首位补一个虚拟顶级节点,pid=0 表示挂在最外层 */ +export async function getCategoryTreeWithRoot(): Promise { + return [{ id: 0, name: '顶级分类' }, ...(await getCategoryTree())]; +} diff --git a/apps/web-antd/src/views/goods/category/components/modal.vue b/apps/web-antd/src/views/goods/category/components/modal.vue new file mode 100644 index 00000000..b066c4a1 --- /dev/null +++ b/apps/web-antd/src/views/goods/category/components/modal.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/web-antd/src/views/goods/category/config/form.ts b/apps/web-antd/src/views/goods/category/config/form.ts new file mode 100644 index 00000000..0f6174fe --- /dev/null +++ b/apps/web-antd/src/views/goods/category/config/form.ts @@ -0,0 +1,66 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCategoryTreeWithRoot } from '../api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '请输入分类名称' }, + fieldName: 'name', + label: '分类名称', + rules: 'required', + }, + { + component: 'ApiTreeSelect', + componentProps: { + api: getCategoryTreeWithRoot, + childrenField: 'children', + labelField: 'name', + valueField: 'id', + }, + defaultValue: 0, + fieldName: 'pid', + label: '上级分类', + rules: 'selectRequired', + }, + { + component: 'UploadImage', + componentProps: { maxCount: 1, multiple: false }, + fieldName: 'url', + label: '分类图', + }, + { + component: 'InputNumber', + componentProps: { min: 0, placeholder: '数字越小越靠前' }, + defaultValue: 0, + fieldName: 'sort', + label: '排序', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/goods/category/config/search.ts b/apps/web-antd/src/views/goods/category/config/search.ts new file mode 100644 index 00000000..547b2545 --- /dev/null +++ b/apps/web-antd/src/views/goods/category/config/search.ts @@ -0,0 +1,31 @@ +import type { VbenFormProps } from '#/adapter/form'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'VbenInput', + componentProps: { placeholder: '输入分类名称' }, + defaultValue: '', + fieldName: 'name', + label: '分类名称', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/goods/category/config/table.ts b/apps/web-antd/src/views/goods/category/config/table.ts new file mode 100644 index 00000000..6898adb2 --- /dev/null +++ b/apps/web-antd/src/views/goods/category/config/table.ts @@ -0,0 +1,24 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { categoryApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { width: 50, treeNode: true }, + { field: 'id', align: 'left', title: 'ID', width: 90 }, + { field: 'name', align: 'left', title: '分类名称', minWidth: 200 }, + { field: 'url', title: '分类图', width: 100, slots: { default: 'image' } }, + { field: 'sort', title: '排序', width: 90 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(220), + ], + // 树表分页会把父节点分到别页导致建树失败,分类量级小,一次全取 + pager: false, + tree: {}, + query: (params) => categoryApi.list(params), +}); diff --git a/apps/web-antd/src/views/goods/category/index.vue b/apps/web-antd/src/views/goods/category/index.vue new file mode 100644 index 00000000..0d66b8db --- /dev/null +++ b/apps/web-antd/src/views/goods/category/index.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/web-antd/src/views/goods/components/category-tree.vue b/apps/web-antd/src/views/goods/components/category-tree.vue new file mode 100644 index 00000000..67e1e646 --- /dev/null +++ b/apps/web-antd/src/views/goods/components/category-tree.vue @@ -0,0 +1,91 @@ + + + diff --git a/apps/web-antd/src/views/goods/image/api/index.ts b/apps/web-antd/src/views/goods/image/api/index.ts new file mode 100644 index 00000000..c3bde3e0 --- /dev/null +++ b/apps/web-antd/src/views/goods/image/api/index.ts @@ -0,0 +1,37 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +export const imageApi = createCrudApi('image'); + +/** 相册类型:与后端 ImageModel 常量一致 */ +export const IMAGE_TYPE = { + physical: 2, + render: 1, +} as const; + +/** 渲染图列表 */ +export async function getRenderImages(catalogueId: number): Promise { + return ( + (await requestClient.get('image/render-graph', { + params: { catalogue_id: catalogueId }, + })) ?? [] + ); +} + +/** 实物图列表 */ +export async function getPhysicalImages(catalogueId: number): Promise { + return ( + (await requestClient.get('image/physical-drawing', { + params: { catalogue_id: catalogueId }, + })) ?? [] + ); +} + +/** 批量追加相册图:url 传数组后端会逐张入库 */ +export async function addImages(data: { + catalogue_id: number; + type: number; + url: string[]; +}) { + return imageApi.create(data); +} diff --git a/apps/web-antd/src/views/goods/image/components/modal.vue b/apps/web-antd/src/views/goods/image/components/modal.vue new file mode 100644 index 00000000..d08c4f7a --- /dev/null +++ b/apps/web-antd/src/views/goods/image/components/modal.vue @@ -0,0 +1,24 @@ + + + diff --git a/apps/web-antd/src/views/goods/image/config/form.ts b/apps/web-antd/src/views/goods/image/config/form.ts new file mode 100644 index 00000000..1dea3d44 --- /dev/null +++ b/apps/web-antd/src/views/goods/image/config/form.ts @@ -0,0 +1,68 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCatalogueOption } from '#/views/goods/catalogue/api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'ApiSelect', + componentProps: { + api: getCatalogueOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择商品', + showSearch: true, + valueField: 'id', + }, + fieldName: 'catalogue_id', + label: '所属商品', + rules: 'selectRequired', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '渲染图', value: 1 }, + { label: '实物图', value: 2 }, + ], + }, + defaultValue: 1, + fieldName: 'type', + label: '图片类型', + rules: 'selectRequired', + }, + { + component: 'UploadImage', + // 新增时可一次选多张,后端会逐张入库 + componentProps: { maxCount: 9, multiple: true }, + fieldName: 'url', + label: '图片', + rules: 'required', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/goods/image/config/search.ts b/apps/web-antd/src/views/goods/image/config/search.ts new file mode 100644 index 00000000..082a2e0c --- /dev/null +++ b/apps/web-antd/src/views/goods/image/config/search.ts @@ -0,0 +1,53 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCatalogueOption } from '#/views/goods/catalogue/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getCatalogueOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择商品', + showSearch: true, + valueField: 'id', + }, + fieldName: 'catalogue_id', + label: '所属商品', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '渲染图', value: 1 }, + { label: '实物图', value: 2 }, + ], + placeholder: '请选择类型', + }, + fieldName: 'type', + label: '图片类型', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/goods/image/config/table.ts b/apps/web-antd/src/views/goods/image/config/table.ts new file mode 100644 index 00000000..d74b7ef1 --- /dev/null +++ b/apps/web-antd/src/views/goods/image/config/table.ts @@ -0,0 +1,20 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { imageApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'url', title: '图片', width: 100, slots: { default: 'image' } }, + { field: 'catalogue_id', title: '商品ID', width: 100 }, + { field: 'type', title: '类型', width: 110, slots: { default: 'type' } }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(160), + ], + query: (params) => imageApi.list(params), +}); diff --git a/apps/web-antd/src/views/goods/image/index.vue b/apps/web-antd/src/views/goods/image/index.vue new file mode 100644 index 00000000..8d3ca611 --- /dev/null +++ b/apps/web-antd/src/views/goods/image/index.vue @@ -0,0 +1,100 @@ + + + diff --git a/apps/web-antd/src/views/goods/price-sheet/api/index.ts b/apps/web-antd/src/views/goods/price-sheet/api/index.ts new file mode 100644 index 00000000..0bd0ad77 --- /dev/null +++ b/apps/web-antd/src/views/goods/price-sheet/api/index.ts @@ -0,0 +1,21 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +export const priceSheetApi = createCrudApi('price-sheet'); + +/** 某商品的全部规格行,抽屉回填用 */ +export async function getPriceSheetRows(catalogueId: number): Promise { + return ( + (await requestClient.get('price-sheet/rows', { + params: { catalogue_id: catalogueId }, + })) ?? [] + ); +} + +/** 覆盖式保存某商品的全部规格行:界面上删掉的行后端会软删 */ +export async function savePriceSheetRows(data: { + catalogue_id: number; + rows: Record[]; +}) { + return requestClient.post('price-sheet/save-rows', data); +} diff --git a/apps/web-antd/src/views/goods/price-sheet/components/modal.vue b/apps/web-antd/src/views/goods/price-sheet/components/modal.vue new file mode 100644 index 00000000..f368e481 --- /dev/null +++ b/apps/web-antd/src/views/goods/price-sheet/components/modal.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/web-antd/src/views/goods/price-sheet/config/form.ts b/apps/web-antd/src/views/goods/price-sheet/config/form.ts new file mode 100644 index 00000000..8635df2c --- /dev/null +++ b/apps/web-antd/src/views/goods/price-sheet/config/form.ts @@ -0,0 +1,66 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCatalogueOption } from '#/views/goods/catalogue/api'; + +export const modalFormProps: VbenFormProps = { + commonConfig: { + componentProps: { class: 'w-full' }, + formItemClass: 'col-span-12', + }, + layout: 'horizontal', + schema: [ + { + component: 'VbenInput', + dependencies: { show: false, triggerFields: ['id'] }, + fieldName: 'id', + label: 'ID', + }, + { + component: 'ApiSelect', + componentProps: { + api: getCatalogueOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择商品', + showSearch: true, + valueField: 'id', + }, + fieldName: 'catalogue_id', + label: '所属商品', + rules: 'selectRequired', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 三人位' }, + fieldName: 'specification', + label: '规格名称', + rules: 'required', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 2200*950*750' }, + fieldName: 'dimension', + label: '尺寸', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '如 3800,或「布艺:3800」' }, + fieldName: 'routine', + label: '常规价', + }, + { + component: 'Select', + componentProps: { + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + }, + defaultValue: 0, + fieldName: 'status', + label: '状态', + }, + ], + showDefaultActions: false, + wrapperClass: 'grid-cols-12', +}; diff --git a/apps/web-antd/src/views/goods/price-sheet/config/search.ts b/apps/web-antd/src/views/goods/price-sheet/config/search.ts new file mode 100644 index 00000000..69152432 --- /dev/null +++ b/apps/web-antd/src/views/goods/price-sheet/config/search.ts @@ -0,0 +1,46 @@ +import type { VbenFormProps } from '#/adapter/form'; + +import { getCatalogueOption } from '#/views/goods/catalogue/api'; + +export const formOptions: VbenFormProps = { + collapsed: false, + schema: [ + { + component: 'ApiSelect', + componentProps: { + allowClear: true, + api: getCatalogueOption, + filterOption: true, + labelField: 'name', + placeholder: '请选择商品', + showSearch: true, + valueField: 'id', + }, + fieldName: 'catalogue_id', + label: '所属商品', + }, + { + component: 'VbenInput', + componentProps: { placeholder: '输入规格名称' }, + fieldName: 'specification', + label: '规格名称', + }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: '启用', value: 0 }, + { label: '禁用', value: 1 }, + ], + placeholder: '请选择状态', + }, + fieldName: 'status', + label: '状态', + }, + ], + showCollapseButton: true, + submitButtonOptions: { content: '查询' }, + submitOnChange: true, + submitOnEnter: false, +}; diff --git a/apps/web-antd/src/views/goods/price-sheet/config/table.ts b/apps/web-antd/src/views/goods/price-sheet/config/table.ts new file mode 100644 index 00000000..2751d4f3 --- /dev/null +++ b/apps/web-antd/src/views/goods/price-sheet/config/table.ts @@ -0,0 +1,21 @@ +import { + actionColumn, + createGridOptions, + statusColumn, +} from '#/components/crud'; + +import { priceSheetApi } from '../api'; + +export const gridOptions = createGridOptions({ + columns: [ + { field: 'id', align: 'left', title: 'ID', width: 80 }, + { field: 'catalogue_id', title: '商品ID', width: 100 }, + { field: 'specification', align: 'left', title: '规格名称', minWidth: 180 }, + { field: 'dimension', title: '尺寸', minWidth: 160 }, + { field: 'routine', title: '常规价', width: 140 }, + statusColumn, + { field: 'created_at', title: '创建时间', width: 180 }, + actionColumn(200), + ], + query: (params) => priceSheetApi.list(params), +}); diff --git a/apps/web-antd/src/views/goods/price-sheet/index.vue b/apps/web-antd/src/views/goods/price-sheet/index.vue new file mode 100644 index 00000000..032406ed --- /dev/null +++ b/apps/web-antd/src/views/goods/price-sheet/index.vue @@ -0,0 +1,98 @@ + + + diff --git a/apps/web-antd/src/views/goods/quote/index.vue b/apps/web-antd/src/views/goods/quote/index.vue new file mode 100644 index 00000000..7d7bf5d9 --- /dev/null +++ b/apps/web-antd/src/views/goods/quote/index.vue @@ -0,0 +1,623 @@ + + + + + diff --git a/apps/web-antd/src/views/material/api/index.ts b/apps/web-antd/src/views/material/api/index.ts new file mode 100644 index 00000000..a6740b96 --- /dev/null +++ b/apps/web-antd/src/views/material/api/index.ts @@ -0,0 +1,207 @@ +import { requestClient } from '#/api/request'; +import { createCrudApi } from '#/components/crud'; + +/** 素材记录,字段与后端 material 表一致 */ +export interface MaterialItem { + created_at: string; + ext: string; + folder_id: number; + height: number; + id: number; + last_scan_at: string; + name: string; + oss_config_id: number; + path: string; + /** 被业务表引用的次数,0 表示可回收 */ + ref_count: number; + size: number; + type: string; + url: string; + width: number; +} + +export interface MaterialListResult { + items: MaterialItem[]; + page: number; + page_count: number; + size: number; + total: number; +} + +export interface MaterialStat { + total: number; + total_size: number; + types: { count: number; type: string }[]; + unused: number; + unused_size: number; +} + +export interface MaterialSyncResult { + finished: boolean; + inserted: number; + next_marker: string; + updated: number; +} + +export interface MaterialScanResult { + referenced: number; + scanned: number; + unused: number; +} + +/** + * 回收失败项:契约只冻结了 failed 是数组,元素形状没定, + * 字符串和对象两种都兜住,免得后端改一次前端就白屏 + */ +export type MaterialReclaimFailure = + | string + | { id?: number; name?: string; path?: string; reason?: string }; + +export interface MaterialReclaimResult { + deleted: number; + failed: MaterialReclaimFailure[]; +} + +export interface FileFolderItem { + children?: FileFolderItem[]; + id: number; + name: string; + pid: number; +} + +export const materialApi = createCrudApi('material'); + +export const fileFolderApi = createCrudApi('file-folder'); + +/** 素材列表;folder_id / type / ext / unused 都是可选筛选项 */ +export async function getMaterialList( + params: Record = {}, +): Promise { + return await materialApi.list(params); +} + +/** 顶部统计卡片数据 */ +export async function getMaterialStat(): Promise { + return await requestClient.get('material/stat'); +} + +/** 单批拉取 OSS 对象;finished 为假时带着 next_marker 再调一次 */ +export async function syncMaterialFromOss(data: { + limit: number; + marker: string; + oss_config_id: number; + prefix: string; +}): Promise { + return await requestClient.post( + 'material/sync-from-oss', + data, + ); +} + +/** 全量扫描业务表里的图片字段,回写 ref_count */ +export async function scanMaterialReferences(): Promise { + return await requestClient.post( + 'material/scan-references', + {}, + ); +} + +/** 回收建议列表:days 天内没被引用过的素材 */ +export async function getMaterialUnusedList(params: { + days: number; + page: number; + pageSize: number; +}): Promise { + return await requestClient.get('material/unused-list', { + params, + }); +} + +/** 真删:连 OSS 上的对象一起删,调用方必须做二次确认 */ +export async function reclaimMaterials( + ids: number[], +): Promise { + return await requestClient.post('material/reclaim', { + ids, + }); +} + +/** 批量归档到文件夹,folder_id 传 0 表示移出文件夹 */ +export async function moveMaterialsToFolder(data: { + folder_id: number; + ids: number[]; +}) { + return await requestClient.post('material/move-to-folder', data); +} + +/** + * 文件夹树 + * + * option 接口可能返回扁平数据(后端未建树),这里按 pid 兜一层, + * 否则子文件夹会全部平铺在根上,层级信息白丢 + */ +export async function getFileFolderTree(): Promise { + const list: FileFolderItem[] = (await fileFolderApi.option()) ?? []; + if (list.length === 0 || list.some((item) => item.children)) { + return list; + } + const nodes = new Map( + list.map((item) => [item.id, { ...item, children: [] }]), + ); + const roots: FileFolderItem[] = []; + for (const item of nodes.values()) { + const parent = nodes.get(item.pid); + if (parent) { + parent.children?.push(item); + } else { + roots.push(item); + } + } + return roots; +} + +/** 表单选上级用:首位补虚拟顶级节点,pid=0 表示挂在最外层 */ +export async function getFileFolderTreeWithRoot(): Promise { + return [{ id: 0, name: '顶级文件夹' }, ...(await getFileFolderTree())]; +} + +const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; + +/** + * 字节转可读体积 + * + * 统计卡片、素材卡片、回收列表都要展示体积,格式必须一致, + * 各处自己算会出现 1024KB 和 1MB 混着显示。 + */ +export function formatSize(bytes?: null | number): string { + const value = Number(bytes ?? 0); + if (!Number.isFinite(value) || value <= 0) { + return '0 B'; + } + const index = Math.min( + Math.floor(Math.log(value) / Math.log(1024)), + SIZE_UNITS.length - 1, + ); + const scaled = value / 1024 ** index; + const text = index === 0 ? String(scaled) : scaled.toFixed(scaled >= 100 ? 0 : 1); + return `${text} ${SIZE_UNITS[index]}`; +} + +const TYPE_LABELS: Record = { + archive: '压缩包', + audio: '音频', + doc: '文档', + image: '图片', + other: '其他', + video: '视频', +}; + +/** 类型中文名;后端新增类型时原样展示,不做白名单拦截 */ +export function materialTypeText(type?: string): string { + return TYPE_LABELS[type ?? ''] ?? (type || '未知'); +} + +/** 只有图片能出缩略图与水印预览,其余类型走图标占位 */ +export function isImageMaterial(item: Pick): boolean { + return item.type === 'image'; +} diff --git a/apps/web-antd/src/views/material/components/folder-modal.vue b/apps/web-antd/src/views/material/components/folder-modal.vue new file mode 100644 index 00000000..f6b28b08 --- /dev/null +++ b/apps/web-antd/src/views/material/components/folder-modal.vue @@ -0,0 +1,60 @@ + + + diff --git a/apps/web-antd/src/views/material/components/move-modal.vue b/apps/web-antd/src/views/material/components/move-modal.vue new file mode 100644 index 00000000..7636e8ab --- /dev/null +++ b/apps/web-antd/src/views/material/components/move-modal.vue @@ -0,0 +1,75 @@ + + + diff --git a/apps/web-antd/src/views/material/components/reclaim-drawer.vue b/apps/web-antd/src/views/material/components/reclaim-drawer.vue new file mode 100644 index 00000000..1016d721 --- /dev/null +++ b/apps/web-antd/src/views/material/components/reclaim-drawer.vue @@ -0,0 +1,192 @@ + + + diff --git a/apps/web-antd/src/views/material/components/sync-drawer.vue b/apps/web-antd/src/views/material/components/sync-drawer.vue new file mode 100644 index 00000000..b7ca0773 --- /dev/null +++ b/apps/web-antd/src/views/material/components/sync-drawer.vue @@ -0,0 +1,216 @@ + + + diff --git a/apps/web-antd/src/views/material/index.vue b/apps/web-antd/src/views/material/index.vue new file mode 100644 index 00000000..929a19d2 --- /dev/null +++ b/apps/web-antd/src/views/material/index.vue @@ -0,0 +1,530 @@ + + + diff --git a/apps/web-antd/src/views/system/admin/components/dept-tree.vue b/apps/web-antd/src/views/system/admin/components/dept-tree.vue new file mode 100644 index 00000000..5965bf56 --- /dev/null +++ b/apps/web-antd/src/views/system/admin/components/dept-tree.vue @@ -0,0 +1,97 @@ + + + diff --git a/apps/web-antd/src/views/system/admin/config/form.ts b/apps/web-antd/src/views/system/admin/config/form.ts index 1e933b6d..7839d7b6 100644 --- a/apps/web-antd/src/views/system/admin/config/form.ts +++ b/apps/web-antd/src/views/system/admin/config/form.ts @@ -1,6 +1,7 @@ import type { VbenFormProps } from '#/adapter/form'; import { z } from '#/adapter/form'; +import { getDepartmentTreeOption } from '#/views/system/department/api'; import { getRoleOption } from '#/views/system/role/api'; const defaultPassword = 'Xk123456@'; @@ -85,6 +86,21 @@ export const modalFormProps: VbenFormProps = { label: '角色', rules: 'required', }, + { + component: 'ApiTreeSelect', + componentProps: { + allowClear: true, + childrenField: 'children', + labelField: 'name', + valueField: 'id', + api: getDepartmentTreeOption, + placeholder: '请选择', + }, + defaultValue: 0, + fieldName: 'department_id', + formItemClass: 'col-span-6', + label: '所属部门', + }, { fieldName: 'password', label: '密码', diff --git a/apps/web-antd/src/views/system/admin/config/search.ts b/apps/web-antd/src/views/system/admin/config/search.ts index bc3ee56b..e225fb7b 100644 --- a/apps/web-antd/src/views/system/admin/config/search.ts +++ b/apps/web-antd/src/views/system/admin/config/search.ts @@ -43,6 +43,16 @@ export const formOptions: VbenFormProps = { fieldName: 'role_id', label: '角色', }, + { + // 左侧部门树点选后写入这里,借表单的 submitOnChange 触发查询 + component: 'VbenInput', + fieldName: 'department_id', + label: '部门', + dependencies: { + show: false, + triggerFields: ['department_id'], + }, + }, ], // 控制表单是否显示折叠按钮 showCollapseButton: true, diff --git a/apps/web-antd/src/views/system/admin/config/table.ts b/apps/web-antd/src/views/system/admin/config/table.ts index 8e6aa49d..96b01ca4 100644 --- a/apps/web-antd/src/views/system/admin/config/table.ts +++ b/apps/web-antd/src/views/system/admin/config/table.ts @@ -8,7 +8,7 @@ interface RowType { email: string; avatar: string; roles: { name: string }[]; - open_id: string; + department_name: string; code: string; platform_id: string; phone: string; @@ -39,7 +39,7 @@ export const gridOptions: VxeGridProps = { width: 130, }, { field: 'role.name', title: '角色' }, - { field: 'open_id', title: 'Open ID' }, + { field: 'department_name', title: '部门' }, { field: 'phone', title: '手机号码' }, { field: 'email', title: '邮箱' }, { field: 'desc', title: '备注' }, diff --git a/apps/web-antd/src/views/system/admin/index.vue b/apps/web-antd/src/views/system/admin/index.vue index 749fa5d2..f6e5c514 100644 --- a/apps/web-antd/src/views/system/admin/index.vue +++ b/apps/web-antd/src/views/system/admin/index.vue @@ -12,13 +12,14 @@ import { Icon } from '#/components/icon'; import { TableAction } from '#/components/table-action'; import { deleteAdmin, resetPassword } from './api'; - -// 重置密码使用的默认新密码:后端不校验旧密码,重置后由管理员告知使用者 -const DEFAULT_RESET_PASSWORD = 'Aa123456'; +import DeptTree from './components/dept-tree.vue'; import FormModalDemo from './components/modal.vue'; import { formOptions } from './config/search'; import { gridOptions } from './config/table'; +// 重置密码使用的默认新密码:后端不校验旧密码,重置后由管理员告知使用者 +const DEFAULT_RESET_PASSWORD = 'Aa123456'; + const hasTopTableDropDownActions = ref(false); const gridEvents: VxeGridListeners = { @@ -71,12 +72,18 @@ const resetPasswordApi = (id: number) => { gridApi.reload(); }); }; + +// 部门树只负责写搜索表单的隐藏字段,查询仍走表单那一条路 +const onDeptSelect = (id?: number) => { + gridApi.formApi.setFieldValue('department_id', id); +};