Files
pickling-mes/frontend/src/views/CostManagement.vue
wangyu 9fb3dcb785 feat(linkage): 计划-鞍座-实绩-停机联动 + 成本管理页
后端:
- 计划录入即「准备好」,队首(最早)自动「在线」(唯一)
- 新增上卷鞍座联动引擎 line_service:移动→鞍座→(有速度/投入生产)→生产中
  →带头达2000m→生产完成并自动产生实绩、持久化运行数据
- 停机自动检测:线速度为0持续>10min 自动新增待补充停机记录,恢复后自动结束
- /plan/start=移动到鞍座, 新增 /plan/{id}/commit 投入生产, /plan/saddle/current,
  /plan/seed 批量插入(轧制力模式);后台引擎循环自动推进
- 新增成本管理:CostRecord 模型 + /cost CRUD + 9 类成本项(乳化液/盐酸/碱/电/水/蒸汽…)

前端:
- 入口跟踪重构为单个上卷鞍座工位(实时速度/带头长度进度/投入生产)+待上卷卡片+队列,
  计划列表/卡片/队列均可「移动」
- 新增成本管理页(成本项切换 + 柱+线图 + 明细表 + 时间筛选 + 新增),布局参考乳化液耗量统计

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:57:59 +08:00

255 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="cost-page">
<!-- 成本项切换 -->
<div class="card">
<div class="card-body" style="padding:8px 12px;">
<div class="item-tabs">
<span
v-for="it in items"
:key="it.item"
:class="['item-tab', { active: it.item === curItem }]"
@click="selectItem(it.item)"
>{{ it.item_name }}</span>
</div>
</div>
</div>
<!-- 图表 + 记录表 -->
<div class="cost-main">
<div class="card chart-card">
<div class="card-header">{{ curMeta.item_name }}耗量统计</div>
<div class="card-body">
<v-chart v-if="records.length" class="cost-chart" :option="chartOption" autoresize />
<div v-else class="empty-tip">暂无数据请调整时间范围或点击新增录入</div>
</div>
</div>
<div class="card table-card">
<div class="card-header">记录明细 <span class="ch-badge">{{ records.length }} </span></div>
<div class="table-scroll" style="max-height:420px;">
<table class="data-table compact">
<thead>
<tr>
<th>A班量</th><th>B班量</th><th>吨耗量</th><th>记录时间</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in recordsDesc" :key="r.id">
<td class="td-num td-ok">{{ fmt(r.shift_a) }}</td>
<td class="td-num td-warn">{{ fmt(r.shift_b) }}</td>
<td class="td-num">{{ fmt(r.unit_cost) }}</td>
<td class="td-muted">{{ fmtTime(r.record_date) }}</td>
<td>
<span class="action-link" @click="openDialog(r)">编辑</span>
<span class="action-link del" @click="remove(r)">删除</span>
</td>
</tr>
<tr v-if="!records.length">
<td colspan="5" class="td-muted" style="text-align:center;padding:14px;">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 时间筛选 -->
<div class="card">
<div class="card-body" style="padding:10px 14px;">
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
<div class="flex-row">
<span class="kv-label">开始时间</span>
<input v-model="query.start_date" type="date" class="kv-input" style="width:150px;" />
</div>
<div class="flex-row">
<span class="kv-label">结束时间</span>
<input v-model="query.end_date" type="date" class="kv-input" style="width:150px;" />
</div>
<div class="flex-row" style="margin-left:auto;gap:8px;">
<button class="btn btn-primary" @click="fetchData">查找</button>
<button class="btn btn-outline" @click="openDialog()"> 新增</button>
</div>
</div>
</div>
</div>
<!-- 新增/编辑 -->
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
<div class="modal-box" style="width:420px;">
<div class="modal-header">
{{ editRow ? '编辑' : '新增' }}{{ curMeta.item_name }}记录
<span class="modal-close" @click="dialogVisible=false"></span>
</div>
<div class="modal-body">
<div class="form-field">
<div class="kv-label">记录时间 *</div>
<input v-model="form.record_date" type="datetime-local" class="kv-input" />
</div>
<div class="grid-2" style="gap:12px;margin-top:10px;">
<div class="form-field">
<div class="kv-label">A班量 ({{ curMeta.unit }})</div>
<input v-model.number="form.shift_a" type="number" step="0.01" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">B班量 ({{ curMeta.unit }})</div>
<input v-model.number="form.shift_b" type="number" step="0.01" class="kv-input" />
</div>
</div>
<div class="form-field" style="margin-top:10px;">
<div class="kv-label">吨耗量 ({{ curMeta.unit_cost_label }})</div>
<input v-model.number="form.unit_cost" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field" style="margin-top:10px;">
<div class="kv-label">备注</div>
<input v-model="form.remark" class="kv-input" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { getCostItems, getCostRecords, createCostRecord, updateCostRecord, deleteCostRecord } from '@/api'
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent])
export default {
name: 'CostManagement',
components: { VChart },
data() {
return {
items: [], curItem: '',
records: [],
query: { start_date: '', end_date: '' },
dialogVisible: false, editRow: null, saving: false,
form: this.emptyForm(),
}
},
computed: {
curMeta() { return this.items.find(x => x.item === this.curItem) || { item_name: '', unit: '', unit_cost_label: '' } },
recordsDesc() { return [...this.records].slice().reverse() },
chartOption() {
const dates = this.records.map(r => this.fmtDay(r.record_date))
const meta = this.curMeta
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['A班耗量', 'B班耗量', '吨耗量'], top: 4, textStyle: { color: '#606266' } },
grid: { left: 50, right: 56, top: 40, bottom: 40 },
xAxis: [{ type: 'category', data: dates, axisLabel: { color: '#909399' }, axisLine: { lineStyle: { color: '#dcdfe6' } } }],
yAxis: [
{ type: 'value', name: `耗量 ${meta.unit}`, nameTextStyle: { color: '#909399' }, axisLabel: { color: '#909399' }, splitLine: { lineStyle: { color: '#ebeef5' } } },
{ type: 'value', name: `吨耗 ${meta.unit_cost_label}`, nameTextStyle: { color: '#909399' }, axisLabel: { color: '#909399' }, splitLine: { show: false } },
],
series: [
{ name: 'A班耗量', type: 'bar', data: this.records.map(r => r.shift_a), itemStyle: { color: '#67C23A' }, barMaxWidth: 22 },
{ name: 'B班耗量', type: 'bar', data: this.records.map(r => r.shift_b), itemStyle: { color: '#E6A23C' }, barMaxWidth: 22 },
{ name: '吨耗量', type: 'line', yAxisIndex: 1, data: this.records.map(r => r.unit_cost), itemStyle: { color: '#C03639' }, lineStyle: { color: '#C03639', width: 2 }, symbol: 'circle', symbolSize: 7 },
],
}
},
},
async created() {
await this.fetchItems()
const today = new Date()
const past = new Date(today.getTime() - 7 * 86400000)
this.query.start_date = this.toDay(past)
this.query.end_date = this.toDay(today)
this.fetchData()
},
methods: {
emptyForm() { return { record_date: '', shift_a: null, shift_b: null, unit_cost: null, remark: '' } },
async fetchItems() {
try {
const res = await getCostItems()
this.items = res.data || []
if (this.items.length && !this.curItem) this.curItem = this.items[0].item
} catch (e) { /* ignore */ }
},
selectItem(item) { this.curItem = item; this.fetchData() },
async fetchData() {
const params = { item: this.curItem }
if (this.query.start_date) params.start_date = this.query.start_date + 'T00:00:00'
if (this.query.end_date) params.end_date = this.query.end_date + 'T23:59:59'
try {
const res = await getCostRecords(params)
this.records = res.data || []
} catch (e) { /* ignore */ }
},
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
fmtDay(t) { return t ? t.slice(5, 10) : '' },
toDay(d) { const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}` },
nowDT() { const d = new Date(); const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}` },
openDialog(row = null) {
this.editRow = row
if (row) {
this.form = { record_date: (row.record_date || '').slice(0, 16), shift_a: row.shift_a, shift_b: row.shift_b, unit_cost: row.unit_cost, remark: row.remark || '' }
} else {
this.form = this.emptyForm(); this.form.record_date = this.nowDT()
}
this.dialogVisible = true
},
async save() {
if (!this.form.record_date) { this.$message.error('记录时间不能为空'); return }
this.saving = true
try {
const d = { ...this.form }
if (d.record_date && d.record_date.length === 16) d.record_date += ':00'
if (this.editRow) await updateCostRecord(this.editRow.id, d)
else await createCostRecord({ ...d, item: this.curItem })
this.$message.success('保存成功')
this.dialogVisible = false; this.fetchData()
} catch (e) {
this.$message.error(e?.response?.data?.detail || '保存失败')
} finally { this.saving = false }
},
async remove(row) {
if (!confirm('确认删除该记录?')) return
try { await deleteCostRecord(row.id); this.$message.success('已删除'); this.fetchData() }
catch (e) { this.$message.error('删除失败') }
},
},
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.cost-page { display: flex; flex-direction: column; gap: 12px; }
.item-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.item-tab {
padding: 5px 14px; font-size: 12px; border-radius: 4px; cursor: pointer;
color: $text-secondary; border: 1px solid $border; background: $bg-card;
&:hover { color: $sms-teal; border-color: $sms-teal; }
&.active { color: #fff; background: $sms-teal; border-color: $sms-teal; }
}
.cost-main { display: grid; grid-template-columns: 3fr 2fr; gap: 12px; align-items: stretch; }
.chart-card, .table-card { display: flex; flex-direction: column; }
.cost-chart { height: 420px; width: 100%; }
.empty-tip { text-align: center; padding: 60px 20px; color: $text-muted; font-size: 13px; }
.form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; }
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } &.del { color: $accent-red; } }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 1100px) { .cost-main { grid-template-columns: 1fr; } }
</style>