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
@@ -12,6 +12,7 @@ const router = createRouter({
children: [
{ path: '', redirect: '/dashboard' },
{ 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: 'sunny', component: () => import('../views/SunnyHouseView.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[][]
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 clips: { sp: SplitLine; bb: { x0: number; y0: number; x1: number; y1: number } }[] = []
for (const sp of (shape.mm?.sp ?? []) as SplitLine[]) {
+18 -22
View File
@@ -2,29 +2,25 @@
<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 style="margin-top:16px" v-loading="loading">
<el-collapse-item v-for="a in articles" :key="a.id" :title="a.title">
<p>{{ a.content }}</p>
</el-collapse-item>
</el-collapse>
</el-card>
</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"
text-color="#bfc9d4" active-text-color="#ffffff">
<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="/sunny"><el-icon><OfficeBuilding /></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>
<el-card>
<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-option v-for="c in catelogs" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-input v-model="name" placeholder="模板名称" clearable style="width:200px" @keyup.enter="load" />
<el-button type="primary" @click="load">查询</el-button>
<div style="flex:1" />
<span class="count">共 {{ total }} 套官方样板</span>
<span class="count">共 {{ total }} 套{{ tab === 'mine' ? '我的' : '官方' }}样板</span>
</div>
<div v-loading="loading" class="grid">
<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 editRef = ref()
const liveTotal = ref<number | null>(null)
const tab = ref<'official' | 'mine'>('official')
let quoteTimer: number | undefined
// 画布变化(改尺寸/加分格)→ 防抖自动重算报价
@@ -90,7 +95,7 @@ function onCanvasChange() {
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&page_size=${page_size}&name=${name.value}${catelogId.value ? `&catelog_id=${catelogId.value}` : ''}`
const 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}`)
rows.value = d
// 接口无分页元数据时按返回量估计