feat(plan): 计划详细面板 + 来料重量/外径/分卷重量 + 在线/生产中状态机 + 入口移动 + 上次模板回填

- backend: plan 增加 incoming_weight/incoming_od/split_weights(JSON) 字段及迁移
- backend: GET /plan/last-template 返回最近一条计划的工艺字段用于新增回填(多端共享)
- backend: PATCH /plan/{id}/start 设为 producing,强制单卷在产(其他 producing 回退 online)
- backend: 生成实绩时按卷号自动把对应计划状态置为 produced
- frontend: 新增计划默认状态 online;新增时调用 last-template 自动回填
- frontend: Plan 表格行点击展开 计划详细 面板(按截图布局)
- frontend: Plan 行操作增加「移动」(ready/online → producing)
- frontend: 物料跟踪页加 在线计划队列 + 入口移动按钮,显示当前生产中卷
- frontend: 计划弹窗新增 轧制模式/来料重量/来料外径/1-6#分卷重量
This commit is contained in:
2026-06-21 23:42:22 +08:00
parent db3945c263
commit 9cf422ef0d
8 changed files with 326 additions and 18 deletions

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from app.database import get_db from app.database import get_db
from app.models.plan import ProductionPlan from app.models.plan import ProductionPlan
from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut from app.schemas.plan import PlanCreate, PlanUpdate, PlanOut, PlanTemplate
from app.schemas.common import Response, PageResponse from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
@@ -22,6 +22,16 @@ def _parse_dt(s):
return None return None
TEMPLATE_FIELDS = (
"steel_grade", "incoming_thickness", "product_thickness",
"deviation_upper", "deviation_lower",
"incoming_width", "product_width",
"packaging_req", "trim_req", "rolling_mode",
"coil_diameter", "split_count", "next_process",
"incoming_weight", "incoming_od", "split_weights",
)
@router.get("/", response_model=Response[PageResponse[PlanOut]]) @router.get("/", response_model=Response[PageResponse[PlanOut]])
async def list_plans( async def list_plans(
page: int = 1, page: int = 1,
@@ -48,6 +58,16 @@ async def list_plans(
return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items)) return Response.ok(PageResponse(total=total, page=page, page_size=page_size, items=items))
@router.get("/last-template", response_model=Response[PlanTemplate])
async def get_last_template(db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
"""最近一条计划的工艺字段(不含计划号/卷号/时间),用于新增时回填。"""
result = await db.execute(select(ProductionPlan).order_by(desc(ProductionPlan.created_at)).limit(1))
p = result.scalar_one_or_none()
if not p:
return Response.ok(PlanTemplate())
return Response.ok(PlanTemplate(**{k: getattr(p, k, None) for k in TEMPLATE_FIELDS}))
@router.post("/", response_model=Response[PlanOut]) @router.post("/", response_model=Response[PlanOut])
async def create_plan( async def create_plan(
body: PlanCreate, body: PlanCreate,
@@ -98,3 +118,24 @@ async def confirm_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Dep
plan.status = "online" plan.status = "online"
await db.flush() await db.flush()
return Response.ok(PlanOut.model_validate(plan)) return Response.ok(PlanOut.model_validate(plan))
@router.patch("/{plan_id}/start", response_model=Response[PlanOut])
async def start_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
"""移动到入口开始生产本条→producing其它 producing→online单卷在产"""
result = await db.execute(select(ProductionPlan).where(ProductionPlan.id == plan_id))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="计划不存在")
# 其它正在生产的全部回退为在线(强制单卷在产)
others = await db.execute(
select(ProductionPlan).where(
ProductionPlan.status == "producing",
ProductionPlan.id != plan_id,
)
)
for o in others.scalars():
o.status = "online"
plan.status = "producing"
await db.flush()
return Response.ok(PlanOut.model_validate(plan))

View File

@@ -6,10 +6,28 @@ from datetime import datetime
from app.database import get_db from app.database import get_db
from app.models.production import ProductionRecord from app.models.production import ProductionRecord
from app.models.plan import ProductionPlan
from app.schemas.production import ProductionRecordCreate, ProductionRecordUpdate, ProductionRecordOut from app.schemas.production import ProductionRecordCreate, ProductionRecordUpdate, ProductionRecordOut
from app.schemas.common import Response, PageResponse from app.schemas.common import Response, PageResponse
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
async def _mark_plan_produced(db: AsyncSession, record: ProductionRecord):
"""根据实绩记录的卷号自动把对应计划标记为 produced。"""
candidates = []
for v in (getattr(record, "hot_coil_no", None), record.coil_no, getattr(record, "sub_coil_no", None)):
if v and v not in candidates:
candidates.append(v)
if not candidates:
return
q = select(ProductionPlan).where(
(ProductionPlan.cold_coil_no.in_(candidates)) | (ProductionPlan.hot_coil_no.in_(candidates))
)
res = await db.execute(q)
for plan in res.scalars():
if plan.status != "produced":
plan.status = "produced"
router = APIRouter() router = APIRouter()
@@ -60,6 +78,8 @@ async def create_record(
record = ProductionRecord(**body.model_dump()) record = ProductionRecord(**body.model_dump())
db.add(record) db.add(record)
await db.flush() await db.flush()
await _mark_plan_produced(db, record)
await db.flush()
return Response.ok(ProductionRecordOut.model_validate(record)) return Response.ok(ProductionRecordOut.model_validate(record))
@@ -86,4 +106,6 @@ async def update_record(
for k, v in body.model_dump(exclude_none=True).items(): for k, v in body.model_dump(exclude_none=True).items():
setattr(record, k, v) setattr(record, k, v)
await db.flush() await db.flush()
await _mark_plan_produced(db, record)
await db.flush()
return Response.ok(ProductionRecordOut.model_validate(record)) return Response.ok(ProductionRecordOut.model_validate(record))

View File

@@ -56,6 +56,9 @@ async def _run_migrations(conn):
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_count INTEGER DEFAULT 1", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_count INTEGER DEFAULT 1",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS coil_diameter DOUBLE PRECISION", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS coil_diameter DOUBLE PRECISION",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS next_process VARCHAR(30)", "ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS next_process VARCHAR(30)",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_weight DOUBLE PRECISION",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_od DOUBLE PRECISION",
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB",
# 状态列改为 VARCHAR 以适配新值 # 状态列改为 VARCHAR 以适配新值
"ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text", "ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text",
# production_records 新字段 # production_records 新字段

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func, JSON
from app.database import Base from app.database import Base
@@ -31,6 +31,9 @@ class ProductionPlan(Base):
coil_diameter = Column(Float, comment="卷径 mm") coil_diameter = Column(Float, comment="卷径 mm")
split_count = Column(Integer, default=1, comment="分卷数") split_count = Column(Integer, default=1, comment="分卷数")
next_process = Column(String(30), comment="下工序") next_process = Column(String(30), comment="下工序")
incoming_weight = Column(Float, comment="来料重量 t")
incoming_od = Column(Float, comment="来料外径 mm")
split_weights = Column(JSON, comment="分卷重量 [t,...]")
# 兼容历史字段 # 兼容历史字段
shift = Column(String(10), comment="班次") shift = Column(String(10), comment="班次")

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
from datetime import datetime from datetime import datetime
@@ -21,7 +21,10 @@ class PlanCreate(BaseModel):
coil_diameter: Optional[float] = None coil_diameter: Optional[float] = None
split_count: Optional[int] = 1 split_count: Optional[int] = 1
next_process: Optional[str] = None next_process: Optional[str] = None
status: Optional[str] = "ready" incoming_weight: Optional[float] = None
incoming_od: Optional[float] = None
split_weights: Optional[List[Optional[float]]] = None
status: Optional[str] = "online"
remark: Optional[str] = None remark: Optional[str] = None
@@ -42,6 +45,9 @@ class PlanUpdate(BaseModel):
coil_diameter: Optional[float] = None coil_diameter: Optional[float] = None
split_count: Optional[int] = None split_count: Optional[int] = None
next_process: Optional[str] = None next_process: Optional[str] = None
incoming_weight: Optional[float] = None
incoming_od: Optional[float] = None
split_weights: Optional[List[Optional[float]]] = None
status: Optional[str] = None status: Optional[str] = None
remark: Optional[str] = None remark: Optional[str] = None
@@ -66,9 +72,32 @@ class PlanOut(BaseModel):
coil_diameter: Optional[float] = None coil_diameter: Optional[float] = None
split_count: Optional[int] = 1 split_count: Optional[int] = 1
next_process: Optional[str] = None next_process: Optional[str] = None
incoming_weight: Optional[float] = None
incoming_od: Optional[float] = None
split_weights: Optional[List[Optional[float]]] = None
remark: Optional[str] = None remark: Optional[str] = None
created_by: Optional[str] = None created_by: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class PlanTemplate(BaseModel):
"""新增计划时回填的"上次录入"模板(不含 plan_no/卷号/时间)"""
steel_grade: Optional[str] = None
incoming_thickness: Optional[float] = None
product_thickness: Optional[float] = None
deviation_upper: Optional[float] = None
deviation_lower: Optional[float] = None
incoming_width: Optional[float] = None
product_width: Optional[float] = None
packaging_req: Optional[str] = None
trim_req: Optional[str] = None
rolling_mode: Optional[str] = None
coil_diameter: Optional[float] = None
split_count: Optional[int] = 1
next_process: Optional[str] = None
incoming_weight: Optional[float] = None
incoming_od: Optional[float] = None
split_weights: Optional[List[Optional[float]]] = None

View File

@@ -20,6 +20,8 @@ export const getPlans = params => request.get('/plan/', { params })
export const createPlan = data => request.post('/plan/', data) export const createPlan = data => request.post('/plan/', data)
export const updatePlan = (id, data) => request.put(`/plan/${id}`, data) export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
export const confirmPlan = id => request.patch(`/plan/${id}/confirm`) export const confirmPlan = id => request.patch(`/plan/${id}/confirm`)
export const startProducing = id => request.patch(`/plan/${id}/start`)
export const getLastPlanTemplate = () => request.get('/plan/last-template')
// 停机管理 // 停机管理
export const getDowntimeCategories = () => request.get('/downtime/categories') export const getDowntimeCategories = () => request.get('/downtime/categories')

View File

@@ -32,6 +32,41 @@
</div> </div>
</div> </div>
<!-- 在线计划队列 + 入口移动 -->
<div class="card" style="margin-bottom:8px;">
<div class="card-header">
在线计划入口队列
<span class="ch-badge">在线 {{ onlinePlans.length }} / 生产中 {{ producingPlan ? 1 : 0 }}</span>
<span style="margin-left:auto;font-size:11px;color:var(--text-muted);">点击移动把队列卷推到入口并开始生产</span>
</div>
<div style="padding:8px 14px;">
<div v-if="producingPlan" class="producing-row">
<span class="badge badge-yellow">生产中</span>
<span class="kv-label">冷卷号</span><span class="kv-value">{{ producingPlan.cold_coil_no || producingPlan.plan_no }}</span>
<span class="kv-label">钢种</span><span class="kv-value">{{ producingPlan.steel_grade || '—' }}</span>
<span class="kv-label">规格</span><span class="kv-value">{{ fmt(producingPlan.product_thickness) }}×{{ fmt(producingPlan.product_width, 0) }}</span>
<span class="kv-label">分卷</span><span class="kv-value">{{ producingPlan.split_count || 1 }}</span>
</div>
<table class="data-table compact" v-if="onlinePlans.length">
<thead><tr><th>冷卷号</th><th>钢种</th><th>厚度</th><th>宽度</th><th>分卷</th><th>下达时间</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="p in onlinePlans" :key="p.id">
<td class="td-num">{{ p.cold_coil_no || p.plan_no }}</td>
<td>{{ p.steel_grade || '—' }}</td>
<td class="td-num">{{ fmt(p.product_thickness) }}</td>
<td class="td-num">{{ fmt(p.product_width, 0) }}</td>
<td class="td-num">{{ p.split_count || 1 }}</td>
<td class="td-muted">{{ fmtTime(p.plan_date) }}</td>
<td>
<button class="btn btn-primary btn-sm" :disabled="moving" @click="movePlan(p)">移动 </button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="!producingPlan" class="td-muted" style="text-align:center;padding:10px;font-size:12px;">暂无在线计划</div>
</div>
</div>
<!-- 产线总图 --> <!-- 产线总图 -->
<div class="line-wrap card"> <div class="line-wrap card">
<div class="card-header">推拉酸洗线 - 物料跟踪总图</div> <div class="card-header">推拉酸洗线 - 物料跟踪总图</div>
@@ -288,6 +323,7 @@
</template> </template>
<script> <script>
import { getPlans, startProducing } from '@/api'
function rnd(base, amp) { return base + (Math.random() - 0.5) * amp } function rnd(base, amp) { return base + (Math.random() - 0.5) * amp }
function fix(v, n = 1) { return Number(v).toFixed(n) } function fix(v, n = 1) { return Number(v).toFixed(n) }
@@ -368,9 +404,14 @@ export default {
dryer: { t1: 145, t2: 168, t3: 152 }, dryer: { t1: 145, t2: 168, t3: 152 },
_timer: null, _timer: null,
_plansTimer: null,
plans: [],
moving: false,
} }
}, },
computed: { computed: {
onlinePlans() { return this.plans.filter(p => p.status === 'online') },
producingPlan() { return this.plans.find(p => p.status === 'producing') || null },
equipments() { equipments() {
const n = EQUIPMENTS.length const n = EQUIPMENTS.length
const xStart = 50, xEnd = 1850 const xStart = 50, xEnd = 1850
@@ -456,6 +497,31 @@ export default {
}, },
}, },
methods: { methods: {
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
async loadPlans() {
try {
const res = await getPlans({ page: 1, page_size: 50 })
this.plans = res.data.items || []
// 把生产中的卷号同步到产线显示
if (this.producingPlan && this.producingPlan.cold_coil_no) {
this.current.coil_no = this.producingPlan.cold_coil_no
}
} catch (e) { /* ignore */ }
},
async movePlan(p) {
if (this.moving) return
this.moving = true
try {
await startProducing(p.id)
this.$message && this.$message.success(`已开始生产 ${p.cold_coil_no || p.plan_no}`)
await this.loadPlans()
} catch (e) {
this.$message && this.$message.error('移动失败')
} finally {
this.moving = false
}
},
// 一行的展示数据:根据设备状态决定卷号/速度/辊缝/辅助列 // 一行的展示数据:根据设备状态决定卷号/速度/辊缝/辅助列
rowOf(eq, i) { rowOf(eq, i) {
const curIdx = this.currentEquipment.idx const curIdx = this.currentEquipment.idx
@@ -561,9 +627,12 @@ export default {
created() { created() {
this.tick() this.tick()
this._timer = setInterval(this.tick, 2000) this._timer = setInterval(this.tick, 2000)
this.loadPlans()
this._plansTimer = setInterval(this.loadPlans, 10000)
}, },
beforeDestroy() { beforeDestroy() {
if (this._timer) clearInterval(this._timer) if (this._timer) clearInterval(this._timer)
if (this._plansTimer) clearInterval(this._plansTimer)
}, },
} }
</script> </script>
@@ -593,6 +662,12 @@ export default {
.track-scroll { max-height: 640px; overflow-y: auto; } .track-scroll { max-height: 640px; overflow-y: auto; }
.producing-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px 10px; font-size: 12px; border-bottom: 1px dashed $border; margin-bottom: 6px;
.kv-label { color: $text-muted; font-size: 11px; margin-left: 6px; }
.kv-value { color: $sms-highlight; font-weight: 600; }
}
.btn-sm { padding: 2px 10px; font-size: 11px; }
.hd-cnt { font-size: 11px; color: #6b7c8d; margin-left: 8px; font-weight: 400; } .hd-cnt { font-size: 11px; color: #6b7c8d; margin-left: 8px; font-weight: 400; }
.sec-body { padding: 10px 14px; background: #161d24; } .sec-body { padding: 10px 14px; background: #161d24; }

View File

@@ -57,7 +57,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(row, idx) in tableData" :key="row.id"> <tr v-for="(row, idx) in tableData" :key="row.id"
:class="{ 'row-selected': selectedRow && selectedRow.id === row.id }"
@click="selectRow(row)" style="cursor:pointer;">
<td class="td-num">{{ idx + 1 }}</td> <td class="td-num">{{ idx + 1 }}</td>
<td class="td-num">{{ row.cold_coil_no || row.plan_no || '—' }}</td> <td class="td-num">{{ row.cold_coil_no || row.plan_no || '—' }}</td>
<td class="td-num">{{ row.hot_coil_no || '—' }}</td> <td class="td-num">{{ row.hot_coil_no || '—' }}</td>
@@ -74,9 +76,10 @@
<td class="td-num">{{ row.next_process != null ? row.next_process : '—' }}</td> <td class="td-num">{{ row.next_process != null ? row.next_process : '—' }}</td>
<td class="td-muted">{{ fmtTime(row.plan_date) }}</td> <td class="td-muted">{{ fmtTime(row.plan_date) }}</td>
<td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td> <td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td>
<td> <td @click.stop>
<span class="action-link" @click="openDialog(row)">编辑</span> <span class="action-link" @click="openDialog(row)">编辑</span>
<span v-if="row.status === 'ready'" class="action-link" style="color:var(--accent-green)" @click="confirmPlan(row)">上线</span> <span v-if="row.status === 'ready' || row.status === 'online'"
class="action-link" style="color:var(--accent-green)" @click="moveToProducing(row)">移动</span>
</td> </td>
</tr> </tr>
<tr v-if="!tableData.length && !loading"> <tr v-if="!tableData.length && !loading">
@@ -87,8 +90,44 @@
</div> </div>
</div> </div>
<!-- 计划详细 -->
<div class="card" v-if="selectedRow">
<div class="card-header">计划详细 {{ selectedRow.cold_coil_no || selectedRow.plan_no }}</div>
<div class="detail-grid">
<div class="dt-row"><span class="dt-k">钢卷号</span><span class="dt-v">{{ selectedRow.cold_coil_no || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">来料厚度</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_thickness) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">来料宽度</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_width, 0) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">1#分卷</span><span class="dt-v">{{ fmtNum(splitW(0), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">钢种</span><span class="dt-v">{{ selectedRow.steel_grade || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">产品厚度</span><span class="dt-v">{{ fmtNum(selectedRow.product_thickness) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">产品宽度</span><span class="dt-v">{{ fmtNum(selectedRow.product_width, 0) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">2#分卷</span><span class="dt-v">{{ fmtNum(splitW(1), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">轧制模式</span><span class="dt-v">{{ selectedRow.rolling_mode || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">偏差上限</span><span class="dt-v">{{ fmtNum(selectedRow.deviation_upper, 3) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">分卷数</span><span class="dt-v">{{ selectedRow.split_count != null ? selectedRow.split_count : 1 }}</span></div>
<div class="dt-row"><span class="dt-k">3#分卷</span><span class="dt-v">{{ fmtNum(splitW(2), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">状态</span><span class="dt-v"><span :class="['badge', statusBadge(selectedRow.status)]">{{ statusLabel(selectedRow.status) }}</span></span></div>
<div class="dt-row"><span class="dt-k">偏差下限</span><span class="dt-v">{{ fmtNum(selectedRow.deviation_lower, 3) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">下工序</span><span class="dt-v">{{ selectedRow.next_process || '—' }}</span></div>
<div class="dt-row"><span class="dt-k">4#分卷</span><span class="dt-v">{{ fmtNum(splitW(3), 3) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">下达时间</span><span class="dt-v">{{ fmtTime(selectedRow.plan_date) }}</span></div>
<div class="dt-row"><span class="dt-k">来料重量</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_weight, 2) }} <i>[t]</i></span></div>
<div class="dt-row"><span class="dt-k">来料外径</span><span class="dt-v">{{ fmtNum(selectedRow.incoming_od, 0) }} <i>[mm]</i></span></div>
<div class="dt-row"><span class="dt-k">5#分卷</span><span class="dt-v">{{ fmtNum(splitW(4), 3) }} <i>[t]</i></span></div>
<div class="dt-row"></div>
<div class="dt-row"></div>
<div class="dt-row"></div>
<div class="dt-row"><span class="dt-k">6#分卷</span><span class="dt-v">{{ fmtNum(splitW(5), 3) }} <i>[t]</i></span></div>
</div>
</div>
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false"> <div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
<div class="modal-box" style="width:780px;"> <div class="modal-box" style="width:880px;">
<div class="modal-header"> <div class="modal-header">
{{ editRow ? '编辑计划' : '新增计划' }} {{ editRow ? '编辑计划' : '新增计划' }}
<span class="modal-close" @click="dialogVisible=false"></span> <span class="modal-close" @click="dialogVisible=false"></span>
@@ -111,6 +150,15 @@
<div class="kv-label">钢种</div> <div class="kv-label">钢种</div>
<input v-model="form.steel_grade" class="kv-input" placeholder="QTGLG-2019" /> <input v-model="form.steel_grade" class="kv-input" placeholder="QTGLG-2019" />
</div> </div>
<div class="form-field">
<div class="kv-label">轧制模式</div>
<select v-model="form.rolling_mode" class="kv-input">
<option value=""></option>
<option value="冷轧">冷轧</option>
<option value="热轧">热轧</option>
<option value="温轧">温轧</option>
</select>
</div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">来料厚度 (mm)</div> <div class="kv-label">来料厚度 (mm)</div>
<input v-model.number="form.incoming_thickness" type="number" step="0.01" class="kv-input" /> <input v-model.number="form.incoming_thickness" type="number" step="0.01" class="kv-input" />
@@ -147,9 +195,17 @@
<div class="kv-label">卷径 (mm)</div> <div class="kv-label">卷径 (mm)</div>
<input v-model.number="form.coil_diameter" type="number" step="1" class="kv-input" /> <input v-model.number="form.coil_diameter" type="number" step="1" class="kv-input" />
</div> </div>
<div class="form-field">
<div class="kv-label">来料外径 (mm)</div>
<input v-model.number="form.incoming_od" type="number" step="1" class="kv-input" />
</div>
<div class="form-field">
<div class="kv-label">来料重量 (t)</div>
<input v-model.number="form.incoming_weight" type="number" step="0.001" class="kv-input" />
</div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">分卷数</div> <div class="kv-label">分卷数</div>
<input v-model.number="form.split_count" type="number" min="1" class="kv-input" /> <input v-model.number="form.split_count" type="number" min="1" max="6" class="kv-input" @change="syncSplitWeights" />
</div> </div>
<div class="form-field"> <div class="form-field">
<div class="kv-label">下工序</div> <div class="kv-label">下工序</div>
@@ -166,6 +222,16 @@
</select> </select>
</div> </div>
</div> </div>
<div style="margin-top:12px;border-top:1px dashed var(--border);padding-top:10px;">
<div class="kv-label" style="margin-bottom:6px;">分卷重量 (t)</div>
<div class="grid-6" style="gap:8px;">
<div v-for="i in 6" :key="i" class="form-field">
<div class="kv-label" style="font-size:11px;">{{ i }}#分卷</div>
<input v-model.number="form.split_weights[i-1]" type="number" step="0.001"
:disabled="i > (form.split_count || 1)" class="kv-input" />
</div>
</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-outline" @click="dialogVisible=false">取消</button> <button class="btn btn-outline" @click="dialogVisible=false">取消</button>
@@ -177,15 +243,21 @@
</template> </template>
<script> <script>
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm } from '@/api' import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm, startProducing, getLastPlanTemplate } from '@/api'
const STATUS_MAP = { const STATUS_MAP = {
ready: { label: '准备好', badge: 'badge-green' }, ready: { label: '准备好', badge: 'badge-gray' },
online: { label: '在线', badge: 'badge-yellow' }, online: { label: '在线', badge: 'badge-green' },
producing: { label: '生产中', badge: 'badge-yellow' }, producing: { label: '生产中', badge: 'badge-yellow' },
produced: { label: '产出', badge: 'badge-blue' }, produced: { label: '生产完成', badge: 'badge-blue' },
} }
const TEMPLATE_KEYS = [
'steel_grade','incoming_thickness','product_thickness','deviation_upper','deviation_lower',
'incoming_width','product_width','packaging_req','trim_req','rolling_mode',
'coil_diameter','split_count','next_process','incoming_weight','incoming_od','split_weights',
]
export default { export default {
name: 'Plan', name: 'Plan',
data() { data() {
@@ -194,11 +266,16 @@ export default {
tableData: [], total: 0, tableData: [], total: 0,
query: { page: 1, page_size: 50, status: '', cold_coil_no: '', start_date: '', end_date: '' }, query: { page: 1, page_size: 50, status: '', cold_coil_no: '', start_date: '', end_date: '' },
statusOptions: Object.entries(STATUS_MAP).map(([value, { label }]) => ({ value, label })), statusOptions: Object.entries(STATUS_MAP).map(([value, { label }]) => ({ value, label })),
dialogVisible: false, editRow: null, form: { split_count: 1, status: 'ready' }, dialogVisible: false, editRow: null,
form: this.emptyForm(),
selectedRow: null,
} }
}, },
created() { this.fetchData() }, created() { this.fetchData() },
methods: { methods: {
emptyForm() {
return { plan_no: '', split_count: 1, status: 'online', split_weights: [null,null,null,null,null,null] }
},
async fetchData() { async fetchData() {
this.loading = true this.loading = true
const params = { page: this.query.page, page_size: this.query.page_size } const params = { page: this.query.page, page_size: this.query.page_size }
@@ -213,20 +290,51 @@ export default {
} }
this.tableData = items this.tableData = items
this.total = res.data.total this.total = res.data.total
if (this.selectedRow) {
const fresh = items.find(x => x.id === this.selectedRow.id)
if (fresh) this.selectedRow = fresh
}
} finally { this.loading = false } } finally { this.loading = false }
}, },
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' }, fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
fmtNum(v, n = 2) { return v != null ? Number(v).toFixed(n) : '—' }, fmtNum(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
statusLabel(s) { return STATUS_MAP[s]?.label || s || '—' }, statusLabel(s) { return STATUS_MAP[s]?.label || s || '—' },
statusBadge(s) { return STATUS_MAP[s]?.badge || 'badge-gray' }, statusBadge(s) { return STATUS_MAP[s]?.badge || 'badge-gray' },
openDialog(row = null) { selectRow(row) { this.selectedRow = row },
splitW(i) {
const arr = this.selectedRow && this.selectedRow.split_weights
return Array.isArray(arr) ? arr[i] : null
},
syncSplitWeights() {
const n = Math.max(1, Math.min(6, Number(this.form.split_count) || 1))
const arr = (this.form.split_weights || []).slice(0, 6)
while (arr.length < 6) arr.push(null)
for (let i = n; i < 6; i++) arr[i] = null
this.$set(this.form, 'split_weights', arr)
},
async openDialog(row = null) {
this.editRow = row this.editRow = row
if (row) { if (row) {
const r = { ...row } const r = { ...row }
if (r.plan_date) r.plan_date = r.plan_date.slice(0, 16) if (r.plan_date) r.plan_date = r.plan_date.slice(0, 16)
if (!Array.isArray(r.split_weights)) r.split_weights = [null,null,null,null,null,null]
else { while (r.split_weights.length < 6) r.split_weights.push(null) }
this.form = r this.form = r
} else { } else {
this.form = { plan_no: '', split_count: 1, status: 'ready', plan_date: this.nowDT() } this.form = this.emptyForm()
this.form.plan_date = this.nowDT()
try {
const res = await getLastPlanTemplate()
const t = res.data || {}
for (const k of TEMPLATE_KEYS) {
if (t[k] != null) this.$set(this.form, k, t[k])
}
if (!Array.isArray(this.form.split_weights)) {
this.form.split_weights = [null,null,null,null,null,null]
} else {
while (this.form.split_weights.length < 6) this.form.split_weights.push(null)
}
} catch (e) { /* 无历史可回填,忽略 */ }
} }
this.dialogVisible = true this.dialogVisible = true
}, },
@@ -240,6 +348,12 @@ export default {
this.$message.success('已上线') this.$message.success('已上线')
this.fetchData() this.fetchData()
}, },
async moveToProducing(row) {
if (!confirm(`将计划 ${row.cold_coil_no || row.plan_no} 移动到入口并开始生产?`)) return
await startProducing(row.id)
this.$message.success('已开始生产')
this.fetchData()
},
async save() { async save() {
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return } if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }
if (!this.form.plan_date) { this.$message.error('计划时间不能为空'); return } if (!this.form.plan_date) { this.$message.error('计划时间不能为空'); return }
@@ -248,6 +362,11 @@ export default {
const d = { ...this.form } const d = { ...this.form }
if (d.plan_date && !d.plan_date.includes(':')) d.plan_date += 'T00:00:00' if (d.plan_date && !d.plan_date.includes(':')) d.plan_date += 'T00:00:00'
else if (d.plan_date && d.plan_date.length === 16) d.plan_date += ':00' else if (d.plan_date && d.plan_date.length === 16) d.plan_date += ':00'
// 修剪 split_weights 到 split_count 长度
const n = Math.max(1, Math.min(6, Number(d.split_count) || 1))
if (Array.isArray(d.split_weights)) {
d.split_weights = d.split_weights.slice(0, n).map(v => v === '' ? null : v)
}
if (this.editRow) await updatePlan(this.editRow.id, d) if (this.editRow) await updatePlan(this.editRow.id, d)
else await createPlan(d) else await createPlan(d)
this.$message.success('保存成功') this.$message.success('保存成功')
@@ -261,8 +380,22 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/assets/styles/variables'; @import '@/assets/styles/variables';
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } } .action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } }
.row-selected { background: rgba(29, 142, 255, 0.12) !important; }
.form-field { display: flex; flex-direction: column; gap: 5px; } .form-field { display: flex; flex-direction: column; gap: 5px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; } .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; }
.grid-6 { display: grid; grid-template-columns: repeat(6, 1fr); }
.detail-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px 24px;
padding: 14px 18px;
border: 1px solid #c43a3a;
margin: 0 6px 8px;
background: rgba(196, 58, 58, 0.04);
}
.dt-row { display: flex; align-items: center; gap: 10px; font-size: 12px; min-height: 22px; }
.dt-k { color: $text-muted; min-width: 64px; }
.dt-v { color: $text-primary; flex: 1; i { color: $text-muted; font-style: normal; font-size: 11px; margin-left: 2px; } }
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; } .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-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-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; } } }