feat(linkage): 移动改为任意入口位置选择,仅上卷鞍座触发生产
- 计划新增 position 字段;新增 /plan/{id}/move?position=… 与 /plan/positions/all
- line_service.place_at_position:放到任意位置(位置唯一占用),上卷鞍座单独触发生产联动
- 入口跟踪:新增入口位置图(单一鞍座)显示占位;移动按钮弹出位置选择框
- 计划管理:移动按钮同样弹出位置选择框
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -142,6 +142,28 @@ async def move_to_saddle(plan_id: int, db: AsyncSession = Depends(get_db), _ = D
|
|||||||
return Response.ok(PlanOut.model_validate(plan))
|
return Response.ok(PlanOut.model_validate(plan))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/positions/all", response_model=Response[list[str]])
|
||||||
|
async def list_positions(_ = Depends(get_current_user)):
|
||||||
|
"""可移动到的入口位置列表(含唯一上卷鞍座)。"""
|
||||||
|
return Response.ok(line_service.ENTRY_POSITIONS)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{plan_id}/move", response_model=Response[PlanOut])
|
||||||
|
async def move_plan(plan_id: int, position: str = Query(...), db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
|
||||||
|
"""移动:把计划放到所选入口位置;放到上卷鞍座才触发生产联动。"""
|
||||||
|
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="计划不存在")
|
||||||
|
try:
|
||||||
|
await line_service.place_at_position(db, plan, position)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(plan)
|
||||||
|
return Response.ok(PlanOut.model_validate(plan))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{plan_id}/commit", response_model=Response[PlanOut])
|
@router.patch("/{plan_id}/commit", response_model=Response[PlanOut])
|
||||||
async def commit_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
|
async def commit_producing(plan_id: int, db: AsyncSession = Depends(get_db), _ = Depends(get_current_user)):
|
||||||
"""投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。"""
|
"""投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。"""
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ async def _run_migrations(conn):
|
|||||||
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS incoming_weight DOUBLE PRECISION",
|
"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 incoming_od DOUBLE PRECISION",
|
||||||
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB",
|
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS split_weights JSONB",
|
||||||
# 上卷鞍座 / 生产联动字段
|
# 入口位置 / 上卷鞍座 / 生产联动字段
|
||||||
|
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS position VARCHAR(30)",
|
||||||
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS on_saddle INTEGER DEFAULT 0",
|
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS on_saddle INTEGER DEFAULT 0",
|
||||||
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS saddle_at TIMESTAMP",
|
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS saddle_at TIMESTAMP",
|
||||||
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS run_started_at TIMESTAMP",
|
"ALTER TABLE production_plans ADD COLUMN IF NOT EXISTS run_started_at TIMESTAMP",
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class ProductionPlan(Base):
|
|||||||
incoming_od = Column(Float, comment="来料外径 mm")
|
incoming_od = Column(Float, comment="来料外径 mm")
|
||||||
split_weights = Column(JSON, comment="分卷重量 [t,...]")
|
split_weights = Column(JSON, comment="分卷重量 [t,...]")
|
||||||
|
|
||||||
# 上卷鞍座 / 生产联动
|
# 入口位置 / 上卷鞍座 / 生产联动
|
||||||
|
position = Column(String(30), comment="当前入口位置(上卷小车/称重位/地辊/倒卷小车/上卷鞍座)")
|
||||||
on_saddle = Column(Integer, default=0, comment="是否在上卷鞍座 0/1")
|
on_saddle = Column(Integer, default=0, comment="是否在上卷鞍座 0/1")
|
||||||
saddle_at = Column(DateTime, comment="移动到鞍座时间")
|
saddle_at = Column(DateTime, comment="移动到鞍座时间")
|
||||||
run_started_at = Column(DateTime, comment="投入生产(有速度)时间")
|
run_started_at = Column(DateTime, comment="投入生产(有速度)时间")
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ class PlanOut(BaseModel):
|
|||||||
remark: Optional[str] = None
|
remark: Optional[str] = None
|
||||||
created_by: Optional[str] = None
|
created_by: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# 上卷鞍座 / 生产联动
|
# 入口位置 / 上卷鞍座 / 生产联动
|
||||||
|
position: Optional[str] = None
|
||||||
on_saddle: Optional[int] = 0
|
on_saddle: Optional[int] = 0
|
||||||
saddle_at: Optional[datetime] = None
|
saddle_at: Optional[datetime] = None
|
||||||
run_started_at: Optional[datetime] = None
|
run_started_at: Optional[datetime] = None
|
||||||
|
|||||||
@@ -17,12 +17,45 @@ from app.models.production import ProductionRecord
|
|||||||
from app.models.downtime import DowntimeRecord
|
from app.models.downtime import DowntimeRecord
|
||||||
from app.models.line_state import LineState
|
from app.models.line_state import LineState
|
||||||
|
|
||||||
|
# ── 入口位置 ──
|
||||||
|
SADDLE_NAME = "上卷鞍座" # 唯一会触发生产联动的位置
|
||||||
|
ENTRY_POSITIONS = [
|
||||||
|
"1#上卷小车", "2#上卷小车",
|
||||||
|
"1#称重位", "2#称重位",
|
||||||
|
"1#地辊", "2#地辊",
|
||||||
|
"1#倒卷小车", "2#倒卷小车",
|
||||||
|
SADDLE_NAME,
|
||||||
|
]
|
||||||
|
|
||||||
# ── 仿真参数 ──
|
# ── 仿真参数 ──
|
||||||
TARGET_LENGTH_M = 2000.0 # 带头目标长度(生产完成阈值)
|
TARGET_LENGTH_M = 2000.0 # 带头目标长度(生产完成阈值)
|
||||||
SIM_SPEED_M_MIN = 600.0 # 仿真线速度 m/min(2000m ≈ 200s)
|
SIM_SPEED_M_MIN = 600.0 # 仿真线速度 m/min(2000m ≈ 200s)
|
||||||
DOWNTIME_THRESHOLD_S = 600 # 速度为0持续超过该秒数判定停机(10min)
|
DOWNTIME_THRESHOLD_S = 600 # 速度为0持续超过该秒数判定停机(10min)
|
||||||
|
|
||||||
|
|
||||||
|
async def place_at_position(db: AsyncSession, plan: ProductionPlan, position: str):
|
||||||
|
"""把计划放到任意入口位置;位置唯一占用。上卷鞍座单独触发生产联动。"""
|
||||||
|
if position not in ENTRY_POSITIONS:
|
||||||
|
raise ValueError("未知入口位置")
|
||||||
|
if position == SADDLE_NAME:
|
||||||
|
await move_to_saddle(db, plan)
|
||||||
|
return
|
||||||
|
# 普通位置:清除同位置上的其它计划,移出鞍座(若在)
|
||||||
|
res = await db.execute(
|
||||||
|
select(ProductionPlan).where(ProductionPlan.position == position, ProductionPlan.id != plan.id)
|
||||||
|
)
|
||||||
|
for o in res.scalars():
|
||||||
|
o.position = None
|
||||||
|
plan.on_saddle = 0
|
||||||
|
plan.run_started_at = None
|
||||||
|
plan.run_speed = 0
|
||||||
|
plan.run_length_m = 0
|
||||||
|
plan.position = position
|
||||||
|
if plan.status == "producing":
|
||||||
|
plan.status = "online"
|
||||||
|
await ensure_online(db)
|
||||||
|
|
||||||
|
|
||||||
def _shift_of(dt: datetime) -> str:
|
def _shift_of(dt: datetime) -> str:
|
||||||
return "甲" if 8 <= dt.hour < 20 else "乙"
|
return "甲" if 8 <= dt.hour < 20 else "乙"
|
||||||
|
|
||||||
@@ -60,6 +93,7 @@ async def move_to_saddle(db: AsyncSession, plan: ProductionPlan):
|
|||||||
if occupied and occupied.id != plan.id:
|
if occupied and occupied.id != plan.id:
|
||||||
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
|
raise ValueError("上卷鞍座已被占用,请等待当前钢卷生产完成")
|
||||||
plan.on_saddle = 1
|
plan.on_saddle = 1
|
||||||
|
plan.position = SADDLE_NAME
|
||||||
plan.saddle_at = datetime.now()
|
plan.saddle_at = datetime.now()
|
||||||
plan.run_started_at = None
|
plan.run_started_at = None
|
||||||
plan.run_speed = 0
|
plan.run_speed = 0
|
||||||
@@ -86,6 +120,7 @@ async def _produce(db: AsyncSession, plan: ProductionPlan):
|
|||||||
plan.status = "produced"
|
plan.status = "produced"
|
||||||
plan.produced_at = now
|
plan.produced_at = now
|
||||||
plan.on_saddle = 0
|
plan.on_saddle = 0
|
||||||
|
plan.position = None
|
||||||
plan.run_speed = 0
|
plan.run_speed = 0
|
||||||
plan.run_length_m = TARGET_LENGTH_M
|
plan.run_length_m = TARGET_LENGTH_M
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ 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 startProducing = id => request.patch(`/plan/${id}/start`) // 移动到上卷鞍座(兼容)
|
||||||
export const moveToSaddle = id => request.patch(`/plan/${id}/start`)
|
export const moveToSaddle = id => request.patch(`/plan/${id}/start`)
|
||||||
|
export const movePlan = (id, position) => request.patch(`/plan/${id}/move`, null, { params: { position } })
|
||||||
|
export const getPositions = () => request.get('/plan/positions/all')
|
||||||
export const commitProducing = id => request.patch(`/plan/${id}/commit`) // 投入生产
|
export const commitProducing = id => request.patch(`/plan/${id}/commit`) // 投入生产
|
||||||
export const getSaddle = () => request.get('/plan/saddle/current')
|
export const getSaddle = () => request.get('/plan/saddle/current')
|
||||||
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
|
export const seedPlans = (count = 50) => request.post('/plan/seed', null, { params: { count } })
|
||||||
|
|||||||
@@ -51,34 +51,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="saddle-empty">
|
<div v-else class="saddle-empty">
|
||||||
上卷鞍座空闲 — 从下方队列点击「移动」把在线计划推到鞍座
|
上卷鞍座空闲 — 从队列点击「移动」,在弹窗中选择「上卷鞍座」即可开始生产
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── 待上卷计划卡片 ─── -->
|
<!-- ─── 入口位置图 ─── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">入口位置 <span class="ch-badge">{{ positions.length }} 个工位</span></div>
|
||||||
待上卷计划
|
|
||||||
<span class="ch-badge">在线/准备 {{ queuePlans.length }} 条</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div v-if="queueCards.length" class="card-grid">
|
<div class="pos-grid">
|
||||||
<div v-for="p in queueCards" :key="p.id" :class="['plan-card', { online: p.status === 'online' }]">
|
<div
|
||||||
<div class="pc-head">
|
v-for="pos in positions"
|
||||||
<span class="pc-coil">{{ p.cold_coil_no || p.plan_no }}</span>
|
:key="pos"
|
||||||
<span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ statusLabel(p.status) }}</span>
|
:class="['pos-cell', { saddle: pos === SADDLE, filled: !!occupantOf(pos) }]"
|
||||||
|
>
|
||||||
|
<div class="pos-title">{{ pos }}<span v-if="pos === SADDLE" class="pos-tag">生产</span></div>
|
||||||
|
<div v-if="occupantOf(pos)" class="pos-occ">
|
||||||
|
<div class="po-coil">{{ occupantOf(pos).cold_coil_no || occupantOf(pos).plan_no }}</div>
|
||||||
|
<div class="po-sub">{{ occupantOf(pos).steel_grade || '—' }}</div>
|
||||||
|
<div class="po-sub">{{ fmt(occupantOf(pos).product_thickness, 2) }}×{{ fmt(occupantOf(pos).product_width, 0) }}</div>
|
||||||
|
<span :class="['badge', badgeOf(occupantOf(pos).status)]" style="margin-top:3px;">{{ statusLabel(occupantOf(pos).status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pc-body">
|
<div v-else class="pos-empty">空</div>
|
||||||
<div class="pc-row"><span>钢种</span><b>{{ p.steel_grade || '—' }}</b></div>
|
|
||||||
<div class="pc-row"><span>规格</span><b>{{ fmt(p.product_thickness, 2) }}×{{ fmt(p.product_width, 0) }}</b></div>
|
|
||||||
<div class="pc-row"><span>重量[t]</span><b>{{ fmt(p.incoming_weight, 3) }}</b></div>
|
|
||||||
<div class="pc-row"><span>轧制模式</span><b>{{ p.rolling_mode || '—' }}</b></div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary fw" :disabled="saddleOccupied" @click="move(p)">移动到鞍座</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +90,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>序号</th><th>冷卷号</th><th>热卷号</th><th>钢种</th>
|
<th>序号</th><th>冷卷号</th><th>热卷号</th><th>钢种</th>
|
||||||
<th>规格(厚×宽)</th><th>来料重量</th><th>轧制模式</th><th>状态</th><th>操作</th>
|
<th>规格(厚×宽)</th><th>来料重量</th><th>轧制模式</th><th>状态</th><th>当前位置</th><th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -105,44 +102,80 @@
|
|||||||
<td class="td-num">{{ fmt(p.product_thickness, 2) }} × {{ fmt(p.product_width, 0) }}</td>
|
<td class="td-num">{{ fmt(p.product_thickness, 2) }} × {{ fmt(p.product_width, 0) }}</td>
|
||||||
<td class="td-num">{{ fmt(p.incoming_weight, 2) }}</td>
|
<td class="td-num">{{ fmt(p.incoming_weight, 2) }}</td>
|
||||||
<td>{{ p.rolling_mode || '—' }}</td>
|
<td>{{ p.rolling_mode || '—' }}</td>
|
||||||
<td><span :class="['badge', p.status === 'online' ? 'badge-green' : 'badge-gray']">{{ statusLabel(p.status) }}</span></td>
|
<td><span :class="['badge', badgeOf(p.status)]">{{ statusLabel(p.status) }}</span></td>
|
||||||
<td><span :class="['action-link', { disabled: saddleOccupied }]" @click="!saddleOccupied && move(p)">移动</span></td>
|
<td class="td-muted">{{ p.position || '—' }}</td>
|
||||||
|
<td><span class="action-link" @click="openMove(p)">移动</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!queuePlans.length">
|
<tr v-if="!queuePlans.length">
|
||||||
<td colspan="9" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td>
|
<td colspan="10" class="td-muted" style="text-align:center;padding:14px;">暂无在线/准备好的计划</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── 移动-位置选择弹窗 ─── -->
|
||||||
|
<div v-if="moveDialog.visible" class="modal-mask" @click.self="moveDialog.visible=false">
|
||||||
|
<div class="modal-box" style="width:480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
移动计划 — {{ moveDialog.plan && (moveDialog.plan.cold_coil_no || moveDialog.plan.plan_no) }}
|
||||||
|
<span class="modal-close" @click="moveDialog.visible=false">✕</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="kv-label" style="margin-bottom:8px;">选择目标位置(只有「上卷鞍座」会触发生产)</div>
|
||||||
|
<div class="pos-pick">
|
||||||
|
<span
|
||||||
|
v-for="pos in positions"
|
||||||
|
:key="pos"
|
||||||
|
:class="['pick-item', { active: moveDialog.target === pos, saddle: pos === SADDLE }]"
|
||||||
|
@click="moveDialog.target = pos"
|
||||||
|
>{{ pos }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline" @click="moveDialog.visible=false">取消</button>
|
||||||
|
<button class="btn btn-primary" :disabled="!moveDialog.target || moving" @click="confirmMove">{{ moving ? '移动中...' : '确定' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getPlans, getSaddle, moveToSaddle, commitProducing } from '@/api'
|
import { getPlans, getSaddle, movePlan, commitProducing } from '@/api'
|
||||||
|
|
||||||
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
|
const STATUS_LABEL = { ready: '准备好', online: '在线', producing: '生产中', produced: '生产完成' }
|
||||||
|
const STATUS_BADGE = { ready: 'badge-gray', online: 'badge-green', producing: 'badge-yellow', produced: 'badge-blue' }
|
||||||
|
const SADDLE = '上卷鞍座'
|
||||||
|
const POSITIONS = [
|
||||||
|
'1#上卷小车', '2#上卷小车',
|
||||||
|
'1#称重位', '2#称重位',
|
||||||
|
'1#地辊', '2#地辊',
|
||||||
|
'1#倒卷小车', '2#倒卷小车',
|
||||||
|
SADDLE,
|
||||||
|
]
|
||||||
const TARGET = 2000
|
const TARGET = 2000
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'EntryTracking',
|
name: 'EntryTracking',
|
||||||
data() {
|
data() {
|
||||||
return { plans: [], saddle: null, TARGET, timer: null, fastTimer: null }
|
return {
|
||||||
|
plans: [], saddle: null, TARGET, SADDLE, positions: POSITIONS,
|
||||||
|
timer: null, fastTimer: null, moving: false,
|
||||||
|
moveDialog: { visible: false, plan: null, target: '' },
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
queuePlans() {
|
queuePlans() {
|
||||||
// 在线 + 准备好,且不在鞍座上;在线排前
|
|
||||||
return this.plans
|
return this.plans
|
||||||
.filter(p => (p.status === 'online' || p.status === 'ready') && p.on_saddle !== 1)
|
.filter(p => (p.status === 'online' || p.status === 'ready') && p.on_saddle !== 1)
|
||||||
.sort((a, b) => (a.status === 'online' ? -1 : 1) - (b.status === 'online' ? -1 : 1))
|
.sort((a, b) => (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1))
|
||||||
},
|
},
|
||||||
queueCards() { return this.queuePlans.slice(0, 8) },
|
|
||||||
saddleOccupied() { return !!this.saddle && this.saddle.status !== 'produced' },
|
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.refreshAll()
|
this.refreshAll()
|
||||||
this.fastTimer = setInterval(this.fetchSaddle, 2000) // 鞍座实时进度
|
this.fastTimer = setInterval(this.fetchSaddle, 2000)
|
||||||
this.timer = setInterval(this.fetchPlans, 5000) // 队列
|
this.timer = setInterval(this.fetchPlans, 5000)
|
||||||
},
|
},
|
||||||
beforeDestroy() { clearInterval(this.timer); clearInterval(this.fastTimer) },
|
beforeDestroy() { clearInterval(this.timer); clearInterval(this.fastTimer) },
|
||||||
methods: {
|
methods: {
|
||||||
@@ -161,20 +194,28 @@ export default {
|
|||||||
},
|
},
|
||||||
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
|
fmt(v, n = 2) { return v != null && v !== '' ? Number(v).toFixed(n) : '—' },
|
||||||
statusLabel(s) { return STATUS_LABEL[s] || s || '—' },
|
statusLabel(s) { return STATUS_LABEL[s] || s || '—' },
|
||||||
|
badgeOf(s) { return STATUS_BADGE[s] || 'badge-gray' },
|
||||||
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
|
progPct(p) { return Math.max(0, Math.min(100, (p.run_length_m || 0) / TARGET * 100)) },
|
||||||
progColor(p) {
|
progColor(p) { return this.progPct(p) >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)' },
|
||||||
const pct = this.progPct(p)
|
occupantOf(pos) {
|
||||||
return pct >= 100 ? 'var(--accent-green)' : 'var(--sms-teal)'
|
if (pos === SADDLE) return this.saddle || this.plans.find(p => p.on_saddle === 1) || null
|
||||||
|
return this.plans.find(p => p.position === pos && p.on_saddle !== 1) || null
|
||||||
},
|
},
|
||||||
async move(p) {
|
openMove(plan) {
|
||||||
if (this.saddleOccupied) { this.$message.warning('鞍座已被占用,请等待当前钢卷生产完成'); return }
|
this.moveDialog = { visible: true, plan, target: plan.position || '' }
|
||||||
|
},
|
||||||
|
async confirmMove() {
|
||||||
|
const { plan, target } = this.moveDialog
|
||||||
|
if (!target) return
|
||||||
|
this.moving = true
|
||||||
try {
|
try {
|
||||||
await moveToSaddle(p.id)
|
await movePlan(plan.id, target)
|
||||||
this.$message.success('已移动到上卷鞍座')
|
this.$message.success(target === SADDLE ? '已移动到上卷鞍座' : `已移动到 ${target}`)
|
||||||
|
this.moveDialog.visible = false
|
||||||
this.refreshAll()
|
this.refreshAll()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.$message.error(e?.response?.data?.detail || '操作失败')
|
this.$message.error(e?.response?.data?.detail || '操作失败')
|
||||||
}
|
} finally { this.moving = false }
|
||||||
},
|
},
|
||||||
async commit(p) {
|
async commit(p) {
|
||||||
try {
|
try {
|
||||||
@@ -197,8 +238,7 @@ export default {
|
|||||||
// ── 鞍座工位 ──
|
// ── 鞍座工位 ──
|
||||||
.saddle-station { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
|
.saddle-station { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; }
|
||||||
.saddle-info {
|
.saddle-info {
|
||||||
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px;
|
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px; align-content: start;
|
||||||
align-content: start;
|
|
||||||
border-right: 1px solid $border; padding-right: 16px;
|
border-right: 1px solid $border; padding-right: 16px;
|
||||||
}
|
}
|
||||||
.si-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
|
.si-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; }
|
||||||
@@ -211,24 +251,39 @@ export default {
|
|||||||
.rp-head { display: flex; justify-content: space-between; font-size: 12px; color: $text-secondary; margin-bottom: 5px; }
|
.rp-head { display: flex; justify-content: space-between; font-size: 12px; color: $text-secondary; margin-bottom: 5px; }
|
||||||
.rp-pct { font-family: $font-mono; font-weight: 700; color: $sms-teal; }
|
.rp-pct { font-family: $font-mono; font-weight: 700; color: $sms-teal; }
|
||||||
.rp-tip { font-size: 11px; color: $text-muted; margin-top: 6px; }
|
.rp-tip { font-size: 11px; color: $text-muted; margin-top: 6px; }
|
||||||
|
|
||||||
.saddle-empty { text-align: center; padding: 30px; color: $text-muted; font-size: 13px; }
|
.saddle-empty { text-align: center; padding: 30px; color: $text-muted; font-size: 13px; }
|
||||||
|
|
||||||
// ── 待上卷卡片 ──
|
// ── 位置图 ──
|
||||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
.pos-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||||
.plan-card {
|
.pos-cell {
|
||||||
background: $bg-panel; border: 1px solid $border; border-radius: 6px;
|
background: $bg-panel; border: 1px solid $border; border-radius: 6px; padding: 8px; min-height: 110px;
|
||||||
padding: 10px; display: flex; flex-direction: column; gap: 8px;
|
&.filled { border-color: $sms-teal; background: rgba($sms-teal, .04); }
|
||||||
&.online { border-color: $accent-green; box-shadow: 0 0 0 1px rgba($accent-green, .25) inset; }
|
&.saddle { border-style: dashed; border-color: $accent-yellow;
|
||||||
|
&.filled { border-style: solid; border-color: $accent-yellow; background: rgba($accent-yellow, .06); } }
|
||||||
}
|
}
|
||||||
.pc-head { display: flex; align-items: center; justify-content: space-between; }
|
.pos-title { font-size: 11.5px; font-weight: 700; color: $text-primary; padding-bottom: 5px; border-bottom: 1px dashed $border; margin-bottom: 6px; display: flex; justify-content: space-between; }
|
||||||
.pc-coil { font-family: $font-mono; font-weight: 700; color: $sms-teal; font-size: 13px; }
|
.pos-tag { font-size: 9px; color: $accent-yellow; border: 1px solid rgba($accent-yellow, .5); border-radius: 2px; padding: 0 4px; }
|
||||||
.pc-body { display: flex; flex-direction: column; gap: 3px; }
|
.pos-occ { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.pc-row { display: flex; justify-content: space-between; font-size: 11.5px; span { color: $text-muted; } b { color: $text-primary; font-family: $font-mono; } }
|
.po-coil { font-family: $font-mono; font-weight: 700; color: $sms-teal; font-size: 12.5px; }
|
||||||
|
.po-sub { font-size: 11px; color: $text-secondary; }
|
||||||
|
.pos-empty { color: $text-muted; font-size: 12px; text-align: center; padding-top: 16px; }
|
||||||
|
|
||||||
.action-link {
|
.action-link { color: $accent-green; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
|
||||||
color: $accent-green; cursor: pointer; font-size: 12px;
|
|
||||||
&:hover { text-decoration: underline; }
|
// ── 移动弹窗 ──
|
||||||
&.disabled { color: $text-muted; cursor: not-allowed; text-decoration: none; }
|
.pos-pick { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.pick-item {
|
||||||
|
padding: 9px 12px; font-size: 12px; text-align: center; border-radius: 6px; cursor: pointer;
|
||||||
|
border: 1px solid $border; color: $text-secondary; background: $bg-card;
|
||||||
|
&:hover { border-color: $sms-teal; color: $sms-teal; }
|
||||||
|
&.active { color: #fff; background: $sms-teal; border-color: $sms-teal; }
|
||||||
|
&.saddle { grid-column: 1 / -1; border-style: dashed; border-color: $accent-yellow; color: $accent-yellow;
|
||||||
|
&.active { color: #fff; background: $accent-yellow; border-style: solid; } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@
|
|||||||
<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 @click.stop>
|
<td @click.stop>
|
||||||
<span class="action-link" @click="openDialog(row)">编辑</span>
|
<span class="action-link" @click="openDialog(row)">编辑</span>
|
||||||
<span v-if="row.status === 'online'"
|
<span v-if="row.status === 'online' || row.status === 'ready'"
|
||||||
class="action-link" style="color:var(--accent-green)" @click="moveToProducing(row)">移动</span>
|
class="action-link" style="color:var(--accent-green)" @click="openMove(row)">移动</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!tableData.length && !loading">
|
<tr v-if="!tableData.length && !loading">
|
||||||
@@ -239,11 +239,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动-位置选择弹窗 -->
|
||||||
|
<div v-if="moveDialog.visible" class="modal-mask" @click.self="moveDialog.visible=false">
|
||||||
|
<div class="modal-box" style="width:480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
移动计划 — {{ moveDialog.plan && (moveDialog.plan.cold_coil_no || moveDialog.plan.plan_no) }}
|
||||||
|
<span class="modal-close" @click="moveDialog.visible=false">✕</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="kv-label" style="margin-bottom:8px;">选择目标位置(只有「上卷鞍座」会触发生产)</div>
|
||||||
|
<div class="pos-pick">
|
||||||
|
<span
|
||||||
|
v-for="pos in positions"
|
||||||
|
:key="pos"
|
||||||
|
:class="['pick-item', { active: moveDialog.target === pos, saddle: pos === '上卷鞍座' }]"
|
||||||
|
@click="moveDialog.target = pos"
|
||||||
|
>{{ pos }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-outline" @click="moveDialog.visible=false">取消</button>
|
||||||
|
<button class="btn btn-primary" :disabled="!moveDialog.target || moving" @click="confirmMove">{{ moving ? '移动中...' : '确定' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm, startProducing, getLastPlanTemplate } from '@/api'
|
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm, movePlan, getLastPlanTemplate } from '@/api'
|
||||||
|
|
||||||
|
const SADDLE = '上卷鞍座'
|
||||||
|
const POSITIONS = [
|
||||||
|
'1#上卷小车', '2#上卷小车', '1#称重位', '2#称重位',
|
||||||
|
'1#地辊', '2#地辊', '1#倒卷小车', '2#倒卷小车', SADDLE,
|
||||||
|
]
|
||||||
|
|
||||||
const STATUS_MAP = {
|
const STATUS_MAP = {
|
||||||
ready: { label: '准备好', badge: 'badge-gray' },
|
ready: { label: '准备好', badge: 'badge-gray' },
|
||||||
@@ -269,6 +300,8 @@ export default {
|
|||||||
dialogVisible: false, editRow: null,
|
dialogVisible: false, editRow: null,
|
||||||
form: this.emptyForm(),
|
form: this.emptyForm(),
|
||||||
selectedRow: null,
|
selectedRow: null,
|
||||||
|
positions: POSITIONS, moving: false,
|
||||||
|
moveDialog: { visible: false, plan: null, target: '' },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() { this.fetchData() },
|
created() { this.fetchData() },
|
||||||
@@ -348,15 +381,21 @@ export default {
|
|||||||
this.$message.success('已上线')
|
this.$message.success('已上线')
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
},
|
},
|
||||||
async moveToProducing(row) {
|
openMove(row) {
|
||||||
if (!confirm(`将计划 ${row.cold_coil_no || row.plan_no} 移动到上卷鞍座?`)) return
|
this.moveDialog = { visible: true, plan: row, target: row.position || '' }
|
||||||
|
},
|
||||||
|
async confirmMove() {
|
||||||
|
const { plan, target } = this.moveDialog
|
||||||
|
if (!target) return
|
||||||
|
this.moving = true
|
||||||
try {
|
try {
|
||||||
await startProducing(row.id)
|
await movePlan(plan.id, target)
|
||||||
this.$message.success('已移动到上卷鞍座')
|
this.$message.success(target === SADDLE ? '已移动到上卷鞍座' : `已移动到 ${target}`)
|
||||||
|
this.moveDialog.visible = false
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.$message.error(e?.response?.data?.detail || '操作失败')
|
this.$message.error(e?.response?.data?.detail || '操作失败')
|
||||||
}
|
} finally { this.moving = false }
|
||||||
},
|
},
|
||||||
async save() {
|
async save() {
|
||||||
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }
|
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }
|
||||||
@@ -405,4 +444,13 @@ export default {
|
|||||||
.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; } } }
|
||||||
.modal-body { padding: 16px; overflow-y: auto; }
|
.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; }
|
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||||
|
.pos-pick { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.pick-item {
|
||||||
|
padding: 9px 12px; font-size: 12px; text-align: center; border-radius: 6px; cursor: pointer;
|
||||||
|
border: 1px solid $border; color: $text-secondary; background: $bg-card;
|
||||||
|
&:hover { border-color: $sms-teal; color: $sms-teal; }
|
||||||
|
&.active { color: #fff; background: $sms-teal; border-color: $sms-teal; }
|
||||||
|
&.saddle { grid-column: 1 / -1; border-style: dashed; border-color: $accent-yellow; color: $accent-yellow;
|
||||||
|
&.active { color: #fff; background: $accent-yellow; border-style: solid; } }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user