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:
HHY
2026-09-20 08:46:04 -07:00
parent d3e6de585c
commit f7a555dd6c
35 changed files with 3702 additions and 10 deletions
+131
View File
@@ -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>
+89
View File
@@ -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>
+51
View File
@@ -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>
+50
View File
@@ -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>
+54
View File
@@ -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>
+76
View File
@@ -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>
+217
View File
@@ -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>
+94
View File
@@ -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>