feat: 前后端全量开发 v0.2
后端: - 客户模块 CRUD+搜索 (api/services/repositories 分层) - 合同模块: FG单号规则/意向单转正式单/订单创建自动快照客户信息 - 优化下料: FFD首递减装箱算法(利用率统计/余料/未排入清单) 前端 (Vue3+TS+Element Plus): - 7页面: 登录/工作台/订单中心/合同/客户/样板库/优化下料 - 自研二维窗型画布渲染器: 解析drawing_data渲染玻璃区域/外框/ 扇开向标记/尺寸标注, 支持缩放平移复位 - 样板库与订单联动(用此样板下单自动带图纸) - axios统一envelope拦截/登录守卫/付费墙提示 工程: - SPA history 回退路由, 构建产物挂载后端 - 全接口回归 6/6 通过
This commit is contained in:
@@ -15,3 +15,6 @@ wheels/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Frontend
|
||||
node_modules/
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# CHANGELOG
|
||||
|
||||
- [2026-09-20 09:05] FEAT: 前后端全量开发 v0.2——后端新增客户/合同(意向单→正式)/优化下料(FFD)模块与订单合同快照;前端 Vue3+Element Plus 七页面(登录/工作台/订单/合同/客户/样板库/优化下料)与自研二维窗型画布渲染器(缩放/平移/开向标记/尺寸标注);构建产物挂载后端+SPA history 回退;全接口回归 6/6 通过 (Files: src/huamenchuang/**, frontend/**, README.md, doc/API.md)
|
||||
- [2026-09-20 08:14] FEAT: 订单模块 v0.1 实现与二维平面图全量采集——FastAPI 分层架构(api/services/repositories/models)、订单状态机(意向单→正式单→生产中→完工/取消)、7节点生产进度、审计日志、JWT认证、样板库API;创建契约对齐目标站{contract_id,drawing_data,pic_url};254张样板平面图全部下载成功并静态挂载;端到端冒烟测试7/7通过 (Files: src/huamenchuang/**, pyproject.toml, README.md, doc/API.md, data/windoor-images/*, data/fetch-images.py)
|
||||
- [2026-09-20 07:18] FEAT: 整站全量采集——GET 扫描 279 个接口全部留档(业务成功76)、POST 安全探测 24 个(付费墙/参数契约确认)、订单写入接口契约经源码静态分析补全(orders CRUD + 创建订单请求体);doc/API.md 新增 §11 采集结果与付费墙语义 (Files: data/full-site/*, doc/API.md, doc/克隆实施方案.md)
|
||||
- [2026-09-20 06:41] FEAT: 完成目标站逆向采集——样板库 254 套模板 drawing_data 全量采集(254/254)、16 个核心接口响应样本、约 300 条接口清单;输出《克隆实施方案》《API 接口文档》(Files: doc/克隆实施方案.md, doc/API.md, data/api-endpoint-inventory.txt, data/api-samples/*, data/windoor-drawings/*)
|
||||
- [2026-09-20 06:41] DOCS: 初始化 README 项目导航 (Files: README.md)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
## 当前进度
|
||||
|
||||
- ✅ 目标站逆向与数据采集(样板库 254 套全量 + 二维平面图 254 张 + 接口全量留档)
|
||||
- ✅ 后端 v0.1:认证 / 订单模块(状态机+生产进度+审计日志)/ 样板库 API
|
||||
- ⏳ 前端 Vue3 与画布编辑器(规划中,见方案 §3.3)
|
||||
- ✅ 后端 v0.2:认证 / 订单(状态机+生产进度+审计日志)/ 客户 / 合同(意向单→正式单)/ 样板库 / 优化下料(FFD)
|
||||
- ✅ 前端 v0.2:Vue3 + Element Plus,7 个页面,自研二维窗型画布渲染器(可渲染全部 254 套样板)
|
||||
- ⏳ 报价引擎 / 打印模板 / 微信生态(规划中,见方案 §6 里程碑)
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -31,11 +32,16 @@ frontend/ 前端(Vue3 + TS,规划中)
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
uv sync # 安装依赖
|
||||
uv sync # 后端依赖
|
||||
npm --prefix frontend install # 前端依赖
|
||||
uv run python -m huamenchuang.scripts.seed # 管理员 + 254 套样板入库
|
||||
uv run uvicorn huamenchuang.main:app --reload # 启动 → http://localhost:8000/docs
|
||||
npm --prefix frontend run build # 构建前端到 frontend/dist(后端自动挂载)
|
||||
uv run uvicorn huamenchuang.main:app --reload # 启动 → http://localhost:8000
|
||||
```
|
||||
|
||||
打开 http://localhost:8000 即为完整系统(SPA 由后端服务,深链接自动回退)。
|
||||
前端开发热更新:`npm --prefix frontend run dev`(5173 端口,/api 代理到 8000)。
|
||||
|
||||
管理员账号(开发):`13800000000 / 123456`。
|
||||
|
||||
环境变量(均可选):`HMC_DATABASE_URL`(默认 SQLite,可切 MySQL)、`HMC_JWT_SECRET`(生产必设)、`HMC_ENABLED_MODULES`(套餐模块开关)。
|
||||
|
||||
+42
-1
@@ -231,7 +231,48 @@
|
||||
| 接口文档 | GET | `/docs` | FastAPI 自动生成 OpenAPI |
|
||||
| 平面图静态 | GET | `/static/windoor-images/{id}.png` | 254 张采集平面图(开发参照,上线前须替换为自绘素材) |
|
||||
|
||||
### 12.5 种子数据
|
||||
### 12.5 客户模块 `/api/customers`(v0.2 新增)
|
||||
|
||||
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 新增客户 | POST | `/api/customers` | `{name(必填), phone, address, note, salesmen}` | |
|
||||
| 客户列表 | GET | `/api/customers?page=1&page_size=15&keyword=` | query | 模糊匹配姓名/电话/地址 |
|
||||
| 编辑客户 | PUT | `/api/customers/{id}` | 同新增 | |
|
||||
| 删除客户 | DELETE | `/api/customers/{id}` | 路径 | |
|
||||
|
||||
### 12.6 合同模块 `/api/contracts`(v0.2 新增)
|
||||
|
||||
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 创建合同 | POST | `/api/contracts` | `{customer_id, customer_name, phone, address, area, count, money, note}` | 合同号 `FG+YYYYMMDD+3位流水`,默认意向单 |
|
||||
| 合同列表 | GET | `/api/contracts?page=1&keyword=&status=` | query | 响应 `data.contracts` 分页(对齐目标站结构) |
|
||||
| 编辑合同 | PUT | `/api/contracts/{id}` | 同创建 | |
|
||||
| **意向单转正式** | POST | `/api/contracts/convert` | `{contract_id}` | 仅意向单可转,重复转换 code=0 |
|
||||
| 删除合同 | DELETE | `/api/contracts/{id}` | 路径 | |
|
||||
|
||||
**合同→订单联动**:创建订单传 `contract_id` 时,后端自动快照合同的客户名/电话/地址到订单(客户资料后续变更不影响订单)。
|
||||
|
||||
### 12.7 优化下料 `/api/optimize1d`(v0.2 新增,纯计算无状态)
|
||||
|
||||
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 一维优化 | POST | `/api/optimize1d` | `{demands:[{length,qty,label}], stocks:[{length,qty,label}], kerf:5}` | FFD 首递减装箱;返回 `plans[]`(每根原料的切割组合+余料)、`unplaced[]`、`stats.utilization`;需求>库存时 code=0 |
|
||||
|
||||
### 12.8 前端(v0.2,Vue3 + Element Plus,构建产物挂载于后端)
|
||||
|
||||
| 路由 | 页面 | 功能 |
|
||||
|---|---|---|
|
||||
| `/login` | 登录 | JWT 登录 |
|
||||
| `/dashboard` | 工作台 | 订单/意向单/客户/合同统计 + 快捷入口 |
|
||||
| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新 |
|
||||
| `/contracts` | 合同管理 | 列表/新建(选客户带出信息)/意向单转正式/一键下订单 |
|
||||
| `/customers` | 客户管理 | CRUD + 搜索 |
|
||||
| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 点击预览(**画布渲染**) + 用此样板下单 |
|
||||
| `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) |
|
||||
|
||||
**画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。
|
||||
|
||||
### 12.9 种子数据
|
||||
|
||||
```bash
|
||||
uv sync # 安装依赖
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>华门窗云平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1886
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "huamenchuang-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
/** axios 实例:自动携带 token、统一处理 401/付费墙 */
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '../router'
|
||||
|
||||
export const http = axios.create({ baseURL: '/', timeout: 30000 })
|
||||
|
||||
http.interceptors.request.use(cfg => {
|
||||
const token = localStorage.getItem('hmc_token')
|
||||
if (token) cfg.headers.Authorization = `Bearer ${token}`
|
||||
return cfg
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
res => {
|
||||
const d = res.data
|
||||
if (d && typeof d === 'object' && 'code' in d) {
|
||||
if (d.code === 1) return res
|
||||
if (String(d.code) === '10086') {
|
||||
ElMessage.warning(d.message ?? '该功能需要购买解锁')
|
||||
return Promise.reject(new Error(d.message))
|
||||
}
|
||||
if (Number(d.code) === 401) {
|
||||
localStorage.removeItem('hmc_token')
|
||||
router.push('/login')
|
||||
return Promise.reject(new Error(d.message))
|
||||
}
|
||||
ElMessage.error(d.message ?? '操作失败')
|
||||
return Promise.reject(new Error(d.message))
|
||||
}
|
||||
return res
|
||||
},
|
||||
err => {
|
||||
ElMessage.error(err.message ?? '网络错误')
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
/** 提取 envelope 的 data */
|
||||
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
|
||||
const res = await http.request({ method, url, data: body })
|
||||
return res.data.data as T
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="canvas-wrap" ref="wrapRef">
|
||||
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
|
||||
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
|
||||
<div class="hud">
|
||||
<span v-if="info">{{ info }}</span>
|
||||
<el-button size="small" text @click="reset">重置视图</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 二维窗型画布渲染器(自研实现)
|
||||
* 读取 shapeManager 序列化的 drawing_data:Frame→玻璃区域多边形 + 扇开向 + 尺寸标注
|
||||
* 支持滚轮缩放、拖拽平移、双击复位
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
bboxOf, parseDrawing, regionPoints,
|
||||
type DrawingData, type FrameShape, type Pt, type SashShape,
|
||||
} from '../types/drawing'
|
||||
|
||||
const props = defineProps<{ drawingData: string | object }>()
|
||||
const cvRef = ref<HTMLCanvasElement>()
|
||||
const wrapRef = ref<HTMLDivElement>()
|
||||
const info = ref('')
|
||||
|
||||
let view = { scale: 1, cx: 0, cy: 0 } // cx/cy=模型坐标中心
|
||||
let drag: { x: number; y: number } | null = null
|
||||
let dd: DrawingData | null = null
|
||||
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
|
||||
|
||||
function render() {
|
||||
const cv = cvRef.value, wrap = wrapRef.value
|
||||
if (!cv || !wrap) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
cv.width = wrap.clientWidth * dpr
|
||||
cv.height = wrap.clientHeight * dpr
|
||||
const ctx = cv.getContext('2d')!
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.clearRect(0, 0, cv.width, cv.height)
|
||||
if (!dd) return
|
||||
|
||||
const W = wrap.clientWidth, H = wrap.clientHeight
|
||||
// 自适应:模型坐标 → 屏幕(Y 轴翻转)
|
||||
const pad = 48
|
||||
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
|
||||
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
|
||||
const scale = fit * view.scale
|
||||
const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2
|
||||
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
|
||||
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
|
||||
|
||||
// 全局样式
|
||||
const glass = '#d3ddda', glassAlpha = 'rgba(211,221,218,0.6)'
|
||||
const profile = '#5c6166'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glass, glassAlpha, profile })
|
||||
|
||||
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
|
||||
}
|
||||
|
||||
function drawFrame(
|
||||
ctx: CanvasRenderingContext2D, shape: FrameShape,
|
||||
tx: (x: number) => number, ty: (y: number) => number,
|
||||
color: { glass: string; glassAlpha: string; profile: string },
|
||||
) {
|
||||
const regionsRaw = shape.polygon?.polygon ?? []
|
||||
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
|
||||
const cm = shape.cm ?? {}
|
||||
|
||||
// ---- 玻璃区域 ----
|
||||
for (const pts of regions) {
|
||||
ctx.beginPath()
|
||||
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = cm.g ?? color.glassAlpha
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.stroke()
|
||||
// 内描边示意型材厚度
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
|
||||
ctx.lineWidth = 0.8
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// ---- 外框(全部区域的联合包围盒,逐段加粗)----
|
||||
if (regions.length) {
|
||||
const all = regions.flat()
|
||||
const bb = bboxOf(all)
|
||||
const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0)
|
||||
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
|
||||
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
|
||||
ctx.strokeStyle = cm.bn ?? color.profile
|
||||
ctx.lineWidth = 3.2
|
||||
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
|
||||
ctx.lineWidth = 1
|
||||
const inset = Math.max(2.5, avg * 0.018)
|
||||
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
|
||||
void glass // 保留引用
|
||||
}
|
||||
|
||||
// ---- 扇(开向标记)----
|
||||
for (const sub of (shape.sm ?? []) as SashShape[]) {
|
||||
const bd = sub.bd
|
||||
if (!bd || bd.hidden) continue
|
||||
const bx0 = tx(Math.min(bd.from.x, bd.to.x)), bx1 = tx(Math.max(bd.from.x, bd.to.x))
|
||||
const by0 = ty(Math.max(bd.from.y, bd.to.y)), by1 = ty(Math.min(bd.from.y, bd.to.y))
|
||||
const w = bx1 - bx0, h = by1 - by0
|
||||
if (w < 4 || h < 4) continue
|
||||
ctx.save()
|
||||
// 扇框(虚线内框)
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
|
||||
ctx.lineWidth = 1.3
|
||||
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
|
||||
ctx.setLineDash([])
|
||||
// 开向三角:od 为角度(度),屏幕系 y 翻转
|
||||
const ang = ((sub.od ?? 0) * Math.PI) / 180
|
||||
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
|
||||
const L = Math.min(w, h) * 0.32
|
||||
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
|
||||
// 垂直方向的两翼
|
||||
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
|
||||
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(ax, ay)
|
||||
ctx.lineTo(cx - wx, cy - wy)
|
||||
ctx.lineTo(cx + wx, cy + wy)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = 'rgba(70,76,82,0.25)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
|
||||
ctx.lineWidth = 1.4
|
||||
ctx.stroke()
|
||||
if (sub.isDoor) {
|
||||
ctx.fillStyle = 'rgba(70,76,82,0.85)'
|
||||
ctx.font = '10px sans-serif'
|
||||
ctx.fillText('门', cx - 6, by1 - 6)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// ---- 总尺寸标注(宽/高)----
|
||||
if (regions.length) {
|
||||
const all = regions.flat()
|
||||
const bb = bboxOf(all)
|
||||
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
|
||||
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
|
||||
ctx.fillStyle = '#7a8087'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${wmm} mm`, (x0 + x1) / 2, y1 + 16)
|
||||
ctx.save()
|
||||
ctx.translate(x0 - 14, (y0 + y1) / 2)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillText(`${hmm} mm`, 0, 0)
|
||||
ctx.restore()
|
||||
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
|
||||
ctx.lineWidth = 1
|
||||
// 尺寸线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
|
||||
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function computeModelBox() {
|
||||
if (!dd) return
|
||||
const pts: Pt[] = []
|
||||
for (const s of dd.sm) {
|
||||
for (const r of (s.polygon?.polygon ?? [])) {
|
||||
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
|
||||
}
|
||||
// 扇包围盒也计入
|
||||
for (const sub of (s.sm ?? []) as SashShape[]) {
|
||||
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
|
||||
}
|
||||
}
|
||||
if (!pts.length) { modelBox = { x0: 0, y0: 0, x1: 1000, y1: 1000 }; return }
|
||||
modelBox = bboxOf(pts)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
view = { scale: 1, cx: 0, cy: 0 }
|
||||
render()
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
|
||||
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
|
||||
render()
|
||||
}
|
||||
function onDown(e: MouseEvent) { drag = { x: e.clientX, y: e.clientY } }
|
||||
function onMove(e: MouseEvent) {
|
||||
if (!drag) return
|
||||
view.cx += e.clientX - drag.x
|
||||
view.cy += e.clientY - drag.y
|
||||
drag = { x: e.clientX, y: e.clientY }
|
||||
render()
|
||||
}
|
||||
function onUp() { drag = null }
|
||||
|
||||
onMounted(() => {
|
||||
watch(() => props.drawingData, () => {
|
||||
try {
|
||||
dd = props.drawingData ? parseDrawing(props.drawingData) : null
|
||||
computeModelBox()
|
||||
reset()
|
||||
} catch (e) {
|
||||
info.value = '数据解析失败'
|
||||
dd = null
|
||||
render()
|
||||
}
|
||||
}, { immediate: true })
|
||||
window.addEventListener('resize', render)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.canvas-wrap { position: relative; width: 100%; height: 100%; min-height: 320px;
|
||||
background: repeating-conic-gradient(#f7f8f9 0% 25%, #eef0f2 0% 50%) 50%/24px 24px; }
|
||||
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
|
||||
canvas:active { cursor: grabbing; }
|
||||
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
|
||||
background: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -0,0 +1,31 @@
|
||||
/** 路由:登录守卫 + 主布局 */
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: () => import('../views/LoginView.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/LayoutView.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
|
||||
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
|
||||
{ path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } },
|
||||
{ path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } },
|
||||
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
|
||||
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(to => {
|
||||
const auth = useAuthStore()
|
||||
if (to.path !== '/login' && !auth.isLogin) return '/login'
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 认证状态 */
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('hmc_token') ?? '',
|
||||
nickname: localStorage.getItem('hmc_nickname') ?? '',
|
||||
}),
|
||||
getters: { isLogin: s => !!s.token },
|
||||
actions: {
|
||||
setSession(token: string, nickname: string) {
|
||||
this.token = token
|
||||
this.nickname = nickname
|
||||
localStorage.setItem('hmc_token', token)
|
||||
localStorage.setItem('hmc_nickname', nickname)
|
||||
},
|
||||
logout() {
|
||||
this.token = ''
|
||||
this.nickname = ''
|
||||
localStorage.removeItem('hmc_token')
|
||||
localStorage.removeItem('hmc_nickname')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/** drawing_data 数据模型类型(与采集的 shapeManager 序化格式同构) */
|
||||
|
||||
export interface Pt { x: number; y: number; name?: string }
|
||||
export interface Vec { x: number; y: number; name?: string }
|
||||
export interface Segment { ps: Pt; pe: Pt; name?: string }
|
||||
|
||||
/** 中梃分割线 */
|
||||
export interface SplitLine {
|
||||
line: { pt: Pt; norm: Vec; name?: string }
|
||||
polyId?: { idx: number; pos: number }
|
||||
alignType?: number
|
||||
equalSplit?: number
|
||||
kw?: boolean // 竖撑
|
||||
ws?: boolean // 窗撑
|
||||
reinforced?: boolean
|
||||
db?: boolean // hidden/disabled
|
||||
}
|
||||
|
||||
/** 尺寸标注 */
|
||||
export interface Dim {
|
||||
at: number; ds: boolean
|
||||
ov?: Vec
|
||||
eidx?: number // 绑定外框边
|
||||
midx?: number // 绑定中梃
|
||||
name?: string
|
||||
type?: 'line' | 'mullion' | string
|
||||
}
|
||||
|
||||
export interface FrameModel {
|
||||
ew?: [number, number, number, number] // 四边料宽
|
||||
vs?: Pt[]
|
||||
db?: unknown[]
|
||||
ejw?: number[]
|
||||
pew?: unknown[]
|
||||
ast?: unknown[]
|
||||
}
|
||||
|
||||
export interface ColorMap {
|
||||
g?: string // 玻璃填充
|
||||
bn?: string // 边框
|
||||
hd?: string // 高光
|
||||
ben?: string
|
||||
}
|
||||
|
||||
export interface SashShape {
|
||||
type: 'Sash' | string
|
||||
bd?: { from: Pt; to: Pt; hidden?: boolean } // 扇所在区域包围盒
|
||||
od?: number // 开向角度
|
||||
opened?: boolean
|
||||
wtf?: boolean // 带转框
|
||||
isDoor?: boolean
|
||||
polyId?: { idx: number; pos: number }
|
||||
enabled?: boolean
|
||||
rid?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface FrameShape {
|
||||
type: 'Frame' | string
|
||||
polygon?: {
|
||||
type?: string
|
||||
polygon?: Segment[][] // 分割后的玻璃区域(每区域为闭合段列表)
|
||||
spLine?: SplitLine
|
||||
ep?: string[]
|
||||
virtual?: boolean
|
||||
}
|
||||
fm?: FrameModel
|
||||
mm?: { sp: SplitLine[]; dbs?: unknown[]; fls?: unknown[]; phs?: unknown[]; lpfs?: unknown[] }
|
||||
dm?: { dims: Dim[] }
|
||||
cm?: ColorMap
|
||||
sm?: SashShape[] // 子形状(扇)
|
||||
ps?: Record<string, number>
|
||||
uid?: string
|
||||
titl?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface DrawingData {
|
||||
s?: { cs?: number; cjs?: number }
|
||||
cp?: unknown[]
|
||||
sm: FrameShape[]
|
||||
epi?: unknown[]
|
||||
sbf?: unknown[]
|
||||
wlc?: string
|
||||
wall?: unknown[]
|
||||
scale?: number
|
||||
note?: unknown[]
|
||||
}
|
||||
|
||||
/** 解析 drawing_data(兼容对象或 JSON 字符串) */
|
||||
export function parseDrawing(raw: string | object): DrawingData {
|
||||
const dd = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
return dd as DrawingData
|
||||
}
|
||||
|
||||
/** 区域多边形 → 点列(段首点顺序连接) */
|
||||
export function regionPoints(region: Segment[]): Pt[] {
|
||||
return region.map(seg => ({ x: seg.ps.x, y: seg.ps.y }))
|
||||
}
|
||||
|
||||
/** 点列包围盒 */
|
||||
export function bboxOf(points: Pt[]): { x0: number; y0: number; x1: number; y1: number } {
|
||||
const xs = points.map(p => p.x)
|
||||
const ys = points.map(p => p.y)
|
||||
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="合同号/客户" clearable style="width:220px" @keyup.enter="load" />
|
||||
<el-select v-model="status" placeholder="全部状态" clearable style="width:130px">
|
||||
<el-option :value="0" label="意向单" /><el-option :value="1" label="正式单" />
|
||||
<el-option :value="2" label="已完成" /><el-option :value="3" label="已作废" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<div style="flex:1" />
|
||||
<el-button type="primary" plain @click="openCreate">新建合同</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="contract_no" label="合同号" width="160" />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="110" />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column prop="count" label="数量" width="70" />
|
||||
<el-table-column prop="money" label="金额" width="110" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 0 ? 'info' : row.status === 1 ? 'primary' : 'success'" size="small">
|
||||
{{ row.status_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 0" size="small" type="primary"
|
||||
@click="convert(row)">转正式单</el-button>
|
||||
<el-button size="small" @click="createOrder(row)">下订单</el-button>
|
||||
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
|
||||
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
|
||||
|
||||
<el-dialog v-model="createVisible" title="新建合同" width="520px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="客户">
|
||||
<el-select v-model="form.customer_id" filterable clearable style="width:100%"
|
||||
placeholder="选择客户(自动带出电话地址)" @change="onCustomer">
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户名"><el-input v-model="form.customer_name" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="数量"><el-input-number v-model="form.count" :min="0" /></el-form-item>
|
||||
<el-form-item label="金额"><el-input-number v-model="form.money" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const page_size = 15
|
||||
const keyword = ref('')
|
||||
const status = ref<number | undefined>()
|
||||
const loading = ref(false)
|
||||
const createVisible = ref(false)
|
||||
const customerOptions = ref<any[]>([])
|
||||
const form = reactive({ customer_id: undefined as number | undefined, customer_name: '', phone: '', address: '', count: 0, money: 0, note: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<any>('get', `/api/contracts?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${status.value !== undefined ? `&status=${status.value}` : ''}`)
|
||||
rows.value = d.contracts.data
|
||||
total.value = d.contracts.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { customer_id: undefined, customer_name: '', phone: '', address: '', count: 0, money: 0, note: '' })
|
||||
api<any>('get', '/api/customers?page=1&page_size=100').then(d => (customerOptions.value = d.data))
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
function onCustomer(cid: number | undefined) {
|
||||
const c = customerOptions.value.find(x => x.id === cid)
|
||||
if (c) { form.customer_name = c.name; form.phone = c.phone; form.address = c.address }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await api('post', '/api/contracts', form)
|
||||
ElMessage.success('合同已创建')
|
||||
createVisible.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
async function convert(row: any) {
|
||||
await api('post', '/api/contracts/convert', { contract_id: row.id })
|
||||
ElMessage.success('已转为正式单')
|
||||
load()
|
||||
}
|
||||
|
||||
async function createOrder(row: any) {
|
||||
await ElMessageBox.confirm(`为合同 ${row.contract_no} 创建订单?`, '提示')
|
||||
await api('post', '/api/orders', { contract_id: row.id, money: row.money, count: row.count })
|
||||
ElMessage.success('订单已创建,可到订单管理查看')
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
async function remove(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除合同 ${row.contract_no}?`, '提示', { type: 'warning' })
|
||||
await api('delete', `/api/contracts/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<div style="flex:1" />
|
||||
<el-button type="primary" plain @click="openEdit()">新增客户</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="姓名" min-width="120" />
|
||||
<el-table-column prop="phone" label="电话" width="140" />
|
||||
<el-table-column prop="address" label="地址" min-width="180" />
|
||||
<el-table-column prop="salesmen" label="业务员" width="100" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
|
||||
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
|
||||
|
||||
<el-dialog v-model="editVisible" :title="form.id ? '编辑客户' : '新增客户'" width="480px">
|
||||
<el-form :model="form" label-width="70px">
|
||||
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const page_size = 15
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
const editVisible = ref(false)
|
||||
const form = reactive({ id: 0, name: '', phone: '', address: '', note: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<any>('get', `/api/customers?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}`)
|
||||
rows.value = d.data
|
||||
total.value = d.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openEdit(row?: any) {
|
||||
Object.assign(form, { id: row?.id ?? 0, name: row?.name ?? '', phone: row?.phone ?? '', address: row?.address ?? '', note: row?.note ?? '' })
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (form.id) await api('put', `/api/customers/${form.id}`, form)
|
||||
else await api('post', '/api/customers', form)
|
||||
ElMessage.success('已保存')
|
||||
editVisible.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除客户「${row.name}」?`, '提示', { type: 'warning' })
|
||||
await api('delete', `/api/customers/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6" v-for="card in cards" :key="card.label">
|
||||
<el-card shadow="hover">
|
||||
<div class="stat"><span class="num">{{ card.value }}</span><span class="label">{{ card.label }}</span></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-card style="margin-top:16px">
|
||||
<template #header>快捷入口</template>
|
||||
<el-space wrap>
|
||||
<el-button type="primary" @click="$router.push('/templates')">浏览样板库</el-button>
|
||||
<el-button @click="$router.push('/orders')">新建订单</el-button>
|
||||
<el-button @click="$router.push('/contracts')">录入合同</el-button>
|
||||
<el-button @click="$router.push('/optimize')">优化下料</el-button>
|
||||
</el-space>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
|
||||
const cards = ref([
|
||||
{ label: '订单总数', value: '-' },
|
||||
{ label: '意向单', value: '-' },
|
||||
{ label: '客户数', value: '-' },
|
||||
{ label: '合同数', value: '-' },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
const [orders, customers, contracts] = await Promise.all([
|
||||
api<any>('post', '/api/orders/search', { page: 1, page_size: 1 }),
|
||||
api<any>('get', '/api/customers?page=1&page_size=1'),
|
||||
api<any>('get', '/api/contracts?page=1&page_size=1'),
|
||||
])
|
||||
cards.value[0].value = orders.total ?? 0
|
||||
cards.value[2].value = customers.total ?? 0
|
||||
cards.value[3].value = contracts.contracts?.total ?? 0
|
||||
const intentions = await api<any>('post', '/api/orders/search', { page: 1, page_size: 1, status: 0 })
|
||||
cards.value[1].value = intentions.total ?? 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat { display: flex; flex-direction: column; align-items: center; padding: 8px 0; }
|
||||
.num { font-size: 30px; font-weight: 700; color: #35506b; }
|
||||
.label { color: #909399; font-size: 13px; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-aside width="200px">
|
||||
<div class="logo">华门窗</div>
|
||||
<el-menu :default-active="route.path" router background-color="#2b3a4d" text-color="#bfc9d4"
|
||||
active-text-color="#ffffff">
|
||||
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item>
|
||||
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
|
||||
<el-menu-item index="/contracts"><el-icon><Document /></el-icon>合同管理</el-menu-item>
|
||||
<el-menu-item index="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
|
||||
<el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
|
||||
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<span class="title">{{ route.meta.title }}</span>
|
||||
<el-dropdown @command="cmd => cmd === 'logout' && doLogout()">
|
||||
<span class="user"><el-icon><UserFilled /></el-icon>{{ auth.nickname || '用户' }}</span>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-main><router-view /></el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
function doLogout() { auth.logout(); router.push('/login') }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { height: 100vh; }
|
||||
.el-aside { background: #2b3a4d; }
|
||||
.logo { color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 20px 0; letter-spacing: 4px; }
|
||||
.el-menu { border-right: none; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center;
|
||||
background: #fff; border-bottom: 1px solid #e4e7ed; }
|
||||
.title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.user { cursor: pointer; color: #606266; display: inline-flex; gap: 6px; align-items: center; }
|
||||
.el-main { background: #f2f4f7; padding: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card">
|
||||
<h2>华门窗云平台</h2>
|
||||
<p class="sub">画图 · 报价 · 订单 · 生产</p>
|
||||
<el-form :model="form" @keyup.enter="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.phone_number" placeholder="手机号" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="tip">开发账号:13800000000 / 123456</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ phone_number: '', password: '' })
|
||||
|
||||
async function doLogin() {
|
||||
if (!form.phone_number || !form.password) return ElMessage.warning('请输入手机号和密码')
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', form)
|
||||
auth.setSession(d.token, d.user?.nickname ?? '')
|
||||
router.push('/dashboard')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); }
|
||||
.login-card { width: 380px; text-align: center; padding: 12px 8px; }
|
||||
h2 { margin: 8px 0 4px; color: #303133; }
|
||||
.sub { color: #909399; font-size: 13px; margin: 0 0 20px; }
|
||||
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-card header="型材需求(每件长度 × 数量)">
|
||||
<div v-for="(d, i) in demands" :key="i" class="row">
|
||||
<el-input v-model="d.label" placeholder="用途" style="width:130px" />
|
||||
<el-input-number v-model="d.length" :min="1" placeholder="长度mm" controls-position="right" />
|
||||
<el-input-number v-model="d.qty" :min="1" placeholder="数量" controls-position="right" />
|
||||
<el-button text type="danger" @click="demands.splice(i, 1)">删</el-button>
|
||||
</div>
|
||||
<el-button text type="primary" @click="demands.push({ label: '', length: 1000, qty: 1 })">+ 添加需求</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card header="库存原料">
|
||||
<div v-for="(s, i) in stocks" :key="i" class="row">
|
||||
<el-input v-model="s.label" placeholder="名称" style="width:130px" />
|
||||
<el-input-number v-model="s.length" :min="1" placeholder="长度mm" controls-position="right" />
|
||||
<el-input-number v-model="s.qty" :min="1" placeholder="根数" controls-position="right" />
|
||||
<el-button text type="danger" @click="stocks.splice(i, 1)">删</el-button>
|
||||
</div>
|
||||
<el-button text type="primary" @click="stocks.push({ label: '', length: 6000, qty: 1 })">+ 添加原料</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-card style="margin-top:16px">
|
||||
<div class="row">
|
||||
<span>锯口损耗:</span>
|
||||
<el-input-number v-model="kerf" :min="0" :max="50" controls-position="right" />
|
||||
<el-button type="primary" :loading="loading" @click="run">开始优化</el-button>
|
||||
</div>
|
||||
<template v-if="result">
|
||||
<el-descriptions :column="4" border style="margin-top:14px">
|
||||
<el-descriptions-item label="总件数">{{ result.stats.total_pieces }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用料根数">{{ result.stats.stock_bars_used }}</el-descriptions-item>
|
||||
<el-descriptions-item label="利用率">{{ (result.stats.utilization * 100).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="未排入">{{ result.unplaced.length }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="result.plans" style="margin-top:14px" border>
|
||||
<el-table-column label="原料" width="200">
|
||||
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="切割组合" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="(c, i) in row.pieces" :key="i" size="small" style="margin: 2px">{{ c.label || '件' }} {{ c.length }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="waste" label="余料mm" width="100" />
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
|
||||
const demands = reactive([{ label: '边框', length: 1200, qty: 3 }, { label: '中梃', length: 800, qty: 2 }])
|
||||
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
|
||||
const kerf = ref(5)
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
|
||||
async function run() {
|
||||
loading.value = true
|
||||
try {
|
||||
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value })
|
||||
ElMessage.success(`优化完成,利用率 ${(result.value.stats.utilization * 100).toFixed(1)}%`)
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="query.keyword" placeholder="订单号/客户/手机号" clearable style="width:240px"
|
||||
@keyup.enter="load" />
|
||||
<el-select v-model="query.status" placeholder="全部状态" clearable style="width:140px">
|
||||
<el-option v-for="(label, val) in statusLabels" :key="val" :label="label" :value="Number(val)" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<div style="flex:1" />
|
||||
<el-button type="primary" plain @click="openCreate">新建订单</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="order_no" label="订单号" width="160" />
|
||||
<el-table-column prop="customer_name" label="客户" min-width="110" />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column prop="count" label="数量" width="70" />
|
||||
<el-table-column prop="area" label="面积㎡" width="90" />
|
||||
<el-table-column prop="money" label="金额" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="tagType(row.status)" size="small">{{ row.status_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDetail(row)">图纸</el-button>
|
||||
<el-button v-for="act in nextActions(row.status)" :key="act.to" size="small"
|
||||
:type="act.type" @click="changeStatus(row, act.to)">{{ act.label }}</el-button>
|
||||
<el-button size="small" type="danger" plain @click="remove(row)">删</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
|
||||
:total="total" :page-size="query.page_size" v-model:current-page="query.page" @current-change="load" />
|
||||
</el-card>
|
||||
|
||||
<!-- 新建订单 -->
|
||||
<el-dialog v-model="createVisible" title="新建订单" width="560px">
|
||||
<el-form :model="createForm" label-width="90px">
|
||||
<el-form-item label="关联合同">
|
||||
<el-select v-model="createForm.contract_id" placeholder="选择合同(可选)" clearable filterable style="width:100%">
|
||||
<el-option v-for="c in contractOptions" :key="c.id"
|
||||
:label="`${c.contract_no} ${c.customer_name}`" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择样板">
|
||||
<el-select v-model="createForm.template_id" placeholder="从样板库带入图纸(可选)" clearable filterable
|
||||
style="width:100%" @change="onTemplateChange">
|
||||
<el-option v-for="t in templateOptions" :key="t.id"
|
||||
:label="t.name || `样板#${t.id}`" :value="t.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数量"><el-input-number v-model="createForm.count" :min="0" /></el-form-item>
|
||||
<el-form-item label="面积㎡"><el-input-number v-model="createForm.area" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="金额"><el-input-number v-model="createForm.money" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="createForm.note" type="textarea" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">创建订单</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图纸详情 -->
|
||||
<el-drawer v-model="detailVisible" size="62%" :title="detail?.order_no ?? '图纸'">
|
||||
<div v-if="detail" class="detail">
|
||||
<div class="meta">
|
||||
<el-tag>{{ detail.status_label }}</el-tag>
|
||||
<span>客户:{{ detail.customer_name || '-' }}</span>
|
||||
<span>金额:{{ detail.money }}</span>
|
||||
<span>面积:{{ detail.area }}㎡</span>
|
||||
</div>
|
||||
<div class="canvas-box">
|
||||
<WindoorCanvas :drawing-data="detail.drawing_data" />
|
||||
</div>
|
||||
<el-divider>生产进度</el-divider>
|
||||
<el-steps :active="processActive" align-center>
|
||||
<el-step v-for="p in detail.processes" :key="p.id" :title="p.name"
|
||||
:status="p.status === 2 ? 'success' : p.status === 1 ? 'process' : 'wait'" />
|
||||
</el-steps>
|
||||
<div style="margin-top:12px">
|
||||
<el-select v-model="processForm.name" style="width:120px">
|
||||
<el-option v-for="p in detail.processes" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<el-select v-model="processForm.status" style="width:110px">
|
||||
<el-option :value="0" label="未开始" /><el-option :value="1" label="进行中" /><el-option :value="2" label="完成" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="updateProcess">更新进度</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { api } from '../api'
|
||||
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' }
|
||||
|
||||
const query = reactive({ page: 1, page_size: 15, keyword: '', status: undefined as number | undefined })
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await api<any>('post', '/api/orders/search', query)
|
||||
rows.value = d.data
|
||||
total.value = d.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 状态机允许的下一步动作(与后端 TRANSITIONS 对齐)
|
||||
const transitions: Record<number, { to: number; label: string; type: string }[]> = {
|
||||
0: [{ to: 1, label: '转正式', type: 'primary' }, { to: 4, label: '取消', type: 'warning' }],
|
||||
1: [{ to: 2, label: '排产', type: 'primary' }, { to: 4, label: '取消', type: 'warning' }],
|
||||
2: [{ to: 3, label: '完工', type: 'success' }],
|
||||
}
|
||||
function nextActions(status: number) { return transitions[status] ?? [] }
|
||||
function tagType(status: number) {
|
||||
return ({ 0: 'info', 1: 'primary', 2: 'warning', 3: 'success', 4: 'danger' } as const)[
|
||||
status as 0 | 1 | 2 | 3 | 4] ?? 'info'
|
||||
}
|
||||
|
||||
async function changeStatus(row: any, to: number) {
|
||||
await api('put', `/api/orders/${row.id}`, { action: 'change_status', target_status: to })
|
||||
ElMessage.success('状态已更新')
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除订单 ${row.order_no}?`, '提示', { type: 'warning' })
|
||||
await api('delete', `/api/orders/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
|
||||
// ---- 新建 ----
|
||||
const createVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const createForm = reactive({ contract_id: undefined as number | undefined, template_id: undefined as number | undefined, count: 1, area: 0, money: 0, note: '', drawing_data: '', pic_url: '' })
|
||||
const contractOptions = ref<any[]>([])
|
||||
const templateOptions = ref<any[]>([])
|
||||
|
||||
async function openCreate() {
|
||||
createVisible.value = true
|
||||
const [cs, ts] = await Promise.all([
|
||||
api<any>('get', '/api/contracts?page=1&page_size=100'),
|
||||
api<any>('get', '/api/windoor/templates?page=1&page_size=254'),
|
||||
])
|
||||
contractOptions.value = cs.contracts?.data ?? []
|
||||
templateOptions.value = ts
|
||||
// 样板库"用此样板下单"跳转带入
|
||||
const tid = Number(route.query.template)
|
||||
if (tid) {
|
||||
createForm.template_id = tid
|
||||
await onTemplateChange(tid)
|
||||
}
|
||||
}
|
||||
|
||||
async function onTemplateChange(tid: number | undefined) {
|
||||
createForm.drawing_data = ''
|
||||
createForm.pic_url = ''
|
||||
if (!tid) return
|
||||
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
|
||||
createForm.drawing_data = t.drawing_data
|
||||
createForm.pic_url = t.pic_url
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const { template_id: _t, ...payload } = createForm
|
||||
await api('post', '/api/orders', payload)
|
||||
ElMessage.success('订单已创建')
|
||||
createVisible.value = false
|
||||
load()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ---- 详情抽屉 ----
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<any>(null)
|
||||
const processForm = reactive({ name: '下料', status: 1 })
|
||||
const processActive = ref(0)
|
||||
|
||||
async function openDetail(row: any) {
|
||||
detail.value = await api<any>('get', `/api/orders/${row.id}`)
|
||||
detailVisible.value = true
|
||||
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
|
||||
}
|
||||
|
||||
async function updateProcess() {
|
||||
await api('post', `/api/orders/${detail.value.id}/process`, processForm)
|
||||
ElMessage.success('进度已更新')
|
||||
detail.value = await api<any>('get', `/api/orders/${detail.value.id}`)
|
||||
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.detail .meta { display: flex; gap: 16px; color: #606266; font-size: 13px; margin-bottom: 10px; align-items: center; }
|
||||
.canvas-box { height: 440px; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-card>
|
||||
<div class="toolbar">
|
||||
<el-select v-model="catelogId" placeholder="全部分类" clearable style="width:160px" @change="load">
|
||||
<el-option v-for="c in catelogs" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<div style="flex:1" />
|
||||
<span class="count">共 {{ total }} 套官方样板</span>
|
||||
</div>
|
||||
<div v-loading="loading" class="grid">
|
||||
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
|
||||
<el-image :src="t.pic_url" fit="contain" lazy class="pic" />
|
||||
<div class="cap">{{ t.name || `样板#${t.id}` }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-pagination style="margin-top:12px; justify-content:center" background layout="total, prev, pager, next"
|
||||
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
|
||||
<div v-if="current" class="preview">
|
||||
<div class="canvas-box">
|
||||
<WindoorCanvas :drawing-data="current.drawing_data" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
|
||||
<span>引用次数: {{ current.times }}</span>
|
||||
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import WindoorCanvas from '../components/WindoorCanvas.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const page_size = 48
|
||||
const name = ref('')
|
||||
const catelogId = ref<number | undefined>()
|
||||
const catelogs = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const current = ref<any>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}`
|
||||
const d = await api<any>('get', `/api/windoor/templates?${qs}`)
|
||||
rows.value = d
|
||||
// 接口无分页元数据时按返回量估计
|
||||
total.value = d.length === page_size ? (page.value - 1) * page_size + d.length + 1 : (page.value - 1) * page_size + d.length
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function preview(t: any) {
|
||||
current.value = await api<any>('get', `/api/windoor/templates/${t.id}`)
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
function useInOrder() {
|
||||
router.push({ path: '/orders', query: { template: current.value.id } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const tree = await api<any[]>('get', '/api/windoor/catelogs')
|
||||
catelogs.value = tree.flatMap(t => [t, ...((t.children ?? []) as any[])])
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; }
|
||||
.count { color: #909399; font-size: 13px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; min-height: 200px; }
|
||||
.item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 8px; cursor: pointer; background: #fff;
|
||||
transition: box-shadow .2s; display: flex; flex-direction: column; align-items: center; }
|
||||
.item:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
|
||||
.pic { width: 100%; height: 110px; }
|
||||
.cap { font-size: 12px; color: #606266; margin-top: 6px; text-align: center; }
|
||||
.preview .canvas-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
|
||||
.preview .meta { display: flex; gap: 20px; margin-top: 10px; color: #606266; font-size: 13px; align-items: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// 开发时 /api 代理到后端 8000;构建产物输出 dist 由后端挂载
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
'/static': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: { outDir: 'dist', emptyOutDir: true },
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
"""合同路由:意向单/正式单管理。"""
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok, paginated
|
||||
from huamenchuang.repositories.contract_repo import status_label
|
||||
from huamenchuang.services.contract_service import ContractService
|
||||
|
||||
router = APIRouter(prefix="/contracts", tags=["合同"])
|
||||
|
||||
|
||||
class ContractBody(BaseModel):
|
||||
customer_id: int | None = None
|
||||
customer_name: str = ""
|
||||
phone: str = ""
|
||||
address: str = ""
|
||||
area: float = 0
|
||||
count: int = 0
|
||||
money: float = 0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class ConvertBody(BaseModel):
|
||||
contract_id: int
|
||||
|
||||
|
||||
def _to_out(c) -> dict:
|
||||
return {
|
||||
"id": c.id, "contract_no": c.contract_no, "customer_id": c.customer_id,
|
||||
"customer_name": c.customer_name, "phone": c.phone, "address": c.address,
|
||||
"area": float(c.area), "count": c.count, "money": float(c.money),
|
||||
"status": c.status, "status_label": status_label(c.status),
|
||||
"sign_status": c.sign_status, "note": c.note,
|
||||
"created_at": str(c.created_at), "updated_at": str(c.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_contract(body: ContractBody, user: CurrentUser, db: DbSession):
|
||||
"""创建合同(默认意向单)。"""
|
||||
contract = await ContractService(db).create(user, body.model_dump())
|
||||
return ok(data=_to_out(contract), message="创建成功")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_contracts(user: CurrentUser, db: DbSession, page: int = 1,
|
||||
page_size: int = 15, keyword: str = "", status: int | None = None):
|
||||
"""合同分页列表(结构对齐目标站 {"contracts": {...}})。"""
|
||||
items, total = await ContractService(db).repo.search(
|
||||
user.company_id, page, page_size, keyword, status)
|
||||
return ok(data={"contracts": paginated(
|
||||
[_to_out(c) for c in items], page, page_size, total)})
|
||||
|
||||
|
||||
@router.put("/{contract_id}")
|
||||
async def update_contract(contract_id: int, body: ContractBody, user: CurrentUser, db: DbSession):
|
||||
"""编辑合同。"""
|
||||
try:
|
||||
c = await ContractService(db).update(user, contract_id, body.model_dump())
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(data=_to_out(c), message="更新成功")
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_contract(body: ConvertBody, user: CurrentUser, db: DbSession):
|
||||
"""意向单转正式单(对齐目标站 convert-contract/)。"""
|
||||
try:
|
||||
c = await ContractService(db).convert_to_formal(user, body.contract_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
return ok(data=_to_out(c), message="已转为正式单")
|
||||
|
||||
|
||||
@router.delete("/{contract_id}")
|
||||
async def delete_contract(contract_id: int, user: CurrentUser, db: DbSession):
|
||||
"""删除合同。"""
|
||||
try:
|
||||
await ContractService(db).delete(user, contract_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(message="删除成功")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""客户路由。"""
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok, paginated
|
||||
from huamenchuang.services.customer_service import CustomerService
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户"])
|
||||
|
||||
|
||||
class CustomerBody(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
phone: str = ""
|
||||
address: str = ""
|
||||
note: str = ""
|
||||
salesmen: str | None = None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_customer(body: CustomerBody, user: CurrentUser, db: DbSession):
|
||||
"""新增客户。"""
|
||||
customer = await CustomerService(db).create(user, body.model_dump())
|
||||
return ok(data={"id": customer.id, "name": customer.name}, message="创建成功")
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search_customers(user: CurrentUser, db: DbSession,
|
||||
page: int = 1, page_size: int = 15, keyword: str = ""):
|
||||
"""客户搜索。"""
|
||||
items, total = await CustomerService(db).repo.search(user.company_id, page, page_size, keyword)
|
||||
return ok(data=paginated([{
|
||||
"id": c.id, "name": c.name, "phone": c.phone, "address": c.address,
|
||||
"note": c.note, "salesmen": c.salesmen, "balance": float(c.balance),
|
||||
"created_at": str(c.created_at),
|
||||
} for c in items], page, page_size, total))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_customers(user: CurrentUser, db: DbSession, page: int = 1,
|
||||
page_size: int = 15, keyword: str = ""):
|
||||
"""客户分页列表。"""
|
||||
items, total = await CustomerService(db).repo.search(user.company_id, page, page_size, keyword)
|
||||
return ok(data=paginated([{
|
||||
"id": c.id, "name": c.name, "phone": c.phone, "address": c.address,
|
||||
"note": c.note, "salesmen": c.salesmen, "balance": float(c.balance),
|
||||
"created_at": str(c.created_at),
|
||||
} for c in items], page, page_size, total))
|
||||
|
||||
|
||||
@router.put("/{customer_id}")
|
||||
async def update_customer(customer_id: int, body: CustomerBody, user: CurrentUser, db: DbSession):
|
||||
"""编辑客户。"""
|
||||
try:
|
||||
c = await CustomerService(db).update(user, customer_id, body.model_dump())
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(data={"id": c.id}, message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{customer_id}")
|
||||
async def delete_customer(customer_id: int, user: CurrentUser, db: DbSession):
|
||||
"""删除客户。"""
|
||||
try:
|
||||
await CustomerService(db).delete(user, customer_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(message="删除成功")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""优化下料路由:一维型材切割方案计算(无状态,纯计算)。"""
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.services.optimize_service import BarDemand, StockBar, optimize_1d
|
||||
|
||||
router = APIRouter(prefix="/optimize1d", tags=["优化下料"])
|
||||
|
||||
|
||||
class OptimizeRequest(BaseModel):
|
||||
demands: list[BarDemand] = Field(description="型材需求 [{length,qty,label}]")
|
||||
stocks: list[StockBar] = Field(description="库存原料 [{length,qty,label}]")
|
||||
kerf: int = Field(default=5, ge=0, le=50, description="锯口损耗(mm)")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def optimize(body: OptimizeRequest, user: CurrentUser):
|
||||
"""一维优化下料(FFD 首递减算法)。"""
|
||||
if not body.demands or not body.stocks:
|
||||
return fail("需求和库存均不能为空")
|
||||
total_demand = sum(d.length * d.qty for d in body.demands)
|
||||
total_stock = sum(s.length * s.qty for s in body.stocks)
|
||||
if total_demand > total_stock:
|
||||
return fail(f"库存不足: 需求 {total_demand}mm > 库存 {total_stock}mm")
|
||||
result = optimize_1d(body.demands, body.stocks, body.kerf)
|
||||
return ok(data=result, message="优化完成")
|
||||
@@ -1,9 +1,12 @@
|
||||
"""路由汇总。"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from huamenchuang.api import auth, orders, windoor
|
||||
from huamenchuang.api import auth, contracts, customers, optimize, orders, windoor
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(orders.router)
|
||||
api_router.include_router(customers.router)
|
||||
api_router.include_router(contracts.router)
|
||||
api_router.include_router(windoor.router)
|
||||
api_router.include_router(optimize.router)
|
||||
|
||||
@@ -53,15 +53,28 @@ async def health():
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
from fastapi.responses import FileResponse # noqa: E402
|
||||
|
||||
# 静态资源:样板平面图(data/windoor-images)
|
||||
_imgdir = Path(__file__).resolve().parents[2] / "data" / "windoor-images"
|
||||
if _imgdir.exists():
|
||||
app.mount("/static/windoor-images", StaticFiles(directory=str(_imgdir)), name="windoor-images")
|
||||
|
||||
# 前端构建产物挂载(前后端规范:构建到后端目录)
|
||||
# 前端构建产物挂载(前后端规范:构建到后端目录)+ history 路由回退
|
||||
_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
||||
if _dist.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_dist), html=True), name="frontend")
|
||||
app.mount("/assets", StaticFiles(directory=str(_dist / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str):
|
||||
"""非 API/静态路径一律返回 index.html(前端 history 模式)。"""
|
||||
if full_path.startswith(("api/", "static/")):
|
||||
return JSONResponse(
|
||||
{"code": 0, "message": "接口不存在", "data": "", "other_data": "", "other_data2": ""})
|
||||
target = _dist / full_path
|
||||
if full_path and target.is_file():
|
||||
return FileResponse(target)
|
||||
return FileResponse(_dist / "index.html")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""合同数据访问层。"""
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.models.contract import Contract
|
||||
|
||||
STATUS_LABELS = {0: "意向单", 1: "正式单", 2: "已完成", 3: "已作废"}
|
||||
|
||||
|
||||
class ContractRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def next_contract_no(self, company_id: int) -> str:
|
||||
"""合同号:FG+YYYYMMDD+3位流水(对齐目标站 FG20260818009 风格)。"""
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
prefix = f"FG{today}"
|
||||
stmt = select(Contract.contract_no).where(
|
||||
Contract.company_id == company_id, Contract.contract_no.like(f"{prefix}%")
|
||||
).order_by(Contract.contract_no.desc()).limit(1)
|
||||
last = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
seq = int(last[-3:]) + 1 if last else 1
|
||||
return f"{prefix}{seq:03d}"
|
||||
|
||||
async def get(self, contract_id: int, company_id: int) -> Contract | None:
|
||||
stmt = select(Contract).where(Contract.id == contract_id, Contract.company_id == company_id)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def search(self, company_id: int, page: int, page_size: int, keyword: str = "",
|
||||
status: int | None = None) -> tuple[list[Contract], int]:
|
||||
conds = [Contract.company_id == company_id]
|
||||
if status is not None:
|
||||
conds.append(Contract.status == status)
|
||||
if kw := keyword.strip():
|
||||
like = f"%{kw}%"
|
||||
conds.append(or_(Contract.contract_no.like(like), Contract.customer_name.like(like), Contract.phone.like(like)))
|
||||
where = *conds,
|
||||
total = (await self.db.execute(select(func.count()).select_from(Contract).where(*where))).scalar_one()
|
||||
stmt = (select(Contract).where(*where).order_by(Contract.id.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size))
|
||||
return list((await self.db.execute(stmt)).scalars()), total
|
||||
|
||||
async def create(self, contract: Contract) -> Contract:
|
||||
self.db.add(contract)
|
||||
await self.db.flush()
|
||||
return contract
|
||||
|
||||
async def delete(self, contract: Contract) -> None:
|
||||
await self.db.delete(contract)
|
||||
|
||||
|
||||
def status_label(status: int) -> str:
|
||||
return STATUS_LABELS.get(status, "未知")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""客户数据访问层。"""
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.models.contract import Customer
|
||||
|
||||
|
||||
class CustomerRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get(self, customer_id: int, company_id: int) -> Customer | None:
|
||||
stmt = select(Customer).where(Customer.id == customer_id, Customer.company_id == company_id)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def search(self, company_id: int, page: int, page_size: int, keyword: str = "") -> tuple[list[Customer], int]:
|
||||
conds = [Customer.company_id == company_id]
|
||||
if kw := keyword.strip():
|
||||
like = f"%{kw}%"
|
||||
conds.append(or_(Customer.name.like(like), Customer.phone.like(like), Customer.address.like(like)))
|
||||
where = *conds,
|
||||
total = (await self.db.execute(select(func.count()).select_from(Customer).where(*where))).scalar_one()
|
||||
stmt = (select(Customer).where(*where).order_by(Customer.id.desc())
|
||||
.offset((page - 1) * page_size).limit(page_size))
|
||||
return list((await self.db.execute(stmt)).scalars()), total
|
||||
|
||||
async def create(self, customer: Customer) -> Customer:
|
||||
self.db.add(customer)
|
||||
await self.db.flush()
|
||||
return customer
|
||||
|
||||
async def delete(self, customer: Customer) -> None:
|
||||
await self.db.delete(customer)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""合同业务逻辑层:意向单→正式单流转。"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.models.contract import Contract
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.repositories.contract_repo import ContractRepository
|
||||
|
||||
|
||||
class ContractService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.repo = ContractRepository(db)
|
||||
|
||||
async def create(self, user: User, data: dict) -> Contract:
|
||||
contract = await self.repo.create(Contract(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
contract_no=await self.repo.next_contract_no(user.company_id),
|
||||
**data,
|
||||
))
|
||||
return contract
|
||||
|
||||
async def update(self, user: User, contract_id: int, data: dict) -> Contract:
|
||||
contract = await self.repo.get(contract_id, user.company_id)
|
||||
if contract is None:
|
||||
raise KeyError("合同不存在")
|
||||
for k, v in data.items():
|
||||
setattr(contract, k, v)
|
||||
return contract
|
||||
|
||||
async def convert_to_formal(self, user: User, contract_id: int) -> Contract:
|
||||
"""意向单转正式单(对齐目标站 convert-contract 语义)。"""
|
||||
contract = await self.repo.get(contract_id, user.company_id)
|
||||
if contract is None:
|
||||
raise KeyError("合同不存在")
|
||||
if contract.status != 0:
|
||||
raise ValueError("仅意向单可转正式单")
|
||||
contract.status = 1
|
||||
return contract
|
||||
|
||||
async def delete(self, user: User, contract_id: int) -> None:
|
||||
contract = await self.repo.get(contract_id, user.company_id)
|
||||
if contract is None:
|
||||
raise KeyError("合同不存在")
|
||||
await self.repo.delete(contract)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""客户业务逻辑层。"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.models.contract import Customer
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.repositories.customer_repo import CustomerRepository
|
||||
|
||||
|
||||
class CustomerService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.repo = CustomerRepository(db)
|
||||
|
||||
async def create(self, user: User, data: dict) -> Customer:
|
||||
return await self.repo.create(Customer(company_id=user.company_id, **data))
|
||||
|
||||
async def update(self, user: User, customer_id: int, data: dict) -> Customer:
|
||||
customer = await self.repo.get(customer_id, user.company_id)
|
||||
if customer is None:
|
||||
raise KeyError("客户不存在")
|
||||
for k, v in data.items():
|
||||
setattr(customer, k, v)
|
||||
return customer
|
||||
|
||||
async def delete(self, user: User, customer_id: int) -> None:
|
||||
customer = await self.repo.get(customer_id, user.company_id)
|
||||
if customer is None:
|
||||
raise KeyError("客户不存在")
|
||||
await self.repo.delete(customer)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""一维优化下料服务:首递减(FFD)装箱算法,输出切割方案与利用率。
|
||||
|
||||
对齐目标站 optimize1d 语义:输入订单型材需求 + 库存原料,输出每根原料的切割组合。
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class BarDemand:
|
||||
"""一段型材需求。"""
|
||||
length: int # 需求长度(mm)
|
||||
qty: int # 数量
|
||||
label: str = "" # 用途标签
|
||||
|
||||
|
||||
@dataclass
|
||||
class StockBar:
|
||||
"""库存原料。"""
|
||||
length: int # 原料长度(mm)
|
||||
qty: int # 库存根数
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutPiece:
|
||||
length: int
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutPlan:
|
||||
"""一根原料的切割方案。"""
|
||||
stock_length: int
|
||||
stock_label: str
|
||||
pieces: list[CutPiece] = field(default_factory=list)
|
||||
waste: int = 0 # 余料浪费
|
||||
|
||||
|
||||
# 锯口损耗(mm),每次切割损耗一段
|
||||
KERF_DEFAULT = 5
|
||||
|
||||
|
||||
def optimize_1d(demands: list[BarDemand], stocks: list[StockBar],
|
||||
kerf: int = KERF_DEFAULT) -> dict:
|
||||
"""首递减装箱:长件优先装入可容纳的原料,输出方案与统计。"""
|
||||
# 展开需求并降序
|
||||
pieces: list[CutPiece] = []
|
||||
for d in demands:
|
||||
pieces.extend(CutPiece(length=d.length, label=d.label) for _ in range(max(0, d.qty)))
|
||||
pieces.sort(key=lambda p: p.length, reverse=True)
|
||||
|
||||
# 展开库存(可切割长度 = 原料长)
|
||||
avail: list[dict] = []
|
||||
for s in stocks:
|
||||
for _ in range(max(0, s.qty)):
|
||||
avail.append({"length": s.length, "label": s.label, "pieces": [], "used": 0})
|
||||
|
||||
unplaced: list[CutPiece] = []
|
||||
for piece in pieces:
|
||||
# 找第一根装得下的原料(剩余空间 >= 件长 + 锯口)
|
||||
placed = False
|
||||
for bar in avail:
|
||||
remain = bar["length"] - bar["used"]
|
||||
cost = piece.length + (kerf if bar["pieces"] else 0)
|
||||
if remain >= cost:
|
||||
bar["pieces"].append(piece)
|
||||
bar["used"] += cost
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
unplaced.append(piece)
|
||||
|
||||
plans = [
|
||||
CutPlan(
|
||||
stock_length=b["length"],
|
||||
stock_label=b["label"],
|
||||
pieces=b["pieces"],
|
||||
waste=b["length"] - b["used"],
|
||||
)
|
||||
for b in avail if b["pieces"]
|
||||
]
|
||||
total_demand = sum(p.length for p in pieces)
|
||||
total_stock = sum(p.stock_length for p in plans)
|
||||
return {
|
||||
"plans": [
|
||||
{
|
||||
"stock_length": p.stock_length,
|
||||
"stock_label": p.stock_label,
|
||||
"pieces": [{"length": c.length, "label": c.label} for c in p.pieces],
|
||||
"waste": p.waste,
|
||||
}
|
||||
for p in plans
|
||||
],
|
||||
"unplaced": [{"length": c.length, "label": c.label} for c in unplaced],
|
||||
"stats": {
|
||||
"total_pieces": len(pieces),
|
||||
"placed_pieces": len(pieces) - len(unplaced),
|
||||
"stock_bars_used": len(plans),
|
||||
"total_demand_mm": total_demand,
|
||||
"total_stock_mm": total_stock,
|
||||
"utilization": round(total_demand / total_stock, 4) if total_stock else 0,
|
||||
"kerf_mm": kerf,
|
||||
},
|
||||
}
|
||||
@@ -14,13 +14,26 @@ class OrderService:
|
||||
self.repo = OrderRepository(db)
|
||||
|
||||
async def create_order(self, user: User, payload: OrderCreate) -> Order:
|
||||
"""创建订单:对齐目标站 {contract_id, drawing_data, pic_url} 契约。"""
|
||||
"""创建订单:对齐目标站 {contract_id, drawing_data, pic_url} 契约。
|
||||
指定 contract_id 时自动快照合同客户信息。"""
|
||||
customer_id = payload.customer_id
|
||||
customer_name = phone = address = ""
|
||||
if payload.contract_id:
|
||||
from huamenchuang.repositories.contract_repo import ContractRepository
|
||||
contract = await ContractRepository(self.db).get(payload.contract_id, user.company_id)
|
||||
if contract is None:
|
||||
raise KeyError("关联合同不存在")
|
||||
customer_id = contract.customer_id
|
||||
customer_name, phone, address = contract.customer_name, contract.phone, contract.address
|
||||
order = Order(
|
||||
order_no=await self.repo.next_order_no(user.company_id),
|
||||
contract_id=payload.contract_id,
|
||||
drawing_data=payload.drawing_data,
|
||||
pic_url=payload.pic_url,
|
||||
customer_id=payload.customer_id,
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_name,
|
||||
phone=phone,
|
||||
address=address,
|
||||
note=payload.note,
|
||||
money=payload.money,
|
||||
count=payload.count,
|
||||
|
||||
Reference in New Issue
Block a user