feat: v0.9 样板数据完整性验证/解析加固/报表统计

- 双源确认样板库完整性: search接口与画图编辑器for_ht目录均为254条, 0遗漏
- 全量体检: 6套样板含7个畸形段(缺ps/pe)导致解析报错
  前后端重放/算料统一加固过滤, 254样板0异常/1046窗格
- 报表统计模块(/chart): 近N天订单数金额按日柱图+8项总量+状态分布
- 样板库官方/我的样板双Tab(is_default过滤)
- 使用指南接入帮助文章接口(种子8篇), seed分类树幂等修复
- 全接口回归18/18通过
This commit is contained in:
HHY
2026-09-20 12:27:19 -07:00
parent 3f871d7eeb
commit 453decf2e4
15 changed files with 233 additions and 29 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-09-20 21:00] FIX+FEAT: v0.9 样板数据完整性验证与解析加固——①双源确认样板库完整性: search接口254条 = 画图编辑器for_ht目录内嵌254条, 0遗漏; ②全量体检发现6套样板含7个畸形段(缺ps/pe)导致解析报错, 前后端重放/算料均加固过滤(0异常/1046窗格); ③新增报表统计模块(/chart: 近N天订单数金额按日+8项总量+状态分布, 自绘SVG柱图); ④样板库官方/我的样板双Tab(is_default过滤); ⑤使用指南接入帮助文章接口(种子8篇自撰文章); ⑥seed脚本分类树幂等修复; 全接口回归18/18通过 (Files: src/huamenchuang/core/drawing.py, src/huamenchuang/api/reports.py, src/huamenchuang/api/windoor.py, src/huamenchuang/scripts/*, frontend/src/types/drawing.ts, frontend/src/views/ReportView.vue, frontend/src/views/TemplatesView.vue, frontend/src/views/GuideView.vue)
- [2026-09-20 20:10] FEAT: v0.8 平台扩展——3D展示/效果图渲染/打印中心/下料方案/设备模块/订单分享/验证码登录。①Three.js 3D窗型(重放区域挤出玻璃+型材盒体, 自研轨道控制);②Canvas场景合成效果图(墙面/玻璃渐变/投影, 三场景, 设置持久化到公司参数);③A4打印中心(订单模板+进度+签字栏, 打印CSS隔离);④合同级下料方案(汇总订单算料→FFD→参数持久化, 修正利用率为已排入/已用料);⑤设备激活绑定(序列号+激活码);⑥订单分享(HMAC可逆token+公开只读页);⑦mock短信验证码登录全流程可用(接签名后换provider);⑧帮助文章接口。新增12组API, 前端+6页面共18页 (Files: src/huamenchuang/api/platform.py, src/huamenchuang/models/extend.py, frontend/src/components/WindoorThreed.vue, frontend/src/components/RenderCanvas.vue, frontend/src/views/*, doc/API.md) - [2026-09-20 20:10] FEAT: v0.8 平台扩展——3D展示/效果图渲染/打印中心/下料方案/设备模块/订单分享/验证码登录。①Three.js 3D窗型(重放区域挤出玻璃+型材盒体, 自研轨道控制);②Canvas场景合成效果图(墙面/玻璃渐变/投影, 三场景, 设置持久化到公司参数);③A4打印中心(订单模板+进度+签字栏, 打印CSS隔离);④合同级下料方案(汇总订单算料→FFD→参数持久化, 修正利用率为已排入/已用料);⑤设备激活绑定(序列号+激活码);⑥订单分享(HMAC可逆token+公开只读页);⑦mock短信验证码登录全流程可用(接签名后换provider);⑧帮助文章接口。新增12组API, 前端+6页面共18页 (Files: src/huamenchuang/api/platform.py, src/huamenchuang/models/extend.py, frontend/src/components/WindoorThreed.vue, frontend/src/components/RenderCanvas.vue, frontend/src/views/*, doc/API.md)
- [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 19:00] FEAT: v0.7 全功能复刻与前端适配——新增产品价格本/报价单(BJ单号,手动报价+样板算料)/售后单(问题描述必填对齐原站)/量尺单(洞口尺寸清单)/阳光房订单(order_type=1)/个人中心(资料/密码/邀请返利 HMC邀请码) 模块;前端 15 页面(登录注册双Tab/商城/使用指南等)+响应式侧边栏(<900px折叠)+图标全局注册;订单新增order_type字段(含SQLite手工迁移) (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/business.py, src/huamenchuang/api/auth.py, frontend/src/views/*, frontend/src/router/index.ts, doc/API.md)
- [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md) - [2026-09-20 11:15] FEAT: v0.6 分割树架构——横梃竖梃完整编辑。①逆向确认原站设计思路: polygon.polygon为基底多边形+mm.sp[]为分割树(polyId指向作用区域), 运行时重放得最终窗格(254样本验证); ②前端 replayShape 分割树重放, 网格渲染真实化(平开1矩形→4-7格); ③中梃编辑: 点击选中(高亮)/拖动移动(line.pt更新→重放,30mm钳制)/删除(移除sp→重放), 加梃指向被点窗格; ④Python侧同语义replay_shape, 报价中梃长度精确到实际作用跨度(2375/2350/1642/979mm); ⑤方案文档§3.3更新原站设计思路与编辑器完成设计 (Files: frontend/src/components/WindoorCanvas.vue, frontend/src/types/drawing.ts, src/huamenchuang/core/drawing.py, src/huamenchuang/services/quote_service.py, doc/克隆实施方案.md)
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -12,6 +12,7 @@ const router = createRouter({
children: [ children: [
{ path: '', redirect: '/dashboard' }, { path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } }, { path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
{ path: 'chart', component: () => import('../views/ReportView.vue'), meta: { title: '报表统计' } },
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } }, { path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
{ path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } }, { path: 'sunny', component: () => import('../views/SunnyHouseView.vue'), meta: { title: '阳光房' } },
{ path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } }, { path: 'quotations', component: () => import('../views/QuotationsView.vue'), meta: { title: '报价单' } },
+5 -1
View File
@@ -141,7 +141,11 @@ export function replayShape(shape: FrameShape): {
regions: Segment[][] regions: Segment[][]
clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[]
} { } {
const base = (shape.polygon?.polygon ?? []).map(r => r.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } }))) const base = (shape.polygon?.polygon ?? [])
.map(r => r
.filter(s => s && typeof s === 'object' && s.ps && s.pe && Number.isFinite(s.ps.x) && Number.isFinite(s.pe.x))
.map(s => ({ ps: { ...s.ps }, pe: { ...s.pe } })))
.filter(r => r.length >= 3)
const regions: Segment[][] = base.map(r => r) const regions: Segment[][] = base.map(r => r)
const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = [] const clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) { for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
+18 -22
View File
@@ -2,29 +2,25 @@
<el-card> <el-card>
<el-alert type="info" :closable="false" show-icon <el-alert type="info" :closable="false" show-icon
title="常用操作指南" description="点击左侧菜单进入各功能模块;画布支持滚轮缩放、拖拽平移、双击复位。" /> title="常用操作指南" description="点击左侧菜单进入各功能模块;画布支持滚轮缩放、拖拽平移、双击复位。" />
<el-collapse style="margin-top:16px"> <el-collapse style="margin-top:16px" v-loading="loading">
<el-collapse-item title="如何创建订单?"> <el-collapse-item v-for="a in articles" :key="a.id" :title="a.title">
<p>方式一:合同管理 → 选择合同 → 「下订单」。</p> <p>{{ a.content }}</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-item>
</el-collapse> </el-collapse>
</el-card> </el-card>
</template> </template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
const articles = ref<any[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
articles.value = await api<any[]>('get', '/api/articles')
} finally { loading.value = false }
})
</script>
+1
View File
@@ -5,6 +5,7 @@
<el-menu :default-active="route.path" router :collapse="collapsed" background-color="#2b3a4d" <el-menu :default-active="route.path" router :collapse="collapsed" background-color="#2b3a4d"
text-color="#bfc9d4" active-text-color="#ffffff"> text-color="#bfc9d4" active-text-color="#ffffff">
<el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item> <el-menu-item index="/dashboard"><el-icon><Odometer /></el-icon>工作台</el-menu-item>
<el-menu-item index="/chart"><el-icon><TrendCharts /></el-icon>报表统计</el-menu-item>
<el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item> <el-menu-item index="/orders"><el-icon><Tickets /></el-icon>订单管理</el-menu-item>
<el-menu-item index="/sunny"><el-icon><OfficeBuilding /></el-icon>阳光房</el-menu-item> <el-menu-item index="/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="/quotations"><el-icon><PriceTag /></el-icon>报价单</el-menu-item>
+99
View File
@@ -0,0 +1,99 @@
<template>
<el-card>
<template #header>
<div class="head">
<span>经营报表(近 {{ days }} 天)</span>
<el-radio-group v-model="days" size="small" @change="load">
<el-radio-button :value="7">近7天</el-radio-button>
<el-radio-button :value="14">近14天</el-radio-button>
<el-radio-button :value="30">近30天</el-radio-button>
</el-radio-group>
</div>
</template>
<el-row :gutter="12">
<el-col :xs="12" :sm="8" :md="6" v-for="t in totalsCards" :key="t.label">
<div class="stat"><span class="num">{{ t.value }}</span><span class="label">{{ t.label }}</span></div>
</el-col>
</el-row>
<h4 class="sec">每日订单量 / 金额</h4>
<div class="chart">
<div class="col" v-for="d in byDay" :key="d.date">
<div class="bars">
<div class="bar money" :style="{ height: moneyH(d.money) + 'px' }" :title="`¥${d.money}`" />
<div class="bar count" :style="{ height: countH(d.count) + 'px' }" :title="`${d.count} 单`" />
</div>
<span class="dlabel">{{ d.date.slice(5) }}</span>
</div>
<div v-if="!byDay.length" class="empty">近期暂无订单</div>
</div>
<div class="legend no-print">
<span><i class="dot money" /> 金额(高度按金额比例)</span>
<span><i class="dot count" /> 订单数</span>
</div>
<h4 class="sec">订单状态分布</h4>
<el-space wrap>
<el-tag v-for="s in statusDist" :key="s.status" style="margin:2px">
{{ statusLabel(s.status) }}:{{ s.count }}</el-tag>
</el-space>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api } from '../api'
const days = ref(14)
const byDay = ref<any[]>([])
const totals = ref<any>({})
const statusDist = ref<any[]>([])
const totalsCards = computed(() => [
{ label: '订单总数', value: totals.value.orders ?? 0 },
{ label: '意向单', value: totals.value.intentions ?? 0 },
{ label: '已完工', value: totals.value.finished ?? 0 },
{ label: '报价单', value: totals.value.quotations ?? 0 },
{ label: '客户数', value: totals.value.customers ?? 0 },
{ label: '合同数', value: totals.value.contracts ?? 0 },
{ label: '量尺单', value: totals.value.measures ?? 0 },
{ label: '售后处理中', value: totals.value.after_sales ?? 0 },
])
const maxCount = computed(() => Math.max(1, ...byDay.value.map(d => d.count)))
const maxMoney = computed(() => Math.max(1, ...byDay.value.map(d => d.money)))
const countH = (c: number) => Math.round(c / maxCount.value * 90) + 2
const moneyH = (m: number) => Math.round(m / maxMoney.value * 90) + 2
const statusLabel = (s: number) => ['意向单', '正式单', '生产中', '已完工', '已取消'][s] ?? s
async function load() {
const d = await api<any>('get', `/api/reports/summary?days=${days.value}`)
byDay.value = d.by_day
totals.value = d.totals
statusDist.value = d.status_dist
}
onMounted(load)
</script>
<style scoped>
.head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.stat { display: flex; flex-direction: column; align-items: center; padding: 8px 0; }
.num { font-size: 26px; font-weight: 700; color: #35506b; }
.label { color: #909399; font-size: 12px; }
.sec { margin: 18px 0 8px; color: #303133; }
.chart { display: flex; gap: 8px; align-items: flex-end; min-height: 120px; padding: 8px; overflow-x: auto; }
.col { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.bars { display: flex; gap: 3px; align-items: flex-end; height: 96px; }
.bar { width: 14px; border-radius: 3px 3px 0 0; }
.bar.money { background: #6b8cae; }
.bar.count { background: #67c23a; }
.dlabel { font-size: 11px; color: #909399; white-space: nowrap; }
.legend { display: flex; gap: 16px; font-size: 12px; color: #909399; margin-top: 8px; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; }
.dot.money { background: #6b8cae; }
.dot.count { background: #67c23a; }
.empty { color: #909399; }
</style>
+7 -2
View File
@@ -2,13 +2,17 @@
<div> <div>
<el-card> <el-card>
<div class="toolbar"> <div class="toolbar">
<el-radio-group v-model="tab" size="small" @change="load">
<el-radio-button value="official">官方样板</el-radio-button>
<el-radio-button value="mine">我的样板</el-radio-button>
</el-radio-group>
<el-select v-model="catelogId" placeholder="全部分类" clearable style="width:160px" @change="load"> <el-select v-model="catelogId" placeholder="全部分类" clearable style="width:160px" @change="load">
<el-option v-for="c in catelogs" :key="c.id" :label="c.name" :value="c.id" /> <el-option v-for="c in catelogs" :key="c.id" :label="c.name" :value="c.id" />
</el-select> </el-select>
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" /> <el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button> <el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" /> <div style="flex:1" />
<span class="count">共 {{ total }} 套官方样板</span> <span class="count">共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板</span>
</div> </div>
<div v-loading="loading" class="grid"> <div v-loading="loading" class="grid">
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)"> <div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
@@ -71,6 +75,7 @@ const editing = ref(false)
const dirty = ref(false) const dirty = ref(false)
const editRef = ref() const editRef = ref()
const liveTotal = ref<number | null>(null) const liveTotal = ref<number | null>(null)
const tab = ref<'official' | 'mine'>('official')
let quoteTimer: number | undefined let quoteTimer: number | undefined
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价 // 画布变化(改尺寸/加分格)→ 防抖自动重算报价
@@ -90,7 +95,7 @@ function onCanvasChange() {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}` const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}&is_default=${tab.value === 'official' ? 1 : 0}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}`
const d = await api<any>('get', `/api/windoor/templates?${qs}`) const d = await api<any>('get', `/api/windoor/templates?${qs}`)
rows.value = d rows.value = d
// 接口无分页元数据时按返回量估计 // 接口无分页元数据时按返回量估计
+53
View File
@@ -0,0 +1,53 @@
"""报表统计路由(对齐原站 /chart 报表)。"""
from datetime import datetime, timedelta
from fastapi import APIRouter
from sqlalchemy import func, select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import ok
from huamenchuang.models.business import AfterSale, MeasureOrder, Quotation
from huamenchuang.models.contract import Contract, Customer
from huamenchuang.models.order import Order
router = APIRouter(prefix="/reports", tags=["报表"])
@router.get("/summary")
async def summary(user: CurrentUser, db: DbSession, days: int = 14):
"""经营汇总:近 N 天订单数/金额按日 + 各实体总数。"""
since = datetime.utcnow() - timedelta(days=days)
cid = user.company_id
# 按日订单数与金额
day_rows = (await db.execute(
select(func.date(Order.created_at), func.count(), func.sum(Order.money))
.where(Order.company_id == cid, Order.created_at >= since)
.group_by(func.date(Order.created_at))
)).all()
by_day = [{"date": str(d), "count": c, "money": float(m or 0)} for d, c, m in day_rows]
async def count(model, extra=None):
conds = [model.company_id == cid]
if extra is not None:
conds.append(extra)
return (await db.execute(select(func.count()).select_from(model).where(*conds))).scalar_one()
totals = {
"orders": await count(Order),
"intentions": await count(Order, Order.status == 0),
"finished": await count(Order, Order.status == 3),
"quotations": await count(Quotation),
"customers": await count(Customer),
"contracts": await count(Contract),
"measures": await count(MeasureOrder),
"after_sales": await count(AfterSale, AfterSale.status != 2),
}
# 订单状态分布
status_rows = (await db.execute(
select(Order.status, func.count()).where(Order.company_id == cid).group_by(Order.status)
)).all()
status_dist = [{"status": s, "count": c} for s, c in status_rows]
return ok(data={"by_day": by_day, "totals": totals, "status_dist": status_dist, "days": days})
+3
View File
@@ -10,6 +10,7 @@ from huamenchuang.api import (
orders, orders,
platform, platform,
quote, quote,
reports,
warehouse, warehouse,
windoor, windoor,
) )
@@ -25,3 +26,5 @@ api_router.include_router(quote.router)
api_router.include_router(warehouse.router) api_router.include_router(warehouse.router)
api_router.include_router(business.router) api_router.include_router(business.router)
api_router.include_router(platform.router) api_router.include_router(platform.router)
api_router.include_router(reports.router)
api_router.include_router(platform.router)
+3
View File
@@ -32,6 +32,7 @@ async def search_templates(
db: DbSession, user: CurrentUser, db: DbSession, user: CurrentUser,
name: str = Query(default="", max_length=64), name: str = Query(default="", max_length=64),
catelog_id: int | None = None, catelog_id: int | None = None,
is_default: int | None = Query(default=None, description="1官方样板 0我的样板"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200), page_size: int = Query(default=50, ge=1, le=200),
): ):
@@ -41,6 +42,8 @@ async def search_templates(
conds.append(WindoorTemplate.name.like(f"%{name}%")) conds.append(WindoorTemplate.name.like(f"%{name}%"))
if catelog_id is not None: if catelog_id is not None:
conds.append(WindoorTemplate.catelog_id == catelog_id) conds.append(WindoorTemplate.catelog_id == catelog_id)
if is_default is not None:
conds.append(WindoorTemplate.is_default == is_default)
stmt = (select(WindoorTemplate).where(*conds) stmt = (select(WindoorTemplate).where(*conds)
.order_by(WindoorTemplate.id).offset((page - 1) * page_size).limit(page_size)) .order_by(WindoorTemplate.id).offset((page - 1) * page_size).limit(page_size))
items = list((await db.execute(stmt)).scalars()) items = list((await db.execute(stmt)).scalars())
+15 -2
View File
@@ -90,13 +90,26 @@ def _split_poly(region: list[dict], axis: str, pos: float) -> tuple[list[dict],
return to_segs(poly_a), to_segs(poly_b) return to_segs(poly_a), to_segs(poly_b)
def _clean_region(region: list) -> list[dict]:
"""过滤畸形段(缺 ps/pe 或类型不对),保证重放/渲染不抛错。"""
out = []
for s in region:
if not isinstance(s, dict):
continue
ps, pe = s.get("ps"), s.get("pe")
if isinstance(ps, dict) and isinstance(pe, dict) and "x" in ps and "y" in ps and "x" in pe and "y" in pe:
out.append({"ps": dict(ps), "pe": dict(pe)})
return out
def replay_shape(shape: dict) -> list[list[dict]]: def replay_shape(shape: dict) -> list[list[dict]]:
"""分割树重放: 基底多边形 + mm.sp[](polyId.idx 指向作用区域) → 最终窗格列表。 """分割树重放: 基底多边形 + mm.sp[](polyId.idx 指向作用区域) → 最终窗格列表。
与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过。 与前端 replayShape 同语义: idx 越界取 0; 退化/非凸跳过; 畸形段过滤。
""" """
base = shape.get("polygon", {}).get("polygon", []) or [] base = shape.get("polygon", {}).get("polygon", []) or []
regions = [[{"ps": dict(s["ps"]), "pe": dict(s["pe"])} for s in r] for r in base] regions = [_clean_region(r) for r in base]
regions = [r for r in regions if r]
if not regions: if not regions:
return regions return regions
for sp in (shape.get("mm") or {}).get("sp", []) or []: for sp in (shape.get("mm") or {}).get("sp", []) or []:
@@ -0,0 +1,12 @@
"""种子文章(帮助中心内容,自撰)。"""
ARTICLES = [
("常见问题解答", "如何更改标线尺寸?", "在画布编辑模式中,直接修改顶部 W/H 输入框,或拖拽外框右缘/下缘/右下角手柄;所有尺寸标注会自动跟随几何更新。"),
("常见问题解答", "如何绘制横竖中梃?", "进入编辑模式后点击「+竖梃」或「+横梃」,再点击目标玻璃区域即可完成分割;点击已有中梃可选中并拖动调整位置,「删除梃」可移除。"),
("常见问题解答", "如何把样板改成自己的?", "样板库 → 点击样板 → 编辑分格/尺寸 → 「另存为我的样板」,原图板不受影响。"),
("常见问题解答", "报价是如何计算的?", "报价 = 外框 + 中梃(按实际跨度)+ 扇框 + 玻璃面积 + 五金(按扇数),价格取自产品管理中的价格本。"),
("功能教程", "订单全流程", "合同管理创建意向单 → 转正式单 → 下订单 → 订单详情中推进生产进度(下料/组装/玻璃/打胶/五金/质检/完工)→ 打印中心输出生产单。"),
("功能教程", "优化下料使用", "录入订单的型材需求与仓库原料,系统按首递减算法输出切割组合、余料与利用率;也可在「下料方案」按合同一键汇总。"),
("功能教程", "3D 展示与效果图", "「效果图渲染」可在场景中预览窗型并调整墙面/玻璃颜色;「3D 展示」提供可旋转的立体视图。"),
("功能教程", "分享订单给客户", "订单管理 → 「分享」→ 复制链接发给客户,客户免登录即可查看订单摘要与图纸。"),
]
+13 -2
View File
@@ -27,9 +27,9 @@ async def seed() -> None:
# 2) 分类树(来自 default_windoor_catelog.json,取目标站原始 id 便于对照) # 2) 分类树(来自 default_windoor_catelog.json,取目标站原始 id 便于对照)
cat_file = DATA / "api-samples" / "default_windoor_catelog.json" cat_file = DATA / "api-samples" / "default_windoor_catelog.json"
if cat_file.exists(): if cat_file.exists():
from sqlalchemy import select as _sel
cats = json.load(open(cat_file, encoding="utf-8"))["data"] cats = json.load(open(cat_file, encoding="utf-8"))["data"]
existing = {c.id for c in (await db.execute( existing = set((await db.execute(_sel(WindoorCatelog.id))).scalars())
__import__("sqlalchemy").select(WindoorCatelog.id))).scalars()}
for top in cats: for top in cats:
if top["id"] not in existing: if top["id"] not in existing:
db.add(WindoorCatelog(id=top["id"], name=top["name"], fid=0, company_id=1)) db.add(WindoorCatelog(id=top["id"], name=top["name"], fid=0, company_id=1))
@@ -71,6 +71,17 @@ async def seed() -> None:
await db.commit() await db.commit()
print("种子数据完成") print("种子数据完成")
# 4) 帮助文章(幂等:按标题去重)
async with SessionLocal() as db:
from huamenchuang.models.extend import Article
from huamenchuang.scripts.article_seed_data import ARTICLES
existing = {t for (t,) in (await db.execute(select(Article.title))).all()}
for i, (cat, title, content) in enumerate(ARTICLES):
if title not in existing:
db.add(Article(title=title, category=cat, content=content, sort=i))
await db.commit()
print("帮助文章就绪")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(seed()) asyncio.run(seed())