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
- [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 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)
+39 -1
View File
@@ -294,7 +294,45 @@
**自动报价**(v0.5):画布变化(改尺寸/加分格)后 600ms 防抖自动调用 offer-price,页面实时显示当前尺寸总价。
**报价弹窗**(`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
uv sync # 安装依赖
+6 -1
View File
@@ -1,9 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
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: 'dashboard', component: () => import('../views/DashboardView.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: '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: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } },
{ path: 'warehouse', component: () => import('../views/WarehouseView.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>
<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-aside :width="collapsed ? '64px' : '200px'" class="aside">
<div class="logo">{{ collapsed ? '门' : '华门窗' }}</div>
<el-menu :default-active="route.path" router :collapse="collapsed" 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="/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="/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="/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="/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-aside>
<el-container>
<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>
<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>
<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>
</el-header>
@@ -29,23 +40,29 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
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') }
</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; }
.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; white-space: nowrap; }
.el-menu { border-right: none; }
.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; }
.user { cursor: pointer; color: #606266; display: inline-flex; gap: 6px; align-items: center; }
.el-main { background: #f2f4f7; padding: 16px; }
@media (max-width: 700px) { .el-main { padding: 8px; } }
</style>
+44 -11
View File
@@ -3,18 +3,34 @@
<el-card class="login-card">
<h2>华门窗云平台</h2>
<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-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-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-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
登 录
</el-button>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">登 录</el-button>
</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>
</div>
</template>
@@ -29,19 +45,36 @@ import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
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() {
if (!form.phone_number || !form.password) return ElMessage.warning('请输入手机号和密码')
if (!loginForm.phone_number || !loginForm.password) return ElMessage.warning('请输入手机号和密码')
loading.value = true
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 ?? '')
router.push('/dashboard')
} finally {
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>
<style scoped>
@@ -49,6 +82,6 @@ async function doLogin() {
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; }
.sub { color: #909399; font-size: 13px; margin: 0 0 12px; }
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
</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 pydantic import BaseModel
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
@@ -42,8 +43,54 @@ async def register(body: LoginRequest, db: DbSession):
@router.get("/current-user")
async def current_user(user: CurrentUser):
"""当前登录用户信息。"""
from huamenchuang.core.config import settings
return ok(data={
"id": user.id, "nickname": user.nickname, "phone_number": user.phone_number,
"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,
})
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 (
auth,
business,
contracts,
customers,
optimize,
@@ -21,3 +22,4 @@ api_router.include_router(windoor.router)
api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
+12
View File
@@ -1,4 +1,11 @@
"""ORM 模型统一导出。"""
from huamenchuang.models.business import (
AfterSale,
MeasureOrder,
Product,
ProductCatelog,
Quotation,
)
from huamenchuang.models.contract import (
Contract,
Customer,
@@ -11,12 +18,17 @@ from huamenchuang.models.user import User
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
__all__ = [
"AfterSale",
"Contract",
"Customer",
"DEFAULT_PROCESSES",
"MeasureOrder",
"Order",
"OrderLog",
"OrderProcess",
"Product",
"ProductCatelog",
"Quotation",
"ReceiptAccount",
"StockMove",
"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)
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(
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: int | None = None
order_type: int = Field(default=0, ge=0, le=1, description="0门窗 1阳光房")
drawing_data: str = Field(default="", description="shapeManager 序列化 JSON 字符串")
pic_url: str = ""
customer_id: int | None = None
@@ -27,6 +27,7 @@ class OrderService:
customer_name, phone, address = contract.customer_name, contract.phone, contract.address
order = Order(
order_no=await self.repo.next_order_no(user.company_id),
order_type=payload.order_type,
contract_id=payload.contract_id,
drawing_data=payload.drawing_data,
pic_url=payload.pic_url,