feat: v0.7 全功能复刻与前端适配
后端: - 产品价格本模块(型材/玻璃/五金/配件, 供报价取价) - 报价单模块(BJ单号, 手动报价/样板算料/转订单) - 售后单(问题描述必填, 对齐原站校验)与状态流转 - 量尺单(洞口尺寸清单)与阳光房订单(order_type=1) - 个人中心: 资料/密码修改/邀请返利(HMC邀请码) 前端: - 新增8页面: 产品/报价单/售后/量尺/阳光房/商城/使用指南/个人中心 - 登录页注册双Tab, 响应式侧边栏(<900px自动折叠) - Element Plus 图标全局注册修复 - 共15个页面覆盖原站核心业务路由
This commit is contained in:
@@ -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
@@ -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 # 安装依赖
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: '个人中心' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
<span class="title">{{ route.meta.title }}</span>
|
||||
<el-dropdown @command="cmd => cmd === 'logout' && doLogout()">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -3,18 +3,34 @@
|
||||
<el-card class="login-card">
|
||||
<h2>华门窗云平台</h2>
|
||||
<p class="sub">画图 · 报价 · 订单 · 生产</p>
|
||||
<el-form :model="form" @keyup.enter="doLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.phone_number" placeholder="手机号" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="doLogin">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="tip">开发账号:13800000000 / 123456</p>
|
||||
<el-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="loginForm.phone_number" placeholder="手机号" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<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-form>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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": "好友通过邀请码注册并购买后,可获得相应奖励",
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user