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:
@@ -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>
|
||||
Reference in New Issue
Block a user