初始化

缺陷:主题配色需要优化整体的同风格
This commit is contained in:
2026-08-14 23:19:06 +08:00
parent 318aeab092
commit 27135f4ae6
187 changed files with 13631 additions and 187 deletions

5
.gitignore vendored
View File

@@ -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
View File

@@ -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](https://img.shields.io/github/license/anncwb/vue-vben-admin.svg)](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` |
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vbenjs_vue-vben-admin&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vbenjs_vue-vben-admin) [![codeql](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/codeql.yml) [![build](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/build.yml) [![ci](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/ci.yml) [![deploy](https://github.com/vbenjs/vue-vben-admin/actions/workflows/deploy.yml/badge.svg)](https://github.com/vbenjs/vue-vben-admin/actions/workflows/deploy.yml)
包管理器:**pnpm**(不要用 npm / yarn
**English** | [中文](./README.zh-CN.md) | [日本語](./README.ja-JP.md)
## Introduction
Vue Vben Admin is a free and open source middle and back-end template. Using the latest `vue3`, `vite`, `TypeScript` and other mainstream technology development, the out-of-the-box middle and back-end front-end solutions can also be used for learning reference.
## Upgrade Notice
This is the latest version, 5.0, and it is not compatible with previous versions. If you are starting a new project, it is recommended to use the latest version. If you wish to view the old version, please use the [v2 branch](https://github.com/vbenjs/vue-vben-admin/tree/v2).
## Features
- **Latest Technology Stack**: Developed with cutting-edge front-end technologies like Vue 3 and Vite
- **TypeScript**: A language for application-scale JavaScript
- **Themes**: Multiple theme colors available with customizable options
- **Internationalization**: Comprehensive built-in internationalization support
- **Permissions**: Built-in solution for dynamic route-based permission generation
## Preview
- [Vben Admin](https://vben.pro/) - Full version Chinese site
Test Account: vben/123456
<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.
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/vbenjs/vue-vben-admin)
## Documentation
[Document](https://doc.vben.pro/)
## Install and Use
1. Get the project code
## 准备
```bash
git clone https://github.com/vbenjs/vue-vben-admin.git
```
2. Install dependencies
```bash
cd vue-vben-admin
npm i -g corepack
pnpm install
```
3. Run
## 启动(两种)
在仓库根目录执行:
```bash
pnpm dev
# 1佛山铂尔曼 —— 读 .env.brm标题 / Logo / 品牌色)
pnpm dev:brm
# 2维伦家具 —— 读 .env.wl
pnpm dev:wl
```
4. Build
默认开发端口见 `apps/web-antd/vite.config.ts`(当前 **14002**)。
接口代理:`/api` → 本地后端 `http://127.0.0.1:18004/api/`
> 仅调试、不区分品牌时可用 `pnpm dev``development` 模式,默认品牌仍是铂尔曼)。
## 打包(两种)
```bash
pnpm build
# 1佛山铂尔曼 → apps/web-antd/dist-brm并可能产出 zip
pnpm build:brm
# 2维伦家具 → apps/web-antd/dist-wl
pnpm build:wl
```
## Change Log
一次打两个品牌:
[CHANGELOG](https://github.com/vbenjs/vue-vben-admin/releases)
```bash
pnpm build:brands
```
## How to Contribute
两个品牌 **outDir 必须分开**,否则会互相覆盖。上线前按品牌分别回归。
You are very welcome to join! [Raise an issue](https://github.com/anncwb/vue-vben-admin/issues/new/choose) or submit a Pull Request.
## 环境文件
**Pull Request Process:**
| 文件 | 用途 |
|---|---|
| `apps/web-antd/.env.brm` | 铂尔曼生产构建 / `dev:brm` |
| `apps/web-antd/.env.wl` | 维伦生产构建 / `dev:wl` |
| `apps/web-antd/.env.development` | 通用本地开发(`pnpm dev` |
1. Fork the code
2. Create your branch: `git checkout -b feat/xxxx`
3. Submit your changes: `git commit -am 'feat(function): add xxxxx'`
4. Push your branch: `git push origin feat/xxxx`
5. Submit `pull request`
品牌相关变量:`VITE_APP_BRAND``VITE_APP_TITLE``VITE_APP_FAVICON``VITE_GLOB_API_URL`
## Git Contribution Submission Specification
## 后端
Reference [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) specification ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
- `feat` Add new features
- `fix` Fix the problem/BUG
- `style` The code style is related and does not affect the running result
- `perf` Optimization/performance improvement
- `refactor` Refactor
- `revert` Undo edit
- `test` Test related
- `docs` Documentation/notes
- `chore` Dependency update/scaffolding configuration modification etc.
- `ci` Continuous integration
- `types` Type definition file changes
## Browser Support
Tailwind CSS v4.0 is designed for Safari 16.4+, Chrome 111+, and Firefox 128+
Support modern browsers, not IE
| [<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
[![Star History Chart](https://api.star-history.com/svg?repos=vbenjs/vue-vben-admin&type=Date)](https://star-history.com/#vbenjs/vue-vben-admin&Date)
## Donate
If you think this project is helpful to you, you can help the author buy a cup of coffee to show your support!
![donate](https://unpkg.com/@vbenjs/static-source@0.1.7/source/sponsor.png)
<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。

View File

@@ -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
View 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
View 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

View File

@@ -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_) {

View File

@@ -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"
},

View 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

View 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

View 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">&#x4F5B;&#x5C71;&#x94C2;&#x5C14;&#x66FC;</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

View 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">&#x4F5B;&#x5C71;&#x94C2;&#x5C14;&#x66FC;</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

View 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

View 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

View 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

View 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">&#x7EF4;&#x4F26;&#x5BB6;&#x5177;</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

View 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">&#x7EF4;&#x4F26;&#x5BB6;&#x5177;</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

View 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

View File

@@ -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 ?? ''}` } },
);
}
/**

View File

@@ -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

View File

@@ -50,8 +50,9 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
// baseRequestClient 默认 responseReturn 是 rawresp.data 才是后端的 {code,message,result}
const resp = await refreshTokenApi();
const newToken = resp.data;
const newToken = resp.data?.result?.token ?? '';
accessStore.setAccessToken(newToken);
return newToken;
}

View 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];

View 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<{
/** 横版高度,登录页建议 5664 */
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>

View 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),
};
}

View 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,
};
}

View 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 ?? '';
}

View 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>;
}

View 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>

View 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>

View 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 };
}

View 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,
};
}

View 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>

View 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>

View 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>

View 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';

View 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>

View 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>

View 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)[];
}

View File

@@ -0,0 +1,2 @@
export type { SearchSelectOption } from './types';
export { default as SearchSelect } from './search-select.vue';

View 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>

View File

@@ -0,0 +1,7 @@
export interface SearchSelectOption {
[key: string]: unknown;
id?: number | string;
name?: string;
/** 企业默认价格倍率,命中时在候选卡片上显示 */
price_number?: string;
}

View File

@@ -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>

View File

@@ -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');

View File

@@ -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 必须 containcover 会裁切并易触发 Avatar 回退怪相
fit: 'contain',
},
sidebar: {
width: 220,
},
theme: {
builtinType: 'violet',
colorPrimary: 'hsl(245 82% 67%)',
builtinType: currentBrand.builtinType,
colorPrimary: currentBrand.colorPrimary,
mode: 'auto',
},
copyright: appCopyrightPreferences,

View 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);
}

View File

@@ -0,0 +1,8 @@
import { createCrudApi } from '#/components/crud';
export const cardClassApi = createCrudApi('card-class');
/** 色卡分类下拉 */
export async function getCardClassOption() {
return (await cardClassApi.option()) ?? [];
}

View 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>

View File

@@ -0,0 +1,3 @@
import { createCrudApi } from '#/components/crud';
export const colorcardApi = createCrudApi('colorcard');

View 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>

View 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',
};

View 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,
};

View 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),
});

View 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>

View File

@@ -0,0 +1,8 @@
import { createCrudApi } from '#/components/crud';
export const companyApi = createCrudApi('company');
/** 公司下拉 */
export async function getCompanyOption() {
return (await companyApi.option()) ?? [];
}

View 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>

View 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',
};

View 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,
};

View 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),
});

View 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>

View 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',
};

View 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 }),
});

View 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>

View 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 ?? '-';
}

View File

@@ -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>

View File

@@ -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>

View 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',
};

View 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,
};

View 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),
});

View 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>

View 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 });
}

View File

@@ -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>

View 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 发的是原始 idnumber/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,
// 这里只用编辑场景的 schemaname + remark + enterprise_id + id
formProps: editFormProps,
transform: (values) => {
if (!isUpdate.value) {
// 新增必须有 user_id取 SearchSelect 选中的原始 id
values.user_id = Number(selectedUserId.value ?? 0);
}
// 编辑时后端 updateField 限定只改 id+nameuser_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>

View File

@@ -0,0 +1,28 @@
/**
* 清单状态枚举。
* cc_list.status0=待处理还没转订单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 },
];

View File

@@ -0,0 +1,58 @@
import type { VbenFormProps } from '#/adapter/form';
import { searchEnterprise } from '#/views/customer/enterprise/api';
/**
* 清单新增/编辑表单。
*
* 后端约定:
* - create 必填 name + user_idinsertField
* - update 仅允许改 nameupdateField 限制了 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',
};

View 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,
};

View 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),
});

View 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>

View 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 });
}

View File

@@ -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>

View 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>

View 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>

View 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_status0=未支付/1=待审核/2=已支付/3=已拒绝
* - 支付记录级 status0=待审核/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>,
);

View 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',
};

View 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,
};

View 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),
});

View 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>

View 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 ?? '',
}));
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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',
};

View 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,
};

View 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),
});

View 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>

View File

@@ -0,0 +1,8 @@
import { createCrudApi } from '#/components/crud';
export const factoryClassApi = createCrudApi('factory-classification');
/** 工厂分类下拉 */
export async function getFactoryClassOption() {
return (await factoryClassApi.option()) ?? [];
}

View 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>

View 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 },
})) ?? []
);
}

View 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>

View 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',
};

View 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,
};

View 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),
});

Some files were not shown because too many files have changed in this diff Show More