feat: 前后端全量开发 v0.2

后端:
- 客户模块 CRUD+搜索 (api/services/repositories 分层)
- 合同模块: FG单号规则/意向单转正式单/订单创建自动快照客户信息
- 优化下料: FFD首递减装箱算法(利用率统计/余料/未排入清单)
前端 (Vue3+TS+Element Plus):
- 7页面: 登录/工作台/订单中心/合同/客户/样板库/优化下料
- 自研二维窗型画布渲染器: 解析drawing_data渲染玻璃区域/外框/
  扇开向标记/尺寸标注, 支持缩放平移复位
- 样板库与订单联动(用此样板下单自动带图纸)
- axios统一envelope拦截/登录守卫/付费墙提示
工程:
- SPA history 回退路由, 构建产物挂载后端
- 全接口回归 6/6 通过
This commit is contained in:
HHY
2026-09-20 08:46:04 -07:00
parent d3e6de585c
commit f7a555dd6c
35 changed files with 3702 additions and 10 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>华门窗云平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1886
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "huamenchuang-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
+8
View File
@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif; }
</style>
+43
View File
@@ -0,0 +1,43 @@
/** axios 实例:自动携带 token、统一处理 401/付费墙 */
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
export const http = axios.create({ baseURL: '/', timeout: 30000 })
http.interceptors.request.use(cfg => {
const token = localStorage.getItem('hmc_token')
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
http.interceptors.response.use(
res => {
const d = res.data
if (d && typeof d === 'object' && 'code' in d) {
if (d.code === 1) return res
if (String(d.code) === '10086') {
ElMessage.warning(d.message ?? '该功能需要购买解锁')
return Promise.reject(new Error(d.message))
}
if (Number(d.code) === 401) {
localStorage.removeItem('hmc_token')
router.push('/login')
return Promise.reject(new Error(d.message))
}
ElMessage.error(d.message ?? '操作失败')
return Promise.reject(new Error(d.message))
}
return res
},
err => {
ElMessage.error(err.message ?? '网络错误')
return Promise.reject(err)
},
)
/** 提取 envelope 的 data */
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
const res = await http.request({ method, url, data: body })
return res.data.data as T
}
+232
View File
@@ -0,0 +1,232 @@
<template>
<div class="canvas-wrap" ref="wrapRef">
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset" />
<div class="hud">
<span v-if="info">{{ info }}</span>
<el-button size="small" text @click="reset">重置视图</el-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 二维窗型画布渲染器(自研实现)
* 读取 shapeManager 序列化的 drawing_data:Frame→玻璃区域多边形 + 扇开向 + 尺寸标注
* 支持滚轮缩放、拖拽平移、双击复位
*/
import { onMounted, ref, watch } from 'vue'
import {
bboxOf, parseDrawing, regionPoints,
type DrawingData, type FrameShape, type Pt, type SashShape,
} from '../types/drawing'
const props = defineProps<{ drawingData: string | object }>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const info = ref('')
let view = { scale: 1, cx: 0, cy: 0 } // cx/cy=模型坐标中心
let drag: { x: number; y: number } | null = null
let dd: DrawingData | null = null
let modelBox = { x0: 0, y0: 0, x1: 100, y1: 100 }
function render() {
const cv = cvRef.value, wrap = wrapRef.value
if (!cv || !wrap) return
const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr
const ctx = cv.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, cv.width, cv.height)
if (!dd) return
const W = wrap.clientWidth, H = wrap.clientHeight
// 自适应:模型坐标 → 屏幕(Y 轴翻转)
const pad = 48
const bw = modelBox.x1 - modelBox.x0, bh = modelBox.y1 - modelBox.y0
const fit = Math.min((W - pad * 2) / (bw || 1), (H - pad * 2) / (bh || 1))
const scale = fit * view.scale
const midX = (modelBox.x0 + modelBox.x1) / 2, midY = (modelBox.y0 + modelBox.y1) / 2
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
const ty = (y: number) => H / 2 - (y - midY) * scale + view.cy
// 全局样式
const glass = '#d3ddda', glassAlpha = 'rgba(211,221,218,0.6)'
const profile = '#5c6166'
ctx.lineJoin = 'round'
for (const shape of dd.sm) drawFrame(ctx, shape, tx, ty, { glass, glassAlpha, profile })
info.value = dd.sm.length > 1 ? `窗型 · ${dd.sm.length} 个窗体组合` : '窗型'
}
function drawFrame(
ctx: CanvasRenderingContext2D, shape: FrameShape,
tx: (x: number) => number, ty: (y: number) => number,
color: { glass: string; glassAlpha: string; profile: string },
) {
const regionsRaw = shape.polygon?.polygon ?? []
const regions = regionsRaw.map(regionPoints).filter(pts => pts.length >= 3)
const cm = shape.cm ?? {}
// ---- 玻璃区域 ----
for (const pts of regions) {
ctx.beginPath()
pts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = cm.g ?? color.glassAlpha
ctx.fill()
ctx.strokeStyle = cm.bn ?? color.profile
ctx.lineWidth = 1.6
ctx.stroke()
// 内描边示意型材厚度
ctx.strokeStyle = 'rgba(255,255,255,0.65)'
ctx.lineWidth = 0.8
ctx.stroke()
}
// ---- 外框(全部区域的联合包围盒,逐段加粗)----
if (regions.length) {
const all = regions.flat()
const bb = bboxOf(all)
const x0 = tx(bb.x0), y0 = ty(bb.y1), x1 = tx(bb.x1), y1 = ty(bb.y0)
const ew = shape.fm?.ew ?? [60, 60, 60, 60]
const avg = ew.reduce((a, b) => a + b, 0) / ew.length
ctx.strokeStyle = cm.bn ?? color.profile
ctx.lineWidth = 3.2
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0)
ctx.strokeStyle = 'rgba(255,255,255,0.5)'
ctx.lineWidth = 1
const inset = Math.max(2.5, avg * 0.018)
ctx.strokeRect(x0 + inset, y0 + inset, (x1 - x0) - inset * 2, (y1 - y0) - inset * 2)
void glass // 保留引用
}
// ---- 扇(开向标记)----
for (const sub of (shape.sm ?? []) as SashShape[]) {
const bd = sub.bd
if (!bd || bd.hidden) continue
const bx0 = tx(Math.min(bd.from.x, bd.to.x)), bx1 = tx(Math.max(bd.from.x, bd.to.x))
const by0 = ty(Math.max(bd.from.y, bd.to.y)), by1 = ty(Math.min(bd.from.y, bd.to.y))
const w = bx1 - bx0, h = by1 - by0
if (w < 4 || h < 4) continue
ctx.save()
// 扇框(虚线内框)
ctx.setLineDash([5, 4])
ctx.strokeStyle = 'rgba(70,76,82,0.9)'
ctx.lineWidth = 1.3
ctx.strokeRect(bx0 + 2, by0 + 2, w - 4, h - 4)
ctx.setLineDash([])
// 开向三角:od 为角度(度),屏幕系 y 翻转
const ang = ((sub.od ?? 0) * Math.PI) / 180
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
const L = Math.min(w, h) * 0.32
const ax = cx + Math.cos(ang) * L, ay = cy - Math.sin(ang) * L
// 垂直方向的两翼
const wx = Math.cos(ang + Math.PI / 2) * L * 0.38
const wy = -Math.sin(ang + Math.PI / 2) * L * 0.38
ctx.beginPath()
ctx.moveTo(ax, ay)
ctx.lineTo(cx - wx, cy - wy)
ctx.lineTo(cx + wx, cy + wy)
ctx.closePath()
ctx.fillStyle = 'rgba(70,76,82,0.25)'
ctx.fill()
ctx.strokeStyle = 'rgba(70,76,82,0.95)'
ctx.lineWidth = 1.4
ctx.stroke()
if (sub.isDoor) {
ctx.fillStyle = 'rgba(70,76,82,0.85)'
ctx.font = '10px sans-serif'
ctx.fillText('门', cx - 6, by1 - 6)
}
ctx.restore()
}
// ---- 总尺寸标注(宽/高)----
if (regions.length) {
const all = regions.flat()
const bb = bboxOf(all)
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y1), y1 = ty(bb.y0)
const wmm = Math.round(bb.x1 - bb.x0), hmm = Math.round(bb.y1 - bb.y0)
ctx.fillStyle = '#7a8087'
ctx.font = '11px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${wmm} mm`, (x0 + x1) / 2, y1 + 16)
ctx.save()
ctx.translate(x0 - 14, (y0 + y1) / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText(`${hmm} mm`, 0, 0)
ctx.restore()
ctx.strokeStyle = 'rgba(122,128,135,0.6)'
ctx.lineWidth = 1
// 尺寸线
ctx.beginPath()
ctx.moveTo(x0, y1 + 6); ctx.lineTo(x1, y1 + 6)
ctx.moveTo(x0 - 6, y0); ctx.lineTo(x0 - 6, y1)
ctx.stroke()
}
}
function computeModelBox() {
if (!dd) return
const pts: Pt[] = []
for (const s of dd.sm) {
for (const r of (s.polygon?.polygon ?? [])) {
for (const seg of r) { pts.push(seg.ps); pts.push(seg.pe) }
}
// 扇包围盒也计入
for (const sub of (s.sm ?? []) as SashShape[]) {
if (sub.bd) { pts.push(sub.bd.from, sub.bd.to) }
}
}
if (!pts.length) { modelBox = { x0: 0, y0: 0, x1: 1000, y1: 1000 }; return }
modelBox = bboxOf(pts)
}
function reset() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
function onWheel(e: WheelEvent) {
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
view.scale = Math.min(40, Math.max(0.2, view.scale * factor))
render()
}
function onDown(e: MouseEvent) { drag = { x: e.clientX, y: e.clientY } }
function onMove(e: MouseEvent) {
if (!drag) return
view.cx += e.clientX - drag.x
view.cy += e.clientY - drag.y
drag = { x: e.clientX, y: e.clientY }
render()
}
function onUp() { drag = null }
onMounted(() => {
watch(() => props.drawingData, () => {
try {
dd = props.drawingData ? parseDrawing(props.drawingData) : null
computeModelBox()
reset()
} catch (e) {
info.value = '数据解析失败'
dd = null
render()
}
}, { immediate: true })
window.addEventListener('resize', render)
})
</script>
<style scoped>
.canvas-wrap { position: relative; width: 100%; height: 100%; min-height: 320px;
background: repeating-conic-gradient(#f7f8f9 0% 25%, #eef0f2 0% 50%) 50%/24px 24px; }
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
canvas:active { cursor: grabbing; }
.hud { position: absolute; right: 10px; top: 10px; display: flex; gap: 8px; align-items: center;
background: rgba(255,255,255,0.85); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: #606266; }
</style>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
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')
+31
View File
@@ -0,0 +1,31 @@
/** 路由:登录守卫 + 主布局 */
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('../views/LoginView.vue') },
{
path: '/',
component: () => import('../views/LayoutView.vue'),
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: '工作台' } },
{ path: 'orders', component: () => import('../views/OrdersView.vue'), meta: { title: '订单管理' } },
{ path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } },
{ path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } },
{ path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } },
{ path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } },
],
},
],
})
router.beforeEach(to => {
const auth = useAuthStore()
if (to.path !== '/login' && !auth.isLogin) return '/login'
return true
})
export default router
+24
View File
@@ -0,0 +1,24 @@
/** 认证状态 */
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('hmc_token') ?? '',
nickname: localStorage.getItem('hmc_nickname') ?? '',
}),
getters: { isLogin: s => !!s.token },
actions: {
setSession(token: string, nickname: string) {
this.token = token
this.nickname = nickname
localStorage.setItem('hmc_token', token)
localStorage.setItem('hmc_nickname', nickname)
},
logout() {
this.token = ''
this.nickname = ''
localStorage.removeItem('hmc_token')
localStorage.removeItem('hmc_nickname')
},
},
})
+106
View File
@@ -0,0 +1,106 @@
/** drawing_data 数据模型类型(与采集的 shapeManager 序化格式同构) */
export interface Pt { x: number; y: number; name?: string }
export interface Vec { x: number; y: number; name?: string }
export interface Segment { ps: Pt; pe: Pt; name?: string }
/** 中梃分割线 */
export interface SplitLine {
line: { pt: Pt; norm: Vec; name?: string }
polyId?: { idx: number; pos: number }
alignType?: number
equalSplit?: number
kw?: boolean // 竖撑
ws?: boolean // 窗撑
reinforced?: boolean
db?: boolean // hidden/disabled
}
/** 尺寸标注 */
export interface Dim {
at: number; ds: boolean
ov?: Vec
eidx?: number // 绑定外框边
midx?: number // 绑定中梃
name?: string
type?: 'line' | 'mullion' | string
}
export interface FrameModel {
ew?: [number, number, number, number] // 四边料宽
vs?: Pt[]
db?: unknown[]
ejw?: number[]
pew?: unknown[]
ast?: unknown[]
}
export interface ColorMap {
g?: string // 玻璃填充
bn?: string // 边框
hd?: string // 高光
ben?: string
}
export interface SashShape {
type: 'Sash' | string
bd?: { from: Pt; to: Pt; hidden?: boolean } // 扇所在区域包围盒
od?: number // 开向角度
opened?: boolean
wtf?: boolean // 带转框
isDoor?: boolean
polyId?: { idx: number; pos: number }
enabled?: boolean
rid?: string
[k: string]: unknown
}
export interface FrameShape {
type: 'Frame' | string
polygon?: {
type?: string
polygon?: Segment[][] // 分割后的玻璃区域(每区域为闭合段列表)
spLine?: SplitLine
ep?: string[]
virtual?: boolean
}
fm?: FrameModel
mm?: { sp: SplitLine[]; dbs?: unknown[]; fls?: unknown[]; phs?: unknown[]; lpfs?: unknown[] }
dm?: { dims: Dim[] }
cm?: ColorMap
sm?: SashShape[] // 子形状(扇)
ps?: Record<string, number>
uid?: string
titl?: string
[k: string]: unknown
}
export interface DrawingData {
s?: { cs?: number; cjs?: number }
cp?: unknown[]
sm: FrameShape[]
epi?: unknown[]
sbf?: unknown[]
wlc?: string
wall?: unknown[]
scale?: number
note?: unknown[]
}
/** 解析 drawing_data(兼容对象或 JSON 字符串) */
export function parseDrawing(raw: string | object): DrawingData {
const dd = typeof raw === 'string' ? JSON.parse(raw) : raw
return dd as DrawingData
}
/** 区域多边形 → 点列(段首点顺序连接) */
export function regionPoints(region: Segment[]): Pt[] {
return region.map(seg => ({ x: seg.ps.x, y: seg.ps.y }))
}
/** 点列包围盒 */
export function bboxOf(points: Pt[]): { x0: number; y0: number; x1: number; y1: number } {
const xs = points.map(p => p.x)
const ys = points.map(p => p.y)
return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }
}
+131
View File
@@ -0,0 +1,131 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="合同号/客户" clearable style="width:220px" @keyup.enter="load" />
<el-select v-model="status" placeholder="全部状态" clearable style="width:130px">
<el-option :value="0" label="意向单" /><el-option :value="1" label="正式单" />
<el-option :value="2" label="已完成" /><el-option :value="3" 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="contract_no" label="合同号" width="160" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="count" label="数量" width="70" />
<el-table-column prop="money" label="金额" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'info' : row.status === 1 ? 'primary' : 'success'" size="small">
{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 0" size="small" type="primary"
@click="convert(row)">转正式单</el-button>
<el-button size="small" @click="createOrder(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="520px">
<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-input v-model="form.customer_name" /></el-form-item>
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="数量"><el-input-number v-model="form.count" :min="0" /></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 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 { 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 status = ref<number | undefined>()
const loading = ref(false)
const createVisible = ref(false)
const customerOptions = ref<any[]>([])
const form = reactive({ customer_id: undefined as number | undefined, customer_name: '', phone: '', address: '', count: 0, money: 0, note: '' })
async function load() {
loading.value = true
try {
const d = await api<any>('get', `/api/contracts?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}${status.value !== undefined ? `&status=${status.value}` : ''}`)
rows.value = d.contracts.data
total.value = d.contracts.total
} finally { loading.value = false }
}
function openCreate() {
Object.assign(form, { customer_id: undefined, customer_name: '', phone: '', address: '', count: 0, money: 0, note: '' })
api<any>('get', '/api/customers?page=1&page_size=100').then(d => (customerOptions.value = d.data))
createVisible.value = true
}
function onCustomer(cid: number | undefined) {
const c = customerOptions.value.find(x => x.id === cid)
if (c) { form.customer_name = c.name; form.phone = c.phone; form.address = c.address }
}
async function save() {
await api('post', '/api/contracts', form)
ElMessage.success('合同已创建')
createVisible.value = false
load()
}
async function convert(row: any) {
await api('post', '/api/contracts/convert', { contract_id: row.id })
ElMessage.success('已转为正式单')
load()
}
async function createOrder(row: any) {
await ElMessageBox.confirm(`为合同 ${row.contract_no} 创建订单?`, '提示')
await api('post', '/api/orders', { contract_id: row.id, money: row.money, count: row.count })
ElMessage.success('订单已创建,可到订单管理查看')
router.push('/orders')
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除合同 ${row.contract_no}?`, '提示', { type: 'warning' })
await api('delete', `/api/contracts/${row.id}`)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<el-card>
<div class="toolbar">
<el-input v-model="keyword" placeholder="姓名/电话/地址" clearable style="width:240px" @keyup.enter="load" />
<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="id" label="ID" width="70" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="phone" label="电话" width="140" />
<el-table-column prop="address" label="地址" min-width="180" />
<el-table-column prop="salesmen" label="业务员" width="100" />
<el-table-column prop="created_at" label="创建时间" width="170" />
<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-input v-model="form.phone" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></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 loading = ref(false)
const editVisible = ref(false)
const form = reactive({ id: 0, name: '', phone: '', address: '', note: '' })
async function load() {
loading.value = true
try {
const d = await api<any>('get', `/api/customers?page=${page.value}&page_size=${page_size}&keyword=${keyword.value}`)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
function openEdit(row?: any) {
Object.assign(form, { id: row?.id ?? 0, name: row?.name ?? '', phone: row?.phone ?? '', address: row?.address ?? '', note: row?.note ?? '' })
editVisible.value = true
}
async function save() {
if (form.id) await api('put', `/api/customers/${form.id}`, form)
else await api('post', '/api/customers', form)
ElMessage.success('已保存')
editVisible.value = false
load()
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除客户「${row.name}」?`, '提示', { type: 'warning' })
await api('delete', `/api/customers/${row.id}`)
ElMessage.success('已删除')
load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div>
<el-row :gutter="16">
<el-col :span="6" v-for="card in cards" :key="card.label">
<el-card shadow="hover">
<div class="stat"><span class="num">{{ card.value }}</span><span class="label">{{ card.label }}</span></div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top:16px">
<template #header>快捷入口</template>
<el-space wrap>
<el-button type="primary" @click="$router.push('/templates')">浏览样板库</el-button>
<el-button @click="$router.push('/orders')">新建订单</el-button>
<el-button @click="$router.push('/contracts')">录入合同</el-button>
<el-button @click="$router.push('/optimize')">优化下料</el-button>
</el-space>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
const cards = ref([
{ label: '订单总数', value: '-' },
{ label: '意向单', value: '-' },
{ label: '客户数', value: '-' },
{ label: '合同数', value: '-' },
])
onMounted(async () => {
const [orders, customers, contracts] = await Promise.all([
api<any>('post', '/api/orders/search', { page: 1, page_size: 1 }),
api<any>('get', '/api/customers?page=1&page_size=1'),
api<any>('get', '/api/contracts?page=1&page_size=1'),
])
cards.value[0].value = orders.total ?? 0
cards.value[2].value = customers.total ?? 0
cards.value[3].value = contracts.contracts?.total ?? 0
const intentions = await api<any>('post', '/api/orders/search', { page: 1, page_size: 1, status: 0 })
cards.value[1].value = intentions.total ?? 0
})
</script>
<style scoped>
.stat { display: flex; flex-direction: column; align-items: center; padding: 8px 0; }
.num { font-size: 30px; font-weight: 700; color: #35506b; }
.label { color: #909399; font-size: 13px; margin-top: 4px; }
</style>
+50
View File
@@ -0,0 +1,50 @@
<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-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="/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="/templates"><el-icon><Grid /></el-icon>样板库</el-menu-item>
<el-menu-item index="/optimize"><el-icon><Scissor /></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()">
<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-menu></template>
</el-dropdown>
</el-header>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
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; }
.el-menu { border-right: none; }
.header { display: flex; justify-content: space-between; align-items: center;
background: #fff; border-bottom: 1px solid #e4e7ed; }
.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; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="login-page">
<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-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ phone_number: '', password: '' })
async function doLogin() {
if (!form.phone_number || !form.password) return ElMessage.warning('请输入手机号和密码')
loading.value = true
try {
const d = await api<{ token: string; user: { nickname: string } }>('post', '/api/auth/login', form)
auth.setSession(d.token, d.user?.nickname ?? '')
router.push('/dashboard')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center;
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; }
.tip { color: #c0c4cc; font-size: 12px; margin-top: 14px; }
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<el-row :gutter="16">
<el-col :span="12">
<el-card header="型材需求(每件长度 × 数量)">
<div v-for="(d, i) in demands" :key="i" class="row">
<el-input v-model="d.label" placeholder="用途" style="width:130px" />
<el-input-number v-model="d.length" :min="1" placeholder="长度mm" controls-position="right" />
<el-input-number v-model="d.qty" :min="1" placeholder="数量" controls-position="right" />
<el-button text type="danger" @click="demands.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="demands.push({ label: '', length: 1000, qty: 1 })">+ 添加需求</el-button>
</el-card>
</el-col>
<el-col :span="12">
<el-card header="库存原料">
<div v-for="(s, i) in stocks" :key="i" class="row">
<el-input v-model="s.label" placeholder="名称" style="width:130px" />
<el-input-number v-model="s.length" :min="1" placeholder="长度mm" controls-position="right" />
<el-input-number v-model="s.qty" :min="1" placeholder="根数" controls-position="right" />
<el-button text type="danger" @click="stocks.splice(i, 1)">删</el-button>
</div>
<el-button text type="primary" @click="stocks.push({ label: '', length: 6000, qty: 1 })">+ 添加原料</el-button>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top:16px">
<div class="row">
<span>锯口损耗:</span>
<el-input-number v-model="kerf" :min="0" :max="50" controls-position="right" />
<el-button type="primary" :loading="loading" @click="run">开始优化</el-button>
</div>
<template v-if="result">
<el-descriptions :column="4" border style="margin-top:14px">
<el-descriptions-item label="总件数">{{ result.stats.total_pieces }}</el-descriptions-item>
<el-descriptions-item label="用料根数">{{ result.stats.stock_bars_used }}</el-descriptions-item>
<el-descriptions-item label="利用率">{{ (result.stats.utilization * 100).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="未排入">{{ result.unplaced.length }}</el-descriptions-item>
</el-descriptions>
<el-table :data="result.plans" style="margin-top:14px" border>
<el-table-column label="原料" width="200">
<template #default="{ row }">{{ row.stock_label }}({{ row.stock_length }}mm)</template>
</el-table-column>
<el-table-column label="切割组合" min-width="300">
<template #default="{ row }">
<el-tag v-for="(c, i) in row.pieces" :key="i" size="small" style="margin: 2px">{{ c.label || '件' }} {{ c.length }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="waste" label="余料mm" width="100" />
</el-table>
</template>
</el-card>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
const demands = reactive([{ label: '边框', length: 1200, qty: 3 }, { label: '中梃', length: 800, qty: 2 }])
const stocks = reactive([{ label: '6米型材', length: 6000, qty: 2 }])
const kerf = ref(5)
const loading = ref(false)
const result = ref<any>(null)
async function run() {
loading.value = true
try {
result.value = await api<any>('post', '/api/optimize1d', { demands, stocks, kerf: kerf.value })
ElMessage.success(`优化完成,利用率 ${(result.value.stats.utilization * 100).toFixed(1)}%`)
} finally { loading.value = false }
}
</script>
<style scoped>
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div>
<el-card>
<div class="toolbar">
<el-input v-model="query.keyword" placeholder="订单号/客户/手机号" clearable style="width:240px"
@keyup.enter="load" />
<el-select v-model="query.status" placeholder="全部状态" clearable style="width:140px">
<el-option v-for="(label, val) in statusLabels" :key="val" :label="label" :value="Number(val)" />
</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="order_no" label="订单号" width="160" />
<el-table-column prop="customer_name" label="客户" min-width="110" />
<el-table-column prop="phone" label="电话" width="130" />
<el-table-column prop="count" label="数量" width="70" />
<el-table-column prop="area" label="面积㎡" width="90" />
<el-table-column prop="money" label="金额" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="tagType(row.status)" size="small">{{ row.status_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">图纸</el-button>
<el-button v-for="act in nextActions(row.status)" :key="act.to" size="small"
:type="act.type" @click="changeStatus(row, act.to)">{{ act.label }}</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="query.page_size" v-model:current-page="query.page" @current-change="load" />
</el-card>
<!-- 新建订单 -->
<el-dialog v-model="createVisible" title="新建订单" width="560px">
<el-form :model="createForm" label-width="90px">
<el-form-item label="关联合同">
<el-select v-model="createForm.contract_id" placeholder="选择合同(可选)" clearable filterable style="width:100%">
<el-option v-for="c in contractOptions" :key="c.id"
:label="`${c.contract_no} ${c.customer_name}`" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="选择样板">
<el-select v-model="createForm.template_id" placeholder="从样板库带入图纸(可选)" clearable filterable
style="width:100%" @change="onTemplateChange">
<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-input-number v-model="createForm.count" :min="0" /></el-form-item>
<el-form-item label="面积㎡"><el-input-number v-model="createForm.area" :min="0" :precision="2" /></el-form-item>
<el-form-item label="金额"><el-input-number v-model="createForm.money" :min="0" :precision="2" /></el-form-item>
<el-form-item label="备注"><el-input v-model="createForm.note" type="textarea" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建订单</el-button>
</template>
</el-dialog>
<!-- 图纸详情 -->
<el-drawer v-model="detailVisible" size="62%" :title="detail?.order_no ?? '图纸'">
<div v-if="detail" class="detail">
<div class="meta">
<el-tag>{{ detail.status_label }}</el-tag>
<span>客户:{{ detail.customer_name || '-' }}</span>
<span>金额:{{ detail.money }}</span>
<span>面积:{{ detail.area }}㎡</span>
</div>
<div class="canvas-box">
<WindoorCanvas :drawing-data="detail.drawing_data" />
</div>
<el-divider>生产进度</el-divider>
<el-steps :active="processActive" align-center>
<el-step v-for="p in detail.processes" :key="p.id" :title="p.name"
:status="p.status === 2 ? 'success' : p.status === 1 ? 'process' : 'wait'" />
</el-steps>
<div style="margin-top:12px">
<el-select v-model="processForm.name" style="width:120px">
<el-option v-for="p in detail.processes" :key="p.name" :label="p.name" :value="p.name" />
</el-select>
<el-select v-model="processForm.status" style="width:110px">
<el-option :value="0" label="未开始" /><el-option :value="1" label="进行中" /><el-option :value="2" label="完成" />
</el-select>
<el-button type="primary" @click="updateProcess">更新进度</el-button>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
const route = useRoute()
const statusLabels: Record<number, string> = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' }
const query = reactive({ page: 1, page_size: 15, keyword: '', status: undefined as number | undefined })
const rows = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const d = await api<any>('post', '/api/orders/search', query)
rows.value = d.data
total.value = d.total
} finally { loading.value = false }
}
// 状态机允许的下一步动作(与后端 TRANSITIONS 对齐)
const transitions: Record<number, { to: number; label: string; type: string }[]> = {
0: [{ to: 1, label: '转正式', type: 'primary' }, { to: 4, label: '取消', type: 'warning' }],
1: [{ to: 2, label: '排产', type: 'primary' }, { to: 4, label: '取消', type: 'warning' }],
2: [{ to: 3, label: '完工', type: 'success' }],
}
function nextActions(status: number) { return transitions[status] ?? [] }
function tagType(status: number) {
return ({ 0: 'info', 1: 'primary', 2: 'warning', 3: 'success', 4: 'danger' } as const)[
status as 0 | 1 | 2 | 3 | 4] ?? 'info'
}
async function changeStatus(row: any, to: number) {
await api('put', `/api/orders/${row.id}`, { action: 'change_status', target_status: to })
ElMessage.success('状态已更新')
load()
}
async function remove(row: any) {
await ElMessageBox.confirm(`确定删除订单 ${row.order_no}?`, '提示', { type: 'warning' })
await api('delete', `/api/orders/${row.id}`)
ElMessage.success('已删除')
load()
}
// ---- 新建 ----
const createVisible = ref(false)
const saving = ref(false)
const createForm = reactive({ contract_id: undefined as number | undefined, template_id: undefined as number | undefined, count: 1, area: 0, money: 0, note: '', drawing_data: '', pic_url: '' })
const contractOptions = ref<any[]>([])
const templateOptions = ref<any[]>([])
async function openCreate() {
createVisible.value = true
const [cs, ts] = await Promise.all([
api<any>('get', '/api/contracts?page=1&page_size=100'),
api<any>('get', '/api/windoor/templates?page=1&page_size=254'),
])
contractOptions.value = cs.contracts?.data ?? []
templateOptions.value = ts
// 样板库"用此样板下单"跳转带入
const tid = Number(route.query.template)
if (tid) {
createForm.template_id = tid
await onTemplateChange(tid)
}
}
async function onTemplateChange(tid: number | undefined) {
createForm.drawing_data = ''
createForm.pic_url = ''
if (!tid) return
const t = await api<any>('get', `/api/windoor/templates/${tid}`)
createForm.drawing_data = t.drawing_data
createForm.pic_url = t.pic_url
}
async function save() {
saving.value = true
try {
const { template_id: _t, ...payload } = createForm
await api('post', '/api/orders', payload)
ElMessage.success('订单已创建')
createVisible.value = false
load()
} finally { saving.value = false }
}
// ---- 详情抽屉 ----
const detailVisible = ref(false)
const detail = ref<any>(null)
const processForm = reactive({ name: '下料', status: 1 })
const processActive = ref(0)
async function openDetail(row: any) {
detail.value = await api<any>('get', `/api/orders/${row.id}`)
detailVisible.value = true
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
}
async function updateProcess() {
await api('post', `/api/orders/${detail.value.id}/process`, processForm)
ElMessage.success('进度已更新')
detail.value = await api<any>('get', `/api/orders/${detail.value.id}`)
processActive.value = detail.value.processes.filter((p: any) => p.status === 2).length
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.detail .meta { display: flex; gap: 16px; color: #606266; font-size: 13px; margin-bottom: 10px; align-items: center; }
.canvas-box { height: 440px; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div>
<el-card>
<div class="toolbar">
<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>
</div>
<div v-loading="loading" class="grid">
<div v-for="t in rows" :key="t.id" class="item" @click="preview(t)">
<el-image :src="t.pic_url" fit="contain" lazy class="pic" />
<div class="cap">{{ t.name || `样板#${t.id}` }}</div>
</div>
</div>
<el-pagination style="margin-top:12px; justify-content:center" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</el-card>
<el-dialog v-model="previewVisible" :title="current?.name || `样板#${current?.id}`" width="72%" top="4vh">
<div v-if="current" class="preview">
<div class="canvas-box">
<WindoorCanvas :drawing-data="current.drawing_data" />
</div>
<div class="meta">
<span>尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm</span>
<span>引用次数: {{ current.times }}</span>
<el-button type="primary" size="small" @click="useInOrder">用此样板下单</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
import WindoorCanvas from '../components/WindoorCanvas.vue'
const router = useRouter()
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const page_size = 48
const name = ref('')
const catelogId = ref<number | undefined>()
const catelogs = ref<any[]>([])
const loading = ref(false)
const previewVisible = ref(false)
const current = ref<any>(null)
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 d = await api<any>('get', `/api/windoor/templates?${qs}`)
rows.value = d
// 接口无分页元数据时按返回量估计
total.value = d.length === page_size ? (page.value - 1) * page_size + d.length + 1 : (page.value - 1) * page_size + d.length
} finally { loading.value = false }
}
async function preview(t: any) {
current.value = await api<any>('get', `/api/windoor/templates/${t.id}`)
previewVisible.value = true
}
function useInOrder() {
router.push({ path: '/orders', query: { template: current.value.id } })
}
onMounted(async () => {
const tree = await api<any[]>('get', '/api/windoor/catelogs')
catelogs.value = tree.flatMap(t => [t, ...((t.children ?? []) as any[])])
load()
})
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; }
.count { color: #909399; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; min-height: 200px; }
.item { border: 1px solid #e4e7ed; border-radius: 8px; padding: 8px; cursor: pointer; background: #fff;
transition: box-shadow .2s; display: flex; flex-direction: column; align-items: center; }
.item:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.pic { width: 100%; height: 110px; }
.cap { font-size: 12px; color: #606266; margin-top: 6px; text-align: center; }
.preview .canvas-box { height: 60vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
.preview .meta { display: flex; gap: 20px; margin-top: 10px; color: #606266; font-size: 13px; align-items: center; }
</style>
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 开发时 /api 代理到后端 8000;构建产物输出 dist 由后端挂载
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/static': { target: 'http://localhost:8000', changeOrigin: true },
},
},
build: { outDir: 'dist', emptyOutDir: true },
})