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:
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
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.services.auth_service import get_current_user
|
||||
|
||||
@@ -22,6 +22,16 @@ def _parse_dt(s):
|
||||
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]])
|
||||
async def list_plans(
|
||||
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))
|
||||
|
||||
|
||||
@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])
|
||||
async def create_plan(
|
||||
body: PlanCreate,
|
||||
@@ -98,3 +118,24 @@ async def confirm_plan(plan_id: int, db: AsyncSession = Depends(get_db), _ = Dep
|
||||
plan.status = "online"
|
||||
await db.flush()
|
||||
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))
|
||||
|
||||
@@ -6,10 +6,28 @@ from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.production import ProductionRecord
|
||||
from app.models.plan import ProductionPlan
|
||||
from app.schemas.production import ProductionRecordCreate, ProductionRecordUpdate, ProductionRecordOut
|
||||
from app.schemas.common import Response, PageResponse
|
||||
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()
|
||||
|
||||
|
||||
@@ -60,6 +78,8 @@ async def create_record(
|
||||
record = ProductionRecord(**body.model_dump())
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
await _mark_plan_produced(db, record)
|
||||
await db.flush()
|
||||
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():
|
||||
setattr(record, k, v)
|
||||
await db.flush()
|
||||
await _mark_plan_produced(db, record)
|
||||
await db.flush()
|
||||
return Response.ok(ProductionRecordOut.model_validate(record))
|
||||
|
||||
@@ -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 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 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 以适配新值
|
||||
"ALTER TABLE production_plans ALTER COLUMN status TYPE VARCHAR(20) USING status::text",
|
||||
# production_records 新字段
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ class ProductionPlan(Base):
|
||||
coil_diameter = Column(Float, comment="卷径 mm")
|
||||
split_count = Column(Integer, default=1, 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="班次")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ class PlanCreate(BaseModel):
|
||||
coil_diameter: Optional[float] = None
|
||||
split_count: Optional[int] = 1
|
||||
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
|
||||
|
||||
|
||||
@@ -42,6 +45,9 @@ class PlanUpdate(BaseModel):
|
||||
coil_diameter: Optional[float] = None
|
||||
split_count: Optional[int] = 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
|
||||
remark: Optional[str] = None
|
||||
|
||||
@@ -66,9 +72,32 @@ class PlanOut(BaseModel):
|
||||
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
|
||||
remark: Optional[str] = None
|
||||
created_by: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
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
|
||||
|
||||
@@ -20,6 +20,8 @@ export const getPlans = params => request.get('/plan/', { params })
|
||||
export const createPlan = data => request.post('/plan/', data)
|
||||
export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
|
||||
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')
|
||||
|
||||
@@ -32,6 +32,41 @@
|
||||
</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="card-header">推拉酸洗线 - 物料跟踪总图</div>
|
||||
@@ -288,6 +323,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPlans, startProducing } from '@/api'
|
||||
function rnd(base, amp) { return base + (Math.random() - 0.5) * amp }
|
||||
function fix(v, n = 1) { return Number(v).toFixed(n) }
|
||||
|
||||
@@ -368,9 +404,14 @@ export default {
|
||||
dryer: { t1: 145, t2: 168, t3: 152 },
|
||||
|
||||
_timer: null,
|
||||
_plansTimer: null,
|
||||
plans: [],
|
||||
moving: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
onlinePlans() { return this.plans.filter(p => p.status === 'online') },
|
||||
producingPlan() { return this.plans.find(p => p.status === 'producing') || null },
|
||||
equipments() {
|
||||
const n = EQUIPMENTS.length
|
||||
const xStart = 50, xEnd = 1850
|
||||
@@ -456,6 +497,31 @@ export default {
|
||||
},
|
||||
},
|
||||
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) {
|
||||
const curIdx = this.currentEquipment.idx
|
||||
@@ -561,9 +627,12 @@ export default {
|
||||
created() {
|
||||
this.tick()
|
||||
this._timer = setInterval(this.tick, 2000)
|
||||
this.loadPlans()
|
||||
this._plansTimer = setInterval(this.loadPlans, 10000)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this._timer) clearInterval(this._timer)
|
||||
if (this._plansTimer) clearInterval(this._plansTimer)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -593,6 +662,12 @@ export default {
|
||||
|
||||
.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; }
|
||||
|
||||
.sec-body { padding: 10px 14px; background: #161d24; }
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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">{{ row.cold_coil_no || row.plan_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-muted">{{ fmtTime(row.plan_date) }}</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 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>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
@@ -87,8 +90,44 @@
|
||||
</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 class="modal-box" style="width:780px;">
|
||||
<div class="modal-box" style="width:880px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑计划' : '新增计划' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
@@ -111,6 +150,15 @@
|
||||
<div class="kv-label">钢种</div>
|
||||
<input v-model="form.steel_grade" class="kv-input" placeholder="QTGLG-2019" />
|
||||
</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="kv-label">来料厚度 (mm)</div>
|
||||
<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>
|
||||
<input v-model.number="form.coil_diameter" type="number" step="1" class="kv-input" />
|
||||
</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="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 class="form-field">
|
||||
<div class="kv-label">下工序</div>
|
||||
@@ -166,6 +222,16 @@
|
||||
</select>
|
||||
</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 class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
@@ -177,15 +243,21 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm } from '@/api'
|
||||
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm, startProducing, getLastPlanTemplate } from '@/api'
|
||||
|
||||
const STATUS_MAP = {
|
||||
ready: { label: '准备好', badge: 'badge-green' },
|
||||
online: { label: '在线', badge: 'badge-yellow' },
|
||||
producing: { label: '生产中', badge: 'badge-yellow' },
|
||||
produced: { label: '产出', badge: 'badge-blue' },
|
||||
ready: { label: '准备好', badge: 'badge-gray' },
|
||||
online: { label: '在线', badge: 'badge-green' },
|
||||
producing: { label: '生产中', badge: 'badge-yellow' },
|
||||
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 {
|
||||
name: 'Plan',
|
||||
data() {
|
||||
@@ -194,11 +266,16 @@ export default {
|
||||
tableData: [], total: 0,
|
||||
query: { page: 1, page_size: 50, status: '', cold_coil_no: '', start_date: '', end_date: '' },
|
||||
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() },
|
||||
methods: {
|
||||
emptyForm() {
|
||||
return { plan_no: '', split_count: 1, status: 'online', split_weights: [null,null,null,null,null,null] }
|
||||
},
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
const params = { page: this.query.page, page_size: this.query.page_size }
|
||||
@@ -213,20 +290,51 @@ export default {
|
||||
}
|
||||
this.tableData = items
|
||||
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 }
|
||||
},
|
||||
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 || '—' },
|
||||
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
|
||||
if (row) {
|
||||
const r = { ...row }
|
||||
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
|
||||
} 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
|
||||
},
|
||||
@@ -240,6 +348,12 @@ export default {
|
||||
this.$message.success('已上线')
|
||||
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() {
|
||||
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }
|
||||
if (!this.form.plan_date) { this.$message.error('计划时间不能为空'); return }
|
||||
@@ -248,6 +362,11 @@ export default {
|
||||
const d = { ...this.form }
|
||||
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'
|
||||
// 修剪 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)
|
||||
else await createPlan(d)
|
||||
this.$message.success('保存成功')
|
||||
@@ -261,8 +380,22 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.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; }
|
||||
.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-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; } } }
|
||||
|
||||
Reference in New Issue
Block a user