初始化
缺陷:主题配色需要优化整体的同风格
5
.gitignore
vendored
@@ -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
|
||||
|
||||
176
README.md
@@ -1,157 +1,67 @@
|
||||
<div align="center">
|
||||
<a href="https://github.com/anncwb/vue-vben-admin">
|
||||
<img alt="VbenAdmin Logo" width="215" src="https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp">
|
||||
</a>
|
||||
<br>
|
||||
<br>
|
||||
# lgp-admin-plus
|
||||
|
||||
[](LICENSE)
|
||||
LGP 管理后台前端(Vben Admin 5.7 / `apps/web-antd`)。
|
||||
两个家具品牌共用一套代码,靠构建模式区分。
|
||||
|
||||
<h1>Vue Vben Admin</h1>
|
||||
</div>
|
||||
| 品牌 | 命令后缀 | AppID | 产出目录 |
|
||||
|---|---|---|---|
|
||||
| 佛山铂尔曼 | `brm` | `wx679d36842570cea7` | `apps/web-antd/dist-brm` |
|
||||
| 维伦家具 | `wl` | `wx57b54060a579c31a` | `apps/web-antd/dist-wl` |
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml) [](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml) [](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
|
||||
|
||||
<div align="center">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview1.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview2.png">
|
||||
<img alt="VbenAdmin Logo" width="100%" src="https://anncwb.github.io/anncwb/images/preview3.png">
|
||||
</div>
|
||||
|
||||
### Use Gitpod
|
||||
|
||||
Open the project in Gitpod (free online dev environment for GitHub) and start coding immediately.
|
||||
|
||||
[](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
|
||||
|
||||
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari |
|
||||
| :-: | :-: | :-: | :-: |
|
||||
| last 2 versions | last 2 versions | last 2 versions | last 2 versions |
|
||||
|
||||
## Maintainer
|
||||
|
||||
[@Vben](https://github.com/anncwb)
|
||||
|
||||
## Star History
|
||||
|
||||
[](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!
|
||||
|
||||

|
||||
|
||||
<a style="display: block;width: 100px;height: 50px;line-height: 50px; color: #fff;text-align: center; background: #408aee;border-radius: 4px;" href="https://www.paypal.com/paypalme/cvvben">Paypal Me</a>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://openomy.app/github/vbenjs/vue-vben-admin" target="_blank" style="display: block; width: 100%;" align="center">
|
||||
<img src="https://openomy.app/svg?repo=vbenjs/vue-vben-admin&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/vbenjs/vue-vben-admin/graphs/contributors">
|
||||
<img alt="Contributors" src="https://contrib.rocks/image?repo=vbenjs/vue-vben-admin" />
|
||||
</a>
|
||||
|
||||
## 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/<YYYY-MM-DD>/01_名字.sql`,不要新建 PHP Migration。
|
||||
|
||||
@@ -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
|
||||
|
||||
27
apps/web-antd/.env.brm
Normal file
@@ -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
|
||||
27
apps/web-antd/.env.wl
Normal file
@@ -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
|
||||
@@ -13,7 +13,9 @@
|
||||
/>
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<!-- 品牌 favicon 由 VITE_APP_FAVICON 注入,.env 里给了默认值保证各 mode 都能替换 -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" type="image/svg+xml" href="%VITE_APP_FAVICON%" />
|
||||
<script>
|
||||
// 生产环境下注入百度统计
|
||||
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
"scripts": {
|
||||
"build": "pnpm vite build --mode production",
|
||||
"build:analyze": "pnpm vite build --mode analyze",
|
||||
"build:brm": "pnpm vite build --mode brm --outDir dist-brm",
|
||||
"build:wl": "pnpm vite build --mode wl --outDir dist-wl",
|
||||
"dev": "pnpm vite --mode development",
|
||||
"dev:brm": "pnpm vite --mode brm",
|
||||
"dev:wl": "pnpm vite --mode wl",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit --skipLibCheck"
|
||||
},
|
||||
|
||||
11
apps/web-antd/public/brand/brm-favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="BORMAN">
|
||||
<title>BORMAN favicon</title>
|
||||
<g fill="none" stroke="#B8924A" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="5" width="54" height="54" rx="14" stroke-width="2"/>
|
||||
<path d="M18 52V18c0-4.5 3.5-7 8-7h4c3.5 0 5.5 2.5 5.5 6.5V36" stroke-width="2.2"/>
|
||||
<path d="M35.5 37h16c3 0 5.5 2 5.5 5.5V46H25" stroke-width="2.2"/>
|
||||
<path d="M35.5 28h12c2.5 0 4 1.5 4 4" stroke-width="2"/>
|
||||
<path d="M28 46v8M50 46v8" stroke-width="2.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
29
apps/web-antd/public/brand/brm-logo-dark.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="BORMAN">
|
||||
<title>BORMAN Dark</title>
|
||||
<defs>
|
||||
<linearGradient id="brmDarkStroke" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#F0E6D2"/>
|
||||
<stop offset="55%" stop-color="#D4B56A"/>
|
||||
<stop offset="100%" stop-color="#E8D9B0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style type="text/css"><![CDATA[
|
||||
.frame-outer,.frame-inner,.mark{fill:none;stroke:url(#brmDarkStroke);stroke-linecap:round;stroke-linejoin:round}
|
||||
.frame-outer{stroke-width:2.5;animation:breath 5s ease-in-out infinite}
|
||||
.frame-inner{stroke-width:1;opacity:.5}
|
||||
.mark{stroke-width:3.1;animation:breath 5s ease-in-out .4s infinite}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.84}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.frame-outer,.mark{animation:none}
|
||||
}
|
||||
]]></style>
|
||||
<rect class="frame-outer" x="10" y="10" width="108" height="108" rx="28"/>
|
||||
<rect class="frame-inner" x="18" y="18" width="92" height="92" rx="22"/>
|
||||
<g class="mark">
|
||||
<path d="M40 102V36c0-9 7-14 16-14h8c7 0 11 5 11 13v39"/>
|
||||
<path d="M75 74h30c6 0 11 4 11 11v7H52"/>
|
||||
<path d="M75 56h24c5 0 8 3 8 8"/>
|
||||
<path d="M56 92v14M98 92v14"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
41
apps/web-antd/public/brand/brm-logo-horizontal-dark.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" width="320" height="80" role="img" aria-label="BORMAN">
|
||||
<title>BORMAN Dark</title>
|
||||
<defs>
|
||||
<linearGradient id="brmHDark" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#F0E6D2"/>
|
||||
<stop offset="55%" stop-color="#D4B56A"/>
|
||||
<stop offset="100%" stop-color="#E8D9B0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style type="text/css"><![CDATA[
|
||||
.ico .frame-outer,.ico .frame-inner,.ico .mark{fill:none;stroke:url(#brmHDark);stroke-linecap:round;stroke-linejoin:round}
|
||||
.ico .frame-outer{stroke-width:2.5;animation:breath 5s ease-in-out infinite}
|
||||
.ico .frame-inner{stroke-width:1;opacity:.5}
|
||||
.ico .mark{stroke-width:3.1}
|
||||
.wm{fill:#F2ECE1;font-family:'Source Han Serif SC','Noto Serif SC','Songti SC',STSong,serif}
|
||||
.sub{fill:#C9BDA8;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif}
|
||||
.rule{stroke:#D4B56A;opacity:.55}
|
||||
.shine{stroke:#F5E9C8;stroke-width:2;opacity:0;pointer-events:none}
|
||||
svg:hover .shine{animation:shine-run .65s ease-out}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.84}}
|
||||
@keyframes shine-run{0%{opacity:0;stroke-dasharray:8 240;stroke-dashoffset:0}30%{opacity:.9}100%{opacity:0;stroke-dashoffset:-180}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.ico .frame-outer,.shine{animation:none!important}
|
||||
}
|
||||
]]></style>
|
||||
<g class="ico" transform="translate(4 8) scale(0.8125)">
|
||||
<rect class="frame-outer" x="10" y="10" width="108" height="108" rx="28"/>
|
||||
<rect class="frame-inner" x="18" y="18" width="92" height="92" rx="22"/>
|
||||
<rect class="shine" x="10" y="10" width="108" height="108" rx="28" fill="none"/>
|
||||
<g class="mark">
|
||||
<path d="M40 102V36c0-9 7-14 16-14h8c7 0 11 5 11 13v39"/>
|
||||
<path d="M75 74h30c6 0 11 4 11 11v7H52"/>
|
||||
<path d="M75 56h24c5 0 8 3 8 8"/>
|
||||
<path d="M56 92v14M98 92v14"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="rule" d="M92 22V58" fill="none" stroke-width="1"/>
|
||||
<text class="wm" x="108" y="40" font-size="26" letter-spacing="2.2">佛山铂尔曼</text>
|
||||
<text class="sub" x="110" y="58" font-size="10" letter-spacing="4.2">BORMAN FURNITURE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
34
apps/web-antd/public/brand/brm-logo-horizontal.svg
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" width="320" height="80" role="img" aria-label="BORMAN">
|
||||
<title>BORMAN</title>
|
||||
<style type="text/css"><![CDATA[
|
||||
.ico .frame-outer,.ico .frame-inner,.ico .mark{fill:none;stroke:#B8924A;stroke-linecap:round;stroke-linejoin:round}
|
||||
.ico .frame-outer{stroke-width:2.4;animation:breath 5s ease-in-out infinite}
|
||||
.ico .frame-inner{stroke-width:1;opacity:.42}
|
||||
.ico .mark{stroke-width:3}
|
||||
.wm{fill:#1C1915;font-family:'Source Han Serif SC','Noto Serif SC','Songti SC',STSong,serif}
|
||||
.sub{fill:#6B6257;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif}
|
||||
.rule{stroke:#B8924A;opacity:.55}
|
||||
.shine{stroke:#E8D5A3;stroke-width:2;opacity:0;pointer-events:none}
|
||||
svg:hover .shine{animation:shine-run .65s ease-out}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.78}}
|
||||
@keyframes shine-run{0%{opacity:0;stroke-dasharray:8 240;stroke-dashoffset:0}30%{opacity:.85}100%{opacity:0;stroke-dashoffset:-180}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.ico .frame-outer,.shine{animation:none!important}
|
||||
}
|
||||
]]></style>
|
||||
<g class="ico" transform="translate(4 8) scale(0.8125)">
|
||||
<rect class="frame-outer" x="10" y="10" width="108" height="108" rx="28"/>
|
||||
<rect class="frame-inner" x="18" y="18" width="92" height="92" rx="22"/>
|
||||
<rect class="shine" x="10" y="10" width="108" height="108" rx="28" fill="none"/>
|
||||
<g class="mark">
|
||||
<path d="M40 102V36c0-9 7-14 16-14h8c7 0 11 5 11 13v39"/>
|
||||
<path d="M75 74h30c6 0 11 4 11 11v7H52"/>
|
||||
<path d="M75 56h24c5 0 8 3 8 8"/>
|
||||
<path d="M56 92v14M98 92v14"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="rule" d="M92 22V58" fill="none" stroke-width="1"/>
|
||||
<text class="wm" x="108" y="40" font-size="26" letter-spacing="2.2">佛山铂尔曼</text>
|
||||
<text class="sub" x="110" y="58" font-size="10" letter-spacing="4.2">BORMAN FURNITURE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
22
apps/web-antd/public/brand/brm-logo.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="BORMAN">
|
||||
<title>BORMAN</title>
|
||||
<style type="text/css"><![CDATA[
|
||||
.frame-outer,.frame-inner,.mark{fill:none;stroke:#B8924A;stroke-linecap:round;stroke-linejoin:round}
|
||||
.frame-outer{stroke-width:2.4;animation:breath 5s ease-in-out infinite}
|
||||
.frame-inner{stroke-width:1;opacity:.42}
|
||||
.mark{stroke-width:3;animation:breath 5s ease-in-out .4s infinite}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.78}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.frame-outer,.mark{animation:none}
|
||||
}
|
||||
]]></style>
|
||||
<rect class="frame-outer" x="10" y="10" width="108" height="108" rx="28"/>
|
||||
<rect class="frame-inner" x="18" y="18" width="92" height="92" rx="22"/>
|
||||
<g class="mark">
|
||||
<path d="M40 102V36c0-9 7-14 16-14h8c7 0 11 5 11 13v39"/>
|
||||
<path d="M75 74h30c6 0 11 4 11 11v7H52"/>
|
||||
<path d="M75 56h24c5 0 8 3 8 8"/>
|
||||
<path d="M56 92v14M98 92v14"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
10
apps/web-antd/public/brand/wl-favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="WEILUN">
|
||||
<title>WEILUN favicon</title>
|
||||
<g fill="none" stroke="#9A6A3A" stroke-linecap="round" stroke-linejoin="miter">
|
||||
<path stroke-width="2" d="M6 18 L18 6 L46 6 Q58 6 58 18 L58 46 Q58 58 46 58 L18 58 Q6 58 6 46 Z"/>
|
||||
<path stroke-width="2.2" d="M18 21.5 L25.6 44 L32 29.5"/>
|
||||
<path stroke-width="2.2" d="M32 34.5 L38.4 44 L46 21.5"/>
|
||||
<path stroke-width="1.5" opacity=".8" d="M20.5 51.5 L43.5 51.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 579 B |
29
apps/web-antd/public/brand/wl-logo-dark.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WEILUN">
|
||||
<title>WEILUN Dark</title>
|
||||
<defs>
|
||||
<linearGradient id="wlDarkStroke" x1="0%" y1="20%" x2="100%" y2="90%">
|
||||
<stop offset="0%" stop-color="#F3E8D6"/>
|
||||
<stop offset="50%" stop-color="#C9955A"/>
|
||||
<stop offset="100%" stop-color="#E6D2B0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style type="text/css"><![CDATA[
|
||||
.frame,.inner,.mark,.base{fill:none;stroke:url(#wlDarkStroke);stroke-linecap:round;stroke-linejoin:miter}
|
||||
.frame{stroke-width:2.6;animation:breath 5.2s ease-in-out infinite}
|
||||
.inner{stroke-width:1.1;opacity:.55}
|
||||
.mark{stroke-width:3.5}
|
||||
.base{stroke-width:2.1;opacity:.85}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.84}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.frame{animation:none}
|
||||
}
|
||||
]]></style>
|
||||
<path class="frame" d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z"/>
|
||||
<path class="inner" d="M20 38 L38 20 L90 20 Q108 20 108 38 L108 90 Q108 108 90 108 L38 108 Q20 108 20 90 Z"/>
|
||||
<g class="mark">
|
||||
<path d="M36 42 L50 88 L64 58"/>
|
||||
<path d="M64 68 L78 88 L92 42"/>
|
||||
</g>
|
||||
<path class="base" d="M42 102 L86 102"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
41
apps/web-antd/public/brand/wl-logo-horizontal-dark.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" width="320" height="80" role="img" aria-label="WEILUN">
|
||||
<title>WEILUN Dark</title>
|
||||
<defs>
|
||||
<linearGradient id="wlHDark" x1="0%" y1="20%" x2="100%" y2="90%">
|
||||
<stop offset="0%" stop-color="#F3E8D6"/>
|
||||
<stop offset="50%" stop-color="#C9955A"/>
|
||||
<stop offset="100%" stop-color="#E6D2B0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style type="text/css"><![CDATA[
|
||||
.ico .frame,.ico .inner,.ico .mark,.ico .base{fill:none;stroke:url(#wlHDark);stroke-linecap:round;stroke-linejoin:miter}
|
||||
.ico .frame{stroke-width:2.6;animation:breath 5.2s ease-in-out infinite}
|
||||
.ico .inner{stroke-width:1.1;opacity:.55}
|
||||
.ico .mark{stroke-width:3.5}
|
||||
.ico .base{stroke-width:2.1;opacity:.85}
|
||||
.wm{fill:#F2ECE1;font-family:'Source Han Serif SC','Noto Serif SC','Songti SC',STSong,serif}
|
||||
.sub{fill:#C9BDA8;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif}
|
||||
.rule{stroke:#C9955A;opacity:.55}
|
||||
.shine{stroke:#F0DFC0;stroke-width:2;opacity:0;pointer-events:none}
|
||||
svg:hover .shine{animation:shine-run .65s ease-out}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.84}}
|
||||
@keyframes shine-run{0%{opacity:0;stroke-dasharray:8 240;stroke-dashoffset:0}30%{opacity:.9}100%{opacity:0;stroke-dashoffset:-200}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.ico .frame,.shine{animation:none!important}
|
||||
}
|
||||
]]></style>
|
||||
<g class="ico" transform="translate(4 8) scale(0.8125)">
|
||||
<path class="frame" d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z"/>
|
||||
<path class="inner" d="M20 38 L38 20 L90 20 Q108 20 108 38 L108 90 Q108 108 90 108 L38 108 Q20 108 20 90 Z"/>
|
||||
<path class="shine" d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z" fill="none"/>
|
||||
<g class="mark">
|
||||
<path d="M36 42 L50 88 L64 58"/>
|
||||
<path d="M64 68 L78 88 L92 42"/>
|
||||
</g>
|
||||
<path class="base" d="M42 102 L86 102"/>
|
||||
</g>
|
||||
<path class="rule" d="M92 22V58" fill="none" stroke-width="1"/>
|
||||
<text class="wm" x="108" y="40" font-size="26" letter-spacing="3">维伦家具</text>
|
||||
<text class="sub" x="110" y="58" font-size="10" letter-spacing="4.2">WEILUN FURNITURE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
34
apps/web-antd/public/brand/wl-logo-horizontal.svg
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" width="320" height="80" role="img" aria-label="WEILUN">
|
||||
<title>WEILUN</title>
|
||||
<style type="text/css"><![CDATA[
|
||||
.ico .frame,.ico .inner,.ico .mark,.ico .base{fill:none;stroke:#9A6A3A;stroke-linecap:round;stroke-linejoin:miter}
|
||||
.ico .frame{stroke-width:2.4;animation:breath 5.2s ease-in-out infinite}
|
||||
.ico .inner{stroke-width:1;opacity:.48}
|
||||
.ico .mark{stroke-width:3.3}
|
||||
.ico .base{stroke-width:2;opacity:.8}
|
||||
.wm{fill:#1C1915;font-family:'Source Han Serif SC','Noto Serif SC','Songti SC',STSong,serif}
|
||||
.sub{fill:#6B6257;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif}
|
||||
.rule{stroke:#9A6A3A;opacity:.55}
|
||||
.shine{stroke:#E0C49A;stroke-width:2;opacity:0;pointer-events:none}
|
||||
svg:hover .shine{animation:shine-run .65s ease-out}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.76}}
|
||||
@keyframes shine-run{0%{opacity:0;stroke-dasharray:8 240;stroke-dashoffset:0}30%{opacity:.85}100%{opacity:0;stroke-dashoffset:-200}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.ico .frame,.shine{animation:none!important}
|
||||
}
|
||||
]]></style>
|
||||
<g class="ico" transform="translate(4 8) scale(0.8125)">
|
||||
<path class="frame" d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z"/>
|
||||
<path class="inner" d="M20 38 L38 20 L90 20 Q108 20 108 38 L108 90 Q108 108 90 108 L38 108 Q20 108 20 90 Z"/>
|
||||
<path class="shine" d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z" fill="none"/>
|
||||
<g class="mark">
|
||||
<path d="M36 42 L50 88 L64 58"/>
|
||||
<path d="M64 68 L78 88 L92 42"/>
|
||||
</g>
|
||||
<path class="base" d="M42 102 L86 102"/>
|
||||
</g>
|
||||
<path class="rule" d="M92 22V58" fill="none" stroke-width="1"/>
|
||||
<text class="wm" x="108" y="40" font-size="26" letter-spacing="3">维伦家具</text>
|
||||
<text class="sub" x="110" y="58" font-size="10" letter-spacing="4.2">WEILUN FURNITURE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
22
apps/web-antd/public/brand/wl-logo.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WEILUN">
|
||||
<title>WEILUN</title>
|
||||
<style type="text/css"><![CDATA[
|
||||
.frame,.inner,.mark,.base{fill:none;stroke:#9A6A3A;stroke-linecap:round;stroke-linejoin:miter}
|
||||
.frame{stroke-width:2.4;animation:breath 5.2s ease-in-out infinite}
|
||||
.inner{stroke-width:1;opacity:.48}
|
||||
.mark{stroke-width:3.3}
|
||||
.base{stroke-width:2;opacity:.8}
|
||||
@keyframes breath{0%,100%{opacity:1}50%{opacity:.76}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.frame{animation:none}
|
||||
}
|
||||
]]></style>
|
||||
<path class="frame" d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z"/>
|
||||
<path class="inner" d="M20 38 L38 20 L90 20 Q108 20 108 38 L108 90 Q108 108 90 108 L38 108 Q20 108 20 90 Z"/>
|
||||
<g class="mark">
|
||||
<path d="M36 42 L50 88 L64 58"/>
|
||||
<path d="M64 68 L78 88 L92 42"/>
|
||||
</g>
|
||||
<path class="base" d="M42 102 L86 102"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1017 B |
@@ -1,3 +1,5 @@
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { baseRequestClient, requestClient } from '#/api/request';
|
||||
|
||||
export namespace AuthApi {
|
||||
@@ -13,9 +15,15 @@ export namespace AuthApi {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** 后端统一响应体 */
|
||||
export interface Envelope<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
result: T;
|
||||
}
|
||||
|
||||
export interface RefreshTokenResult {
|
||||
data: string;
|
||||
status: number;
|
||||
token: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,20 +36,33 @@ export async function loginApi(data: AuthApi.LoginParams) {
|
||||
|
||||
/**
|
||||
* 刷新accessToken
|
||||
*
|
||||
* 走 baseRequestClient 是为了避开 requestClient 的 401 拦截器(否则续签失败会自己套自己),
|
||||
* 但续签必须带上那张已过期的 token,所以这里手动补 Authorization。
|
||||
*/
|
||||
export async function refreshTokenApi() {
|
||||
return baseRequestClient.post<AuthApi.RefreshTokenResult>('admin/refresh', {
|
||||
withCredentials: true,
|
||||
});
|
||||
const accessStore = useAccessStore();
|
||||
return baseRequestClient.post<
|
||||
AuthApi.Envelope<AuthApi.RefreshTokenResult>
|
||||
>(
|
||||
'refresh',
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${accessStore.accessToken ?? ''}` } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*
|
||||
* 后端据 token 删掉 Redis 会话,token 缺失或已过期都不报错。
|
||||
*/
|
||||
export async function logoutApi() {
|
||||
return baseRequestClient.post('admin/logout', {
|
||||
withCredentials: true,
|
||||
});
|
||||
const accessStore = useAccessStore();
|
||||
return baseRequestClient.post(
|
||||
'logout',
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${accessStore.accessToken ?? ''}` } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,22 @@ export async function uploadFile(data: any) {
|
||||
return requestClient.upload('upload/image', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频上传
|
||||
* @param data
|
||||
*/
|
||||
export async function uploadVideo(data: any) {
|
||||
return requestClient.upload('upload/video', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档上传(商品图册 PDF、订单凭证 PDF 等)
|
||||
* @param data
|
||||
*/
|
||||
export async function uploadDocument(data: any) {
|
||||
return requestClient.upload('upload/file', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件id集合获取文件信息
|
||||
* @param data
|
||||
|
||||
@@ -50,8 +50,9 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
*/
|
||||
async function doRefreshToken() {
|
||||
const accessStore = useAccessStore();
|
||||
// baseRequestClient 默认 responseReturn 是 raw,resp.data 才是后端的 {code,message,result}
|
||||
const resp = await refreshTokenApi();
|
||||
const newToken = resp.data;
|
||||
const newToken = resp.data?.result?.token ?? '';
|
||||
accessStore.setAccessToken(newToken);
|
||||
return newToken;
|
||||
}
|
||||
|
||||
74
apps/web-antd/src/brand.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { BuiltinThemeType } from '@vben/types';
|
||||
|
||||
/**
|
||||
* 双品牌配置
|
||||
*
|
||||
* 两个品牌共用一套代码与数据库,差异只有标题、logo、主色和 API 地址。
|
||||
* 靠 VITE_APP_BRAND 在构建期选定,不要在页面里到处判断品牌。
|
||||
* pnpm build:brm → 佛山铂尔曼
|
||||
* pnpm build:wl → 维伦家具
|
||||
*/
|
||||
export type BrandKey = 'brm' | 'wl';
|
||||
|
||||
export interface BrandProfile {
|
||||
/** 小程序 AppID,仅用于后台展示归属,不参与调用 */
|
||||
appId: string;
|
||||
/** 内置主题色板,custom 表示完全由 colorPrimary 推导 */
|
||||
builtinType: BuiltinThemeType;
|
||||
/** 主色,HSL 写法与 vben 主题变量一致 */
|
||||
colorPrimary: string;
|
||||
/** 侧边栏与登录页方形标(亮色) */
|
||||
logo: string;
|
||||
/** 方形标暗色:后台主题切换用,不能靠 OS prefers-color-scheme */
|
||||
logoDark: string;
|
||||
/** 横版组合标(亮色),登录页 / 导出页头 */
|
||||
logoHorizontal: string;
|
||||
/** 横版暗色 */
|
||||
logoHorizontalDark: string;
|
||||
/** favicon(静态,无动画) */
|
||||
favicon: string;
|
||||
/** 后台标题与登录页主标题 */
|
||||
name: string;
|
||||
/** 登录页副标题 */
|
||||
slogan: string;
|
||||
}
|
||||
|
||||
const PROFILES: Record<BrandKey, BrandProfile> = {
|
||||
brm: {
|
||||
appId: 'wx679d36842570cea7',
|
||||
builtinType: 'custom',
|
||||
// 香槟金
|
||||
colorPrimary: 'hsl(38 52% 52%)',
|
||||
// ?v= 打掉浏览器对损坏 SVG 的缓存
|
||||
logo: '/brand/brm-logo.svg?v=5',
|
||||
logoDark: '/brand/brm-logo-dark.svg?v=5',
|
||||
logoHorizontal: '/brand/brm-logo-horizontal.svg?v=5',
|
||||
logoHorizontalDark: '/brand/brm-logo-horizontal-dark.svg?v=5',
|
||||
favicon: '/brand/brm-favicon.svg?v=5',
|
||||
name: '佛山铂尔曼',
|
||||
slogan: '高端定制家具管理平台',
|
||||
},
|
||||
wl: {
|
||||
appId: 'wx57b54060a579c31a',
|
||||
builtinType: 'custom',
|
||||
// 黄铜偏暖棕
|
||||
colorPrimary: 'hsl(28 42% 45%)',
|
||||
logo: '/brand/wl-logo.svg?v=5',
|
||||
logoDark: '/brand/wl-logo-dark.svg?v=5',
|
||||
logoHorizontal: '/brand/wl-logo-horizontal.svg?v=5',
|
||||
logoHorizontalDark: '/brand/wl-logo-horizontal-dark.svg?v=5',
|
||||
favicon: '/brand/wl-favicon.svg?v=5',
|
||||
name: '维伦家具',
|
||||
slogan: '轻奢家居全链路管理',
|
||||
},
|
||||
};
|
||||
|
||||
function resolveBrandKey(): BrandKey {
|
||||
const key = import.meta.env.VITE_APP_BRAND as BrandKey;
|
||||
// 未指定品牌时(本地开发、analyze)默认按铂尔曼
|
||||
return key in PROFILES ? key : 'brm';
|
||||
}
|
||||
|
||||
export const currentBrandKey: BrandKey = resolveBrandKey();
|
||||
|
||||
export const currentBrand: BrandProfile = PROFILES[currentBrandKey];
|
||||
40
apps/web-antd/src/components/brand-logo.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 品牌横版 Logo(登录页)
|
||||
* 按后台主题(isDark)在亮/暗横版 SVG 间切换;高度放大以匹配轻奢气质。
|
||||
* 动效写在 SVG 内部(入场描边 / idle 呼吸 / hover 高光),img 也能播。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import { currentBrand } from '#/brand';
|
||||
|
||||
defineOptions({ name: 'BrandLogo' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 横版高度,登录页建议 56–64 */
|
||||
height?: number;
|
||||
}>(),
|
||||
{ height: 60 },
|
||||
);
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
|
||||
const src = computed(() =>
|
||||
isDark.value
|
||||
? currentBrand.logoHorizontalDark
|
||||
: currentBrand.logoHorizontal,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img
|
||||
:alt="currentBrand.name"
|
||||
class="brand-logo-horizontal"
|
||||
:height="height"
|
||||
:src="src"
|
||||
:style="{ height: `${height}px`, width: 'auto' }"
|
||||
/>
|
||||
</template>
|
||||
30
apps/web-antd/src/components/crud/crud-api.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface CrudApi {
|
||||
create: (data: Record<string, any>) => Promise<any>;
|
||||
delete: (data: Record<string, any>) => Promise<any>;
|
||||
detail: (id: number | string) => Promise<any>;
|
||||
list: (params?: Record<string, any>) => Promise<any>;
|
||||
option: (params?: Record<string, any>) => Promise<any>;
|
||||
status: (data: Record<string, any>) => Promise<any>;
|
||||
update: (data: Record<string, any>) => Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端每个业务模块的 CRUD 路径完全一致(BaseController 自动注册),
|
||||
* 逐个模块手写六个请求函数只是把同一段代码抄十二遍。
|
||||
*
|
||||
* @param prefix 模块路由前缀,如 'catalogue'
|
||||
*/
|
||||
export function createCrudApi(prefix: string): CrudApi {
|
||||
const base = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
||||
return {
|
||||
list: (params = {}) => requestClient.get<any>(`${base}list`, { params }),
|
||||
option: (params = {}) => requestClient.get<any>(`${base}option`, { params }),
|
||||
detail: (id) => requestClient.get<any>(`${base}detail`, { params: { id } }),
|
||||
create: (data) => requestClient.post<any>(`${base}create`, data),
|
||||
update: (data) => requestClient.post<any>(`${base}update`, data),
|
||||
delete: (data) => requestClient.post<any>(`${base}delete`, data),
|
||||
status: (data) => requestClient.post<any>(`${base}status`, data),
|
||||
};
|
||||
}
|
||||
98
apps/web-antd/src/components/crud/grid-options.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
interface CreateGridOptions {
|
||||
/** 除勾选列外的业务列 */
|
||||
columns: NonNullable<VxeGridProps<any>['columns']>;
|
||||
/** 追加或覆盖任意原生配置 */
|
||||
extra?: Partial<VxeGridProps<any>>;
|
||||
/** 列表请求,收到已合并的分页参数与搜索条件 */
|
||||
query: (params: Record<string, any>) => Promise<any>;
|
||||
/** 关闭分页;树表必须关闭,否则父节点被分到别页会建树失败 */
|
||||
pager?: boolean;
|
||||
/** 是否显示勾选列 */
|
||||
selectable?: boolean;
|
||||
/** 树表配置,传入即启用 */
|
||||
tree?: { expandAll?: boolean; parentField?: string; rowField?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页表格配置的公共部分。
|
||||
* 各模块的 table.ts 只写自己的列与请求,其余(勾选、工具栏、高度、代理)统一在这里。
|
||||
*/
|
||||
export function createGridOptions(options: CreateGridOptions): VxeGridProps<any> {
|
||||
const {
|
||||
columns,
|
||||
extra = {},
|
||||
pager = true,
|
||||
query,
|
||||
selectable = true,
|
||||
tree,
|
||||
} = options;
|
||||
|
||||
const fullColumns = selectable
|
||||
? [{ type: 'checkbox' as const, width: 60 }, ...columns]
|
||||
: columns;
|
||||
|
||||
const base: VxeGridProps<any> = {
|
||||
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<string, any>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
20
apps/web-antd/src/components/crud/index.ts
Normal file
@@ -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 ?? '';
|
||||
}
|
||||
12
apps/web-antd/src/components/crud/popup-data.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 弹窗 / 抽屉传参的类型化读取
|
||||
*
|
||||
* `modalApi.getData()` 的返回类型是 `{}`,直接 `.record` 会报 TS2339,
|
||||
* 而它的泛型重载在当前 vben 版本已被移除(传泛型报 TS2558)。
|
||||
* 脚手架里几十处都在硬吃这两个错误,新代码统一走这里,别再复制那个写法。
|
||||
*/
|
||||
export function popupData<T extends Record<string, any> = Record<string, any>>(
|
||||
api: { getData: () => unknown },
|
||||
): Partial<T> {
|
||||
return (api.getData() ?? {}) as Partial<T>;
|
||||
}
|
||||
72
apps/web-antd/src/components/crud/simple-name-modal.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import type { CrudApi } from './crud-api';
|
||||
|
||||
import { useCrudModal } from './use-crud-modal';
|
||||
|
||||
defineOptions({
|
||||
name: 'SimpleNameModal',
|
||||
});
|
||||
|
||||
/**
|
||||
* 字典型模块的表单弹窗。
|
||||
* 属性名刻意避开 title / class 等 Modal 自身的 state 键,否则 vben 会告警。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
crudApi: CrudApi;
|
||||
moduleTitle: string;
|
||||
nameLabel: string;
|
||||
}>();
|
||||
|
||||
const formProps: 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: `请输入${props.nameLabel}` },
|
||||
fieldName: 'name',
|
||||
label: props.nameLabel,
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 0 },
|
||||
{ label: '禁用', value: 1 },
|
||||
],
|
||||
},
|
||||
defaultValue: 0,
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
wrapperClass: 'grid-cols-12',
|
||||
};
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: props.crudApi,
|
||||
formProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="`${isUpdate ? '编辑' : '新增'}${moduleTitle}`"
|
||||
class="w-[32%]"
|
||||
>
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
115
apps/web-antd/src/components/crud/simple-name-page.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CrudApi } from './crud-api';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Switch } from 'ant-design-vue';
|
||||
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { actionColumn, createGridOptions, statusColumn } from './grid-options';
|
||||
import SimpleNameModal from './simple-name-modal.vue';
|
||||
import { useCrudTable } from './use-crud-table';
|
||||
|
||||
/**
|
||||
* 「名称 + 状态」两列的字典型模块页面(工厂分类、色卡分类等)。
|
||||
* 这类模块在老项目里是一模一样的 166 行 CRUD,抄多份没有意义。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
crudApi: CrudApi;
|
||||
/** 名称列标题,如「分类名称」 */
|
||||
nameLabel: string;
|
||||
/** 页面与弹窗标题,如「工厂分类」 */
|
||||
moduleTitle: string;
|
||||
}>();
|
||||
|
||||
const gridOptions = createGridOptions({
|
||||
columns: [
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 90 },
|
||||
{ field: 'name', align: 'left', title: props.nameLabel, minWidth: 220 },
|
||||
statusColumn,
|
||||
{ field: 'created_at', title: '创建时间', width: 180 },
|
||||
actionColumn(160),
|
||||
],
|
||||
query: (params) => props.crudApi.list(params),
|
||||
});
|
||||
|
||||
const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
useCrudTable({
|
||||
api: props.crudApi,
|
||||
defaultValues: { status: 0 },
|
||||
gridOptions,
|
||||
modalComponent: SimpleNameModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
status: row.status ?? 0,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height :title="moduleTitle">
|
||||
<FormModal
|
||||
:crud-api="crudApi"
|
||||
:module-title="moduleTitle"
|
||||
:name-label="nameLabel"
|
||||
/>
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的数据吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 0"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: any) => toggleStatus(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该数据吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
79
apps/web-antd/src/components/crud/use-crud-modal.ts
Normal file
@@ -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<string, any>) => Promise<any>;
|
||||
update: (data: Record<string, any>) => Promise<any>;
|
||||
};
|
||||
formProps: VbenFormProps;
|
||||
/** 提交前处理表单值,如图片数组转字符串 */
|
||||
transform?: (
|
||||
values: Record<string, any>,
|
||||
isUpdate: boolean,
|
||||
) => Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单弹窗的公共装配:打开时回填、提交时按 update 走不同接口、成功后刷新列表
|
||||
*/
|
||||
export function useCrudModal(options: UseCrudModalOptions) {
|
||||
const { api, formProps, transform } = options;
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const gridApi = ref<any>();
|
||||
|
||||
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<string, any>);
|
||||
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 };
|
||||
}
|
||||
119
apps/web-antd/src/components/crud/use-crud-table.ts
Normal file
@@ -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<string, any>) => Promise<any>;
|
||||
status?: (data: Record<string, any>) => Promise<any>;
|
||||
};
|
||||
/** 打开新增弹窗时的初始值 */
|
||||
defaultValues?: Record<string, any>;
|
||||
formOptions?: VbenFormProps;
|
||||
gridOptions: VxeGridProps<any>;
|
||||
/** 表单弹窗组件 */
|
||||
modalComponent: Component;
|
||||
/** 行数据 → 表单值,处理图片字符串转数组这类差异 */
|
||||
toFormValues?: (row: Record<string, any>) => Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页的公共装配:表格 + 勾选状态 + 表单弹窗 + 删除 + 状态开关
|
||||
*
|
||||
* 每个模块的 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<any> = {
|
||||
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<string, any>, 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<string, any>, 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,
|
||||
};
|
||||
}
|
||||
186
apps/web-antd/src/components/customer/user-detail-modal.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SearchSelectOption } from '#/components/search-select';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Descriptions, DescriptionsItem, Empty, Spin, Table, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { getListByUser } from '#/views/customer/list/api';
|
||||
import { LIST_STATUS_TEXT } from '#/views/customer/list/config/constants';
|
||||
import { getListByOrder as getOrdersByUser } from '#/views/customer/order/api';
|
||||
import { ORDER_PAY_STATUS_TEXT, ORDER_STATUS_TEXT } from '#/views/customer/order/config/constants';
|
||||
import { wxUserApi } from '#/views/customer/wx-user/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerUserDetailModal',
|
||||
});
|
||||
|
||||
/**
|
||||
* 用户详情模态框:被清单、订单列表复用,点客户名时打开。
|
||||
* 三个 tab:基本信息、该用户的清单、该用户的订单。
|
||||
* 数据从 wx-user/detail、list/by-user、order/by-user 三个接口拉,
|
||||
* 不复用主列表的 gridApi,自己内部维护三个 ref。
|
||||
*/
|
||||
const userId = ref(0);
|
||||
const loading = ref(false);
|
||||
const info = ref<Record<string, any>>({});
|
||||
const lists = ref<any[]>([]);
|
||||
const orders = ref<any[]>([]);
|
||||
const activeTab = ref<'base' | 'list' | 'order'>('base');
|
||||
|
||||
const listColumns = [
|
||||
{ dataIndex: 'id', title: 'ID', width: 80 },
|
||||
{ dataIndex: 'list_no', title: '清单号' },
|
||||
{ dataIndex: 'name', title: '名称' },
|
||||
{ dataIndex: 'item_count', title: '商品数', width: 100 },
|
||||
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
|
||||
{ dataIndex: 'created_at', title: '创建时间', width: 180 },
|
||||
];
|
||||
|
||||
const orderColumns = [
|
||||
{ dataIndex: 'id', title: 'ID', width: 80 },
|
||||
{ dataIndex: 'order_no', title: '订单号' },
|
||||
{ dataIndex: 'total_amount_text', title: '总金额', width: 120 },
|
||||
{ dataIndex: 'status', key: 'status', title: '订单状态', width: 110 },
|
||||
{ dataIndex: 'pay_status', key: 'pay_status', title: '支付状态', width: 110 },
|
||||
{ dataIndex: 'created_at', title: '下单时间', width: 180 },
|
||||
];
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
// 调用方 setData({ userId }) 或 setData({ record })
|
||||
const data = popupData<{ userId?: number; record?: Record<string, any> }>(
|
||||
modalApi,
|
||||
);
|
||||
const id = Number(data.userId ?? data.record?.id ?? 0);
|
||||
if (id <= 0) {
|
||||
return;
|
||||
}
|
||||
userId.value = id;
|
||||
activeTab.value = 'base';
|
||||
await loadAll();
|
||||
},
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
modalApi.setState({ loading: true });
|
||||
try {
|
||||
// 三个接口相互独立,并行拉
|
||||
const [detail, userLists, userOrders] = await Promise.all([
|
||||
wxUserApi.detail(userId.value),
|
||||
getListByUser(userId.value),
|
||||
getOrdersByUser(userId.value),
|
||||
]);
|
||||
info.value = detail ?? {};
|
||||
lists.value = userLists ?? [];
|
||||
orders.value = userOrders ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
modalApi.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** 兼容外部以 record 传入(列表行)的场景:暴露对外选用户 */
|
||||
defineExpose({
|
||||
open(record: SearchSelectOption | Record<string, any>) {
|
||||
modalApi.setData({ record });
|
||||
modalApi.open();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[680px]" :footer="false" title="客户详情">
|
||||
<Spin :spinning="loading">
|
||||
<Empty v-if="userId === 0" description="未选择客户" />
|
||||
<Tabs v-else v-model:active-key="activeTab">
|
||||
<Tabs.TabPane key="base" tab="基本信息">
|
||||
<Alert
|
||||
v-if="!info.id"
|
||||
class="mb-3"
|
||||
message="加载中..."
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
<Descriptions v-else bordered :column="2" size="small">
|
||||
<DescriptionsItem label="昵称">
|
||||
{{ info.nick_name ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="手机号">
|
||||
{{ info.phone || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="所属企业">
|
||||
{{ info.enterprise_name || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="上级代理">
|
||||
{{ info.parent_name || '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="价格倍率">
|
||||
x{{ info.price_number ?? '1' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="代理商">
|
||||
<Tag :color="info.is_p === 1 ? 'blue' : 'default'">
|
||||
{{ info.is_p === 1 ? '代理商' : '普通' }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="下级数量">
|
||||
{{ info.child_count ?? 0 }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="注册时间">
|
||||
{{ info.created_at ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="list" :tab="`清单 (${lists.length})`">
|
||||
<Empty v-if="lists.length === 0" description="该用户还没有清单" />
|
||||
<Table
|
||||
v-else
|
||||
:columns="listColumns"
|
||||
:data-source="lists"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: row }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag :color="row.status === 1 ? 'green' : 'default'">
|
||||
{{ LIST_STATUS_TEXT[row.status] ?? '-' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="order" :tab="`订单 (${orders.length})`">
|
||||
<Empty v-if="orders.length === 0" description="该用户还没有订单" />
|
||||
<Table
|
||||
v-else
|
||||
:columns="orderColumns"
|
||||
:data-source="orders"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: row }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<Tag>{{ ORDER_STATUS_TEXT[row.status] ?? '-' }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'pay_status'">
|
||||
<Tag>{{ ORDER_PAY_STATUS_TEXT[row.pay_status] ?? '-' }}</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
89
apps/web-antd/src/components/form/components/upload-doc.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Button, message, Upload } from 'ant-design-vue';
|
||||
|
||||
import { uploadDocument } from '#/api/core/upload';
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
defineOptions({
|
||||
name: 'UploadDoc',
|
||||
});
|
||||
|
||||
/**
|
||||
* 单文档上传(PDF 等)。
|
||||
* 表单里存的是可访问的 URL 字符串,与后端字段直接对应。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accept?: string;
|
||||
modelValue?: string;
|
||||
}>(),
|
||||
{
|
||||
accept: '.pdf',
|
||||
modelValue: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const uploading = ref(false);
|
||||
|
||||
const fileName = computed(() => {
|
||||
if (!props.modelValue) {
|
||||
return '';
|
||||
}
|
||||
return decodeURIComponent(props.modelValue.split('/').pop() ?? '');
|
||||
});
|
||||
|
||||
async function customRequest(options: any) {
|
||||
const { file, onError, onSuccess } = options;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const res: any = await uploadDocument({ file });
|
||||
emit('update:modelValue', res?.url ?? '');
|
||||
onSuccess(res);
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
onError(error);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Upload
|
||||
:accept="accept"
|
||||
:custom-request="customRequest"
|
||||
:show-upload-list="false"
|
||||
>
|
||||
<Button :loading="uploading">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:cloud-upload-outlined" />
|
||||
</template>
|
||||
选择文件
|
||||
</Button>
|
||||
</Upload>
|
||||
<template v-if="modelValue">
|
||||
<a
|
||||
:href="modelValue"
|
||||
class="max-w-[220px] truncate text-primary"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
:title="fileName"
|
||||
>
|
||||
{{ fileName }}
|
||||
</a>
|
||||
<Button danger size="small" type="link" @click="clear">移除</Button>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground text-xs">未上传</span>
|
||||
</div>
|
||||
</template>
|
||||
401
apps/web-antd/src/components/product/brand-album-loading.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 图册模式品牌化 Loading
|
||||
* - initial:首屏骨架墙 + 品牌线框徽标呼吸
|
||||
* - more:触底加载条,不整页闪烁
|
||||
* brm 香槟金靠背椅意象;wl 暖黄铜榫卯 W 意象
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { currentBrand, currentBrandKey } from '#/brand';
|
||||
|
||||
defineOptions({ name: 'BrandAlbumLoading' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 骨架卡数量(仅 initial) */
|
||||
count?: number;
|
||||
mode?: 'initial' | 'more';
|
||||
}>(),
|
||||
{
|
||||
count: 8,
|
||||
mode: 'initial',
|
||||
},
|
||||
);
|
||||
|
||||
const isBrm = computed(() => currentBrandKey === 'brm');
|
||||
const brandLabel = computed(() => currentBrand.name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 触底加载:细金属线扫光 + 文案 -->
|
||||
<div
|
||||
v-if="mode === 'more'"
|
||||
class="brand-load-more"
|
||||
:class="isBrm ? 'is-brm' : 'is-wl'"
|
||||
role="status"
|
||||
>
|
||||
<div class="brand-load-more__rail">
|
||||
<span class="brand-load-more__sheen"></span>
|
||||
</div>
|
||||
<div class="brand-load-more__meta">
|
||||
<span class="brand-load-more__mark" aria-hidden="true">
|
||||
<svg v-if="isBrm" viewBox="0 0 32 32" class="brand-load-more__svg">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.6"
|
||||
>
|
||||
<path d="M8 28V9c0-2.2 1.7-3.5 4-3.5h2c1.7 0 2.7 1.2 2.7 3.2V18" />
|
||||
<path d="M16.7 19h8c1.5 0 2.8 1 2.8 2.7V24H12" />
|
||||
<path d="M16.7 14h6c1.2 0 2 .8 2 2" />
|
||||
<path d="M13 24v4M25 24v4" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 32 32" class="brand-load-more__svg">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="miter"
|
||||
stroke-width="1.6"
|
||||
>
|
||||
<path d="M4 10 L10 4 H22 Q28 4 28 10 V22 Q28 28 22 28 H10 Q4 28 4 22 Z" />
|
||||
<path d="M10 9 L14 22 L16.5 14" />
|
||||
<path d="M16.5 17 L19 22 L22 9" />
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-load-more__text">正在载入更多 {{ brandLabel }} 图册…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 首屏:骨架墙 + 中央品牌徽标 -->
|
||||
<div
|
||||
v-else
|
||||
class="brand-album-loading"
|
||||
:class="isBrm ? 'is-brm' : 'is-wl'"
|
||||
role="status"
|
||||
:aria-label="`${brandLabel}图册加载中`"
|
||||
>
|
||||
<div class="brand-album-loading__hero">
|
||||
<div class="brand-album-loading__orb">
|
||||
<svg v-if="isBrm" viewBox="0 0 128 128" class="brand-album-loading__logo">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
class="logo-frame"
|
||||
x="10"
|
||||
y="10"
|
||||
width="108"
|
||||
height="108"
|
||||
rx="28"
|
||||
stroke-width="2.4"
|
||||
/>
|
||||
<rect
|
||||
x="18"
|
||||
y="18"
|
||||
width="92"
|
||||
height="92"
|
||||
rx="22"
|
||||
stroke-width="1"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<g stroke-width="3">
|
||||
<path d="M40 102V36c0-9 7-14 16-14h8c7 0 11 5 11 13v39" />
|
||||
<path d="M75 74h30c6 0 11 4 11 11v7H52" />
|
||||
<path d="M75 56h24c5 0 8 3 8 8" />
|
||||
<path d="M56 92v14M98 92v14" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 128 128" class="brand-album-loading__logo">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="miter"
|
||||
>
|
||||
<path
|
||||
class="logo-frame"
|
||||
stroke-width="2.4"
|
||||
d="M12 36 L36 12 L92 12 Q116 12 116 36 L116 92 Q116 116 92 116 L36 116 Q12 116 12 92 Z"
|
||||
/>
|
||||
<path
|
||||
stroke-width="1"
|
||||
opacity="0.45"
|
||||
d="M20 38 L38 20 L90 20 Q108 20 108 38 L108 90 Q108 108 90 108 L38 108 Q20 108 20 90 Z"
|
||||
/>
|
||||
<g stroke-width="3.2">
|
||||
<path d="M36 42 L50 88 L64 58" />
|
||||
<path d="M64 68 L78 88 L92 42" />
|
||||
</g>
|
||||
<path stroke-width="2" opacity="0.8" d="M42 102 L86 102" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-album-loading__caption">
|
||||
<span class="brand-album-loading__title">{{ brandLabel }}</span>
|
||||
<span class="brand-album-loading__sub">图册加载中</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="brand-album-loading__grid">
|
||||
<div
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="brand-skel"
|
||||
:style="{ animationDelay: `${(i - 1) * 0.08}s` }"
|
||||
>
|
||||
<div class="brand-skel__img"></div>
|
||||
<div class="brand-skel__line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-album-loading,
|
||||
.brand-load-more {
|
||||
--brand-metal: hsl(38 52% 52%);
|
||||
--brand-metal-soft: hsl(38 40% 62% / 0.35);
|
||||
}
|
||||
|
||||
.brand-album-loading.is-wl,
|
||||
.brand-load-more.is-wl {
|
||||
--brand-metal: hsl(28 42% 45%);
|
||||
--brand-metal-soft: hsl(28 36% 55% / 0.35);
|
||||
}
|
||||
|
||||
.brand-album-loading {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
padding: 8px 4px 16px;
|
||||
}
|
||||
|
||||
.brand-album-loading__hero {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-album-loading__orb {
|
||||
display: flex;
|
||||
height: 88px;
|
||||
width: 88px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--brand-metal-soft);
|
||||
border-radius: 24px;
|
||||
background: hsl(var(--card, var(--background)) / 0.72);
|
||||
box-shadow: 0 12px 40px hsl(var(--foreground) / 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--brand-metal);
|
||||
animation: brand-orb-breath 3.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-album-loading__logo {
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
.logo-frame {
|
||||
animation: brand-frame-draw 1.4s ease-out both;
|
||||
}
|
||||
|
||||
.brand-album-loading__caption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--card, var(--background)) / 0.78);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.brand-album-loading__title {
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.brand-album-loading__sub {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.brand-album-loading__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.brand-skel {
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
animation: brand-skel-rise 0.7s ease-out both;
|
||||
}
|
||||
|
||||
.brand-skel__img {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.brand-skel__img::after,
|
||||
.brand-skel__line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 30%,
|
||||
var(--brand-metal-soft) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: brand-sheen 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-skel__line {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin: 14px 12px 16px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.brand-load-more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 12px 10px;
|
||||
}
|
||||
|
||||
.brand-load-more__rail {
|
||||
position: relative;
|
||||
width: min(280px, 70%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--border));
|
||||
}
|
||||
|
||||
.brand-load-more__sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 40%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--brand-metal),
|
||||
transparent
|
||||
);
|
||||
animation: brand-rail 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-load-more__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.brand-load-more__mark {
|
||||
display: inline-flex;
|
||||
color: var(--brand-metal);
|
||||
animation: brand-orb-breath 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-load-more__svg {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
@keyframes brand-orb-breath {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.82;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-frame-draw {
|
||||
from {
|
||||
stroke-dasharray: 360;
|
||||
stroke-dashoffset: 360;
|
||||
}
|
||||
to {
|
||||
stroke-dasharray: 360;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-sheen {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-skel-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-rail {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(280%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-album-loading__orb,
|
||||
.brand-load-more__mark,
|
||||
.brand-skel__img::after,
|
||||
.brand-skel__line::after,
|
||||
.brand-load-more__sheen,
|
||||
.logo-frame,
|
||||
.brand-skel {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
apps/web-antd/src/components/product/index.ts
Normal file
@@ -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';
|
||||
127
apps/web-antd/src/components/product/product-card.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ProductCardProps, ProductRecord } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Checkbox, Skeleton, Tag } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { thumbUrl } from '#/util/media';
|
||||
|
||||
defineOptions({
|
||||
name: 'ProductCard',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<ProductCardProps>(), {
|
||||
actions: () => [],
|
||||
layout: 'grid',
|
||||
loading: false,
|
||||
selectable: false,
|
||||
selected: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [record: ProductRecord];
|
||||
preview: [record: ProductRecord];
|
||||
select: [checked: boolean, record: ProductRecord];
|
||||
}>();
|
||||
|
||||
const isList = computed(() => props.layout === 'list');
|
||||
|
||||
const title = computed(
|
||||
() => props.record.title || props.record.name || '未命名商品',
|
||||
);
|
||||
|
||||
/** 编号优先,没编号退别名,两个都没有就不占位置 */
|
||||
const subTitle = computed(() => props.record.identifier || props.record.alias || '');
|
||||
|
||||
const cover = computed(() =>
|
||||
props.record.cover ? thumbUrl(props.record.cover, isList.value ? 240 : 360) : '',
|
||||
);
|
||||
|
||||
const priceText = computed(() => {
|
||||
const price = props.record.price;
|
||||
if (price === undefined || price === null || price === '') {
|
||||
return '价格待询';
|
||||
}
|
||||
return `¥ ${price}`;
|
||||
});
|
||||
|
||||
const specCount = computed(
|
||||
() => props.record.spec_count ?? props.record.price_sheet?.length ?? 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'group bg-card relative overflow-hidden rounded-lg border transition-all duration-200',
|
||||
isList ? 'flex items-stretch' : 'flex flex-col',
|
||||
loading
|
||||
? ''
|
||||
: 'cursor-pointer hover:-translate-y-1 hover:shadow-lg hover:border-primary/40',
|
||||
selected ? 'border-primary shadow-md' : '',
|
||||
]"
|
||||
@click="loading ? undefined : emit('click', record)"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<div class="w-full p-3">
|
||||
<Skeleton.Image class="!h-[140px] !w-full" />
|
||||
<Skeleton active class="mt-3" :paragraph="{ rows: 2 }" :title="false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Checkbox
|
||||
v-if="selectable"
|
||||
class="absolute right-2 top-2 z-10"
|
||||
:checked="selected"
|
||||
@change="(e: any) => emit('select', !!e.target.checked, record)"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'bg-muted/40 flex shrink-0 items-center justify-center overflow-hidden',
|
||||
isList ? 'w-[160px]' : 'h-[160px] w-full',
|
||||
]"
|
||||
@click.stop="emit('preview', record)"
|
||||
>
|
||||
<img
|
||||
v-if="cover"
|
||||
:alt="title"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
:src="cover"
|
||||
/>
|
||||
<Icon v-else class="text-muted-foreground" icon="lucide:image-off" :size="28" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 p-3">
|
||||
<div class="truncate font-medium" :title="title">{{ title }}</div>
|
||||
<div v-if="subTitle" class="text-muted-foreground truncate text-xs">
|
||||
{{ subTitle }}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<Tag v-if="record.category_name" color="blue">
|
||||
{{ record.category_name }}
|
||||
</Tag>
|
||||
<Tag v-if="specCount > 0">{{ specCount }} 个规格</Tag>
|
||||
</div>
|
||||
<div class="text-primary mt-auto text-sm font-semibold">
|
||||
{{ priceText }}
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.actions || actions.length > 0"
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<slot name="actions" :record="record">
|
||||
<TableAction :actions="actions" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
287
apps/web-antd/src/components/product/product-list.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 商品列表:网格 / 列表 / 真瀑布流
|
||||
* 瀑布流用固定卡宽 + translate3d 落最短列(与报价单图册模式一致),
|
||||
* 不要用等分 CSS grid 冒充 masonry。
|
||||
*/
|
||||
import type {
|
||||
ProductFetchResult,
|
||||
ProductLayout,
|
||||
ProductListProps,
|
||||
ProductRecord,
|
||||
} from './types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { Empty, Pagination, Segmented, Spin } from 'ant-design-vue';
|
||||
|
||||
import ProductCard from './product-card.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ProductList',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<ProductListProps>(), {
|
||||
cardActions: undefined,
|
||||
defaultLayout: 'grid',
|
||||
pageSize: 24,
|
||||
params: () => ({}),
|
||||
rowKey: 'id',
|
||||
selectable: false,
|
||||
selected: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'card-click': [record: ProductRecord];
|
||||
preview: [record: ProductRecord];
|
||||
'update:selected': [keys: (number | string)[]];
|
||||
}>();
|
||||
|
||||
/** 首屏骨架卡数量,和一行放得下的张数对齐,视觉上不会缺一块 */
|
||||
const SKELETON_COUNT = 8;
|
||||
const WATERFALL_CARD_WIDTH = 240;
|
||||
|
||||
const layout = ref<ProductLayout>(props.defaultLayout);
|
||||
const loading = ref(false);
|
||||
const items = ref<ProductRecord[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const size = ref(props.pageSize);
|
||||
|
||||
const waterfallRef = ref<HTMLElement | null>(null);
|
||||
let columnHeights: number[] = [];
|
||||
let lastRenderedIndex = 0;
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const layoutOptions = computed(() => {
|
||||
const options = [
|
||||
{ label: '网格', value: 'grid' },
|
||||
{ label: '列表', value: 'list' },
|
||||
];
|
||||
// 仅当默认就是瀑布流时开放第三档,避免普通浏览页多一个无用开关
|
||||
if (props.defaultLayout === 'waterfall') {
|
||||
options.push({ label: '瀑布流', value: 'waterfall' });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const showSkeleton = computed(() => loading.value && items.value.length === 0);
|
||||
|
||||
const containerClass = computed(() => {
|
||||
if (layout.value === 'list') {
|
||||
return 'flex flex-col gap-3';
|
||||
}
|
||||
if (layout.value === 'waterfall') {
|
||||
return 'waterfall-container';
|
||||
}
|
||||
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5';
|
||||
});
|
||||
|
||||
function keyOf(record: ProductRecord) {
|
||||
return record[props.rowKey];
|
||||
}
|
||||
|
||||
function calculateLayout() {
|
||||
if (!waterfallRef.value) {
|
||||
return null;
|
||||
}
|
||||
const containerWidth = waterfallRef.value.clientWidth;
|
||||
const columns = Math.max(1, Math.floor(containerWidth / WATERFALL_CARD_WIDTH));
|
||||
const space =
|
||||
(containerWidth - columns * WATERFALL_CARD_WIDTH) / (columns + 1);
|
||||
return { space, columns };
|
||||
}
|
||||
|
||||
/** 真瀑布流布局:落到当前最短列 */
|
||||
function updateWaterfallLayout(isAppend = false) {
|
||||
if (layout.value !== 'waterfall' || !waterfallRef.value) {
|
||||
return;
|
||||
}
|
||||
const info = calculateLayout();
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
const children = waterfallRef.value.children;
|
||||
if (!isAppend || columnHeights.length !== info.columns) {
|
||||
columnHeights = new Array(info.columns).fill(0);
|
||||
lastRenderedIndex = 0;
|
||||
}
|
||||
for (let i = lastRenderedIndex; i < children.length; i++) {
|
||||
const item = children[i] as HTMLElement;
|
||||
const minTop = Math.min(...columnHeights);
|
||||
const minIndex = columnHeights.indexOf(minTop);
|
||||
const left = (minIndex + 1) * info.space + minIndex * WATERFALL_CARD_WIDTH;
|
||||
item.style.width = `${WATERFALL_CARD_WIDTH}px`;
|
||||
item.style.transform = `translate3d(${left}px, ${minTop}px, 0)`;
|
||||
columnHeights[minIndex] += item.offsetHeight + 16;
|
||||
}
|
||||
lastRenderedIndex = children.length;
|
||||
waterfallRef.value.style.height = `${Math.max(...columnHeights, 0)}px`;
|
||||
}
|
||||
|
||||
function onWaterfallResize() {
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer);
|
||||
}
|
||||
resizeTimer = setTimeout(() => {
|
||||
requestAnimationFrame(() => updateWaterfallLayout(false));
|
||||
}, 120);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: ProductFetchResult = await props.fetch({
|
||||
...props.params,
|
||||
page: page.value,
|
||||
pageSize: size.value,
|
||||
});
|
||||
items.value = res?.items ?? [];
|
||||
total.value = res?.total ?? 0;
|
||||
await nextTick();
|
||||
updateWaterfallLayout(false);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认回到第一页:筛选条件变了还停在第 5 页大概率是空的 */
|
||||
async function reload(resetPage = true) {
|
||||
if (resetPage) {
|
||||
page.value = 1;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
watch(() => props.params, () => reload(), { deep: true });
|
||||
watch(layout, async () => {
|
||||
await nextTick();
|
||||
updateWaterfallLayout(false);
|
||||
});
|
||||
|
||||
function onPageChange(current: number, pageSize: number) {
|
||||
page.value = current;
|
||||
size.value = pageSize;
|
||||
load();
|
||||
}
|
||||
|
||||
function isSelected(record: ProductRecord) {
|
||||
return props.selected.includes(keyOf(record));
|
||||
}
|
||||
|
||||
function onSelect(checked: boolean, record: ProductRecord) {
|
||||
const key = keyOf(record);
|
||||
emit(
|
||||
'update:selected',
|
||||
checked
|
||||
? [...props.selected, key]
|
||||
: props.selected.filter((item) => item !== key),
|
||||
);
|
||||
}
|
||||
|
||||
/** 只在当前页数据里找;跨页勾选的行不在内存里,需要的话调用方按 key 自己取 */
|
||||
function getSelected(): ProductRecord[] {
|
||||
return items.value.filter((item) => isSelected(item));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onWaterfallResize);
|
||||
load();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onWaterfallResize);
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ getSelected, reload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<slot name="filter"></slot>
|
||||
</div>
|
||||
<Segmented
|
||||
:options="layoutOptions"
|
||||
:value="layout"
|
||||
@change="(value: any) => (layout = value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading && items.length > 0" class="min-h-0 flex-1 overflow-auto">
|
||||
<div v-if="showSkeleton" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<ProductCard
|
||||
v-for="index in SKELETON_COUNT"
|
||||
:key="`skeleton-${index}`"
|
||||
:layout="layout === 'waterfall' ? 'grid' : layout"
|
||||
loading
|
||||
:record="{}"
|
||||
/>
|
||||
</div>
|
||||
<Empty v-else-if="items.length === 0" class="py-10" description="没有符合条件的商品" />
|
||||
<div
|
||||
v-else
|
||||
ref="waterfallRef"
|
||||
:class="[containerClass, layout === 'waterfall' ? 'product-waterfall' : '']"
|
||||
>
|
||||
<div
|
||||
v-for="record in items"
|
||||
:key="String(keyOf(record))"
|
||||
:class="layout === 'waterfall' ? 'product-waterfall__item' : ''"
|
||||
>
|
||||
<ProductCard
|
||||
:actions="cardActions ? cardActions(record) : []"
|
||||
:layout="layout === 'waterfall' ? 'grid' : layout"
|
||||
:record="record"
|
||||
:selectable="selectable"
|
||||
:selected="isSelected(record)"
|
||||
@click="emit('card-click', record)"
|
||||
@preview="emit('preview', record)"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template v-if="$slots.actions" #actions="{ record: row }">
|
||||
<slot name="actions" :record="row"></slot>
|
||||
</template>
|
||||
</ProductCard>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Pagination
|
||||
:current="page"
|
||||
:page-size="size"
|
||||
:page-size-options="['12', '24', '48', '96']"
|
||||
show-size-changer
|
||||
:show-total="(count: number) => `共 ${count} 个商品`"
|
||||
:total="total"
|
||||
@change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-waterfall {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-waterfall__item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
64
apps/web-antd/src/components/product/types.ts
Normal file
@@ -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<ProductFetchResult>;
|
||||
pageSize?: number;
|
||||
/** 筛选条件由外部持有(配合 filter 插槽),变化即回到第一页重查 */
|
||||
params?: Record<string, any>;
|
||||
/** 取哪个字段当选中标识 */
|
||||
rowKey?: string;
|
||||
selectable?: boolean;
|
||||
selected?: (number | string)[];
|
||||
}
|
||||
2
apps/web-antd/src/components/search-select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { SearchSelectOption } from './types';
|
||||
export { default as SearchSelect } from './search-select.vue';
|
||||
306
apps/web-antd/src/components/search-select/search-select.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SearchSelectOption } from './types';
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Empty, Input, Popover, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
defineOptions({
|
||||
name: 'SearchSelect',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 远程搜索,收到关键词返回候选项 */
|
||||
api: (params: {
|
||||
keyword: string;
|
||||
limit: number;
|
||||
}) => Promise<SearchSelectOption[]>;
|
||||
/** 网格列数,影响左右方向键的步进 */
|
||||
columns?: number;
|
||||
disabled?: boolean;
|
||||
/** 描述字段,显示在名称下方 */
|
||||
descField?: string;
|
||||
labelField?: string;
|
||||
limit?: number;
|
||||
/** 头像/图标字段 */
|
||||
logoField?: string;
|
||||
modelValue?: null | number | string;
|
||||
/** 已选项的显示文本,编辑回填时用(避免为了显示一个名字再请求一次) */
|
||||
modelLabel?: string;
|
||||
placeholder?: string;
|
||||
valueField?: string;
|
||||
}>(),
|
||||
{
|
||||
columns: 2,
|
||||
descField: 'phone',
|
||||
disabled: false,
|
||||
labelField: 'name',
|
||||
limit: 30,
|
||||
logoField: 'logo',
|
||||
modelLabel: '',
|
||||
modelValue: undefined,
|
||||
placeholder: '输入关键词搜索',
|
||||
valueField: 'id',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [option: SearchSelectOption | undefined];
|
||||
'update:modelValue': [value: null | number | string | undefined];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const keyword = ref('');
|
||||
const loading = ref(false);
|
||||
const options = ref<SearchSelectOption[]>([]);
|
||||
const activeIndex = ref(-1);
|
||||
const selectedLabel = ref(props.modelLabel);
|
||||
|
||||
let timer: any;
|
||||
/** 慢请求会在快请求之后返回并覆盖结果,用 AbortController 掐掉过期请求 */
|
||||
let controller: AbortController | undefined;
|
||||
|
||||
const displayText = computed(() => selectedLabel.value || '');
|
||||
|
||||
watch(
|
||||
() => props.modelLabel,
|
||||
(label) => {
|
||||
selectedLabel.value = label;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
selectedLabel.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function fetchOptions() {
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
loading.value = true;
|
||||
try {
|
||||
const list = await props.api({
|
||||
keyword: keyword.value.trim(),
|
||||
limit: props.limit,
|
||||
});
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
options.value = list ?? [];
|
||||
activeIndex.value = options.value.length > 0 ? 0 : -1;
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFetch() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(fetchOptions, 300);
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
open.value = true;
|
||||
if (options.value.length === 0) {
|
||||
fetchOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
open.value = false;
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
|
||||
function pick(option: SearchSelectOption) {
|
||||
const value = option[props.valueField];
|
||||
selectedLabel.value = String(option[props.labelField] ?? '');
|
||||
emit('update:modelValue', value as number | string);
|
||||
emit('change', option);
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedLabel.value = '';
|
||||
keyword.value = '';
|
||||
emit('update:modelValue', undefined);
|
||||
emit('change', undefined);
|
||||
}
|
||||
|
||||
/** 网格里按列步进,越界后首尾循环,保证键盘操作不会卡在边缘 */
|
||||
function move(step: number) {
|
||||
const total = options.value.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
const next = (activeIndex.value + step + total * 2) % total;
|
||||
activeIndex.value = next;
|
||||
nextTick(() => {
|
||||
document
|
||||
.querySelector(`[data-search-option="${next}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!open.value && ['ArrowDown', 'Enter'].includes(event.key)) {
|
||||
openPanel();
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
move(props.columns);
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
move(-props.columns);
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
// 中文输入法下回车是在确认拼音,此时不能当成选中
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const option = options.value[activeIndex.value];
|
||||
if (option) {
|
||||
pick(option);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
closePanel();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(timer);
|
||||
controller?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
v-model:open="open"
|
||||
:arrow="false"
|
||||
overlay-class-name="search-select-popover"
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
>
|
||||
<template #content>
|
||||
<div class="w-[420px]">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="options.length === 0" class="py-6">
|
||||
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="没有匹配的结果" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid max-h-[320px] gap-2 overflow-y-auto"
|
||||
:style="{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }"
|
||||
role="listbox"
|
||||
:aria-activedescendant="`search-option-${activeIndex}`"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:id="`search-option-${index}`"
|
||||
:key="String(option[valueField])"
|
||||
:class="[
|
||||
'flex cursor-pointer items-center gap-2 rounded-md border p-2 transition-colors',
|
||||
index === activeIndex
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-transparent hover:bg-accent',
|
||||
]"
|
||||
:data-search-option="index"
|
||||
role="option"
|
||||
:aria-selected="index === activeIndex"
|
||||
@click="pick(option)"
|
||||
@mouseenter="activeIndex = index"
|
||||
>
|
||||
<img
|
||||
v-if="option[logoField]"
|
||||
:src="String(option[logoField])"
|
||||
class="h-9 w-9 shrink-0 rounded object-cover"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-accent text-xs"
|
||||
>
|
||||
{{ String(option[labelField] ?? '').slice(0, 1) }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">{{ option[labelField] }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{{ option[descField] || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<Tag v-if="option.price_number && option.price_number !== '1'">
|
||||
x{{ option.price_number }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>↑ ↓ ← → 选择,回车确认,Esc 关闭</span>
|
||||
<span>共 {{ options.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
:allow-clear="false"
|
||||
:disabled="disabled"
|
||||
:placeholder="displayText || placeholder"
|
||||
@focus="openPanel"
|
||||
@input="scheduleFetch"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<button
|
||||
v-if="displayText"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
@click.stop="clear"
|
||||
>
|
||||
<Icon icon="ant-design:close-circle-outlined" />
|
||||
</button>
|
||||
</template>
|
||||
</Input>
|
||||
<div v-if="displayText" class="mt-1 truncate text-xs text-primary">
|
||||
已选:{{ displayText }}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
7
apps/web-antd/src/components/search-select/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SearchSelectOption {
|
||||
[key: string]: unknown;
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
/** 企业默认价格倍率,命中时在候选卡片上显示 */
|
||||
price_number?: string;
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
<!-- 登录页用横版标 + 放大高度;覆盖默认 42px 方标 -->
|
||||
<template #logo>
|
||||
<div class="absolute top-0 left-0 z-10 flex flex-1">
|
||||
<div class="mt-4 ml-4 flex flex-1 items-center sm:top-6 sm:left-6">
|
||||
<BrandLogo :height="60" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AuthPageLayout>
|
||||
</template>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
apps/web-antd/src/util/media.ts
Normal file
@@ -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);
|
||||
}
|
||||
8
apps/web-antd/src/views/color/card-class/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
export const cardClassApi = createCrudApi('card-class');
|
||||
|
||||
/** 色卡分类下拉 */
|
||||
export async function getCardClassOption() {
|
||||
return (await cardClassApi.option()) ?? [];
|
||||
}
|
||||
13
apps/web-antd/src/views/color/card-class/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { SimpleNamePage } from '#/components/crud';
|
||||
|
||||
import { cardClassApi } from './api';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SimpleNamePage
|
||||
:crud-api="cardClassApi"
|
||||
module-title="色卡分类"
|
||||
name-label="分类名称"
|
||||
/>
|
||||
</template>
|
||||
3
apps/web-antd/src/views/color/card/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
export const colorcardApi = createCrudApi('colorcard');
|
||||
22
apps/web-antd/src/views/color/card/components/modal.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { firstImage, useCrudModal } from '#/components/crud';
|
||||
|
||||
import { colorcardApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'ColorcardFormModal',
|
||||
});
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: colorcardApi,
|
||||
formProps: modalFormProps,
|
||||
transform: (values) => ({ ...values, cover: firstImage(values.cover) }),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}色卡`" class="w-[46%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
81
apps/web-antd/src/views/color/card/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
59
apps/web-antd/src/views/color/card/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
22
apps/web-antd/src/views/color/card/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||
97
apps/web-antd/src/views/color/card/index.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Image, Switch } from 'ant-design-vue';
|
||||
|
||||
import { toImageList, useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { colorcardApi } from './api';
|
||||
import ColorcardFormModal from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
useCrudTable({
|
||||
api: colorcardApi,
|
||||
defaultValues: { status: 0 },
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: ColorcardFormModal,
|
||||
toFormValues: (row) => ({
|
||||
card_class: row.card_class,
|
||||
company: row.company,
|
||||
cover: toImageList(row.cover),
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
price: row.price,
|
||||
status: row.status ?? 0,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="色卡管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增色卡',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的色卡吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #cover="{ row }">
|
||||
<Image v-if="row.cover" :height="40" :src="row.cover" :width="40" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 0"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: any) => toggleStatus(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该色卡吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
8
apps/web-antd/src/views/color/company/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
export const companyApi = createCrudApi('company');
|
||||
|
||||
/** 公司下拉 */
|
||||
export async function getCompanyOption() {
|
||||
return (await companyApi.option()) ?? [];
|
||||
}
|
||||
21
apps/web-antd/src/views/color/company/components/modal.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { useCrudModal } from '#/components/crud';
|
||||
|
||||
import { companyApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'CompanyFormModal',
|
||||
});
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: companyApi,
|
||||
formProps: modalFormProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}公司`" class="w-[36%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
50
apps/web-antd/src/views/color/company/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
37
apps/web-antd/src/views/color/company/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
20
apps/web-antd/src/views/color/company/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||
91
apps/web-antd/src/views/color/company/index.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Switch } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { companyApi } from './api';
|
||||
import CompanyFormModal from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
useCrudTable({
|
||||
api: companyApi,
|
||||
defaultValues: { status: 0 },
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: CompanyFormModal,
|
||||
toFormValues: (row) => ({
|
||||
address: row.address,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
status: row.status ?? 0,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="公司管理">
|
||||
<FormModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增公司',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的公司吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 0"
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: any) => toggleStatus(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该公司吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
23
apps/web-antd/src/views/customer/dealer/config/search.ts
Normal file
@@ -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',
|
||||
};
|
||||
40
apps/web-antd/src/views/customer/dealer/config/table.ts
Normal file
@@ -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 }),
|
||||
});
|
||||
218
apps/web-antd/src/views/customer/dealer/index.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 经销商管理:仅 is_p=1 的微信用户
|
||||
* 微信用户列表仍展示全部(含经销商),本页专注倍率、下级、企业与专属模板绑定
|
||||
*/
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
InputNumber,
|
||||
message,
|
||||
Popover,
|
||||
Select,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import {
|
||||
bindTemplate,
|
||||
setAgent,
|
||||
setPriceNumber,
|
||||
setShowPrice,
|
||||
wxUserApi,
|
||||
} from '#/views/customer/wx-user/api';
|
||||
import WxUserBindModal from '#/views/customer/wx-user/components/bind-modal.vue';
|
||||
import WxUserChildrenDrawer from '#/views/customer/wx-user/components/children-drawer.vue';
|
||||
import WxUserFormModal from '#/views/customer/wx-user/components/modal.vue';
|
||||
import { wxTemplateApi } from '#/views/wx/template/api';
|
||||
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({ name: 'CustomerDealer' });
|
||||
|
||||
const { FormModal, Grid, gridApi, showModal } = useCrudTable({
|
||||
api: wxUserApi,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: WxUserFormModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
nick_name: row.nick_name,
|
||||
phone: row.phone,
|
||||
price_number: row.price_number ?? '1',
|
||||
show_price: row.show_price ?? 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const [BindModal, bindModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserBindModal,
|
||||
});
|
||||
const [ChildrenDrawer, childrenDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: WxUserChildrenDrawer,
|
||||
});
|
||||
|
||||
const multiplierDraft = ref<Record<number, number>>({});
|
||||
const templateOptions = ref<{ label: string; value: string }[]>([
|
||||
{ label: '跟随品牌默认', value: '' },
|
||||
]);
|
||||
|
||||
async function loadTemplateOptions() {
|
||||
const res = await wxTemplateApi.list({ page: 1, pageSize: 200, status: 0 });
|
||||
templateOptions.value = [
|
||||
{ label: '跟随品牌默认', value: '' },
|
||||
...(res?.items ?? []).map((item: any) => ({
|
||||
label: `${item.name}(${item.code})`,
|
||||
value: item.code,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function openBind(row: Record<string, any>) {
|
||||
bindModalApi.setData({ gridApi, record: row });
|
||||
bindModalApi.open();
|
||||
}
|
||||
|
||||
function openChildren(row: Record<string, any>) {
|
||||
childrenDrawerApi.setData({ record: row });
|
||||
childrenDrawerApi.open();
|
||||
}
|
||||
|
||||
async function revokeDealer(row: Record<string, any>) {
|
||||
await setAgent({ id: row.id, is_p: 0 });
|
||||
message.success('已取消经销商身份');
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
async function toggleShowPrice(row: Record<string, any>, checked: boolean) {
|
||||
await setShowPrice({ id: row.id, show_price: checked ? 1 : 0 });
|
||||
message.success('操作成功');
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
async function saveMultiplier(row: Record<string, any>) {
|
||||
const value = multiplierDraft.value[row.id] ?? Number(row.price_number ?? 1);
|
||||
if (!value || value <= 0) {
|
||||
message.warning('倍率必须大于 0');
|
||||
return;
|
||||
}
|
||||
await setPriceNumber({ id: row.id, number: String(value) });
|
||||
message.success('倍率已更新');
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
async function onTemplateChange(row: Record<string, any>, code: any) {
|
||||
await bindTemplate({ id: row.id, template_code: String(code ?? '') });
|
||||
message.success(code ? '已绑定专属模板' : '已改为跟随品牌默认');
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
onMounted(loadTemplateOptions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page
|
||||
auto-content-height
|
||||
description="仅展示已认定的经销商。普通用户认定请在「微信用户」操作;本页不隐藏他们。"
|
||||
title="经销商管理"
|
||||
>
|
||||
<FormModal />
|
||||
<BindModal />
|
||||
<ChildrenDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
列表固定筛选 is_p=1;专属模板仅经销商登录后优先生效。
|
||||
</span>
|
||||
</template>
|
||||
<template #avatar="{ row }">
|
||||
<Avatar :size="32" :src="row.avatar">
|
||||
{{ (row.nick_name ?? '').slice(0, 1) }}
|
||||
</Avatar>
|
||||
</template>
|
||||
<template #enterprise="{ row }">
|
||||
<Button size="small" type="link" @click="openBind(row)">
|
||||
{{ row.enterprise_name || '未绑定' }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #showPrice="{ row }">
|
||||
<Switch
|
||||
:checked="row.show_price === 1"
|
||||
checked-children="可见"
|
||||
un-checked-children="隐藏"
|
||||
@change="(checked: any) => toggleShowPrice(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #multiplier="{ row }">
|
||||
<Popover trigger="click">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:value="multiplierDraft[row.id] ?? Number(row.price_number ?? 1)"
|
||||
@update:value="(val: any) => (multiplierDraft[row.id] = Number(val))"
|
||||
/>
|
||||
<Button size="small" type="primary" @click="saveMultiplier(row)">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<Tag class="cursor-pointer" color="blue">
|
||||
x{{ row.price_number || '1' }}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #template="{ row }">
|
||||
<Select
|
||||
allow-clear
|
||||
class="w-full min-w-[180px]"
|
||||
:options="templateOptions"
|
||||
placeholder="跟随品牌默认"
|
||||
:value="row.template_code || ''"
|
||||
@change="(val: any) => onTemplateChange(row, val ?? '')"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定企业',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => openBind(row),
|
||||
},
|
||||
{
|
||||
label: '下级',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: () => openChildren(row),
|
||||
},
|
||||
{
|
||||
label: '取消经销商',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '取消后将清空专属模板,确定吗?',
|
||||
confirm: () => revokeDealer(row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
22
apps/web-antd/src/views/customer/enterprise/api/index.ts
Normal file
@@ -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 ?? '-';
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Descriptions, Empty, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
|
||||
import { enterpriseApi, settleTypeText } from '../api';
|
||||
|
||||
defineOptions({
|
||||
name: 'EnterpriseDetailDrawer',
|
||||
});
|
||||
|
||||
const info = ref<Record<string, any>>({});
|
||||
|
||||
const userColumns = [
|
||||
{ dataIndex: 'nick_name', title: '昵称' },
|
||||
{ dataIndex: 'phone', title: '手机号' },
|
||||
{ dataIndex: 'is_p', key: 'is_p', title: '代理商', width: 90 },
|
||||
{ dataIndex: 'price_number', title: '倍率', width: 80 },
|
||||
{ dataIndex: 'created_at', title: '注册时间', width: 170 },
|
||||
];
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const record = popupData(drawerApi).record ?? {};
|
||||
drawerApi.setState({ loading: true });
|
||||
try {
|
||||
info.value = await enterpriseApi.detail(record.id);
|
||||
} finally {
|
||||
drawerApi.setState({ loading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose(drawerApi);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer class="w-[720px]" :footer="false" title="企业详情">
|
||||
<Descriptions bordered :column="2" size="small">
|
||||
<Descriptions.Item label="企业名称" :span="2">
|
||||
{{ info.name }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联系人">
|
||||
{{ info.contact_name || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">
|
||||
{{ info.phone || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算方式">
|
||||
{{ settleTypeText(info.settle_type) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="默认倍率">
|
||||
x{{ info.price_number || '1' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="税号" :span="2">
|
||||
{{ info.tax_no || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="地址" :span="2">
|
||||
{{ info.address || '-' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" :span="2">
|
||||
{{ info.remark || '-' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div class="mb-2 mt-4 font-medium">
|
||||
关联微信用户({{ info.user_count ?? 0 }})
|
||||
</div>
|
||||
<Empty v-if="!info.users || info.users.length === 0" description="暂无关联用户" />
|
||||
<Table
|
||||
v-else
|
||||
:columns="userColumns"
|
||||
:data-source="info.users"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'is_p'">
|
||||
<Tag :color="record.is_p === 1 ? 'gold' : 'default'">
|
||||
{{ record.is_p === 1 ? '代理商' : '普通' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { firstImage, useCrudModal } from '#/components/crud';
|
||||
|
||||
import { enterpriseApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'EnterpriseFormModal',
|
||||
});
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: enterpriseApi,
|
||||
formProps: modalFormProps,
|
||||
transform: (values) => ({ ...values, logo: firstImage(values.logo) }),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}企业`" class="w-[52%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
93
apps/web-antd/src/views/customer/enterprise/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
43
apps/web-antd/src/views/customer/enterprise/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
34
apps/web-antd/src/views/customer/enterprise/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||
127
apps/web-antd/src/views/customer/enterprise/index.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Button, Image, Switch, Tag } from 'ant-design-vue';
|
||||
|
||||
import { toImageList, useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { enterpriseApi, settleTypeText } from './api';
|
||||
import EnterpriseDetailDrawer from './components/detail-drawer.vue';
|
||||
import EnterpriseFormModal from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const { FormModal, Grid, hasSelection, removeRows, showModal, toggleStatus } =
|
||||
useCrudTable({
|
||||
api: enterpriseApi,
|
||||
defaultValues: { price_number: '1', settle_type: 0, status: 0 },
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: EnterpriseFormModal,
|
||||
toFormValues: (row) => ({
|
||||
address: row.address,
|
||||
contact_name: row.contact_name,
|
||||
id: row.id,
|
||||
logo: toImageList(row.logo),
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
price_number: row.price_number ?? '1',
|
||||
remark: row.remark,
|
||||
settle_type: row.settle_type ?? 0,
|
||||
status: row.status ?? 0,
|
||||
tax_no: row.tax_no,
|
||||
}),
|
||||
});
|
||||
|
||||
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: EnterpriseDetailDrawer,
|
||||
});
|
||||
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailDrawerApi.setData({ record: row });
|
||||
detailDrawerApi.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="企业管理">
|
||||
<FormModal />
|
||||
<DetailDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增企业',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
disabled: !hasSelection,
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的企业吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #logo="{ row }">
|
||||
<Image v-if="row.logo" :height="40" :src="row.logo" :width="40" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #settle="{ row }">
|
||||
<Tag>{{ settleTypeText(row.settle_type) }}</Tag>
|
||||
</template>
|
||||
<template #users="{ row }">
|
||||
<Button size="small" type="link" @click="openDetail(row)">
|
||||
{{ row.user_count ?? 0 }} 人
|
||||
</Button>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Switch
|
||||
:checked="row.status === 0"
|
||||
checked-children="正常"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: any) => toggleStatus(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
icon: 'lucide:eye',
|
||||
size: 'small',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该企业吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
50
apps/web-antd/src/views/customer/list/api/index.ts
Normal file
@@ -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<any[]> {
|
||||
return (
|
||||
(await requestClient.get<any[]>('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<any>('list/to-order', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存清单明细:后台帮客户补规格/数量/单价。
|
||||
* 必须传 id,其余字段可选。
|
||||
*/
|
||||
export async function saveListItem(data: Record<string, any>) {
|
||||
return requestClient.post<any>('list/save-item', data);
|
||||
}
|
||||
|
||||
/** 删除清单明细:软删,后端置 deleted_at。 */
|
||||
export async function deleteListItem(ids: number[]) {
|
||||
return requestClient.post<any>('list/delete-item', { ids });
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Button, Descriptions, DescriptionsItem, Empty, Image, Input, message, Select, Spin, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { listApi, listToOrder } from '#/views/customer/list/api';
|
||||
|
||||
import { DELIVERY_TYPE_OPTIONS, LIST_STATUS_TEXT } from '../config/constants';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerListDetailDrawer',
|
||||
});
|
||||
|
||||
/**
|
||||
* 清单详情抽屉。
|
||||
* 后端 list/detail 返回:基本信息 + items(带算过倍率的 unit_price / total_price / routine_list)+ total_amount_text。
|
||||
* 底部给「转为订单」按钮,调 list/to-order,需补收货信息(可选,缺省时后端用用户信息兜底)。
|
||||
*/
|
||||
const record = ref<Record<string, any>>({});
|
||||
const detail = ref<Record<string, any>>({});
|
||||
const loading = ref(false);
|
||||
|
||||
const showOrderForm = ref(false);
|
||||
const receiverName = ref('');
|
||||
const receiverPhone = ref('');
|
||||
const receiverAddress = ref('');
|
||||
const deliveryType = ref(1);
|
||||
const remark = ref('');
|
||||
|
||||
const columns = [
|
||||
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 80 },
|
||||
{ dataIndex: 'title', title: '商品' },
|
||||
{ dataIndex: 'specification', title: '规格' },
|
||||
{ dataIndex: 'quantity', title: '数量', width: 80 },
|
||||
{ dataIndex: 'unit_price_text', title: '单价', width: 100 },
|
||||
{ dataIndex: 'total_price_text', title: '小计', width: 100 },
|
||||
];
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
record.value = popupData(drawerApi).record ?? {};
|
||||
showOrderForm.value = false;
|
||||
resetOrderForm();
|
||||
await loadDetail();
|
||||
},
|
||||
});
|
||||
|
||||
async function loadDetail() {
|
||||
const id = Number(record.value.id ?? 0);
|
||||
if (id <= 0) return;
|
||||
loading.value = true;
|
||||
drawerApi.setState({ loading: true });
|
||||
try {
|
||||
detail.value = (await listApi.detail(id)) ?? {};
|
||||
// 默认用客户信息预填收货,免得运营每单都重输
|
||||
receiverName.value = detail.value.user?.nick_name ?? '';
|
||||
receiverPhone.value = detail.value.user?.phone ?? '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
drawerApi.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
function resetOrderForm() {
|
||||
receiverName.value = '';
|
||||
receiverPhone.value = '';
|
||||
receiverAddress.value = '';
|
||||
deliveryType.value = 1;
|
||||
remark.value = '';
|
||||
}
|
||||
|
||||
async function submitToOrder() {
|
||||
const id = Number(record.value.id ?? 0);
|
||||
if (id <= 0) return;
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await listToOrder({
|
||||
id,
|
||||
delivery_type: deliveryType.value,
|
||||
receiver_name: receiverName.value.trim() || undefined,
|
||||
receiver_phone: receiverPhone.value.trim() || undefined,
|
||||
receiver_address: receiverAddress.value.trim() || undefined,
|
||||
remark: remark.value.trim() || undefined,
|
||||
});
|
||||
message.success('订单已生成');
|
||||
showOrderForm.value = false;
|
||||
popupData(drawerApi).gridApi?.reload();
|
||||
drawerApi.close();
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="w-[760px]"
|
||||
:footer="false"
|
||||
:title="`清单详情 - ${record.list_no ?? ''}`"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<Empty v-if="!detail.id" description="加载中..." />
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<Descriptions bordered :column="2" size="small">
|
||||
<DescriptionsItem label="清单号">
|
||||
{{ detail.list_no ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="名称">
|
||||
{{ detail.name ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="客户">
|
||||
{{ detail.user?.nick_name ?? '-' }}
|
||||
<span v-if="detail.user?.phone" class="text-muted-foreground">
|
||||
/ {{ detail.user.phone }}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="所属企业">
|
||||
{{ detail.enterprise?.name ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="状态">
|
||||
<Tag :color="detail.status === 1 ? 'green' : 'default'">
|
||||
{{ LIST_STATUS_TEXT[detail.status] ?? '-' }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="合计">
|
||||
<span class="text-primary font-semibold">
|
||||
¥{{ detail.total_amount_text ?? '0' }}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="detail.items ?? []"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: row }">
|
||||
<template v-if="column.key === 'cover'">
|
||||
<Image
|
||||
v-if="row.cover"
|
||||
:height="48"
|
||||
:src="row.cover"
|
||||
:width="48"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground">无图</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Alert
|
||||
v-if="detail.status === 1"
|
||||
message="该清单已转订单,无需再次下单。"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
|
||||
<div v-if="detail.status !== 1" class="border-t pt-3">
|
||||
<Button
|
||||
v-if="!showOrderForm"
|
||||
type="primary"
|
||||
@click="showOrderForm = true"
|
||||
>
|
||||
转为订单
|
||||
</Button>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div class="text-sm font-medium">
|
||||
收货信息(可选,留空将使用客户资料)
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input v-model:value="receiverName" placeholder="收件人" />
|
||||
<Input v-model:value="receiverPhone" placeholder="联系电话" />
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="receiverAddress"
|
||||
placeholder="收货地址"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="deliveryType"
|
||||
:options="DELIVERY_TYPE_OPTIONS"
|
||||
placeholder="配送方式"
|
||||
/>
|
||||
<Input v-model:value="remark" placeholder="订单备注" />
|
||||
<div class="flex gap-2">
|
||||
<Button type="primary" @click="submitToOrder">
|
||||
确认下单
|
||||
</Button>
|
||||
<Button @click="showOrderForm = false">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
64
apps/web-antd/src/views/customer/list/components/modal.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SearchSelectOption } from '#/components/search-select';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useCrudModal } from '#/components/crud';
|
||||
import { SearchSelect } from '#/components/search-select';
|
||||
import { searchWxUser } from '#/views/customer/wx-user/api';
|
||||
|
||||
import { listApi } from '../api';
|
||||
import { editFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerListFormModal',
|
||||
});
|
||||
|
||||
/**
|
||||
* 清单的新增/编辑弹窗。
|
||||
*
|
||||
* 编辑:useCrudModal 把行数据原样回填,清单名称、备注等字段直接展示。
|
||||
* 新增:必须选一个客户(user_id),用 SearchSelect 挑出来,提交时一并带上。
|
||||
* 客户字段不放表单 schema(避免和 SearchSelect 双向同步出 bug),单独在下方渲染。
|
||||
*
|
||||
* 注意 SearchSelect 的契约:
|
||||
* - update:modelValue 发的是原始 id(number/string),不是 option 对象
|
||||
* - 整个 option 对象走 change 事件
|
||||
* 所以这里用 selectedUserId 存 id(提交用),selectedUserLabel 存名称(显示用),
|
||||
* 不要把 v-model 绑到对象 ref 上,否则 .value 会变成数字导致 ?.id 恒 undefined。
|
||||
*/
|
||||
const selectedUserId = ref<number | string | undefined>();
|
||||
const selectedUserLabel = ref('');
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: listApi,
|
||||
// 这里只用编辑场景的 schema(name + remark + enterprise_id + id)
|
||||
formProps: editFormProps,
|
||||
transform: (values) => {
|
||||
if (!isUpdate.value) {
|
||||
// 新增必须有 user_id,取 SearchSelect 选中的原始 id
|
||||
values.user_id = Number(selectedUserId.value ?? 0);
|
||||
}
|
||||
// 编辑时后端 updateField 限定只改 id+name,user_id 会被后端忽略
|
||||
return values;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}清单`" class="w-[48%]">
|
||||
<Form />
|
||||
<div v-if="!isUpdate" class="mb-3">
|
||||
<div class="mb-1 text-sm">
|
||||
客户 <span class="text-destructive">*</span>
|
||||
</div>
|
||||
<SearchSelect
|
||||
v-model="selectedUserId"
|
||||
:api="searchWxUser"
|
||||
:model-label="selectedUserLabel"
|
||||
placeholder="输入客户昵称搜索"
|
||||
@change="(opt: SearchSelectOption | undefined) => (selectedUserLabel = opt?.name ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
28
apps/web-antd/src/views/customer/list/config/constants.ts
Normal file
@@ -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<number, string> = LIST_STATUS_OPTIONS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.value] = item.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
/**
|
||||
* 配送方式枚举(与 cc_order.delivery_type 对齐,订单详情抽屉里下单时用)。
|
||||
* 1=物流快递,2=上门自提,3=公司配送。值与后端 OrderModel::DELIVERY_* 常量一致。
|
||||
*/
|
||||
export const DELIVERY_TYPE_OPTIONS = [
|
||||
{ label: '物流快递', value: 1 },
|
||||
{ label: '上门自提', value: 2 },
|
||||
{ label: '公司配送', value: 3 },
|
||||
];
|
||||
58
apps/web-antd/src/views/customer/list/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
66
apps/web-antd/src/views/customer/list/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
41
apps/web-antd/src/views/customer/list/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||
129
apps/web-antd/src/views/customer/list/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import UserDetailModal from '#/components/customer/user-detail-modal.vue';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { listApi } from './api';
|
||||
import CustomerListDetailDrawer from './components/detail-drawer.vue';
|
||||
import CustomerListFormModal from './components/modal.vue';
|
||||
import { LIST_STATUS_TEXT } from './config/constants';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerList',
|
||||
});
|
||||
|
||||
const { FormModal, Grid, gridApi, removeRows, showModal } = useCrudTable({
|
||||
api: listApi,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: CustomerListFormModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
enterprise_id: row.enterprise_id,
|
||||
remark: row.remark,
|
||||
}),
|
||||
});
|
||||
|
||||
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: CustomerListDetailDrawer,
|
||||
});
|
||||
|
||||
const [UserModal, userModalApi] = useVbenModal({
|
||||
connectedComponent: UserDetailModal,
|
||||
});
|
||||
|
||||
/** 行操作:查看清单详情(含明细与转订单) */
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailDrawerApi.setData({ record: row, gridApi });
|
||||
detailDrawerApi.open();
|
||||
}
|
||||
|
||||
/** 行操作:点客户名打开用户详情模态框 */
|
||||
function openUser(row: Record<string, any>) {
|
||||
userModalApi.setData({ record: { id: row.user_id } });
|
||||
userModalApi.open();
|
||||
}
|
||||
|
||||
async function batchDelete() {
|
||||
await removeRows();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="清单管理">
|
||||
<FormModal />
|
||||
<DetailDrawer />
|
||||
<UserModal />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '新增清单',
|
||||
type: 'primary',
|
||||
icon: 'ant-design:plus-outlined',
|
||||
onClick: () => showModal(),
|
||||
},
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的清单吗?',
|
||||
confirm: batchDelete,
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #user="{ row }">
|
||||
<a class="text-primary" @click="openUser(row)">
|
||||
{{ row.user_name || '-' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'green' : 'default'">
|
||||
{{ LIST_STATUS_TEXT[row.status] ?? '-' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
icon: 'uil:eye',
|
||||
size: 'small',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该清单吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
62
apps/web-antd/src/views/customer/order/api/index.ts
Normal file
@@ -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<any>('order/stat');
|
||||
}
|
||||
|
||||
/** 某用户的订单(用户详情模态框) */
|
||||
export async function getListByOrder(userId: number): Promise<any[]> {
|
||||
return (
|
||||
(await requestClient.get<any[]>('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<any>('order/audit-payment', {
|
||||
payment_id: data.payment_id,
|
||||
status: data.pass ? 1 : 0,
|
||||
remark: data.remark ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
/** 发货:物流/自提/公司配送 */
|
||||
export async function shipOrder(data: Record<string, any>) {
|
||||
return requestClient.post<any>('order/ship', data);
|
||||
}
|
||||
|
||||
/** 取消订单(仅未付款或已付款未发货可取消) */
|
||||
export async function cancelOrder(id: number) {
|
||||
return requestClient.post<any>('order/cancel', { id });
|
||||
}
|
||||
|
||||
/** 完成订单(确认收货,已发货状态可用) */
|
||||
export async function completeOrder(id: number) {
|
||||
return requestClient.post<any>('order/complete', { id });
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
DescriptionsItem,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import {
|
||||
auditPayment,
|
||||
cancelOrder,
|
||||
completeOrder,
|
||||
orderApi,
|
||||
} from '#/views/customer/order/api';
|
||||
import {
|
||||
DELIVERY_TYPE_TEXT,
|
||||
ORDER_PAY_STATUS_TEXT,
|
||||
ORDER_PAY_TYPE_TEXT,
|
||||
ORDER_STATUS_COLOR,
|
||||
ORDER_STATUS_TEXT,
|
||||
PAYMENT_STATUS_TEXT,
|
||||
} from '#/views/customer/order/config/constants';
|
||||
|
||||
import ShipModal from './ship-modal.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerOrderDetailDrawer',
|
||||
});
|
||||
|
||||
/**
|
||||
* 订单详情抽屉。
|
||||
*
|
||||
* 后端 OrderService::detail 返回:
|
||||
* 订单基本信息 + items(带 unit_price_text / total_price_text)+ payments + deliveries + voucher_list
|
||||
* 底部操作按订单状态动态显示:
|
||||
* 待付款(0):取消、查看凭证审核
|
||||
* 已付款(1):发货
|
||||
* 已发货(2):完成(确认收货)
|
||||
* 已完成(3)/已取消(4):无操作
|
||||
*/
|
||||
const record = ref<Record<string, any>>({});
|
||||
const detail = ref<Record<string, any>>({});
|
||||
const loading = ref(false);
|
||||
const auditRemark = ref<Record<number, string>>({});
|
||||
|
||||
const itemColumns = [
|
||||
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 70 },
|
||||
{ dataIndex: 'title', title: '商品' },
|
||||
{ dataIndex: 'specification', title: '规格' },
|
||||
{ dataIndex: 'quantity', title: '数量', width: 70 },
|
||||
{ dataIndex: 'unit_price_text', title: '单价', width: 100 },
|
||||
{ dataIndex: 'total_price_text', title: '小计', width: 100 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 分转元用于单笔支付记录金额展示。
|
||||
* 订单级金额(total_amount_text 等)后端已格式化,这里只处理 payment.amount
|
||||
* (OrderCoreService::detail 没给 payment 补 _text,前端兜底转一下)。
|
||||
*/
|
||||
function centsToYuan(cents: any): string {
|
||||
const n = Number(cents) || 0;
|
||||
return (n / 100).toFixed(2);
|
||||
}
|
||||
|
||||
const [ShipModalComp, shipModalApi] = useVbenModal({
|
||||
connectedComponent: ShipModal,
|
||||
});
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
record.value = popupData(drawerApi).record ?? {};
|
||||
await loadDetail();
|
||||
},
|
||||
});
|
||||
|
||||
async function loadDetail() {
|
||||
const id = Number(record.value.id ?? 0);
|
||||
if (id <= 0) return;
|
||||
loading.value = true;
|
||||
drawerApi.setState({ loading: true });
|
||||
try {
|
||||
detail.value = (await orderApi.detail(id)) ?? {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
drawerApi.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核转账凭证:pass=true 确认收款,false 拒绝 */
|
||||
async function doAudit(paymentId: number, pass: boolean) {
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await auditPayment({
|
||||
payment_id: paymentId,
|
||||
pass,
|
||||
remark: auditRemark.value[paymentId] ?? '',
|
||||
});
|
||||
message.success(pass ? '已确认收款' : '已拒绝');
|
||||
await loadDetail();
|
||||
popupData(drawerApi).gridApi?.reload();
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
function openShip() {
|
||||
shipModalApi.setData({ orderId: record.value.id, gridApi: { reload: loadDetail } });
|
||||
shipModalApi.open();
|
||||
}
|
||||
|
||||
async function doCancel() {
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await cancelOrder(Number(record.value.id));
|
||||
message.success('已取消');
|
||||
await loadDetail();
|
||||
popupData(drawerApi).gridApi?.reload();
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
async function doComplete() {
|
||||
drawerApi.lock();
|
||||
try {
|
||||
await completeOrder(Number(record.value.id));
|
||||
message.success('已完成');
|
||||
await loadDetail();
|
||||
popupData(drawerApi).gridApi?.reload();
|
||||
} finally {
|
||||
drawerApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** 组装状态时间线:付款 → 发货 → 完成 */
|
||||
function buildTimeline(): { color: string; content: string }[] {
|
||||
const items: { color: string; content: string }[] = [];
|
||||
const d = detail.value;
|
||||
if (!d.id) return items;
|
||||
items.push({
|
||||
color: 'blue',
|
||||
content: `下单:${d.order_no ?? ''}(${d.created_at ?? ''})`,
|
||||
});
|
||||
if (d.paid_at) {
|
||||
items.push({
|
||||
color: 'green',
|
||||
content: `付款:${ORDER_PAY_TYPE_TEXT[d.pay_type] ?? ''} ¥${d.total_amount_text ?? ''}(${d.paid_at ?? ''})`,
|
||||
});
|
||||
}
|
||||
for (const delivery of d.deliveries ?? []) {
|
||||
items.push({
|
||||
color: 'blue',
|
||||
content: `发货:${DELIVERY_TYPE_TEXT[delivery.delivery_type] ?? ''} ${delivery.tracking_no || delivery.pickup_point || ''}(${delivery.shipped_at ?? ''})`,
|
||||
});
|
||||
}
|
||||
if (d.status === 3) {
|
||||
items.push({ color: 'green', content: '已完成' });
|
||||
}
|
||||
if (d.status === 4) {
|
||||
items.push({ color: 'red', content: '已取消' });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="w-[820px]"
|
||||
:footer="false"
|
||||
:title="`订单详情 - ${record.order_no ?? ''}`"
|
||||
>
|
||||
<ShipModalComp />
|
||||
<Spin :spinning="loading">
|
||||
<Empty v-if="!detail.id" description="加载中..." />
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<Descriptions bordered :column="3" size="small">
|
||||
<DescriptionsItem label="订单号">
|
||||
{{ detail.order_no ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="状态">
|
||||
<Tag :color="ORDER_STATUS_COLOR[detail.status] ?? 'default'">
|
||||
{{ ORDER_STATUS_TEXT[detail.status] ?? '-' }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="支付状态">
|
||||
<Tag>{{ ORDER_PAY_STATUS_TEXT[detail.pay_status] ?? '-' }}</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="客户">
|
||||
{{ detail.user?.nick_name ?? '-' }}
|
||||
<span v-if="detail.user?.phone" class="text-muted-foreground">
|
||||
/ {{ detail.user.phone }}
|
||||
</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="企业">
|
||||
{{ detail.enterprise?.name ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="下单时间">
|
||||
{{ detail.created_at ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="总金额">
|
||||
<span class="text-primary font-semibold">¥{{ detail.total_amount_text ?? '0' }}</span>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="已付金额">
|
||||
¥{{ detail.paid_amount_text ?? '0' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="配送方式">
|
||||
{{ DELIVERY_TYPE_TEXT[detail.delivery_type] ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem v-if="detail.receiver_name" label="收件人">
|
||||
{{ detail.receiver_name }} / {{ detail.receiver_phone }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem
|
||||
v-if="detail.receiver_address"
|
||||
label="收货地址"
|
||||
:span="2"
|
||||
>
|
||||
{{ detail.receiver_address }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem v-if="detail.remark" label="订单备注" :span="3">
|
||||
{{ detail.remark }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
|
||||
<Tabs default-active-key="items">
|
||||
<Tabs.TabPane key="items" tab="商品明细">
|
||||
<Table
|
||||
:columns="itemColumns"
|
||||
:data-source="detail.items ?? []"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: row }">
|
||||
<template v-if="column.key === 'cover'">
|
||||
<Image
|
||||
v-if="row.cover"
|
||||
:height="40"
|
||||
:src="row.cover"
|
||||
:width="40"
|
||||
/>
|
||||
<span v-else class="text-muted-foreground">无图</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="payments" :tab="`支付记录 (${(detail.payments ?? []).length})`">
|
||||
<Empty
|
||||
v-if="(detail.payments ?? []).length === 0"
|
||||
description="还没有支付记录"
|
||||
/>
|
||||
<div
|
||||
v-for="payment in detail.payments ?? []"
|
||||
:key="payment.id"
|
||||
class="border mb-3 rounded p-3"
|
||||
>
|
||||
<Descriptions :column="2" size="small">
|
||||
<DescriptionsItem label="支付方式">
|
||||
{{ ORDER_PAY_TYPE_TEXT[payment.pay_type] ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="金额">
|
||||
¥{{ centsToYuan(payment.amount) }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="状态">
|
||||
<Tag>{{ PAYMENT_STATUS_TEXT[payment.status] ?? '-' }}</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="提交时间">
|
||||
{{ payment.created_at ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
<div
|
||||
v-if="payment.voucher"
|
||||
class="mt-2 flex flex-wrap gap-2"
|
||||
>
|
||||
<Image
|
||||
v-for="(img, idx) in String(payment.voucher).split(',').filter(Boolean)"
|
||||
:key="idx"
|
||||
:height="80"
|
||||
:src="img"
|
||||
:width="80"
|
||||
/>
|
||||
</div>
|
||||
<!-- 仅待审核状态可操作 -->
|
||||
<div
|
||||
v-if="payment.status === 0"
|
||||
class="mt-2 flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
v-model:value="auditRemark[payment.id]"
|
||||
class="flex-1"
|
||||
placeholder="审核备注(可选)"
|
||||
size="small"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确认通过该转账凭证吗?通过后会自动推进订单状态。"
|
||||
@confirm="doAudit(payment.id, true)"
|
||||
>
|
||||
<Button size="small" type="primary">通过</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
@click="doAudit(payment.id, false)"
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="timeline" tab="状态轨迹">
|
||||
<Timeline>
|
||||
<TimelineItem
|
||||
v-for="(item, idx) in buildTimeline()"
|
||||
:key="idx"
|
||||
:color="item.color"
|
||||
>
|
||||
{{ item.content }}
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<Alert
|
||||
v-if="detail.status === 4"
|
||||
message="该订单已取消"
|
||||
show-icon
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<!-- 底部操作区:按状态显示 -->
|
||||
<div
|
||||
v-if="detail.status !== 3 && detail.status !== 4"
|
||||
class="flex justify-end gap-2 border-t pt-3"
|
||||
>
|
||||
<Popconfirm
|
||||
v-if="detail.status === 0 || detail.status === 1"
|
||||
title="确定取消该订单吗?"
|
||||
@confirm="doCancel"
|
||||
>
|
||||
<Button danger>取消订单</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
v-if="detail.status === 1"
|
||||
type="primary"
|
||||
@click="openShip"
|
||||
>
|
||||
发货
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-if="detail.status === 2"
|
||||
title="确认该订单已完成收货?"
|
||||
@confirm="doComplete"
|
||||
>
|
||||
<Button type="primary">确认收货</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
26
apps/web-antd/src/views/customer/order/components/modal.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { useCrudModal } from '#/components/crud';
|
||||
|
||||
import { orderApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerOrderFormModal',
|
||||
});
|
||||
|
||||
/**
|
||||
* 订单编辑弹窗。
|
||||
* 仅允许改收件信息与备注(后端 OrderService::update 的白名单)。
|
||||
* 不支持新增订单(订单只能由清单转出)。
|
||||
*/
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: orderApi,
|
||||
formProps: modalFormProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="isUpdate ? '编辑订单' : '订单'" class="w-[48%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
113
apps/web-antd/src/views/customer/order/components/ship-modal.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Form, Input, InputNumber, message, Select } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { shipOrder } from '#/views/customer/order/api';
|
||||
import { DELIVERY_TYPE_OPTIONS } from '#/views/customer/order/config/constants';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerOrderShipModal',
|
||||
});
|
||||
|
||||
/**
|
||||
* 发货弹窗。
|
||||
* 后端 OrderCoreService::ship 按 delivery_type 校验:
|
||||
* - 物流快递(1):必填 tracking_no
|
||||
* - 上门自提(2):必填 pickup_point
|
||||
* - 公司配送(3):可选 driver_info
|
||||
*/
|
||||
const orderId = ref(0);
|
||||
const deliveryType = ref(1);
|
||||
const company = ref('');
|
||||
const trackingNo = ref('');
|
||||
const pickupPoint = ref('');
|
||||
const driverInfo = ref('');
|
||||
const remark = ref('');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
draggable: true,
|
||||
fullscreenButton: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
async onConfirm() {
|
||||
if (orderId.value <= 0) {
|
||||
message.warning('参数错误');
|
||||
return;
|
||||
}
|
||||
if (deliveryType.value === 1 && !trackingNo.value.trim()) {
|
||||
message.warning('请填写运单号');
|
||||
return;
|
||||
}
|
||||
if (deliveryType.value === 2 && !pickupPoint.value.trim()) {
|
||||
message.warning('请填写自提点');
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
await shipOrder({
|
||||
id: orderId.value,
|
||||
delivery_type: deliveryType.value,
|
||||
company: company.value.trim(),
|
||||
tracking_no: trackingNo.value.trim(),
|
||||
pickup_point: pickupPoint.value.trim(),
|
||||
driver_info: driverInfo.value.trim(),
|
||||
remark: remark.value.trim(),
|
||||
});
|
||||
message.success('已发货');
|
||||
popupData(modalApi).gridApi?.reload();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const data = popupData<{ orderId?: number }>(modalApi);
|
||||
orderId.value = Number(data.orderId ?? 0);
|
||||
deliveryType.value = 1;
|
||||
company.value = '';
|
||||
trackingNo.value = '';
|
||||
pickupPoint.value = '';
|
||||
driverInfo.value = '';
|
||||
remark.value = '';
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[520px]" title="发货">
|
||||
<Form layout="horizontal">
|
||||
<Form.Item label="配送方式">
|
||||
<Select v-model:value="deliveryType" :options="DELIVERY_TYPE_OPTIONS" />
|
||||
</Form.Item>
|
||||
<Form.Item v-if="deliveryType === 1" label="物流公司">
|
||||
<Input v-model:value="company" placeholder="物流公司(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
v-if="deliveryType === 1"
|
||||
label="运单号"
|
||||
:required="true"
|
||||
>
|
||||
<Input v-model:value="trackingNo" placeholder="运单号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
v-if="deliveryType === 2"
|
||||
label="自提点"
|
||||
:required="true"
|
||||
>
|
||||
<Input v-model:value="pickupPoint" placeholder="自提点地址" />
|
||||
</Form.Item>
|
||||
<Form.Item v-if="deliveryType === 3" label="司机信息">
|
||||
<Input v-model:value="driverInfo" placeholder="司机/车牌/电话" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注">
|
||||
<Input v-model:value="remark" placeholder="备注(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</template>
|
||||
96
apps/web-antd/src/views/customer/order/config/constants.ts
Normal file
@@ -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<number, string> =
|
||||
ORDER_STATUS_OPTIONS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.value] = item.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
/** 状态 → Tag 颜色,列表里展示状态时用 */
|
||||
export const ORDER_STATUS_COLOR: Record<number, string> = {
|
||||
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<number, string> =
|
||||
ORDER_PAY_STATUS_OPTIONS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.value] = item.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
/**
|
||||
* 单笔支付记录(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<number, string> =
|
||||
PAYMENT_STATUS_OPTIONS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.value] = item.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
|
||||
export const ORDER_PAY_TYPE_TEXT: Record<number, string> = {
|
||||
1: '转账凭证',
|
||||
2: '微信支付',
|
||||
};
|
||||
|
||||
export const DELIVERY_TYPE_OPTIONS = [
|
||||
{ label: '物流快递', value: 1 },
|
||||
{ label: '上门自提', value: 2 },
|
||||
{ label: '公司配送', value: 3 },
|
||||
];
|
||||
|
||||
export const DELIVERY_TYPE_TEXT: Record<number, string> =
|
||||
DELIVERY_TYPE_OPTIONS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.value] = item.label;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, string>,
|
||||
);
|
||||
62
apps/web-antd/src/views/customer/order/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
72
apps/web-antd/src/views/customer/order/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
39
apps/web-antd/src/views/customer/order/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||
185
apps/web-antd/src/views/customer/order/index.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import UserDetailModal from '#/components/customer/user-detail-modal.vue';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getOrderStat, orderApi } from './api';
|
||||
import CustomerOrderDetailDrawer from './components/detail-drawer.vue';
|
||||
import CustomerOrderFormModal from './components/modal.vue';
|
||||
import {
|
||||
ORDER_PAY_STATUS_TEXT,
|
||||
ORDER_STATUS_COLOR,
|
||||
ORDER_STATUS_TEXT,
|
||||
} from './config/constants';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
defineOptions({
|
||||
name: 'CustomerOrder',
|
||||
});
|
||||
|
||||
const { FormModal, Grid, gridApi, removeRows, showModal } = useCrudTable({
|
||||
api: orderApi,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: CustomerOrderFormModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
receiver_name: row.receiver_name,
|
||||
receiver_phone: row.receiver_phone,
|
||||
receiver_address: row.receiver_address,
|
||||
delivery_type: row.delivery_type,
|
||||
remark: row.remark,
|
||||
}),
|
||||
});
|
||||
|
||||
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: CustomerOrderDetailDrawer,
|
||||
});
|
||||
|
||||
const [UserModal, userModalApi] = useVbenModal({
|
||||
connectedComponent: UserDetailModal,
|
||||
});
|
||||
|
||||
/** 顶部统计:后端 order/stat 返回各状态数量与总金额 */
|
||||
const statLoading = ref(false);
|
||||
const stat = ref<{
|
||||
total: number;
|
||||
amount: number;
|
||||
amount_text: string;
|
||||
auditing: number;
|
||||
status: { status: number; count: number; amount: number }[];
|
||||
}>({ total: 0, amount: 0, amount_text: '0', auditing: 0, status: [] });
|
||||
|
||||
async function loadStat() {
|
||||
statLoading.value = true;
|
||||
try {
|
||||
stat.value = (await getOrderStat()) ?? stat.value;
|
||||
} finally {
|
||||
statLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{ key: 'total', label: '订单总数', icon: 'lucide:receipt', getValue: (s: typeof stat.value) => String(s.total) },
|
||||
{ key: 'amount', label: '订单总额', icon: 'lucide:circle-dollar-sign', getValue: (s: typeof stat.value) => `¥${s.amount_text}` },
|
||||
{ key: 'auditing', label: '待审凭证', icon: 'lucide:badge-check', getValue: (s: typeof stat.value) => String(s.auditing) },
|
||||
{ key: 'pending', label: '待付款', icon: 'lucide:clock', getValue: (s: typeof stat.value) => String(s.status.find((i) => i.status === 0)?.count ?? 0) },
|
||||
];
|
||||
|
||||
onMounted(loadStat);
|
||||
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailDrawerApi.setData({ record: row, gridApi });
|
||||
detailDrawerApi.open();
|
||||
}
|
||||
|
||||
function openUser(row: Record<string, any>) {
|
||||
userModalApi.setData({ record: { id: row.user_id } });
|
||||
userModalApi.open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height content-class="flex flex-col gap-3" title="订单管理">
|
||||
<FormModal />
|
||||
<DetailDrawer />
|
||||
<UserModal />
|
||||
|
||||
<!-- 顶部统计条 -->
|
||||
<Spin :spinning="statLoading">
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="card in statCards"
|
||||
:key="card.key"
|
||||
class="bg-card flex items-center gap-3 rounded-lg p-3"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<Icon :icon="card.icon" :size="20" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-muted-foreground text-xs">{{ card.label }}</div>
|
||||
<div class="truncate text-lg font-semibold">
|
||||
{{ card.getValue(stat) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '批量删除',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
popConfirm: {
|
||||
title: '确定删除勾选的订单吗?',
|
||||
confirm: () => removeRows(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '刷新统计',
|
||||
icon: 'lucide:refresh-cw',
|
||||
onClick: loadStat,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #user="{ row }">
|
||||
<a class="text-primary" @click="openUser(row)">
|
||||
{{ row.user_name || '-' }}
|
||||
</a>
|
||||
</template>
|
||||
<template #payStatus="{ row }">
|
||||
<Tag>{{ ORDER_PAY_STATUS_TEXT[row.pay_status] ?? '-' }}</Tag>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<Tag :color="ORDER_STATUS_COLOR[row.status] ?? 'default'">
|
||||
{{ ORDER_STATUS_TEXT[row.status] ?? '-' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
icon: 'uil:eye',
|
||||
size: 'small',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: 'ant-design:delete-outlined',
|
||||
size: 'small',
|
||||
popConfirm: {
|
||||
title: '确定删除该订单吗?',
|
||||
confirm: () => removeRows(row.id),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
71
apps/web-antd/src/views/customer/wx-user/api/index.ts
Normal file
@@ -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<any>('wx-user/update-user-is-p', data);
|
||||
}
|
||||
|
||||
/** 设置价格倍率(后端会同时打开价格可见) */
|
||||
export async function setPriceNumber(data: { id: number; number: string }) {
|
||||
return requestClient.post<any>('wx-user/update-show-price', data);
|
||||
}
|
||||
|
||||
/** 单独控制价格可见性,不动倍率 */
|
||||
export async function setShowPrice(data: { id: number; show_price: number }) {
|
||||
return requestClient.post<any>('wx-user/update-show-price-status', data);
|
||||
}
|
||||
|
||||
/** 绑定企业,enterprise_id 传 0 表示解绑 */
|
||||
export async function bindEnterprise(data: {
|
||||
enterprise_id: number;
|
||||
id: number;
|
||||
}) {
|
||||
return requestClient.post<any>('wx-user/bind-user', data);
|
||||
}
|
||||
|
||||
/** 经销商绑定专属装修模板;空字符串=跟随品牌默认 */
|
||||
export async function bindTemplate(data: {
|
||||
id: number;
|
||||
template_code: string;
|
||||
}) {
|
||||
return requestClient.post<any>('wx-user/bind-template', data);
|
||||
}
|
||||
|
||||
/** 该代理商名下的下级用户 */
|
||||
export async function getChildren(id: number): Promise<any[]> {
|
||||
return (
|
||||
(await requestClient.get<any>('wx-user/children', { params: { id } })) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户搜索:给清单新增、订单代客等场景挑客户用。
|
||||
* 后端 queryField 支持 nick_name / phone 模糊,这里只把第一个非空字段当 keyword。
|
||||
* 返回与 SearchSelect 约定一致的 {id, name, phone, ...} 列表。
|
||||
*/
|
||||
export async function searchWxUser(params: {
|
||||
keyword: string;
|
||||
limit: number;
|
||||
}): Promise<any[]> {
|
||||
const keyword = (params.keyword ?? '').trim();
|
||||
const query: Record<string, any> = { 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 ?? '',
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SearchSelectOption } from '#/components/search-select';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Button, message } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
import { SearchSelect } from '#/components/search-select';
|
||||
import { searchEnterprise } from '#/views/customer/enterprise/api';
|
||||
|
||||
import { bindEnterprise } from '../api';
|
||||
|
||||
defineOptions({
|
||||
name: 'WxUserBindModal',
|
||||
});
|
||||
|
||||
const record = ref<Record<string, any>>({});
|
||||
const enterpriseId = ref<null | number | string | undefined>();
|
||||
const enterpriseName = ref('');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
draggable: true,
|
||||
fullscreenButton: false,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm() {
|
||||
modalApi.setState({ confirmLoading: true, loading: true });
|
||||
bindEnterprise({
|
||||
enterprise_id: Number(enterpriseId.value ?? 0),
|
||||
id: record.value.id,
|
||||
})
|
||||
.then(() => {
|
||||
message.success('绑定成功');
|
||||
popupData(modalApi).gridApi?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ confirmLoading: false, loading: false });
|
||||
});
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
record.value = popupData(modalApi).record ?? {};
|
||||
enterpriseId.value = record.value.enterprise_id || undefined;
|
||||
enterpriseName.value = record.value.enterprise_name ?? '';
|
||||
},
|
||||
});
|
||||
|
||||
function onChange(option: SearchSelectOption | undefined) {
|
||||
enterpriseName.value = option ? String(option.name ?? '') : '';
|
||||
}
|
||||
|
||||
/** 解绑就是绑到 0,后端按 0 清空 enterprise_id */
|
||||
function unbind() {
|
||||
enterpriseId.value = 0;
|
||||
enterpriseName.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[520px]" title="绑定所属企业">
|
||||
<Alert
|
||||
class="mb-3"
|
||||
:message="`当前用户:${record.nick_name ?? ''}${record.phone ? ` / ${record.phone}` : ''}`"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
<SearchSelect
|
||||
v-model="enterpriseId"
|
||||
:api="searchEnterprise"
|
||||
:columns="2"
|
||||
:model-label="enterpriseName"
|
||||
placeholder="输入企业名称、联系人或电话搜索"
|
||||
@change="onChange"
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<Button danger size="small" type="link" @click="unbind">
|
||||
解除绑定
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { Alert, Empty, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { popupData } from '#/components/crud';
|
||||
|
||||
import { getChildren } from '../api';
|
||||
|
||||
defineOptions({
|
||||
name: 'WxUserChildrenDrawer',
|
||||
});
|
||||
|
||||
const record = ref<Record<string, any>>({});
|
||||
const list = ref<any[]>([]);
|
||||
|
||||
const columns = [
|
||||
{ dataIndex: 'id', title: 'ID', width: 80 },
|
||||
{ dataIndex: 'nick_name', title: '昵称' },
|
||||
{ dataIndex: 'phone', title: '手机号', width: 140 },
|
||||
{ dataIndex: 'show_price', key: 'show_price', title: '可见价格', width: 100 },
|
||||
{ dataIndex: 'price_number', title: '倍率', width: 90 },
|
||||
{ dataIndex: 'created_at', title: '注册时间', width: 170 },
|
||||
];
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
record.value = popupData(drawerApi).record ?? {};
|
||||
drawerApi.setState({ loading: true });
|
||||
try {
|
||||
list.value = await getChildren(record.value.id);
|
||||
} finally {
|
||||
drawerApi.setState({ loading: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose(drawerApi);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="w-[680px]"
|
||||
:footer="false"
|
||||
:title="`下级用户 - ${record.nick_name ?? ''}`"
|
||||
>
|
||||
<Alert
|
||||
class="mb-3"
|
||||
message="下级是通过代理商分享链接(带 p_user_id)进入小程序的用户,会继承代理商的价格倍率。"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
<Empty v-if="list.length === 0" description="还没有下级用户" />
|
||||
<Table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: row }">
|
||||
<template v-if="column.key === 'show_price'">
|
||||
<Tag :color="row.show_price === 1 ? 'green' : 'default'">
|
||||
{{ row.show_price === 1 ? '可见' : '不可见' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { useCrudModal } from '#/components/crud';
|
||||
|
||||
import { wxUserApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'WxUserFormModal',
|
||||
});
|
||||
|
||||
const { Form, Modal } = useCrudModal({
|
||||
api: wxUserApi,
|
||||
formProps: modalFormProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="编辑微信用户" class="w-[34%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
48
apps/web-antd/src/views/customer/wx-user/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
66
apps/web-antd/src/views/customer/wx-user/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
34
apps/web-antd/src/views/customer/wx-user/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||
167
apps/web-antd/src/views/customer/wx-user/index.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Avatar, Button, InputNumber, message, Popover, Switch, Tag } from 'ant-design-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useCrudTable } from '#/components/crud';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
|
||||
import { setAgent, setPriceNumber, setShowPrice, wxUserApi } from './api';
|
||||
import WxUserBindModal from './components/bind-modal.vue';
|
||||
import WxUserChildrenDrawer from './components/children-drawer.vue';
|
||||
import WxUserFormModal from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
const { FormModal, Grid, gridApi, showModal } = useCrudTable({
|
||||
api: wxUserApi,
|
||||
formOptions,
|
||||
gridOptions,
|
||||
modalComponent: WxUserFormModal,
|
||||
toFormValues: (row) => ({
|
||||
id: row.id,
|
||||
nick_name: row.nick_name,
|
||||
phone: row.phone,
|
||||
price_number: row.price_number ?? '1',
|
||||
show_price: row.show_price ?? 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const [BindModal, bindModalApi] = useVbenModal({
|
||||
connectedComponent: WxUserBindModal,
|
||||
});
|
||||
|
||||
const [ChildrenDrawer, childrenDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: WxUserChildrenDrawer,
|
||||
});
|
||||
|
||||
/** 倍率气泡里的临时值,按行 id 存,避免多行互相覆盖 */
|
||||
const multiplierDraft = ref<Record<number, number>>({});
|
||||
|
||||
function openBind(row: Record<string, any>) {
|
||||
bindModalApi.setData({ gridApi, record: row });
|
||||
bindModalApi.open();
|
||||
}
|
||||
|
||||
function openChildren(row: Record<string, any>) {
|
||||
childrenDrawerApi.setData({ record: row });
|
||||
childrenDrawerApi.open();
|
||||
}
|
||||
|
||||
async function toggleAgent(row: Record<string, any>, checked: boolean) {
|
||||
await setAgent({ id: row.id, is_p: checked ? 1 : 0 });
|
||||
message.success(checked ? '已认定为代理商' : '已取消代理商');
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
async function toggleShowPrice(row: Record<string, any>, checked: boolean) {
|
||||
await setShowPrice({ id: row.id, show_price: checked ? 1 : 0 });
|
||||
message.success('操作成功');
|
||||
gridApi.reload();
|
||||
}
|
||||
|
||||
async function saveMultiplier(row: Record<string, any>) {
|
||||
const value = multiplierDraft.value[row.id] ?? Number(row.price_number ?? 1);
|
||||
if (!value || value <= 0) {
|
||||
message.warning('倍率必须大于 0');
|
||||
return;
|
||||
}
|
||||
await setPriceNumber({ id: row.id, number: String(value) });
|
||||
message.success('倍率已更新,价格已对该用户可见');
|
||||
gridApi.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="微信用户">
|
||||
<FormModal />
|
||||
<BindModal />
|
||||
<ChildrenDrawer />
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
微信用户由小程序授权登录产生,后台不支持新建。
|
||||
</span>
|
||||
</template>
|
||||
<template #avatar="{ row }">
|
||||
<Avatar :size="32" :src="row.avatar">
|
||||
{{ (row.nick_name ?? '').slice(0, 1) }}
|
||||
</Avatar>
|
||||
</template>
|
||||
<template #enterprise="{ row }">
|
||||
<Button size="small" type="link" @click="openBind(row)">
|
||||
{{ row.enterprise_name || '未绑定' }}
|
||||
</Button>
|
||||
</template>
|
||||
<template #agent="{ row }">
|
||||
<Switch
|
||||
:checked="row.is_p === 1"
|
||||
checked-children="代理"
|
||||
un-checked-children="普通"
|
||||
@change="(checked: any) => toggleAgent(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #showPrice="{ row }">
|
||||
<Switch
|
||||
:checked="row.show_price === 1"
|
||||
checked-children="可见"
|
||||
un-checked-children="隐藏"
|
||||
@change="(checked: any) => toggleShowPrice(row, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template #multiplier="{ row }">
|
||||
<Popover trigger="click">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-2">
|
||||
<InputNumber
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
:value="multiplierDraft[row.id] ?? Number(row.price_number ?? 1)"
|
||||
@update:value="(val: any) => (multiplierDraft[row.id] = Number(val))"
|
||||
/>
|
||||
<Button size="small" type="primary" @click="saveMultiplier(row)">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
保存后该用户价格即为「原价 × 倍率」,并自动开启价格可见。
|
||||
</p>
|
||||
</template>
|
||||
<Tag class="cursor-pointer" color="blue">
|
||||
x{{ row.price_number || '1' }}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'link',
|
||||
icon: 'uil:edit',
|
||||
size: 'small',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '绑定企业',
|
||||
type: 'link',
|
||||
icon: 'lucide:building',
|
||||
size: 'small',
|
||||
onClick: () => openBind(row),
|
||||
},
|
||||
{
|
||||
label: '下级',
|
||||
type: 'link',
|
||||
icon: 'lucide:users',
|
||||
size: 'small',
|
||||
ifShow: row.is_p === 1,
|
||||
onClick: () => openChildren(row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
8
apps/web-antd/src/views/factory/class/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createCrudApi } from '#/components/crud';
|
||||
|
||||
export const factoryClassApi = createCrudApi('factory-classification');
|
||||
|
||||
/** 工厂分类下拉 */
|
||||
export async function getFactoryClassOption() {
|
||||
return (await factoryClassApi.option()) ?? [];
|
||||
}
|
||||
13
apps/web-antd/src/views/factory/class/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { SimpleNamePage } from '#/components/crud';
|
||||
|
||||
import { factoryClassApi } from './api';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SimpleNamePage
|
||||
:crud-api="factoryClassApi"
|
||||
module-title="工厂分类"
|
||||
name-label="分类名称"
|
||||
/>
|
||||
</template>
|
||||
13
apps/web-antd/src/views/factory/image/api/index.ts
Normal file
@@ -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<any[]> {
|
||||
return (
|
||||
(await requestClient.get<any>('factory-image/image-list', {
|
||||
params: { factory: factoryId },
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
23
apps/web-antd/src/views/factory/image/components/modal.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import { firstImage, useCrudModal } from '#/components/crud';
|
||||
|
||||
import { factoryImageApi } from '../api';
|
||||
import { modalFormProps } from '../config/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'FactoryImageFormModal',
|
||||
});
|
||||
|
||||
const { Form, isUpdate, Modal } = useCrudModal({
|
||||
api: factoryImageApi,
|
||||
formProps: modalFormProps,
|
||||
transform: (values, update) =>
|
||||
update ? { ...values, url: firstImage(values.url) } : values,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}产品图`" class="w-[40%]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
54
apps/web-antd/src/views/factory/image/config/form.ts
Normal file
@@ -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',
|
||||
};
|
||||
40
apps/web-antd/src/views/factory/image/config/search.ts
Normal file
@@ -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,
|
||||
};
|
||||
19
apps/web-antd/src/views/factory/image/config/table.ts
Normal file
@@ -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),
|
||||
});
|
||||