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:
2026-06-29 14:10:16 +08:00
parent 9fb3dcb785
commit 03709da1ca
8 changed files with 233 additions and 68 deletions

View File

@@ -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)):
"""投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。""" """投入生产:把鞍座上的计划置为生产中(兜底未实时变化的数据)。"""

View File

@@ -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",

View File

@@ -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="投入生产(有速度)时间")

View File

@@ -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

View File

@@ -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/min2000m ≈ 200s SIM_SPEED_M_MIN = 600.0 # 仿真线速度 m/min2000m ≈ 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

View File

@@ -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 } })

View File

@@ -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>

View File

@@ -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>