feat: v0.7 全功能复刻与前端适配

后端:
- 产品价格本模块(型材/玻璃/五金/配件, 供报价取价)
- 报价单模块(BJ单号, 手动报价/样板算料/转订单)
- 售后单(问题描述必填, 对齐原站校验)与状态流转
- 量尺单(洞口尺寸清单)与阳光房订单(order_type=1)
- 个人中心: 资料/密码修改/邀请返利(HMC邀请码)
前端:
- 新增8页面: 产品/报价单/售后/量尺/阳光房/商城/使用指南/个人中心
- 登录页注册双Tab, 响应式侧边栏(<900px自动折叠)
- Element Plus 图标全局注册修复
- 共15个页面覆盖原站核心业务路由
This commit is contained in:
HHY
2026-09-20 11:53:31 -07:00
parent 78563267c4
commit b2730af80b
22 changed files with 1404 additions and 29 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-09-20 19:00] FEAT: v0.7 全功能复刻与前端适配——新增产品价格本/报价单(BJ单号,手动报价+样板算料)/售后单(问题描述必填对齐原站)/量尺单(洞口尺寸清单)/阳光房订单(order_type=1)/个人中心(资料/密码/邀请返利 HMC邀请码) 模块;前端 15 页面(登录注册双Tab/商城/使用指南等)+响应式侧边栏(<900px折叠)+图标全局注册;订单新增order_type字段(含SQLite手工迁移) (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/business.py, src/huamenchuang/api/auth.py, frontend/src/views/*, frontend/src/router/index.ts, doc/API.md)
- [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md) - [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md)
- [2026-09-20 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md) - [2026-09-20 10:39] FEAT: v0.5 画布尺寸编辑+自动报价+扇样式保真修复——①参数化尺寸编辑: W/H输入框与拖拽外框右缘/下缘/右下角手柄, 全部几何按比例自动缩放(料宽不变,最小300mm), 拖拽静默/松手提交, 撤销栈; ②自动报价: 画布变化600ms防抖自动调offer-price并实时显示总价(样板库/订单详情); ③修复"样板显示为默认样式": 扇样式推断引擎三级解析(有效bd→polyId区域映射双扇对半→帧bbox按扇数竖向均分), 还原真实分格/开向(od角度/inward空心标记/门标注), 无图纸订单显示引导提示 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, frontend/src/views/*, doc/API.md)
- [2026-09-20 09:04] FEAT: v0.4 画布编辑器(拖拽布置v1)——WindoorCanvas 增加 editable 模式:+竖梃/+横梃点击玻璃区域实时分割(两次交叉点法多边形重算+mm.sp分割线同步)、撤销栈;订单详情可保存图纸(PUT orders),样板库可另存我的样板(新增 POST windoor/templates is_default=0);10接口回归全通过 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/views/*, src/huamenchuang/api/windoor.py, src/huamenchuang/schemas/windoor.py, doc/API.md) - [2026-09-20 09:04] FEAT: v0.4 画布编辑器(拖拽布置v1)——WindoorCanvas 增加 editable 模式:+竖梃/+横梃点击玻璃区域实时分割(两次交叉点法多边形重算+mm.sp分割线同步)、撤销栈;订单详情可保存图纸(PUT orders),样板库可另存我的样板(新增 POST windoor/templates is_default=0);10接口回归全通过 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/views/*, src/huamenchuang/api/windoor.py, src/huamenchuang/schemas/windoor.py, doc/API.md)
+39 -1
View File
@@ -294,7 +294,45 @@
**自动报价**(v0.5):画布变化(改尺寸/加分格)后 600ms 防抖自动调用 offer-price,页面实时显示当前尺寸总价。 **自动报价**(v0.5):画布变化(改尺寸/加分格)后 600ms 防抖自动调用 offer-price,页面实时显示当前尺寸总价。
**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。 **报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。
### 12.11 种子数据 ### 12.11 业务扩展模块(v0.7,`/api`)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---|
| 产品列表 | GET | `/api/products?page=&keyword=&material=` | query | 产品价格本(型材/玻璃/五金/配件) |
| 新增/编辑/删除产品 | POST/PUT/DELETE | `/api/products[/{id}]` | `{name, material, spec, unit, price, cost}` | |
| 保存报价单 | POST | `/api/quotation` | `{customer_id, items[], total, drawing_data, ...}` | 单号 `BJ+YYYYMMDD+3位流水` |
| 报价单列表/详情/更新/删除 | GET/PUT/DELETE | `/api/quotations[/{id}]` | 分页+keyword+status | status: 0草稿 1已发送 2已成交 3作废 |
| 提交售后单 | POST | `/api/after_sales` | `{title, description(必填), order_no?}` | 对齐原站"问题描述必填"校验 |
| 售后列表/状态流转 | GET/PUT | `/api/after_sales[/{id}]` | status: 0待处理 1处理中 2已完成 | |
| 创建量尺单 | POST | `/api/measures` | `{customer_name, phone, address, measure_date, surveyor, data[{name,width,height,qty}]}` | 对齐原站量尺管理 |
| 量尺列表/更新 | GET/PUT | `/api/measures[/{id}]` | status: 0待量尺 1已量尺 2已出图 | |
| 阳光房订单 | GET | `/api/sunny_house_orders` | 分页 | 订单按 `order_type=1` 过滤(Order 新增 order_type 字段) |
| 修改资料/密码 | PUT | `/api/auth/profile` `/api/auth/password` | — | 对齐原站个人中心 |
| 邀请信息 | GET | `/api/auth/invitation` | — | 邀请码 `HMC+6位用户ID` |
### 12.12 前端页面总览(v0.7,15 个页面,响应式布局)
| 路由 | 页面 | 说明 |
|---|---|---|
| `/login` | 登录/注册 | 双 Tab(注册对齐原站 register) |
| `/dashboard` | 工作台 | 统计 + 快捷入口 |
| `/orders` | 订单管理 | 搜索/创建/图纸编辑/状态机/进度/报价/打印 |
| `/sunny` | 阳光房 | 阳光房订单(order_type=1) |
| `/quotations` | 报价单 | 手动报价(选样板自动算料+手动调整)/转订单 |
| `/contracts` | 合同管理 | 意向单→正式单/一键下单 |
| `/customers` | 客户管理 | CRUD |
| `/measure` | 量尺管理 | 量尺单 + 洞口尺寸清单 |
| `/aftersale` | 售后管理 | 提交/流转 |
| `/templates` | 样板库 | 254 套样板/画布预览/编辑/报价/下单 |
| `/products` | 产品管理 | 价格本 CRUD |
| `/warehouse` | 仓库库存 | 出入库/预警 |
| `/optimize` | 优化下料 | FFD 计算 |
| `/market` | 商城 | 模块开通(购买流程占位,支付需外部资质) |
| `/guide` + `/personal` | 使用指南/个人中心 | 帮助文档/资料/密码/邀请返利 |
**响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。
### 12.13 种子数据
```bash ```bash
uv sync # 安装依赖 uv sync # 安装依赖
+6 -1
View File
@@ -1,9 +1,14 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app') const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+8
View File
@@ -13,11 +13,19 @@ const router = createRouter({
{ path: '', redirect: '/dashboard' }, { path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } }, { path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } }, { path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
{ path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } },
{ path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } },
{ path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } }, { path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } },
{ path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } }, { path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } },
{ path: 'measure', component: () => import('../views/MeasureView.vue'), meta: { title: '量尺管理' } },
{ path: 'aftersale', component: () => import('../views/AfterSaleView.vue'), meta: { title: '售后管理' } },
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } }, { path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
{ path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } },
{ path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
{ path: 'market', component: () => import('../views/MarketView.vue'), meta: { title: '商城' } },
{ path: 'guide', component: () => import('../views/GuideView.vue'), meta: { title: '使用指南' } },
{ path: 'personal', component: () => import('../views/PersonalView.vue'), meta: { title: '个人中心' } },
], ],
}, },
], ],
+96
View File
@@ -0,0 +1,96 @@
<template>
<el-card>
<div class="toolbar">
<el-select v-model="status" placeholder="全部状态" clearable style="width:140px" @change="load">
<el-option :value="0" label="待处理" /><el-option :value="1" label="处理中" /><el-option :value="2" 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="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="120" />
<el-table-column prop="description" label="问题描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="order_no" label="关联订单" width="150" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="(['warning', 'primary', 'success'] as const)[row.status]">
{{ ['待处理', '处理中', '已完成'][row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="提交时间" width="170" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status !== 2" size="small" @click="advance(row)">
{{ row.status === 0 ? '开始处理' : '完成' }}</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-input v-model="form.title" /></el-form-item>
<el-form-item label="问题描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="关联订单"><el-input v-model="form.order_no" placeholder="订单号(可选)" /></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 { ElMessage } from 'element-plus'
import { api } from '../api'
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const status = ref<number | undefined>()
const loading = ref(false)
const createVisible = ref(false)
const form = reactive({ title: '', description: '', order_no: '' })
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}${status.value !== undefined ? `&status=${status.value}` : ''}`
const d = await api<any>('get', `/api/after_sales?${qs}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openCreate() {
Object.assign(form, { title: '', description: '', order_no: '' })
createVisible.value = true
}
async function save() {
if (!form.description.trim()) return ElMessage.warning('问题描述必填')
await api('post', '/api/after_sales', form)
ElMessage.success('已提交')
createVisible.value = false
load()
}
async function advance(row: any) {
const next = row.status === 0 ? 1 : 2
await api('put', `/api/after_sales/${row.id}`, { status: next })
ElMessage.success('状态已更新')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<el-card>
<el-alert type="info" :closable="false" show-icon
title="常用操作指南" description="点击左侧菜单进入各功能模块;画布支持滚轮缩放、拖拽平移、双击复位。" />
<el-collapse style="margin-top:16px">
<el-collapse-item title="如何创建订单?">
<p>方式一:合同管理 → 选择合同 → 「下订单」。</p>
<p>方式二:订单管理 → 新建订单 → 选择合同与样板(自动带入图纸)→ 创建。</p>
</el-collapse-item>
<el-collapse-item title="如何编辑窗型?">
<p>样板库:点击样板 → 「编辑分格/尺寸」;订单详情 → 「编辑分格/尺寸」。</p>
<p>- 改 W/H 输入框或拖拽外框右下角手柄:整体按比例缩放(型材料宽不变)。</p>
<p>- +竖梃 / +横梃:点击玻璃区域完成分割。</p>
<p>- 点击已有中梃(变蓝)→ 拖动调整位置;「删除梃」移除。</p>
<p>- 任何改动后自动重算报价;「保存图纸」写回订单,「另存为我的样板」生成新样板。</p>
</el-collapse-item>
<el-collapse-item title="如何使用优化下料?">
<p>优化下料 → 录入型材需求(长度×数量)与库存原料 → 开始优化。</p>
<p>系统按首递减算法输出每根原料的切割组合、余料与利用率。</p>
</el-collapse-item>
<el-collapse-item title="报价如何计算?">
<p>报价 = 外框型材 + 中梃型材(按实际跨度)+ 扇框 + 玻璃面积 + 五金(按扇数)。</p>
<p>默认价格可在产品管理中维护,报价时按价格本取价。</p>
</el-collapse-item>
<el-collapse-item title="订单状态流转">
<p>意向单 → 正式单 → 生产中 → 已完工;未完工可取消。生产进度按 下料/组装/玻璃/打胶/五金/质检/完工 七节点推进。</p>
</el-collapse-item>
</el-collapse>
</el-card>
</template>
+26 -9
View File
@@ -1,25 +1,36 @@
<template> <template>
<el-container class="layout"> <el-container class="layout">
<el-aside width="200px"> <el-aside :width="collapsed ? '64px' : '200px'" class="aside">
<div class="logo">华门窗</div> <div class="logo">{{ collapsed ? '门' : '华门窗' }}</div>
<el-menu :default-active="route.path" router background-color="#2b3a4d" text-color="#bfc9d4" <el-menu :default-active="route.path" router :collapse="collapsed" background-color="#2b3a4d"
active-text-color="#ffffff"> text-color="#bfc9d4" active-text-color="#ffffff">
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item> <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="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item>
<el-menu-item index="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
<el-menu-item index="/contracts"><el-icon><Document /></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="/customers"><el-icon><User /></el-icon>客户管理</el-menu-item>
<el-menu-item index="/measure"><el-icon><ScaleToOriginal /></el-icon>量尺管理</el-menu-item>
<el-menu-item index="/aftersale"><el-icon><Service /></el-icon>售后管理</el-menu-item>
<el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item> <el-menu-item index="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon>产品管理</el-menu-item>
<el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item> <el-menu-item index="/warehouse"><el-icon><Box /></el-icon>仓库库存</el-menu-item>
<el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item> <el-menu-item index="/optimize"><el-icon><Scissor /></el-icon>优化下料</el-menu-item>
<el-menu-item index="/market"><el-icon><ShoppingCart /></el-icon>商城</el-menu-item>
<el-menu-item index="/guide"><el-icon><QuestionFilled /></el-icon>使用指南</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>
<el-header class="header"> <el-header class="header">
<div class="header-left">
<el-icon class="fold-btn" @click="collapsed = !collapsed"><Expand v-if="collapsed" /><Fold v-else /></el-icon>
<span class="title">{{ route.meta.title }}</span> <span class="title">{{ route.meta.title }}</span>
<el-dropdown @command="cmd => cmd === 'logout' && doLogout()"> </div>
<el-dropdown @command="cmd => cmd === 'logout' ? doLogout() : $router.push('/personal')">
<span class="user"><el-icon><UserFilled /></el-icon>{{ auth.nickname || '用户' }}</span> <span class="user"><el-icon><UserFilled /></el-icon>{{ auth.nickname || '用户' }}</span>
<template #dropdown><el-dropdown-menu> <template #dropdown><el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item> <el-dropdown-item command="personal">个人中心</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu></template> </el-dropdown-menu></template>
</el-dropdown> </el-dropdown>
</el-header> </el-header>
@@ -29,23 +40,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const collapsed = ref(window.innerWidth < 900)
watch(() => window.innerWidth, w => { if (w < 900) collapsed.value = true })
function doLogout() { auth.logout(); router.push('/login') } function doLogout() { auth.logout(); router.push('/login') }
</script> </script>
<style scoped> <style scoped>
.layout { height: 100vh; } .layout { height: 100vh; }
.el-aside { background: #2b3a4d; } .aside { background: #2b3a4d; transition: width .2s; overflow-x: hidden; }
.logo { color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 20px 0; letter-spacing: 4px; } .logo { color: #fff; font-size: 20px; font-weight: 700; text-align: center; padding: 20px 0; letter-spacing: 4px; white-space: nowrap; }
.el-menu { border-right: none; } .el-menu { border-right: none; }
.header { display: flex; justify-content: space-between; align-items: center; .header { display: flex; justify-content: space-between; align-items: center;
background: #fff; border-bottom: 1px solid #e4e7ed; } background: #fff; border-bottom: 1px solid #e4e7ed; padding: 0 16px; }
.header-left { display: flex; align-items: center; gap: 12px; }
.fold-btn { cursor: pointer; font-size: 18px; color: #606266; }
.title { font-size: 16px; font-weight: 600; color: #303133; } .title { font-size: 16px; font-weight: 600; color: #303133; }
.user { cursor: pointer; color: #606266; display: inline-flex; gap: 6px; align-items: center; } .user { cursor: pointer; color: #606266; display: inline-flex; gap: 6px; align-items: center; }
.el-main { background: #f2f4f7; padding: 16px; } .el-main { background: #f2f4f7; padding: 16px; }
@media (max-width: 700px) { .el-main { padding: 8px; } }
</style> </style>
+44 -11
View File
@@ -3,18 +3,34 @@
<el-card class="login-card"> <el-card class="login-card">
<h2>华门窗云平台</h2> <h2>华门窗云平台</h2>
<p class="sub">画图 · 报价 · 订单 · 生产</p> <p class="sub">画图 · 报价 · 订单 · 生产</p>
<el-form :model="form" @keyup.enter="doLogin"> <el-tabs v-model="tab" stretch>
<el-tab-pane label="登录" name="login">
<el-form :model="loginForm" @keyup.enter="doLogin">
<el-form-item> <el-form-item>
<el-input v-model="form.phone_number" placeholder="手机号" size="large" /> <el-input v-model="loginForm.phone_number" placeholder="手机号" size="large" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password /> <el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password />
</el-form-item> </el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin"> <el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">登 录</el-button>
登 录
</el-button>
</el-form> </el-form>
<p class="tip">开发账号:13800000000 / 123456</p> </el-tab-pane>
<el-tab-pane label="注册" name="register">
<el-form :model="regForm" @keyup.enter="doRegister">
<el-form-item>
<el-input v-model="regForm.phone_number" placeholder="手机号" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.password" type="password" placeholder="设置密码(至少4位)" size="large" show-password />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.invite_code" placeholder="邀请码(可选)" size="large" />
</el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doRegister">注 册</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
<p class="tip">开发账号:13800000000 / 123456 | 忘记密码请联系管理员</p>
</el-card> </el-card>
</div> </div>
</template> </template>
@@ -29,19 +45,36 @@ import { useAuthStore } from '../stores/auth'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
const form = reactive({ phone_number: '', password: '' }) const tab = ref('login')
const loginForm = reactive({ phone_number: '', password: '' })
const regForm = reactive({ phone_number: '', password: '', invite_code: '' })
async function doLogin() { async function doLogin() {
if (!form.phone_number || !form.password) return ElMessage.warning('请输入手机号和密码') if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码')
loading.value = true loading.value = true
try { try {
const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', form) const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', loginForm)
auth.setSession(d.token, d.user?.nickname ?? '') auth.setSession(d.token, d.user?.nickname ?? '')
router.push('/dashboard') router.push('/dashboard')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function doRegister() {
if (!regForm.phone_number || !regForm.password) return ElMessage.warning('请输入手机号和密码')
loading.value = true
try {
const d = await api<{ token: string; user_id: number }>('post', '/api/auth/register', {
phone_number: regForm.phone_number, password: regForm.password,
})
auth.setSession(d.token, regForm.phone_number.slice(-4))
ElMessage.success('注册成功')
router.push('/dashboard')
} finally {
loading.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@@ -49,6 +82,6 @@ async function doLogin() {
background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); } background: linear-gradient(135deg, #35506b 0%, #6b8cae 100%); }
.login-card { width: 380px; text-align: center; padding: 12px 8px; } .login-card { width: 380px; text-align: center; padding: 12px 8px; }
h2 { margin: 8px 0 4px; color: #303133; } h2 { margin: 8px 0 4px; color: #303133; }
.sub { color: #909399; font-size: 13px; margin: 0 0 20px; } .sub { color: #909399; font-size: 13px; margin: 0 0 12px; }
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; } .tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
</style> </style>
+60
View File
@@ -0,0 +1,60 @@
<template>
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :md="8" v-for="m in modules" :key="m.key">
<el-card shadow="hover" class="module">
<div class="m-head">
<span class="m-name">{{ m.name }}</span>
<el-tag size="small" :type="m.enabled ? 'success' : 'info'">{{ m.enabled ? '已开通' : '未开通' }}</el-tag>
</div>
<p class="m-desc">{{ m.desc }}</p>
<div class="m-foot">
<span class="m-price">{{ m.price > 0 ? `¥${m.price}/年` : '免费' }}</span>
<el-button size="small" :type="m.enabled ? 'default' : 'primary'" :disabled="m.enabled"
@click="buy(m)">{{ m.enabled ? '已开通' : '立即购买' }}</el-button>
</div>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
const modules = ref<any[]>([])
// 全部功能对演示账户开放(后端 HMC_ENABLED_MODULES 为准)
const MODULE_DEFS = [
{ key: 'order', name: '订单管理', desc: '订单全流程:报价下单、生产进度、图纸管理', price: 299 },
{ key: 'quote', name: '智能报价', desc: '画图自动算料报价,型材/玻璃/五金明细', price: 199 },
{ key: 'optimize', name: '优化下料', desc: '一维型材优化切割,提高利用率', price: 199 },
{ key: 'template', name: '样板库', desc: '254 套官方窗型样板 + 自定义样板', price: 0 },
{ key: 'warehouse', name: '仓库库存', desc: '材料出入库、库存预警', price: 99 },
{ key: 'render', name: '效果图渲染', desc: '照片级渲染(规划中)', price: 399 },
]
onMounted(async () => {
let enabled: string[] = []
try {
const u = await api<any>('get', '/api/auth/current-user')
enabled = u.enabled_modules ?? MODULE_DEFS.map(m => m.key)
} catch { enabled = [] }
modules.value = MODULE_DEFS.map(m => ({ ...m, enabled: enabled.includes(m.key) }))
})
async function buy(m: any) {
await ElMessageBox.confirm(
`开通「${m.name}」需要 ¥${m.price}/年。演示环境未接入支付,可联系管理员在 HMC_ENABLED_MODULES 中开通。`,
'购买', { type: 'info' })
}
</script>
<style scoped>
.module { margin-bottom: 16px; }
.m-head { display: flex; justify-content: space-between; align-items: center; }
.m-name { font-size: 16px; font-weight: 600; }
.m-desc { color: #909399; font-size: 13px; min-height: 38px; margin: 10px 0; }
.m-foot { display: flex; justify-content: space-between; align-items: center; }
.m-price { color: #e6483d; font-weight: 700; }
</style>
+127
View File
@@ -0,0 +1,127 @@
<template>
<el-card>
<div class="toolbar">
<el-select v-model="status" placeholder="全部状态" clearable style="width:140px" @change="load">
<el-option :value="0" label="待量尺" /><el-option :value="1" label="已量尺" /><el-option :value="2" 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="id" label="ID" width="70" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="address" label="地址" min-width="160" show-overflow-tooltip />
<el-table-column prop="measure_date" label="量尺时间" width="160" />
<el-table-column prop="surveyor" label="量尺人" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="(['warning', 'primary', 'success'] as const)[row.status]">
{{ ['待量尺', '已量尺', '已出图'][row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="洞口数据" width="90">
<template #default="{ row }">{{ (row.data || []).length }} 项</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(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="量尺单" width="680px">
<el-form :model="form" label-width="80px">
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="客户"><el-input v-model="form.customer_name" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="地址"><el-input v-model="form.address" /></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="量尺时间">
<el-date-picker v-model="form.measure_date" type="datetime" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="量尺人"><el-input v-model="form.surveyor" /></el-form-item></el-col>
</el-row>
<el-form-item label="洞口尺寸">
<div style="width:100%">
<div v-for="(d, i) in form.data" :key="i" class="row">
<el-input v-model="d.name" placeholder="位置名" style="width:130px" />
<el-input-number v-model="d.width" :min="0" placeholder="宽" controls-position="right" style="width:110px" />
<el-input-number v-model="d.height" :min="0" placeholder="高" controls-position="right" style="width:110px" />
<el-input-number v-model="d.qty" :min="1" placeholder="数量" controls-position="right" style="width:90px" />
<el-button text type="danger" @click="form.data.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="form.data.push({ name: '', width: 0, height: 0, qty: 1 })">+ 添加洞口</el-button>
</div>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width:140px">
<el-option :value="0" label="待量尺" /><el-option :value="1" label="已量尺" /><el-option :value="2" label="已出图" />
</el-select>
</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 } from 'element-plus'
import { api } from '../api'
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const status = ref<number | undefined>()
const loading = ref(false)
const editVisible = ref(false)
const editingId = ref(0)
const form = reactive<any>({ customer_name: '', phone: '', address: '', measure_date: null,
surveyor: '', status: 0, data: [], note: '' })
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}${status.value !== undefined ? `&status=${status.value}` : ''}`
const d = await api<any>('get', `/api/measures?${qs}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openEdit(row?: any) {
editingId.value = row?.id ?? 0
Object.assign(form, row ?? { customer_name: '', phone: '', address: '', measure_date: null,
surveyor: '', status: 0, data: [], note: '' })
editVisible.value = true
}
async function save() {
const body = { ...form, measure_date: form.measure_date ? new Date(form.measure_date).toISOString() : null }
if (editingId.value) {
await api('put', `/api/measures/${editingId.value}`, body)
} else {
await api('post', '/api/measures', body)
}
ElMessage.success('已保存')
editVisible.value = false
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; }
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-card header="个人资料">
<el-form :model="profile" label-width="80px">
<el-form-item label="手机号"><el-input v-model="profile.phone_number" disabled /></el-form-item>
<el-form-item label="昵称"><el-input v-model="profile.nickname" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="profile.email" /></el-form-item>
<el-form-item label="到期时间"><el-input v-model="profile.try_expiration_time" disabled /></el-form-item>
<el-button type="primary" @click="saveProfile">保存资料</el-button>
</el-form>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card header="修改密码">
<el-form :model="pwd" label-width="80px">
<el-form-item label="旧密码"><el-input v-model="pwd.old_password" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="pwd.new_password" type="password" show-password /></el-form-item>
<el-button type="primary" @click="savePwd">修改密码</el-button>
</el-form>
</el-card>
<el-card header="邀请返利" style="margin-top:16px">
<p>我的邀请码:<span class="code">{{ invite.invite_code }}</span></p>
<p>已邀请:<b>{{ invite.invited_count }}</b> 人 | 奖励余额:<b>¥{{ invite.bounty }}</b></p>
<p class="rule">{{ invite.rule }}</p>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const profile = reactive<any>({ phone_number: '', nickname: '', email: '', try_expiration_time: '' })
const pwd = reactive({ old_password: '', new_password: '' })
const invite = reactive<any>({ invite_code: '', invited_count: 0, bounty: '0.00', rule: '' })
onMounted(async () => {
const u = await api<any>('get', '/api/auth/current-user')
Object.assign(profile, u)
Object.assign(invite, await api<any>('get', '/api/auth/invitation'))
})
async function saveProfile() {
await api('put', '/api/auth/profile', { nickname: profile.nickname, email: profile.email })
ElMessage.success('资料已更新')
}
async function savePwd() {
await api('put', '/api/auth/password', pwd)
ElMessage.success('密码已修改')
pwd.old_password = pwd.new_password = ''
}
</script>
<style scoped>
.code { font-weight: 700; color: #409eff; font-size: 16px; letter-spacing: 1px; }
.rule { color: #909399; font-size: 12px; }
</style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="名称/规格" clearable style="width:200px" @keyup.enter="load" />
<el-select v-model="material" placeholder="全部类型" clearable style="width:130px" @change="load">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
<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="name" label="名称" min-width="140" />
<el-table-column label="类型" width="80">
<template #default="{ row }">{{ label(row.material) }}</template>
</el-table-column>
<el-table-column prop="spec" label="规格" width="120" />
<el-table-column prop="unit" label="单位" width="70" />
<el-table-column prop="price" label="售价" width="90" />
<el-table-column prop="cost" label="成本" width="90" />
<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-select v-model="form.material" style="width:100%">
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
</el-select>
</el-form-item>
<el-form-item label="规格"><el-input v-model="form.spec" /></el-form-item>
<el-form-item label="单位"><el-input v-model="form.unit" style="width:100px" /></el-form-item>
<el-form-item label="售价"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
<el-form-item label="成本"><el-input-number v-model="form.cost" :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="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 material = ref<string | undefined>()
const loading = ref(false)
const editVisible = ref(false)
const form = reactive<any>({ id: 0, name: '', material: 'profile', spec: '', unit: '米', price: 0, cost: 0, note: '' })
const label = (m: string) => ({ profile: '型材', glass: '玻璃', hardware: '五金', addon: '配件' } as Record<string, string>)[m] ?? m
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${material.value ? `&material=${material.value}` : ''}`
const d = await api<any>('get', `/api/products?${qs}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openEdit(row?: any) {
Object.assign(form, row ?? { id: 0, name: '', material: 'profile', spec: '', unit: '米', price: 0, cost: 0, note: '' })
editVisible.value = true
}
async function save() {
if (form.id) await api('put', `/api/products/${form.id}`, form)
else await api('post', '/api/products', form)
ElMessage.success('已保存')
editVisible.value = false
load()
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/products/${row.id}`)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="报价单号/客户" clearable style="width:220px" @keyup.enter="load" />
<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="quote_no" label="报价单号" width="150" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column label="窗型尺寸" width="150">
<template #default="{ row }">{{ row.width_mm }} × {{ row.height_mm }} mm</template>
</el-table-column>
<el-table-column prop="glass_area" label="玻璃㎡" width="90" />
<el-table-column prop="total" label="总价" width="110" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="(['info', 'primary', 'success', 'danger'] as const)[row.status]">
{{ ['草稿', '已发送', '已成交', '已作废'][row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="detail(row)">详情</el-button>
<el-button size="small" type="success" plain @click="toOrder(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="640px">
<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-select v-model="form.template_id" filterable clearable style="width:100%"
placeholder="选择样板自动算料(可再手动调整)" @change="calcTemplate">
<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-table :data="form.items" size="small" border style="width:100%">
<el-table-column label="项目" width="110">
<template #default="{ row }"><el-input v-model="row.name" size="small" /></template>
</el-table-column>
<el-table-column label="数量" width="110">
<template #default="{ row }"><el-input-number v-model="row.qty" :min="0" size="small" controls-position="right" style="width:90px" /></template>
</el-table-column>
<el-table-column label="单价" width="110">
<template #default="{ row }"><el-input-number v-model="row.unit_price" :min="0" :precision="2" size="small" controls-position="right" style="width:90px" /></template>
</el-table-column>
<el-table-column label="金额" width="90">
<template #default="{ row }">{{ (row.qty * row.unit_price).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="操作" width="60">
<template #default="{ $index }"><el-button text type="danger" size="small" @click="form.items.splice($index, 1)">删</el-button></template>
</el-table-column>
</el-table>
<el-button text type="primary" size="small" style="margin-top:6px"
@click="form.items.push({ name: '', qty: 1, unit_price: 0 })">+ 添加行</el-button>
</el-form-item>
<el-form-item label="总价">
<span class="total">{{ totalCalc }} 元</span>
</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-dialog v-model="detailVisible" :title="current?.quote_no" width="560px">
<el-descriptions v-if="current" :column="2" border size="small">
<el-descriptions-item label="客户">{{ current.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="总价">{{ current.total }} 元</el-descriptions-item>
<el-descriptions-item label="尺寸">{{ current.width_mm }} × {{ current.height_mm }} mm</el-descriptions-item>
<el-descriptions-item label="玻璃">{{ current.glass_area }} ㎡</el-descriptions-item>
</el-descriptions>
<el-table v-if="current" :data="current.items" size="small" border style="margin-top:10px">
<el-table-column prop="name" label="项目" />
<el-table-column prop="qty" label="数量" width="80" />
<el-table-column prop="unit_price" label="单价" width="90" />
<el-table-column prop="amount" label="金额" width="90" />
</el-table>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { computed, 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 loading = ref(false)
const createVisible = ref(false)
const detailVisible = ref(false)
const current = ref<any>(null)
const customerOptions = ref<any[]>([])
const templateOptions = ref<any[]>([])
const form = reactive<any>({ customer_id: undefined, customer_name: '', contract_id: null, template_id: undefined,
drawing_data: '', items: [], glass_area: 0, width_mm: 0, height_mm: 0, total: 0, note: '', status: 0 })
const totalCalc = computed(() => form.items.reduce((s: number, r: any) => s + (r.qty || 0) * (r.unit_price || 0), 0).toFixed(2))
async function load() {
loading.value = true
try {
const d = await api<any>('get', `/api/quotations?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
async function openCreate() {
Object.assign(form, { customer_id: undefined, customer_name: '', contract_id: null, template_id: undefined,
drawing_data: '', items: [], glass_area: 0, width_mm: 0, height_mm: 0, total: 0, note: '', status: 0 })
const [cs, ts] = await Promise.all([
api<any>('get', '/api/customers?page=1&page_size=100'),
api<any>('get', '/api/windoor/templates?page=1&page_size=254'),
])
customerOptions.value = cs.data
templateOptions.value = ts
createVisible.value = true
}
function onCustomer(cid: number | undefined) {
const c = customerOptions.value.find(x => x.id === cid)
if (c) form.customer_name = c.name
}
async function calcTemplate(tid: number | undefined) {
if (!tid) return
const r = await api<any>('post', '/api/offer-price', { template_id: tid })
form.items = r.rows.map((row: any) => ({
name: row.note || row.kind, qty: row.qty, unit_price: row.unit_price, amount: row.amount,
}))
form.glass_area = r.glass_area
form.width_mm = r.width_mm
form.height_mm = r.height_mm
form.total = r.total
form.drawing_data = ''
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
form.drawing_data = t.drawing_data
}
async function save() {
form.total = Number(totalCalc.value)
await api('post', '/api/quotations', form)
ElMessage.success('报价单已保存')
createVisible.value = false
load()
}
async function detail(row: any) {
current.value = await api<any>('get', `/api/quotations/${row.id}`)
detailVisible.value = true
}
async function toOrder(row: any) {
await ElMessageBox.confirm(`将报价单 ${row.quote_no} 转为订单?`, '提示')
await api('post', '/api/orders', { customer_id: row.customer_id, money: row.total,
drawing_data: '', note: `来自报价单${row.quote_no}` })
ElMessage.success('订单已创建')
router.push('/orders')
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除 ${row.quote_no}?`, '提示', { type: 'warning' })
await api('delete', `/api/quotations/${row.id}`)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.total { color: #e6483d; font-size: 18px; font-weight: 700; }
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<el-card>
<div class="toolbar">
<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="money" label="金额" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="(['info', 'primary', 'warning', 'success', 'danger'] as const)[row.status]">
{{ ['意向单', '正式单', '生产中', '已完工', '已取消'][row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
</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="480px">
<el-form :model="form" label-width="80px">
<el-form-item label="客户">
<el-select v-model="form.customer_id" filterable clearable style="width:100%" @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-number v-model="form.money" :min="0" :precision="2" /></el-form-item>
<el-form-item label="面积㎡"><el-input-number v-model="form.area" :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 { ElMessage } from 'element-plus'
import { api } from '../api'
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 15
const loading = ref(false)
const createVisible = ref(false)
const customerOptions = ref<any[]>([])
const form = reactive<any>({ customer_id: undefined, money: 0, area: 0, note: '' })
async function load() {
loading.value = true
try {
const d = await api<any>('get', `/api/sunny_house_orders?page=${page.value}&page_size=${page_size}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
async function openCreate() {
Object.assign(form, { customer_id: undefined, money: 0, area: 0, note: '' })
customerOptions.value = (await api<any>('get', '/api/customers?page=1&page_size=100')).data
createVisible.value = true
}
function onCustomer(cid: number | undefined) {
form.customer_name = customerOptions.value.find(x => x.id === cid)?.name ?? ''
}
async function save() {
await api('post', '/api/orders', { ...form, order_type: 1 })
ElMessage.success('阳光房订单已创建')
createVisible.value = false
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+48 -1
View File
@@ -1,5 +1,6 @@
"""认证路由:登录/注册/当前用户。""" """认证路由:登录/注册/当前用户/个人中心。"""
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel
from huamenchuang.core.deps import CurrentUser, DbSession from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok from huamenchuang.core.response import fail, ok
@@ -42,8 +43,54 @@ async def register(body: LoginRequest, db: DbSession):
@router.get("/current-user") @router.get("/current-user")
async def current_user(user: CurrentUser): async def current_user(user: CurrentUser):
"""当前登录用户信息。""" """当前登录用户信息。"""
from huamenchuang.core.config import settings
return ok(data={ return ok(data={
"id": user.id, "nickname": user.nickname, "phone_number": user.phone_number, "id": user.id, "nickname": user.nickname, "phone_number": user.phone_number,
"company_id": user.company_id, "language": user.language, "company_id": user.company_id, "language": user.language,
"enabled_modules": settings.enabled_modules,
"try_expiration_time": str(user.try_expiration_time) if user.try_expiration_time else None, "try_expiration_time": str(user.try_expiration_time) if user.try_expiration_time else None,
}) })
class ProfileBody(BaseModel):
nickname: str | None = None
email: str | None = None
class PasswordBody(BaseModel):
old_password: str
new_password: str
@router.put("/profile")
async def update_profile(body: ProfileBody, user: CurrentUser, db: DbSession):
"""修改个人资料(对齐原站 personal-center)。"""
from huamenchuang.services.auth_service import hash_password # noqa: F401
if body.nickname is not None:
user.nickname = body.nickname[:64]
if body.email is not None:
user.email = body.email[:128]
return ok(message="资料已更新")
@router.put("/password")
async def change_password(body: PasswordBody, user: CurrentUser, db: DbSession):
"""修改密码(校验旧密码)。"""
from huamenchuang.services.auth_service import hash_password, verify_password
if not verify_password(body.old_password, user.password_hash):
return fail("旧密码错误")
if len(body.new_password) < 4:
return fail("新密码至少4位")
user.password_hash = hash_password(body.new_password)
return ok(message="密码已修改")
@router.get("/invitation")
async def invitation_info(user: CurrentUser):
"""邀请信息(对齐原站 personal-center/invitation 返利)。"""
return ok(data={
"invite_code": f"HMC{user.id:06d}",
"invited_count": 0,
"bounty": "0.00",
"rule": "好友通过邀请码注册并购买后,可获得相应奖励",
})
+331
View File
@@ -0,0 +1,331 @@
"""业务扩展路由:产品价格本、报价单、售后、量尺、阳光房订单过滤。"""
import datetime
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from huamenchuang.core.database import SessionLocal
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok, paginated
from huamenchuang.models.business import AfterSale, MeasureOrder, Product, Quotation
from huamenchuang.models.order import Order
router = APIRouter(tags=["业务扩展"])
# ---------------- 产品价格本 ----------------
class ProductBody(BaseModel):
name: str = Field(min_length=1, max_length=128)
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$")
spec: str = ""
unit: str = "米"
price: float = 0
cost: float = 0
note: str = ""
enabled: int = 1
@router.get("/products")
async def list_products(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", material: str | None = None):
conds = [Product.company_id == user.company_id]
if material:
conds.append(Product.material == material)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Product.name.like(like), Product.spec.like(like)))
where = *conds,
total = (await db.execute(select(func.count()).select_from(Product).where(*where))).scalar_one()
stmt = (select(Product).where(*where).order_by(Product.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": p.id, "name": p.name, "material": p.material, "spec": p.spec, "unit": p.unit,
"price": float(p.price), "cost": float(p.cost), "enabled": p.enabled,
"note": p.note,
} for p in items], page, page_size, total))
@router.post("/products")
async def create_product(body: ProductBody, user: CurrentUser, db: DbSession):
p = Product(company_id=user.company_id, **body.model_dump())
db.add(p)
await db.flush()
return ok(data={"id": p.id}, message="创建成功")
@router.put("/products/{pid}")
async def update_product(pid: int, body: ProductBody, user: CurrentUser, db: DbSession):
p = await db.get(Product, pid)
if p is None or p.company_id != user.company_id:
return fail("产品不存在")
for k, v in body.model_dump().items():
setattr(p, k, v)
return ok(message="更新成功")
@router.delete("/products/{pid}")
async def delete_product(pid: int, user: CurrentUser, db: DbSession):
p = await db.get(Product, pid)
if p is None or p.company_id != user.company_id:
return fail("产品不存在")
await db.delete(p)
return ok(message="删除成功")
# ---------------- 报价单 ----------------
class QuotationBody(BaseModel):
customer_id: int | None = None
customer_name: str = ""
contract_id: int | None = None
template_id: int | None = None
drawing_data: str = ""
items: list[dict] = []
glass_area: float = 0
width_mm: int = 0
height_mm: int = 0
total: float = 0
note: str = ""
status: int = Field(default=0, ge=0, le=3)
async def _next_quote_no(db, company_id: int) -> str:
today = datetime.date.today().strftime("%Y%m%d")
prefix = f"BJ{today}"
stmt = select(Quotation.quote_no).where(
Quotation.company_id == company_id, Quotation.quote_no.like(f"{prefix}%")
).order_by(Quotation.quote_no.desc()).limit(1)
import json as _json
last = (await db.execute(stmt)).scalar_one_or_none()
seq = int(last[-3:]) + 1 if last else 1
return f"{prefix}{seq:03d}"
def _quote_out(q: Quotation) -> dict:
return {
"id": q.id, "quote_no": q.quote_no, "customer_id": q.customer_id,
"customer_name": q.customer_name, "contract_id": q.contract_id,
"template_id": q.template_id, "glass_area": float(q.glass_area),
"width_mm": q.width_mm, "height_mm": q.height_mm, "total": float(q.total),
"status": q.status, "note": q.note, "created_at": str(q.created_at),
}
@router.post("/quotations")
async def create_quotation(body: QuotationBody, user: CurrentUser, db: DbSession):
q = Quotation(
company_id=user.company_id, user_id=user.id,
quote_no=await _next_quote_no(db, user.company_id),
items=__import__("json").dumps(body.items, ensure_ascii=False),
**{k: v for k, v in body.model_dump().items() if k != "items"},
)
db.add(q)
await db.flush()
return ok(data=_quote_out(q), message="报价单已保存")
@router.get("/quotations")
async def list_quotations(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
keyword: str = "", status: int | None = None):
conds = [Quotation.company_id == user.company_id]
if status is not None:
conds.append(Quotation.status == status)
if kw := keyword.strip():
like = f"%{kw}%"
conds.append(or_(Quotation.quote_no.like(like), Quotation.customer_name.like(like)))
where = *conds,
total = (await db.execute(select(func.count()).select_from(Quotation).where(*where))).scalar_one()
stmt = (select(Quotation).where(*where).order_by(Quotation.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([_quote_out(q) for q in items], page, page_size, total))
@router.get("/quotations/{qid}")
async def quotation_detail(qid: int, user: CurrentUser, db: DbSession):
q = await db.get(Quotation, qid)
if q is None or q.company_id != user.company_id:
return fail("报价单不存在")
import json as _json
d = _quote_out(q)
d["items"] = _json.loads(q.items or "[]")
d["drawing_data"] = q.drawing_data
return ok(data=d)
@router.put("/quotations/{qid}")
async def update_quotation(qid: int, body: QuotationBody, user: CurrentUser, db: DbSession):
q = await db.get(Quotation, qid)
if q is None or q.company_id != user.company_id:
return fail("报价单不存在")
for k, v in body.model_dump().items():
if k == "items":
q.items = __import__("json").dumps(v, ensure_ascii=False)
else:
setattr(q, k, v)
return ok(data=_quote_out(q), message="更新成功")
@router.delete("/quotations/{qid}")
async def delete_quotation(qid: int, user: CurrentUser, db: DbSession):
q = await db.get(Quotation, qid)
if q is None or q.company_id != user.company_id:
return fail("报价单不存在")
await db.delete(q)
return ok(message="删除成功")
# ---------------- 售后单 ----------------
class AfterSaleBody(BaseModel):
title: str = ""
description: str = Field(min_length=1, description="问题描述(必填,对齐原站校验)")
order_id: int | None = None
order_no: str = ""
status: int = Field(default=0, ge=0, le=2)
result: str = ""
@router.post("/after_sales")
async def create_after_sale(body: AfterSaleBody, user: CurrentUser, db: DbSession):
a = AfterSale(company_id=user.company_id, user_id=user.id, **body.model_dump())
db.add(a)
await db.flush()
return ok(data={"id": a.id}, message="售后单已提交")
@router.get("/after_sales")
async def list_after_sales(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
status: int | None = None):
conds = [AfterSale.company_id == user.company_id]
if status is not None:
conds.append(AfterSale.status == status)
where = *conds,
total = (await db.execute(select(func.count()).select_from(AfterSale).where(*where))).scalar_one()
stmt = (select(AfterSale).where(*where).order_by(AfterSale.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": a.id, "title": a.title, "description": a.description, "order_id": a.order_id,
"order_no": a.order_no, "status": a.status, "result": a.result,
"created_at": str(a.created_at),
} for a in items], page, page_size, total))
class AfterSaleFlowBody(BaseModel):
status: int = Field(ge=0, le=2)
result: str = ""
@router.put("/after_sales/{aid}")
async def update_after_sale(aid: int, body: AfterSaleFlowBody, user: CurrentUser, db: DbSession):
"""售后状态流转。"""
a = await db.get(AfterSale, aid)
if a is None or a.company_id != user.company_id:
return fail("售后单不存在")
a.status = body.status
if body.result:
a.result = body.result
return ok(message="状态已更新")
class MeasureUpdateBody(BaseModel):
customer_name: str | None = None
phone: str | None = None
address: str | None = None
measure_date: str | None = None
surveyor: str | None = None
status: int | None = Field(default=None, ge=0, le=2)
data: list[dict] | None = None
note: str | None = None
@router.put("/measures/{mid}")
async def update_measure(mid: int, body: MeasureUpdateBody, user: CurrentUser, db: DbSession):
"""更新量尺单。"""
m = await db.get(MeasureOrder, mid)
if m is None or m.company_id != user.company_id:
return fail("量尺单不存在")
data = body.model_dump(exclude_none=True)
md = data.pop("measure_date", None)
if md is not None:
try:
m.measure_date = datetime.datetime.fromisoformat(md)
except ValueError:
return fail("日期格式错误")
items = data.pop("data", None)
for k, v in data.items():
setattr(m, k, v)
if items is not None:
m.data = __import__("json").dumps(items, ensure_ascii=False)
return ok(message="更新成功")
# ---------------- 量尺单 ----------------
class MeasureBody(BaseModel):
customer_id: int | None = None
customer_name: str = ""
phone: str = ""
address: str = ""
measure_date: str | None = None
surveyor: str = ""
status: int = Field(default=0, ge=0, le=2)
data: list[dict] = [] # 洞口尺寸 [{name,width,height,qty}]
note: str = ""
@router.post("/measures")
async def create_measure(body: MeasureBody, user: CurrentUser, db: DbSession):
md = None
if body.measure_date:
try:
md = datetime.datetime.fromisoformat(body.measure_date)
except ValueError:
return fail("量尺日期格式错误")
m = MeasureOrder(company_id=user.company_id, customer_id=body.customer_id,
customer_name=body.customer_name, phone=body.phone, address=body.address,
measure_date=md, surveyor=body.surveyor, status=body.status,
data=__import__("json").dumps(body.data, ensure_ascii=False), note=body.note)
db.add(m)
await db.flush()
return ok(data={"id": m.id}, message="量尺单已创建")
@router.get("/measures")
async def list_measures(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
status: int | None = None):
conds = [MeasureOrder.company_id == user.company_id]
if status is not None:
conds.append(MeasureOrder.status == status)
where = *conds,
total = (await db.execute(select(func.count()).select_from(MeasureOrder).where(*where))).scalar_one()
stmt = (select(MeasureOrder).where(*where).order_by(MeasureOrder.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
import json as _json
return ok(data=paginated([{
"id": m.id, "customer_name": m.customer_name, "phone": m.phone, "address": m.address,
"measure_date": str(m.measure_date) if m.measure_date else None, "surveyor": m.surveyor,
"status": m.status, "data": _json.loads(m.data or "[]"), "note": m.note,
"created_at": str(m.created_at),
} for m in items], page, page_size, total))
# ---------------- 阳光房订单(订单按类型过滤) ----------------
@router.get("/sunny_house_orders")
async def list_sunny_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
"""阳光房订单列表(order_type=1,对齐目标站 sunny_house_order/)。"""
conds = [Order.company_id == user.company_id, Order.order_type == 1]
total = (await db.execute(select(func.count()).select_from(Order).where(*conds))).scalar_one()
stmt = (select(Order).where(*conds).order_by(Order.id.desc())
.offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars())
return ok(data=paginated([{
"id": o.id, "order_no": o.order_no, "customer_name": o.customer_name,
"money": float(o.money), "status": o.status, "created_at": str(o.created_at),
} for o in items], page, page_size, total))
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter
from huamenchuang.api import ( from huamenchuang.api import (
auth, auth,
business,
contracts, contracts,
customers, customers,
optimize, optimize,
@@ -21,3 +22,4 @@ api_router.include_router(windoor.router)
api_router.include_router(optimize.router) api_router.include_router(optimize.router)
api_router.include_router(quote.router) api_router.include_router(quote.router)
api_router.include_router(warehouse.router) api_router.include_router(warehouse.router)
api_router.include_router(business.router)
+12
View File
@@ -1,4 +1,11 @@
"""ORM 模型统一导出。""" """ORM 模型统一导出。"""
from huamenchuang.models.business import (
AfterSale,
MeasureOrder,
Product,
ProductCatelog,
Quotation,
)
from huamenchuang.models.contract import ( from huamenchuang.models.contract import (
Contract, Contract,
Customer, Customer,
@@ -11,12 +18,17 @@ from huamenchuang.models.user import User
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
__all__ = [ __all__ = [
"AfterSale",
"Contract", "Contract",
"Customer", "Customer",
"DEFAULT_PROCESSES", "DEFAULT_PROCESSES",
"MeasureOrder",
"Order", "Order",
"OrderLog", "OrderLog",
"OrderProcess", "OrderProcess",
"Product",
"ProductCatelog",
"Quotation",
"ReceiptAccount", "ReceiptAccount",
"StockMove", "StockMove",
"User", "User",
+113
View File
@@ -0,0 +1,113 @@
"""产品、报价单、售后单、量尺单模型(业务扩展模块)。"""
import datetime
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from huamenchuang.core.database import Base
class Product(Base):
"""产品价格本(型材/玻璃/五金/配件),供报价算料取价。"""
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
catelog_id: Mapped[int | None] = mapped_column(ForeignKey("product_catelogs.id"), nullable=True)
name: Mapped[str] = mapped_column(String(128))
# profile型材/glass玻璃/hardware五金/addon配件
material: Mapped[str] = mapped_column(String(16), default="profile", index=True)
spec: Mapped[str] = mapped_column(String(64), default="")
unit: Mapped[str] = mapped_column(String(8), default="米") # 计价单位
price: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
cost: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
note: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[int] = mapped_column(default=1)
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class ProductCatelog(Base):
"""产品分类树。"""
__tablename__ = "product_catelogs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64))
fid: Mapped[int] = mapped_column(default=0, index=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
class Quotation(Base):
"""报价单:画布算料结果 + 手动调整,归属客户/合同。"""
__tablename__ = "quotations"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
quote_no: Mapped[str] = mapped_column(String(32), unique=True) # BJ+日期+流水
company_id: Mapped[int] = mapped_column(default=1, index=True)
user_id: Mapped[int] = mapped_column(default=0)
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id"), nullable=True)
customer_name: Mapped[str] = mapped_column(String(128), default="")
contract_id: Mapped[int | None] = mapped_column(ForeignKey("contracts.id"), nullable=True)
template_id: Mapped[int | None] = mapped_column(default=None)
drawing_data: Mapped[str] = mapped_column(Text, default="")
# 报价明细 JSON: [{kind,name,length,qty,unit_price,unit,amount}]
items: Mapped[str] = mapped_column(Text, default="[]")
glass_area: Mapped[float] = mapped_column(Numeric(10, 3), default=0)
width_mm: Mapped[int] = mapped_column(default=0)
height_mm: Mapped[int] = mapped_column(default=0)
total: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
note: Mapped[str] = mapped_column(Text, default="")
status: Mapped[int] = mapped_column(default=0) # 0草稿 1已发送 2已成交 3已作废
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class AfterSale(Base):
"""售后单(对齐目标站 after_sales_orders:问题描述必填)。"""
__tablename__ = "after_sales"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
user_id: Mapped[int] = mapped_column(default=0)
order_id: Mapped[int | None] = mapped_column(default=None, index=True)
order_no: Mapped[str] = mapped_column(String(32), default="")
title: Mapped[str] = mapped_column(String(128), default="")
description: Mapped[str] = mapped_column(Text, default="") # 必填(对齐原站校验)
status: Mapped[int] = mapped_column(default=0) # 0待处理 1处理中 2已完成
result: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
class MeasureOrder(Base):
"""量尺单(对齐目标站 measure-management)。"""
__tablename__ = "measure_orders"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
company_id: Mapped[int] = mapped_column(default=1, index=True)
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id"), nullable=True)
customer_name: Mapped[str] = mapped_column(String(128), default="")
phone: Mapped[str] = mapped_column(String(32), default="")
address: Mapped[str] = mapped_column(String(255), default="")
measure_date: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
surveyor: Mapped[str] = mapped_column(String(64), default="") # 量尺人
# 0待量尺 1已量尺 2已出图
status: Mapped[int] = mapped_column(default=0)
# 量尺数据 JSON(洞口尺寸清单)
data: Mapped[str] = mapped_column(Text, default="[]")
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
)
+2
View File
@@ -36,6 +36,8 @@ class Order(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
order_no: Mapped[str] = mapped_column(String(32), unique=True) # DO+YYYYMMDD+4位流水 order_no: Mapped[str] = mapped_column(String(32), unique=True) # DO+YYYYMMDD+4位流水
# 订单类型: 0=门窗订单 1=阳光房订单(对齐目标站 sunny_house_order)
order_type: Mapped[int] = mapped_column(Integer, default=0, index=True)
contract_id: Mapped[int | None] = mapped_column( contract_id: Mapped[int | None] = mapped_column(
ForeignKey("contracts.id"), nullable=True, index=True ForeignKey("contracts.id"), nullable=True, index=True
) )
+1
View File
@@ -11,6 +11,7 @@ class OrderCreate(BaseModel):
"""创建订单(对齐目标站契约:contract_id + drawing_data + pic_url)。""" """创建订单(对齐目标站契约:contract_id + drawing_data + pic_url)。"""
contract_id: int | None = None contract_id: int | None = None
order_type: int = Field(default=0, ge=0, le=1, description="0门窗 1阳光房")
drawing_data: str = Field(default="", description="shapeManager 序列化 JSON 字符串") drawing_data: str = Field(default="", description="shapeManager 序列化 JSON 字符串")
pic_url: str = "" pic_url: str = ""
customer_id: int | None = None customer_id: int | None = None
@@ -27,6 +27,7 @@ class OrderService:
customer_name, phone, address = contract.customer_name, contract.phone, contract.address customer_name, phone, address = contract.customer_name, contract.phone, contract.address
order = Order( order = Order(
order_no=await self.repo.next_order_no(user.company_id), order_no=await self.repo.next_order_no(user.company_id),
order_type=payload.order_type,
contract_id=payload.contract_id, contract_id=payload.contract_id,
drawing_data=payload.drawing_data, drawing_data=payload.drawing_data,
pic_url=payload.pic_url, pic_url=payload.pic_url,