agentworkshop 0.7.11 → 0.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{BLzCac9K2.js → CcwJ2mrD2.js} +1 -1
  3. package/.output/public/_nuxt/CvYfzHYz.js +1 -0
  4. package/.output/public/_nuxt/{B5CYgFAD.js → D2JyEL28.js} +4 -4
  5. package/.output/public/_nuxt/{CGx-mtWf.js → D8xY-Yjc.js} +1 -1
  6. package/.output/public/_nuxt/DaE5saZg.js +1 -0
  7. package/.output/public/_nuxt/Dsdy4Ua-.js +1 -0
  8. package/.output/public/_nuxt/{B8WV7GzS.js → E88a-JCG.js} +1 -1
  9. package/.output/public/_nuxt/bAFueD6a.js +1 -0
  10. package/.output/public/_nuxt/builds/latest.json +1 -1
  11. package/.output/public/_nuxt/builds/meta/54e22640-4fd0-4fee-9077-31d4d4bb7969.json +1 -0
  12. package/.output/public/_nuxt/dcw.rpqpOYQ8.css +1 -0
  13. package/.output/public/_nuxt/entry.Bv_GCmPP.css +1 -0
  14. package/.output/public/_nuxt/hb_ceMJP.js +1 -0
  15. package/.output/public/_nuxt/logs.BS1sb41K.css +1 -0
  16. package/.output/public/_nuxt/pages.BkE-AfIb.css +1 -0
  17. package/.output/public/_nuxt/permissions.DTRtZeTw.css +1 -0
  18. package/.output/public/_nuxt/plugins.BcSEwwGb.css +1 -0
  19. package/.output/public/_nuxt/tokens.rW1xHxRW.css +1 -0
  20. package/.output/public/_nuxt/workshop.CbymC2LY.css +1 -0
  21. package/.output/server/chunks/_/nitro.mjs +772 -772
  22. package/.output/server/chunks/build/{dcw-DqjHrl5Z.mjs → dcw-B0dtiHFG.mjs} +43 -43
  23. package/.output/server/chunks/build/dcw-B0dtiHFG.mjs.map +1 -0
  24. package/.output/server/chunks/build/{default-YcUHc1Ev.mjs → default-DdV642BY.mjs} +23 -2
  25. package/.output/server/chunks/build/{default-YcUHc1Ev.mjs.map → default-DdV642BY.mjs.map} +1 -1
  26. package/.output/server/chunks/build/{logs-Co854R4y.mjs → logs-Crf6f1IC.mjs} +34 -34
  27. package/.output/server/chunks/build/{logs-Co854R4y.mjs.map → logs-Crf6f1IC.mjs.map} +1 -1
  28. package/.output/server/chunks/build/{pages-DPC1Vp6w.mjs → pages-BmRUbGUi.mjs} +28 -18
  29. package/.output/server/chunks/build/pages-BmRUbGUi.mjs.map +1 -0
  30. package/.output/server/chunks/build/{permissions-Eu9e6hfZ.mjs → permissions-CxEgXic0.mjs} +12 -12
  31. package/.output/server/chunks/build/{permissions-Eu9e6hfZ.mjs.map → permissions-CxEgXic0.mjs.map} +1 -1
  32. package/.output/server/chunks/build/{plugins-D84mvK9G.mjs → plugins-wLIKHcIE.mjs} +17 -17
  33. package/.output/server/chunks/build/plugins-wLIKHcIE.mjs.map +1 -0
  34. package/.output/server/chunks/build/{tokens-CZpnEDVf.mjs → tokens-BKLzcdMg.mjs} +44 -75
  35. package/.output/server/chunks/build/tokens-BKLzcdMg.mjs.map +1 -0
  36. package/.output/server/chunks/build/{workshop-Crsai2Y9.mjs → workshop-CTcYDq2i.mjs} +21 -16
  37. package/.output/server/chunks/build/{workshop-Crsai2Y9.mjs.map → workshop-CTcYDq2i.mjs.map} +1 -1
  38. package/.output/server/chunks/routes/renderer.mjs +1 -1
  39. package/.output/server/chunks/virtual/entry.mjs +12 -10
  40. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  41. package/.output/server/package.json +1 -1
  42. package/app/assets/css/main.css +161 -0
  43. package/app/components/AwChart.vue +12 -2
  44. package/app/composables/useRouteMeta.ts +8 -1
  45. package/app/pages/dcw/index.vue +1079 -1079
  46. package/app/pages/index.vue +7 -2
  47. package/app/pages/logs/index.vue +621 -621
  48. package/app/pages/permissions/index.vue +330 -327
  49. package/app/pages/plugins/index.vue +6 -3
  50. package/app/pages/tokens.vue +9 -13
  51. package/app/pages/workshop/index.vue +3 -1
  52. package/i18n/locales/en.ts +1944 -1943
  53. package/i18n/locales/zh-CN.ts +1943 -1942
  54. package/package.json +1 -1
  55. package/scripts/_dbg-kicker-check.mjs +11 -0
  56. package/scripts/_dbg-perms-e2e.mjs +3 -3
  57. package/scripts/_dbg-plugins-e2e.mjs +1 -1
  58. package/scripts/_dbg-swatch-check.mjs +10 -0
  59. package/scripts/_dbg-ui-shots.mjs +28 -0
  60. package/server/data/dcw-rollback.json +6 -6
  61. package/server/data/dcws.json +345 -345
  62. package/.output/public/_nuxt/Bckshc9V.js +0 -1
  63. package/.output/public/_nuxt/D7jiode2.js +0 -1
  64. package/.output/public/_nuxt/DEaY_uvR.js +0 -1
  65. package/.output/public/_nuxt/builds/meta/88479a5b-ce6a-400f-9737-1cc9f54b1fe5.json +0 -1
  66. package/.output/public/_nuxt/d_Sz9KGm.js +0 -1
  67. package/.output/public/_nuxt/dcw.DcDjwvqv.css +0 -1
  68. package/.output/public/_nuxt/entry.B03HBJGC.css +0 -1
  69. package/.output/public/_nuxt/logs.BO-BSyjm.css +0 -1
  70. package/.output/public/_nuxt/pages.CytEOfUs.css +0 -1
  71. package/.output/public/_nuxt/permissions.CwZo6t1V.css +0 -1
  72. package/.output/public/_nuxt/plugins.Clsky_kl.css +0 -1
  73. package/.output/public/_nuxt/tJuVvBSl.js +0 -1
  74. package/.output/public/_nuxt/tokens.BPLe9ioQ.css +0 -1
  75. package/.output/public/_nuxt/workshop.BIdoGfoC.css +0 -1
  76. package/.output/server/chunks/build/dcw-DqjHrl5Z.mjs.map +0 -1
  77. package/.output/server/chunks/build/pages-DPC1Vp6w.mjs.map +0 -1
  78. package/.output/server/chunks/build/plugins-D84mvK9G.mjs.map +0 -1
  79. package/.output/server/chunks/build/tokens-CZpnEDVf.mjs.map +0 -1
@@ -1,1079 +1,1079 @@
1
- <script setup lang="ts">
2
- import { computed, reactive, ref } from 'vue'
3
- import { DCW_LINE_COLORS, type DcwTemplateIcon, type LineView } from '#shared/dcw-protocol'
4
- import { useDcwStream } from '~/composables/workshop/useDcwStream'
5
-
6
- const { t } = useI18n()
7
-
8
- const dcw = useDcwStream()
9
- void dcw.load()
10
-
11
- // ---------- 产线总览 ----------
12
- interface LineCard {
13
- line: LineView
14
- nodes: number
15
- products: number
16
- recipes: number
17
- }
18
-
19
- const cards = computed<LineCard[]>(() =>
20
- dcw.lines.map(line => ({
21
- line,
22
- nodes: dcw.nodes.filter(n => n.lineId === line.id).length,
23
- products: dcw.products.filter(p => p.lineId === line.id).length,
24
- recipes: dcw.recipes.filter(r => r.lineId === line.id).length,
25
- })),
26
- )
27
- const unassignedCount = computed(() =>
28
- dcw.nodes.filter(n => !n.lineId).length + dcw.products.filter(p => !p.lineId).length,
29
- )
30
-
31
- // ---------- 清单筛选(94 条历史线的管理页:导航先于遍历) ----------
32
- const filterState = ref<'all' | 'running' | 'idle'>('all')
33
- const searchText = ref('')
34
- const runningCount = computed(() => cards.value.filter(c => dcw.lineStateOf(c.line.id).active).length)
35
- const shownCards = computed(() => cards.value.filter((c) => {
36
- const active = dcw.lineStateOf(c.line.id).active
37
- if (filterState.value === 'running' && !active) return false
38
- if (filterState.value === 'idle' && active) return false
39
- const q = searchText.value.trim().toLowerCase()
40
- if (q && !c.line.name.toLowerCase().includes(q) && !(c.line.description ?? '').toLowerCase().includes(q)) return false
41
- return true
42
- }))
43
-
44
- // ---------- 新建产线 ----------
45
- const createOpen = ref(false)
46
- const createSaving = ref(false)
47
- const createError = ref('')
48
- const createForm = reactive({ name: '', description: '', color: '' })
49
-
50
- const nextColor = computed(() => DCW_LINE_COLORS[dcw.lines.length % DCW_LINE_COLORS.length] ?? '#3aa0ff')
51
-
52
- function openCreate(): void {
53
- createForm.name = ''
54
- createForm.description = ''
55
- createForm.color = ''
56
- createError.value = ''
57
- createOpen.value = true
58
- }
59
-
60
- async function doCreateLine(): Promise<void> {
61
- createSaving.value = true
62
- createError.value = ''
63
- try {
64
- await dcw.createLine({
65
- name: createForm.name.trim(),
66
- description: createForm.description.trim(),
67
- color: createForm.color || undefined,
68
- })
69
- createOpen.value = false
70
- }
71
- catch (err) {
72
- createError.value = apiErrorMessage(err)
73
- }
74
- finally {
75
- createSaving.value = false
76
- }
77
- }
78
-
79
- // ---------- 快捷启停(卡片上) ----------
80
- const quickBusy = ref('')
81
- const quickErr = ref('')
82
- const quickPick = reactive<Record<string, string>>({})
83
-
84
- async function quickStart(card: LineCard): Promise<void> {
85
- quickBusy.value = card.line.id
86
- quickErr.value = ''
87
- try {
88
- const rid = quickPick[card.line.id] ?? ''
89
- if (!rid) throw new Error(t('dcw.k188cswn039'))
90
- await dcw.startLine(card.line.id, rid)
91
- }
92
- catch (err) {
93
- quickErr.value = apiErrorMessage(err)
94
- }
95
- finally {
96
- quickBusy.value = ''
97
- }
98
- }
99
-
100
- async function quickStop(card: LineCard): Promise<void> {
101
- quickBusy.value = card.line.id
102
- quickErr.value = ''
103
- try {
104
- await dcw.stopLine(card.line.id)
105
- }
106
- catch (err) {
107
- quickErr.value = apiErrorMessage(err)
108
- }
109
- finally {
110
- quickBusy.value = ''
111
- }
112
- }
113
-
114
- /** 卡片配方下拉:本产线配方(继承自产品) */
115
- function recipesOf(lineId: string) {
116
- return dcw.recipes.filter(r => r.lineId === lineId)
117
- }
118
-
119
- /** 删除产线(弹窗两步确认;purge 勾选 = 连同旗下节点/产品/配方一并清理) */
120
- const delOpen = ref(false)
121
- const delBusy = ref(false)
122
- const delErr = ref('')
123
- const delPurge = ref(true)
124
- const delCard = ref<LineCard | null>(null)
125
-
126
- function openDelete(card: LineCard): void {
127
- delCard.value = card
128
- delPurge.value = true
129
- delErr.value = ''
130
- delOpen.value = true
131
- }
132
-
133
- async function doDeleteLine(): Promise<void> {
134
- if (!delCard.value)
135
- return
136
- delBusy.value = true
137
- delErr.value = ''
138
- try {
139
- await dcw.removeLine(delCard.value.line.id, delPurge.value)
140
- delOpen.value = false
141
- }
142
- catch (err) {
143
- delErr.value = apiErrorMessage(err)
144
- }
145
- finally {
146
- delBusy.value = false
147
- }
148
- }
149
-
150
- /** 编辑产线(名称/描述/光晕色) */
151
- const editOpen = ref(false)
152
- const editSaving = ref(false)
153
- const editError = ref('')
154
- const editForm = reactive({ id: '', name: '', description: '', color: '' })
155
-
156
- function openEdit(card: LineCard): void {
157
- editForm.id = card.line.id
158
- editForm.name = card.line.name
159
- editForm.description = card.line.description
160
- editForm.color = card.line.color
161
- editError.value = ''
162
- editOpen.value = true
163
- }
164
-
165
- async function doEditLine(): Promise<void> {
166
- editSaving.value = true
167
- editError.value = ''
168
- try {
169
- await dcw.updateLine(editForm.id, {
170
- name: editForm.name.trim(),
171
- description: editForm.description.trim(),
172
- color: editForm.color || undefined,
173
- })
174
- editOpen.value = false
175
- }
176
- catch (err) {
177
- editError.value = apiErrorMessage(err)
178
- }
179
- finally {
180
- editSaving.value = false
181
- }
182
- }
183
-
184
- // ---------- 控制模板管理(自定义分类) ----------
185
- const tplOpen = ref(false)
186
- const tplError = ref('')
187
- const tplForm = reactive({ name: '', ch: '', code: '', unit: '', min: '' as number | '', max: '' as number | '', decimals: 1, icon: 'gateway' as DcwTemplateIcon, semantics: '' })
188
- const tplIcons: Array<{ key: DcwTemplateIcon, label: string }> = [
189
- { key: 'thermo', label: t('dcw.k422b8040') },
190
- { key: 'pressure', label: t('dcw.k3x6ff041') },
191
- { key: 'tension', label: t('dcw.k3z9xc042') },
192
- { key: 'encoder', label: t('dcw.kjb3vhs043') },
193
- { key: 'camera', label: t('dcw.k47atw044') },
194
- { key: 'gateway', label: t('dcw.k48c07045') },
195
- ]
196
-
197
- async function doCreateTemplate(): Promise<void> {
198
- tplError.value = ''
199
- try {
200
- if (!tplForm.name.trim()) throw new Error(t('dcw.k6ugbw2046'))
201
- if (tplForm.min === '' || tplForm.max === '') throw new Error(t('dcw.k13awowo047'))
202
- await dcw.createTemplate({
203
- name: tplForm.name.trim(),
204
- ch: tplForm.ch.trim() || tplForm.name.trim(),
205
- code: tplForm.code.trim() || 'CUSTOM',
206
- unit: tplForm.unit.trim() || '-',
207
- min: Number(tplForm.min),
208
- max: Number(tplForm.max),
209
- decimals: Number(tplForm.decimals) || 0,
210
- icon: tplForm.icon,
211
- semantics: tplForm.semantics.trim() || undefined,
212
- })
213
- tplForm.name = ''
214
- tplForm.ch = ''
215
- tplForm.code = ''
216
- tplForm.unit = ''
217
- tplForm.min = ''
218
- tplForm.max = ''
219
- }
220
- catch (err) {
221
- tplError.value = apiErrorMessage(err)
222
- }
223
- }
224
-
225
- async function doRemoveTemplate(key: string): Promise<void> {
226
- tplError.value = ''
227
- try {
228
- await fetch(`/api/workshop/dcw/templates/${key}`, {
229
- method: 'DELETE',
230
- headers: { authorization: `Bearer ${document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] ?? ''}` },
231
- }).then(async (r) => {
232
- const json = await r.json().catch(() => ({}))
233
- if (json?.code !== 0) throw new Error(json?.message ?? t('dcw.k1bphrb3048'))
234
- })
235
- const i = dcw.templates.findIndex(t => t.key === key)
236
- if (i >= 0) dcw.templates.splice(i, 1)
237
- }
238
- catch (err) {
239
- tplError.value = apiErrorMessage(err)
240
- }
241
- }
242
-
243
- const builtinCount = computed(() => dcw.templates.filter(t => t.builtin).length)
244
- </script>
245
-
246
- <template>
247
- <div class="page">
248
- <div class="aw-page-head">
249
- <div>
250
- <p class="aw-kicker">
251
- AGENTWORKSHOP / LINE OPERATIONS
252
- </p>
253
- <h1>{{ $t('dcw.k1b2tk5c009') }}</h1>
254
- <p class="sub">
255
- {{ $t('dcw.k5q0aqi010') }}
256
- </p>
257
- </div>
258
- <div class="badges mono">
259
- <span
260
- v-if="unassignedCount"
261
- class="badge warn-badge"
262
- :title="$t('dcw.k1i5a9vw001')"
263
- >{{ $t('dcw.k3ootr6049') }} {{ unassignedCount }}</span>
264
- <span class="badge">{{ $t('dcw.k3wj9n050') }} {{ dcw.lines.length }}</span>
265
- <button
266
- class="badge tpl-btn"
267
- @click="tplOpen = true"
268
- >
269
- {{ $t('dcw.k11nndsp051') }} {{ dcw.templates.length }}
270
- </button>
271
- </div>
272
- </div>
273
-
274
- <p
275
- v-if="quickErr"
276
- class="banner bad"
277
- >
278
- {{ quickErr }}
279
- </p>
280
-
281
- <!-- 清单筛选:状态分段 + 名称搜索(复用 aw-seg / 页内 inp 语言) -->
282
- <div class="fleet-filter">
283
- <div class="aw-seg">
284
- <button
285
- :class="{ on: filterState === 'all' }"
286
- @click="filterState = 'all'"
287
- >
288
- {{ $t('common.all') }} {{ cards.length }}
289
- </button>
290
- <button
291
- :class="{ on: filterState === 'running' }"
292
- @click="filterState = 'running'"
293
- >
294
- {{ $t('dcw.k1eox1el055') }} {{ runningCount }}
295
- </button>
296
- <button
297
- :class="{ on: filterState === 'idle' }"
298
- @click="filterState = 'idle'"
299
- >
300
- {{ $t('dcw.k149r6y7059') }} {{ cards.length - runningCount }}
301
- </button>
302
- </div>
303
- <input
304
- v-model="searchText"
305
- class="inp fleet-search"
306
- type="search"
307
- :placeholder="$t('dcw.filterSearchPh')"
308
- >
309
- </div>
310
-
311
- <!-- 产线卡片栅格 -->
312
- <div class="line-grid">
313
- <div
314
- v-for="c in shownCards"
315
- :key="c.line.id"
316
- class="line-card"
317
- :class="{ idle: !dcw.lineStateOf(c.line.id).active }"
318
- :style="{ '--lc': c.line.color }"
319
- >
320
- <div class="lc-head">
321
- <span class="lc-dot" />
322
- <b class="lc-name">{{ c.line.name }}</b>
323
- <span
324
- class="lc-state"
325
- :class="{ on: dcw.lineStateOf(c.line.id).active }"
326
- >{{ dcw.lineStateOf(c.line.id).active ? $t('dcw.k1eox1el055') : $t('dcw.k149r6y7059') }}</span>
327
- <button
328
- class="lc-act"
329
- :title="$t('common.edit')"
330
- @click="openEdit(c)"
331
- >
332
- <span class="i-tabler-pencil" />
333
- </button>
334
- <button
335
- class="lc-act danger"
336
- :title="$t('dcw.k3xakp026')"
337
- @click="openDelete(c)"
338
- >
339
- <span class="i-tabler-trash" />
340
- </button>
341
- </div>
342
- <small
343
- v-if="dcw.lineStateOf(c.line.id).active"
344
- class="lc-run mono"
345
- >{{ dcw.lineStateOf(c.line.id).productName }} · {{ dcw.lineStateOf(c.line.id).recipeName }} · {{ $t('dcw.k3zz0f052') }} {{ dcw.lineStateOf(c.line.id).taggedSamples }}</small>
346
- <small
347
- v-else
348
- class="lc-run dim"
349
- :class="{ ph: !c.line.description }"
350
- >{{ c.line.description || $t('dcw.k18moyq1056') }}</small>
351
- <div class="lc-stats mono">
352
- <span>{{ $t('dcw.k45uio011') }} <b>{{ c.nodes }}</b></span>
353
- <span>{{ $t('dcw.k3waz1012') }} <b>{{ c.products }}</b></span>
354
- <span>{{ $t('dcw.k48grv013') }} <b>{{ c.recipes }}</b></span>
355
- </div>
356
- <div class="lc-ctl">
357
- <template v-if="!dcw.lineStateOf(c.line.id).active">
358
- <select
359
- v-model="quickPick[c.line.id]"
360
- class="inp"
361
- :disabled="recipesOf(c.line.id).length === 0"
362
- :title="$t('dcw.k1dhaby4003')"
363
- >
364
- <option value="">
365
- {{ recipesOf(c.line.id).length ? $t('dcw.kutxzsz057') : $t('dcw.k1elczt9060') }}
366
- </option>
367
- <option
368
- v-for="r in recipesOf(c.line.id)"
369
- :key="r.id"
370
- :value="r.id"
371
- :disabled="r.params.length === 0"
372
- >
373
- {{ r.name }}({{ r.params.length }})
374
- </option>
375
- </select>
376
- <button
377
- class="pill-btn"
378
- :disabled="quickBusy === c.line.id || !quickPick[c.line.id]"
379
- @click="quickStart(c)"
380
- >
381
- {{ $t('dcw.k149b4vg014') }}
382
- </button>
383
- </template>
384
- <button
385
- v-else
386
- class="pill-btn stop"
387
- :disabled="quickBusy === c.line.id"
388
- @click="quickStop(c)"
389
- >
390
- {{ $t('dcw.k148rclf015') }}
391
- </button>
392
- </div>
393
- <NuxtLink
394
- class="lc-manage"
395
- :to="`/dcw/${c.line.id}`"
396
- >
397
- {{ $t('dcw.k1fwqw5g016') }}
398
- </NuxtLink>
399
- </div>
400
-
401
- <!-- 新建产线卡 -->
402
- <button
403
- class="line-card new-card"
404
- @click="openCreate"
405
- >
406
- <span class="i-tabler-plus" />
407
- {{ $t('dcw.k1efe391017') }}
408
- <small>{{ $t('dcw.k19dlbli053') }}{{ nextColor }})</small>
409
- </button>
410
- </div>
411
-
412
- <p
413
- v-if="dcw.loaded && dcw.lines.length === 0"
414
- class="banner"
415
- >
416
- {{ $t('dcw.k1sk85vs018') }}
417
- </p>
418
- <p
419
- v-else-if="dcw.loaded && shownCards.length === 0"
420
- class="banner"
421
- >
422
- {{ $t('dcw.filterEmpty') }}
423
- </p>
424
-
425
- <!-- 新建产线弹窗 -->
426
- <Transition name="modal">
427
- <div
428
- v-if="createOpen"
429
- class="modal-mask"
430
- @click.self="createOpen = false"
431
- >
432
- <div class="modal">
433
- <h3 class="m-title">
434
- {{ $t('dcw.k1efe391017') }}
435
- </h3>
436
- <label class="f">
437
- <span>{{ $t('dcw.k1b2ioko019') }}<em>*</em></span>
438
- <input
439
- v-model="createForm.name"
440
- class="inp"
441
- :placeholder="$t('dcw.kru37i3004')"
442
- >
443
- </label>
444
- <label class="f">
445
- <span>{{ $t('dcw.k24dxcd020') }}</span>
446
- <input
447
- v-model="createForm.description"
448
- class="inp"
449
- :placeholder="$t('dcw.k1f2nwsp005')"
450
- >
451
- </label>
452
- <div class="f">
453
- <span>{{ $t('dcw.k1x7nubr021') }}</span>
454
- <div class="color-row">
455
- <button
456
- v-for="c in DCW_LINE_COLORS"
457
- :key="c"
458
- class="color-dot"
459
- :class="{ on: createForm.color === c }"
460
- :style="{ background: c }"
461
- @click="createForm.color = createForm.color === c ? '' : c"
462
- />
463
- <small class="dim">{{ createForm.color || $t('dcw.k1qidbpy061', { p0: nextColor }) }}</small>
464
- </div>
465
- </div>
466
- <p
467
- v-if="createError"
468
- class="m-err"
469
- >
470
- {{ createError }}
471
- </p>
472
- <div class="m-actions">
473
- <button
474
- class="mini-btn"
475
- @click="createOpen = false"
476
- >
477
- {{ $t('dcw.k3xdnn022') }}
478
- </button>
479
- <button
480
- class="pill-btn"
481
- :disabled="createSaving || !createForm.name.trim()"
482
- @click="doCreateLine"
483
- >
484
- {{ $t('dcw.k3wzi2023') }}
485
- </button>
486
- </div>
487
- </div>
488
- </div>
489
- </Transition>
490
-
491
- <!-- 编辑产线弹窗 -->
492
- <Transition name="modal">
493
- <div
494
- v-if="editOpen"
495
- class="modal-mask"
496
- @click.self="editOpen = false"
497
- >
498
- <div class="modal">
499
- <h3 class="m-title">
500
- {{ $t('dcw.k5tq8wc071') }}
501
- </h3>
502
- <label class="f">
503
- <span>{{ $t('dcw.k1b2ioko019') }}<em>*</em></span>
504
- <input
505
- v-model="editForm.name"
506
- class="inp"
507
- :placeholder="$t('dcw.kru37i3004')"
508
- >
509
- </label>
510
- <label class="f">
511
- <span>{{ $t('dcw.k24dxcd020') }}</span>
512
- <input
513
- v-model="editForm.description"
514
- class="inp"
515
- :placeholder="$t('dcw.k1f2nwsp005')"
516
- >
517
- </label>
518
- <div class="f">
519
- <span>{{ $t('dcw.k1x7nubr021') }}</span>
520
- <div class="color-row">
521
- <button
522
- v-for="c in DCW_LINE_COLORS"
523
- :key="c"
524
- class="color-dot"
525
- :class="{ on: editForm.color === c }"
526
- :style="{ background: c }"
527
- @click="editForm.color = editForm.color === c ? '' : c"
528
- />
529
- <small class="dim">{{ editForm.color || $t('dcw.k1qidbpy061', { p0: nextColor }) }}</small>
530
- </div>
531
- </div>
532
- <p
533
- v-if="editError"
534
- class="m-err"
535
- >
536
- {{ editError }}
537
- </p>
538
- <div class="m-actions">
539
- <button
540
- class="mini-btn"
541
- @click="editOpen = false"
542
- >
543
- {{ $t('dcw.k3xdnn022') }}
544
- </button>
545
- <button
546
- class="pill-btn"
547
- :disabled="editSaving || !editForm.name.trim()"
548
- @click="doEditLine"
549
- >
550
- {{ $t('common.save') }}
551
- </button>
552
- </div>
553
- </div>
554
- </div>
555
- </Transition>
556
-
557
- <!-- 删除产线弹窗(两步确认;purge 勾选决定是否级联清理) -->
558
- <Transition name="modal">
559
- <div
560
- v-if="delOpen && delCard"
561
- class="modal-mask"
562
- @click.self="delOpen = false"
563
- >
564
- <div class="modal">
565
- <h3 class="m-title danger-title">
566
- <span class="i-tabler-alert-triangle" />
567
- {{ $t('dcw.k7xq2mfd063', { p0: delCard.line.name }) }}
568
- </h3>
569
- <p class="del-summary">
570
- {{ delPurge
571
- ? $t('dcw.k4r7nbd073', { p0: delCard.line.name, p1: delCard.nodes, p2: delCard.products, p3: delCard.recipes })
572
- : $t('dcw.k1gp649b062', { p0: delCard.line.name, p1: delCard.nodes, p2: delCard.products, p3: delCard.recipes }) }}
573
- </p>
574
- <label class="del-purge">
575
- <input
576
- v-model="delPurge"
577
- type="checkbox"
578
- >
579
- <span>{{ $t('dcw.k9m2vxa072') }}</span>
580
- </label>
581
- <p
582
- v-if="delErr"
583
- class="m-err"
584
- >
585
- {{ delErr }}
586
- </p>
587
- <div class="m-actions">
588
- <button
589
- class="mini-btn"
590
- @click="delOpen = false"
591
- >
592
- {{ $t('dcw.k3xdnn022') }}
593
- </button>
594
- <button
595
- class="pill-btn danger"
596
- :disabled="delBusy"
597
- @click="doDeleteLine"
598
- >
599
- {{ $t('dcw.k3xakp026') }}
600
- </button>
601
- </div>
602
- </div>
603
- </div>
604
- </Transition>
605
-
606
- <!-- 控制模板管理弹窗 -->
607
- <Transition name="modal">
608
- <div
609
- v-if="tplOpen"
610
- class="modal-mask"
611
- @click.self="tplOpen = false"
612
- >
613
- <div class="modal wide">
614
- <h3 class="m-title">
615
- {{ $t('dcw.k11oadmx024') }} <small class="dim mono">{{ $t('dcw.k3x23c054') }} {{ builtinCount }} · {{ $t('dcw.k3t616a058') }} {{ dcw.templates.length - builtinCount }}</small>
616
- </h3>
617
- <p class="dim tpl-hint">
618
- {{ $t('dcw.k2hlav5025') }}
619
- </p>
620
- <div class="tpl-list">
621
- <div
622
- v-for="t in dcw.templates"
623
- :key="t.key"
624
- class="tpl-row"
625
- :title="t.semantics ?? ''"
626
- >
627
- <b>{{ t.name }}</b>
628
- <small class="mono dim">{{ t.code }} · {{ t.min }}~{{ t.max }} {{ t.unit }}</small>
629
- <small
630
- v-if="t.semantics"
631
- class="tpl-sem"
632
- >{{ t.semantics.slice(0, 40) }}{{ t.semantics.length > 40 ? '…' : '' }}</small>
633
- <span
634
- class="tpl-tag"
635
- :class="{ builtin: t.builtin }"
636
- >{{ t.builtin ? $t('dcw.k3x23c054') : $t('dcw.k3t616a058') }}</span>
637
- <button
638
- v-if="!t.builtin"
639
- class="mini-btn danger"
640
- @click="doRemoveTemplate(t.key)"
641
- >
642
- {{ $t('dcw.k3xakp026') }}
643
- </button>
644
- </div>
645
- </div>
646
- <p class="sec-label">
647
- {{ $t('dcw.k169eb4s027') }}
648
- </p>
649
- <div class="tpl-form">
650
- <label class="f">
651
- <span>{{ $t('dcw.k3xhia028') }}<em>*</em></span>
652
- <input
653
- v-model="tplForm.name"
654
- class="inp"
655
- :placeholder="$t('dcw.k1tooi3o006')"
656
- >
657
- </label>
658
- <label class="f">
659
- <span>{{ $t('dcw.k1bqk219029') }}</span>
660
- <input
661
- v-model="tplForm.ch"
662
- class="inp"
663
- :placeholder="$t('dcw.k698qz0007')"
664
- >
665
- </label>
666
- <label class="f">
667
- <span>{{ $t('dcw.k1ayxrqb030') }}</span>
668
- <input
669
- v-model="tplForm.code"
670
- class="inp"
671
- placeholder="如 MOTOR · I"
672
- >
673
- </label>
674
- <label class="f">
675
- <span>{{ $t('dcw.k3x4ef031') }}</span>
676
- <input
677
- v-model="tplForm.unit"
678
- class="inp"
679
- placeholder="如 A"
680
- >
681
- </label>
682
- <label class="f">
683
- <span>{{ $t('dcw.k1l9jv5m032') }}<em>*</em></span>
684
- <input
685
- v-model.number="tplForm.min"
686
- type="number"
687
- class="inp"
688
- >
689
- </label>
690
- <label class="f">
691
- <span>{{ $t('dcw.k1l9jv4p033') }}<em>*</em></span>
692
- <input
693
- v-model.number="tplForm.max"
694
- type="number"
695
- class="inp"
696
- >
697
- </label>
698
- <label class="f">
699
- <span>{{ $t('dcw.k3mxmcx034') }}</span>
700
- <input
701
- v-model.number="tplForm.decimals"
702
- type="number"
703
- class="inp"
704
- >
705
- </label>
706
- <label class="f">
707
- <span>{{ $t('dcw.k3xx56035') }}</span>
708
- <select
709
- v-model="tplForm.icon"
710
- class="inp"
711
- >
712
- <option
713
- v-for="ic in tplIcons"
714
- :key="ic.key"
715
- :value="ic.key"
716
- >
717
- {{ ic.label }}
718
- </option>
719
- </select>
720
- </label>
721
- </div>
722
- <label class="f">
723
- <span>{{ $t('dcw.k1b6qorg036') }}</span>
724
- <textarea
725
- v-model="tplForm.semantics"
726
- class="inp"
727
- rows="3"
728
- :placeholder="$t('dcw.k1qdnfzc008')"
729
- />
730
- </label>
731
- <p
732
- v-if="tplError"
733
- class="m-err"
734
- >
735
- {{ tplError }}
736
- </p>
737
- <div class="m-actions">
738
- <button
739
- class="mini-btn"
740
- @click="tplOpen = false"
741
- >
742
- {{ $t('dcw.k3x62t037') }}
743
- </button>
744
- <button
745
- class="pill-btn"
746
- :disabled="!tplForm.name.trim() || tplForm.min === '' || tplForm.max === ''"
747
- @click="doCreateTemplate"
748
- >
749
- {{ $t('dcw.k1bg9nga038') }}
750
- </button>
751
- </div>
752
- </div>
753
- </div>
754
- </Transition>
755
-
756
- <p
757
- v-if="dcw.error"
758
- class="banner bad"
759
- >
760
- {{ dcw.error }}
761
- </p>
762
- </div>
763
- </template>
764
-
765
- <style scoped>
766
- .page { display: flex; flex-direction: column; gap: 14px; }
767
- .badges { display: flex; gap: 8px; }
768
- /* 状态徽章 = 玻璃芯片:双主题令牌化,浅色不再发白发黄 */
769
- .badge {
770
- padding: 3px 10px;
771
- font-size: 11px;
772
- color: var(--ink-faint);
773
- background: var(--glass-bg);
774
- border: 1px solid var(--glass-line);
775
- border-radius: 999px;
776
- backdrop-filter: var(--frost-blur);
777
- -webkit-backdrop-filter: var(--frost-blur);
778
- }
779
- .warn-badge { color: var(--tone-warning-dot); background: var(--tone-warning-bg); border-color: color-mix(in srgb, var(--tone-warning-dot) 32%, transparent); }
780
- .tpl-btn { color: var(--tone-info-dot); background: var(--tone-info-bg); border-color: color-mix(in srgb, var(--tone-info-dot) 34%, transparent); cursor: pointer; }
781
- .tpl-btn:hover { border-color: color-mix(in srgb, var(--tone-info-dot) 60%, transparent); }
782
-
783
- /* 清单筛选条:状态分段 + 搜索(与页内控件同一语言) */
784
- .fleet-filter {
785
- display: flex;
786
- flex-wrap: wrap;
787
- gap: 12px;
788
- align-items: center;
789
- margin-bottom: 14px;
790
- }
791
- .fleet-filter .aw-seg button {
792
- font-family: var(--font-mono);
793
- font-size: 11.5px;
794
- font-variant-numeric: tabular-nums;
795
- }
796
- .fleet-search {
797
- width: 220px;
798
- margin-left: auto;
799
- height: 30px;
800
- padding: 0 10px;
801
- font-size: 11.5px;
802
- color: var(--ink);
803
- background: var(--paper-deep);
804
- border: 1px solid var(--line-strong);
805
- border-radius: var(--radius-panel-sm);
806
- }
807
- .fleet-search:focus { outline: none; border-color: var(--tone-success-dot); box-shadow: 0 0 0 3px var(--accent-soft); }
808
- .line-grid {
809
- display: grid;
810
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
811
- gap: 14px;
812
- }
813
- /* 产线卡 = Aurora Glass 仪表砖:半透 surface + 深模糊 + 内缘折射;
814
- 运行卡在玻璃顶缘叠一线产线色洗染做「图」,待机卡收敛为磨砂做「底」 */
815
- .line-card {
816
- position: relative;
817
- display: flex;
818
- flex-direction: column;
819
- gap: 9px;
820
- padding: 14px 16px;
821
- text-align: left;
822
- background: var(--surface-glass);
823
- backdrop-filter: var(--aurora-blur);
824
- -webkit-backdrop-filter: var(--aurora-blur);
825
- border: 1px solid var(--glass-line);
826
- border-radius: var(--radius-panel);
827
- box-shadow: var(--glass-edge);
828
- transition: border-color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base);
829
- }
830
- @media (hover: hover) and (prefers-reduced-motion: no-preference) {
831
- .line-card:hover {
832
- border-color: color-mix(in srgb, var(--lc, #3aa0ff) 55%, var(--line-strong));
833
- transform: translateY(-2px);
834
- box-shadow: var(--glass-edge), var(--shadow-float);
835
- }
836
- }
837
- .line-card:not(.idle) {
838
- border-color: color-mix(in srgb, var(--lc, #3aa0ff) 38%, var(--line-strong));
839
- background:
840
- linear-gradient(180deg, color-mix(in srgb, var(--lc, #3aa0ff) 9%, transparent), transparent 46%),
841
- var(--surface-glass);
842
- }
843
- .line-card.idle {
844
- background: var(--frost-bg);
845
- }
846
- .line-card.idle .lc-dot {
847
- box-shadow: none;
848
- opacity: 0.72;
849
- }
850
- .line-card.idle .lc-name {
851
- color: var(--ink-faint);
852
- }
853
- .lc-head { display: flex; gap: 8px; align-items: center; }
854
- .lc-dot {
855
- width: 10px;
856
- height: 10px;
857
- flex: none;
858
- background: var(--lc);
859
- border-radius: 4px;
860
- box-shadow: 0 0 10px color-mix(in srgb, var(--lc) 70%, transparent);
861
- }
862
- .lc-name {
863
- overflow: hidden;
864
- font-size: 14.5px;
865
- color: var(--ink);
866
- text-overflow: ellipsis;
867
- white-space: nowrap;
868
- }
869
- .lc-state {
870
- margin-left: auto;
871
- flex: none;
872
- font-family: var(--font-mono);
873
- font-size: 10px;
874
- color: var(--ink-fainter);
875
- }
876
- .lc-state.on { color: var(--tone-success-dot); }
877
- /* 头部图标钮:编辑/删除,悬停语义色染色 */
878
- .lc-act {
879
- display: inline-flex;
880
- flex: none;
881
- align-items: center;
882
- justify-content: center;
883
- width: 22px;
884
- height: 22px;
885
- padding: 0;
886
- font-size: 13px;
887
- color: var(--ink-fainter);
888
- background: transparent;
889
- border: 0;
890
- border-radius: 6px;
891
- cursor: pointer;
892
- opacity: 0;
893
- transition:
894
- color 0.15s ease,
895
- background 0.15s ease,
896
- opacity 0.15s ease,
897
- transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
898
- }
899
- .line-card:hover .lc-act,
900
- .line-card:focus-within .lc-act { opacity: 1; }
901
- .lc-act:hover { color: var(--tone-info-dot); background: var(--tone-info-bg); }
902
- .lc-act.danger:hover { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
903
- .lc-act:active { transform: scale(0.9); }
904
- .lc-run { font-size: 10.5px; color: var(--tone-info-dot); }
905
- .lc-run.dim { color: var(--ink-fainter); }
906
- /* 无描述时的兜底提示进一步退后(77 张卡同文反复出现即是噪音;不压到 0.55 以下,保浅色可读) */
907
- .lc-run.dim.ph { opacity: 0.75; }
908
- .lc-stats {
909
- display: flex;
910
- gap: 14px;
911
- font-size: 10.5px;
912
- color: var(--ink-faint);
913
- }
914
- .lc-stats b { color: var(--ink); font-weight: 600; }
915
- .lc-ctl { display: flex; gap: 8px; }
916
- .lc-ctl .inp {
917
- flex: 1;
918
- min-width: 0;
919
- height: 30px;
920
- padding: 0 9px;
921
- font-size: 11.5px;
922
- color: var(--ink);
923
- background: var(--paper-deep);
924
- border: 1px solid var(--line-strong);
925
- border-radius: var(--radius-panel-sm);
926
- }
927
- .lc-ctl .inp:focus { outline: none; border-color: var(--tone-success-dot); box-shadow: 0 0 0 3px var(--accent-soft); }
928
- .pill-btn {
929
- flex: none;
930
- height: 30px;
931
- padding: 0 14px;
932
- font-size: 11.5px;
933
- font-weight: 600;
934
- color: var(--on-accent);
935
- background: var(--accent);
936
- border: 0;
937
- border-radius: var(--radius-panel-sm);
938
- cursor: pointer;
939
- transition: background 0.15s, box-shadow 0.15s;
940
- }
941
- .pill-btn:hover:not(:disabled) { background: var(--accent-strong); box-shadow: 0 0 14px var(--accent-soft); }
942
- .pill-btn:disabled { opacity: 0.4; cursor: default; }
943
- .pill-btn.danger,
944
- .pill-btn.stop { background: var(--tone-danger-bg); color: var(--tone-danger-dot); }
945
- .pill-btn.danger:hover:not(:disabled),
946
- .pill-btn.stop:hover:not(:disabled) { background: color-mix(in srgb, var(--tone-danger-dot) 26%, transparent); box-shadow: none; }
947
- .lc-manage {
948
- font-size: 11.5px;
949
- font-weight: 600;
950
- color: var(--tone-info-dot);
951
- text-decoration: none;
952
- }
953
- .lc-manage:hover { text-decoration: underline; }
954
- .new-card {
955
- align-items: center;
956
- justify-content: center;
957
- gap: 6px;
958
- font-size: 13px;
959
- font-weight: 600;
960
- color: var(--ink-faint);
961
- background: transparent;
962
- border: 1px dashed var(--line-strong);
963
- box-shadow: none;
964
- backdrop-filter: none;
965
- -webkit-backdrop-filter: none;
966
- cursor: pointer;
967
- }
968
- .new-card:hover { color: var(--tone-success-dot); border-color: var(--tone-success-dot); transform: none; }
969
- .new-card small { font-weight: 400; font-size: 10px; color: var(--ink-fainter); }
970
-
971
- .banner { padding: 8px 12px; font-size: 12px; border-radius: 10px; }
972
- .banner.bad { color: var(--tone-danger-dot); background: var(--tone-danger-bg); border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 30%, transparent); }
973
-
974
- .modal-mask {
975
- position: fixed;
976
- inset: 0;
977
- z-index: 80;
978
- display: grid;
979
- place-items: center;
980
- background: var(--scrim);
981
- backdrop-filter: blur(3px);
982
- }
983
- /* 弹窗入退场(occasional 频次;防跳变):遮罩淡入淡出,卡片 fade+上浮 8px 入场;
984
- * modal 不锚定触发器,transform-origin 居中豁免;退场对称快出 */
985
- .modal-enter-active { transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1); }
986
- .modal-leave-active { transition: opacity 130ms ease; }
987
- .modal-enter-from,
988
- .modal-leave-to { opacity: 0; }
989
- .modal-enter-active .modal { transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1); }
990
- .modal-enter-from .modal { transform: translateY(8px) scale(0.96); }
991
- .modal {
992
- display: flex;
993
- flex-direction: column;
994
- gap: 12px;
995
- width: min(440px, calc(100vw - 40px));
996
- max-height: 82vh;
997
- padding: 18px;
998
- overflow: auto;
999
- background: var(--surface-glass-strong);
1000
- backdrop-filter: var(--aurora-blur);
1001
- -webkit-backdrop-filter: var(--aurora-blur);
1002
- border: 1px solid var(--glass-line);
1003
- border-radius: var(--radius-panel);
1004
- box-shadow: var(--glass-edge), var(--shadow-float);
1005
- }
1006
- .modal.wide { width: min(640px, calc(100vw - 40px)); }
1007
- .m-title { display: flex; gap: 10px; align-items: baseline; font-size: 15px; color: var(--ink); }
1008
- .m-title.danger-title { align-items: center; color: var(--tone-danger-dot); }
1009
- .f { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--ink-faint); }
1010
- .f em { color: var(--tone-danger-dot); font-style: normal; }
1011
- .f .inp {
1012
- height: 30px;
1013
- padding: 0 9px;
1014
- font-size: 12px;
1015
- color: var(--ink);
1016
- background: var(--paper-deep);
1017
- border: 1px solid var(--line-strong);
1018
- border-radius: var(--radius-panel-sm);
1019
- }
1020
- .f .inp:focus { outline: none; border-color: var(--tone-success-dot); box-shadow: 0 0 0 3px var(--accent-soft); }
1021
- .color-row { display: flex; gap: 7px; align-items: center; }
1022
- .color-dot {
1023
- width: 20px;
1024
- height: 20px;
1025
- border: 2px solid transparent;
1026
- border-radius: 6px;
1027
- cursor: pointer;
1028
- }
1029
- .color-dot.on { border-color: var(--ink); box-shadow: 0 0 10px currentColor; }
1030
- .m-actions { display: flex; gap: 8px; justify-content: flex-end; }
1031
- .m-err { font-size: 11px; color: var(--tone-danger-dot); }
1032
- .dim { color: var(--ink-fainter); }
1033
- .sec-label { font-size: 10px; font-weight: 700; color: var(--ink-fainter); letter-spacing: 0.16em; }
1034
- /* 删除确认:摘要行 + 级联勾选 */
1035
- .del-summary { font-size: 12px; line-height: 1.6; color: var(--ink-soft); }
1036
- .del-purge { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--ink); cursor: pointer; }
1037
- .del-purge input {
1038
- width: 14px;
1039
- height: 14px;
1040
- accent-color: var(--tone-danger-dot);
1041
- }
1042
- .tpl-hint { font-size: 11px; }
1043
- .tpl-list { display: flex; flex-direction: column; gap: 6px; }
1044
- .tpl-row {
1045
- display: flex;
1046
- gap: 10px;
1047
- align-items: center;
1048
- padding: 7px 10px;
1049
- font-size: 12px;
1050
- color: var(--ink);
1051
- background: var(--frost-bg);
1052
- border: 1px solid var(--glass-line);
1053
- border-radius: 9px;
1054
- }
1055
- .tpl-row small { flex: 1; }
1056
- .tpl-tag {
1057
- padding: 1px 8px;
1058
- font-family: var(--font-mono);
1059
- font-size: 9px;
1060
- color: var(--tone-info-dot);
1061
- border: 1px solid color-mix(in srgb, var(--tone-info-dot) 40%, transparent);
1062
- border-radius: 5px;
1063
- }
1064
- .tpl-tag.builtin { color: var(--ink-fainter); border-color: var(--line-strong); }
1065
- .tpl-form { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
1066
- .tpl-sem { flex: 1; color: var(--ink-faint); }
1067
- textarea.inp { height: auto; padding: 6px 9px; font-size: 11.5px; resize: vertical; }
1068
- .mini-btn {
1069
- padding: 4px 10px;
1070
- font-size: 10.5px;
1071
- color: var(--ink-faint);
1072
- background: var(--paper-deep);
1073
- border: 1px solid var(--line-strong);
1074
- border-radius: 7px;
1075
- cursor: pointer;
1076
- }
1077
- .mini-btn:hover { border-color: var(--ink-faint); color: var(--ink); }
1078
- .mini-btn.danger { color: var(--tone-danger-dot); }
1079
- </style>
1
+ <script setup lang="ts">
2
+ import { computed, reactive, ref } from 'vue'
3
+ import { DCW_LINE_COLORS, type DcwTemplateIcon, type LineView } from '#shared/dcw-protocol'
4
+ import { useDcwStream } from '~/composables/workshop/useDcwStream'
5
+
6
+ const { t } = useI18n()
7
+
8
+ const dcw = useDcwStream()
9
+ void dcw.load()
10
+
11
+ // ---------- 产线总览 ----------
12
+ interface LineCard {
13
+ line: LineView
14
+ nodes: number
15
+ products: number
16
+ recipes: number
17
+ }
18
+
19
+ const cards = computed<LineCard[]>(() =>
20
+ dcw.lines.map(line => ({
21
+ line,
22
+ nodes: dcw.nodes.filter(n => n.lineId === line.id).length,
23
+ products: dcw.products.filter(p => p.lineId === line.id).length,
24
+ recipes: dcw.recipes.filter(r => r.lineId === line.id).length,
25
+ })),
26
+ )
27
+ const unassignedCount = computed(() =>
28
+ dcw.nodes.filter(n => !n.lineId).length + dcw.products.filter(p => !p.lineId).length,
29
+ )
30
+
31
+ // ---------- 清单筛选(94 条历史线的管理页:导航先于遍历) ----------
32
+ const filterState = ref<'all' | 'running' | 'idle'>('all')
33
+ const searchText = ref('')
34
+ const runningCount = computed(() => cards.value.filter(c => dcw.lineStateOf(c.line.id).active).length)
35
+ const shownCards = computed(() => cards.value.filter((c) => {
36
+ const active = dcw.lineStateOf(c.line.id).active
37
+ if (filterState.value === 'running' && !active) return false
38
+ if (filterState.value === 'idle' && active) return false
39
+ const q = searchText.value.trim().toLowerCase()
40
+ if (q && !c.line.name.toLowerCase().includes(q) && !(c.line.description ?? '').toLowerCase().includes(q)) return false
41
+ return true
42
+ }))
43
+
44
+ // ---------- 新建产线 ----------
45
+ const createOpen = ref(false)
46
+ const createSaving = ref(false)
47
+ const createError = ref('')
48
+ const createForm = reactive({ name: '', description: '', color: '' })
49
+
50
+ const nextColor = computed(() => DCW_LINE_COLORS[dcw.lines.length % DCW_LINE_COLORS.length] ?? '#3aa0ff')
51
+
52
+ function openCreate(): void {
53
+ createForm.name = ''
54
+ createForm.description = ''
55
+ createForm.color = ''
56
+ createError.value = ''
57
+ createOpen.value = true
58
+ }
59
+
60
+ async function doCreateLine(): Promise<void> {
61
+ createSaving.value = true
62
+ createError.value = ''
63
+ try {
64
+ await dcw.createLine({
65
+ name: createForm.name.trim(),
66
+ description: createForm.description.trim(),
67
+ color: createForm.color || undefined,
68
+ })
69
+ createOpen.value = false
70
+ }
71
+ catch (err) {
72
+ createError.value = apiErrorMessage(err)
73
+ }
74
+ finally {
75
+ createSaving.value = false
76
+ }
77
+ }
78
+
79
+ // ---------- 快捷启停(卡片上) ----------
80
+ const quickBusy = ref('')
81
+ const quickErr = ref('')
82
+ const quickPick = reactive<Record<string, string>>({})
83
+
84
+ async function quickStart(card: LineCard): Promise<void> {
85
+ quickBusy.value = card.line.id
86
+ quickErr.value = ''
87
+ try {
88
+ const rid = quickPick[card.line.id] ?? ''
89
+ if (!rid) throw new Error(t('dcw.k188cswn039'))
90
+ await dcw.startLine(card.line.id, rid)
91
+ }
92
+ catch (err) {
93
+ quickErr.value = apiErrorMessage(err)
94
+ }
95
+ finally {
96
+ quickBusy.value = ''
97
+ }
98
+ }
99
+
100
+ async function quickStop(card: LineCard): Promise<void> {
101
+ quickBusy.value = card.line.id
102
+ quickErr.value = ''
103
+ try {
104
+ await dcw.stopLine(card.line.id)
105
+ }
106
+ catch (err) {
107
+ quickErr.value = apiErrorMessage(err)
108
+ }
109
+ finally {
110
+ quickBusy.value = ''
111
+ }
112
+ }
113
+
114
+ /** 卡片配方下拉:本产线配方(继承自产品) */
115
+ function recipesOf(lineId: string) {
116
+ return dcw.recipes.filter(r => r.lineId === lineId)
117
+ }
118
+
119
+ /** 删除产线(弹窗两步确认;purge 勾选 = 连同旗下节点/产品/配方一并清理) */
120
+ const delOpen = ref(false)
121
+ const delBusy = ref(false)
122
+ const delErr = ref('')
123
+ const delPurge = ref(true)
124
+ const delCard = ref<LineCard | null>(null)
125
+
126
+ function openDelete(card: LineCard): void {
127
+ delCard.value = card
128
+ delPurge.value = true
129
+ delErr.value = ''
130
+ delOpen.value = true
131
+ }
132
+
133
+ async function doDeleteLine(): Promise<void> {
134
+ if (!delCard.value)
135
+ return
136
+ delBusy.value = true
137
+ delErr.value = ''
138
+ try {
139
+ await dcw.removeLine(delCard.value.line.id, delPurge.value)
140
+ delOpen.value = false
141
+ }
142
+ catch (err) {
143
+ delErr.value = apiErrorMessage(err)
144
+ }
145
+ finally {
146
+ delBusy.value = false
147
+ }
148
+ }
149
+
150
+ /** 编辑产线(名称/描述/光晕色) */
151
+ const editOpen = ref(false)
152
+ const editSaving = ref(false)
153
+ const editError = ref('')
154
+ const editForm = reactive({ id: '', name: '', description: '', color: '' })
155
+
156
+ function openEdit(card: LineCard): void {
157
+ editForm.id = card.line.id
158
+ editForm.name = card.line.name
159
+ editForm.description = card.line.description
160
+ editForm.color = card.line.color
161
+ editError.value = ''
162
+ editOpen.value = true
163
+ }
164
+
165
+ async function doEditLine(): Promise<void> {
166
+ editSaving.value = true
167
+ editError.value = ''
168
+ try {
169
+ await dcw.updateLine(editForm.id, {
170
+ name: editForm.name.trim(),
171
+ description: editForm.description.trim(),
172
+ color: editForm.color || undefined,
173
+ })
174
+ editOpen.value = false
175
+ }
176
+ catch (err) {
177
+ editError.value = apiErrorMessage(err)
178
+ }
179
+ finally {
180
+ editSaving.value = false
181
+ }
182
+ }
183
+
184
+ // ---------- 控制模板管理(自定义分类) ----------
185
+ const tplOpen = ref(false)
186
+ const tplError = ref('')
187
+ const tplForm = reactive({ name: '', ch: '', code: '', unit: '', min: '' as number | '', max: '' as number | '', decimals: 1, icon: 'gateway' as DcwTemplateIcon, semantics: '' })
188
+ const tplIcons: Array<{ key: DcwTemplateIcon, label: string }> = [
189
+ { key: 'thermo', label: t('dcw.k422b8040') },
190
+ { key: 'pressure', label: t('dcw.k3x6ff041') },
191
+ { key: 'tension', label: t('dcw.k3z9xc042') },
192
+ { key: 'encoder', label: t('dcw.kjb3vhs043') },
193
+ { key: 'camera', label: t('dcw.k47atw044') },
194
+ { key: 'gateway', label: t('dcw.k48c07045') },
195
+ ]
196
+
197
+ async function doCreateTemplate(): Promise<void> {
198
+ tplError.value = ''
199
+ try {
200
+ if (!tplForm.name.trim()) throw new Error(t('dcw.k6ugbw2046'))
201
+ if (tplForm.min === '' || tplForm.max === '') throw new Error(t('dcw.k13awowo047'))
202
+ await dcw.createTemplate({
203
+ name: tplForm.name.trim(),
204
+ ch: tplForm.ch.trim() || tplForm.name.trim(),
205
+ code: tplForm.code.trim() || 'CUSTOM',
206
+ unit: tplForm.unit.trim() || '-',
207
+ min: Number(tplForm.min),
208
+ max: Number(tplForm.max),
209
+ decimals: Number(tplForm.decimals) || 0,
210
+ icon: tplForm.icon,
211
+ semantics: tplForm.semantics.trim() || undefined,
212
+ })
213
+ tplForm.name = ''
214
+ tplForm.ch = ''
215
+ tplForm.code = ''
216
+ tplForm.unit = ''
217
+ tplForm.min = ''
218
+ tplForm.max = ''
219
+ }
220
+ catch (err) {
221
+ tplError.value = apiErrorMessage(err)
222
+ }
223
+ }
224
+
225
+ async function doRemoveTemplate(key: string): Promise<void> {
226
+ tplError.value = ''
227
+ try {
228
+ await fetch(`/api/workshop/dcw/templates/${key}`, {
229
+ method: 'DELETE',
230
+ headers: { authorization: `Bearer ${document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] ?? ''}` },
231
+ }).then(async (r) => {
232
+ const json = await r.json().catch(() => ({}))
233
+ if (json?.code !== 0) throw new Error(json?.message ?? t('dcw.k1bphrb3048'))
234
+ })
235
+ const i = dcw.templates.findIndex(t => t.key === key)
236
+ if (i >= 0) dcw.templates.splice(i, 1)
237
+ }
238
+ catch (err) {
239
+ tplError.value = apiErrorMessage(err)
240
+ }
241
+ }
242
+
243
+ const builtinCount = computed(() => dcw.templates.filter(t => t.builtin).length)
244
+ </script>
245
+
246
+ <template>
247
+ <div class="page">
248
+ <div class="aw-page-head">
249
+ <div>
250
+ <p class="aw-kicker">
251
+ AGENTWORKSHOP / LINE OPERATIONS
252
+ </p>
253
+ <h1>{{ $t('dcw.k1b2tk5c009') }}</h1>
254
+ <p class="sub">
255
+ {{ $t('dcw.k5q0aqi010') }}
256
+ </p>
257
+ </div>
258
+ <div class="badges mono">
259
+ <span
260
+ v-if="unassignedCount"
261
+ class="badge warn-badge"
262
+ :title="$t('dcw.k1i5a9vw001')"
263
+ >{{ $t('dcw.k3ootr6049') }} {{ unassignedCount }}</span>
264
+ <span class="badge">{{ $t('dcw.k3wj9n050') }} {{ dcw.lines.length }}</span>
265
+ <button
266
+ class="badge tpl-btn"
267
+ @click="tplOpen = true"
268
+ >
269
+ {{ $t('dcw.k11nndsp051') }} {{ dcw.templates.length }}
270
+ </button>
271
+ </div>
272
+ </div>
273
+
274
+ <p
275
+ v-if="quickErr"
276
+ class="banner bad"
277
+ >
278
+ {{ quickErr }}
279
+ </p>
280
+
281
+ <!-- 清单筛选:状态分段 + 名称搜索(复用 aw-seg / 页内 inp 语言) -->
282
+ <div class="fleet-filter">
283
+ <div class="aw-seg">
284
+ <button
285
+ :class="{ on: filterState === 'all' }"
286
+ @click="filterState = 'all'"
287
+ >
288
+ {{ $t('common.all') }} {{ cards.length }}
289
+ </button>
290
+ <button
291
+ :class="{ on: filterState === 'running' }"
292
+ @click="filterState = 'running'"
293
+ >
294
+ {{ $t('dcw.k1eox1el055') }} {{ runningCount }}
295
+ </button>
296
+ <button
297
+ :class="{ on: filterState === 'idle' }"
298
+ @click="filterState = 'idle'"
299
+ >
300
+ {{ $t('dcw.k149r6y7059') }} {{ cards.length - runningCount }}
301
+ </button>
302
+ </div>
303
+ <input
304
+ v-model="searchText"
305
+ class="inp fleet-search"
306
+ type="search"
307
+ :placeholder="$t('dcw.filterSearchPh')"
308
+ >
309
+ </div>
310
+
311
+ <!-- 产线卡片栅格 -->
312
+ <div class="line-grid aw-stagger">
313
+ <div
314
+ v-for="c in shownCards"
315
+ :key="c.line.id"
316
+ class="line-card aw-liftable"
317
+ :class="{ idle: !dcw.lineStateOf(c.line.id).active }"
318
+ :style="{ '--lc': c.line.color }"
319
+ >
320
+ <div class="lc-head">
321
+ <span class="lc-dot" />
322
+ <b class="lc-name">{{ c.line.name }}</b>
323
+ <span
324
+ class="lc-state"
325
+ :class="{ on: dcw.lineStateOf(c.line.id).active }"
326
+ >{{ dcw.lineStateOf(c.line.id).active ? $t('dcw.k1eox1el055') : $t('dcw.k149r6y7059') }}</span>
327
+ <button
328
+ class="lc-act"
329
+ :title="$t('common.edit')"
330
+ @click="openEdit(c)"
331
+ >
332
+ <span class="i-tabler-pencil" />
333
+ </button>
334
+ <button
335
+ class="lc-act danger"
336
+ :title="$t('dcw.k3xakp026')"
337
+ @click="openDelete(c)"
338
+ >
339
+ <span class="i-tabler-trash" />
340
+ </button>
341
+ </div>
342
+ <small
343
+ v-if="dcw.lineStateOf(c.line.id).active"
344
+ class="lc-run mono"
345
+ >{{ dcw.lineStateOf(c.line.id).productName }} · {{ dcw.lineStateOf(c.line.id).recipeName }} · {{ $t('dcw.k3zz0f052') }} {{ dcw.lineStateOf(c.line.id).taggedSamples }}</small>
346
+ <small
347
+ v-else
348
+ class="lc-run dim"
349
+ :class="{ ph: !c.line.description }"
350
+ >{{ c.line.description || $t('dcw.k18moyq1056') }}</small>
351
+ <div class="lc-stats mono">
352
+ <span>{{ $t('dcw.k45uio011') }} <b>{{ c.nodes }}</b></span>
353
+ <span>{{ $t('dcw.k3waz1012') }} <b>{{ c.products }}</b></span>
354
+ <span>{{ $t('dcw.k48grv013') }} <b>{{ c.recipes }}</b></span>
355
+ </div>
356
+ <div class="lc-ctl">
357
+ <template v-if="!dcw.lineStateOf(c.line.id).active">
358
+ <select
359
+ v-model="quickPick[c.line.id]"
360
+ class="inp"
361
+ :disabled="recipesOf(c.line.id).length === 0"
362
+ :title="$t('dcw.k1dhaby4003')"
363
+ >
364
+ <option value="">
365
+ {{ recipesOf(c.line.id).length ? $t('dcw.kutxzsz057') : $t('dcw.k1elczt9060') }}
366
+ </option>
367
+ <option
368
+ v-for="r in recipesOf(c.line.id)"
369
+ :key="r.id"
370
+ :value="r.id"
371
+ :disabled="r.params.length === 0"
372
+ >
373
+ {{ r.name }}({{ r.params.length }})
374
+ </option>
375
+ </select>
376
+ <button
377
+ class="pill-btn"
378
+ :disabled="quickBusy === c.line.id || !quickPick[c.line.id]"
379
+ @click="quickStart(c)"
380
+ >
381
+ {{ $t('dcw.k149b4vg014') }}
382
+ </button>
383
+ </template>
384
+ <button
385
+ v-else
386
+ class="pill-btn stop"
387
+ :disabled="quickBusy === c.line.id"
388
+ @click="quickStop(c)"
389
+ >
390
+ {{ $t('dcw.k148rclf015') }}
391
+ </button>
392
+ </div>
393
+ <NuxtLink
394
+ class="lc-manage"
395
+ :to="`/dcw/${c.line.id}`"
396
+ >
397
+ {{ $t('dcw.k1fwqw5g016') }}
398
+ </NuxtLink>
399
+ </div>
400
+
401
+ <!-- 新建产线卡 -->
402
+ <button
403
+ class="line-card new-card"
404
+ @click="openCreate"
405
+ >
406
+ <span class="i-tabler-plus" />
407
+ {{ $t('dcw.k1efe391017') }}
408
+ <small>{{ $t('dcw.k19dlbli053') }}</small>
409
+ </button>
410
+ </div>
411
+
412
+ <p
413
+ v-if="dcw.loaded && dcw.lines.length === 0"
414
+ class="banner"
415
+ >
416
+ {{ $t('dcw.k1sk85vs018') }}
417
+ </p>
418
+ <p
419
+ v-else-if="dcw.loaded && shownCards.length === 0"
420
+ class="banner"
421
+ >
422
+ {{ $t('dcw.filterEmpty') }}
423
+ </p>
424
+
425
+ <!-- 新建产线弹窗 -->
426
+ <Transition name="modal">
427
+ <div
428
+ v-if="createOpen"
429
+ class="modal-mask"
430
+ @click.self="createOpen = false"
431
+ >
432
+ <div class="modal">
433
+ <h3 class="m-title">
434
+ {{ $t('dcw.k1efe391017') }}
435
+ </h3>
436
+ <label class="f">
437
+ <span>{{ $t('dcw.k1b2ioko019') }}<em>*</em></span>
438
+ <input
439
+ v-model="createForm.name"
440
+ class="inp"
441
+ :placeholder="$t('dcw.kru37i3004')"
442
+ >
443
+ </label>
444
+ <label class="f">
445
+ <span>{{ $t('dcw.k24dxcd020') }}</span>
446
+ <input
447
+ v-model="createForm.description"
448
+ class="inp"
449
+ :placeholder="$t('dcw.k1f2nwsp005')"
450
+ >
451
+ </label>
452
+ <div class="f">
453
+ <span>{{ $t('dcw.k1x7nubr021') }}</span>
454
+ <div class="color-row">
455
+ <button
456
+ v-for="c in DCW_LINE_COLORS"
457
+ :key="c"
458
+ class="color-dot"
459
+ :class="{ on: createForm.color === c }"
460
+ :style="{ background: c }"
461
+ @click="createForm.color = createForm.color === c ? '' : c"
462
+ />
463
+ <small class="dim">{{ createForm.color || $t('dcw.k1qidbpy061', { p0: nextColor }) }}</small>
464
+ </div>
465
+ </div>
466
+ <p
467
+ v-if="createError"
468
+ class="m-err"
469
+ >
470
+ {{ createError }}
471
+ </p>
472
+ <div class="m-actions">
473
+ <button
474
+ class="mini-btn"
475
+ @click="createOpen = false"
476
+ >
477
+ {{ $t('dcw.k3xdnn022') }}
478
+ </button>
479
+ <button
480
+ class="pill-btn"
481
+ :disabled="createSaving || !createForm.name.trim()"
482
+ @click="doCreateLine"
483
+ >
484
+ {{ $t('dcw.k3wzi2023') }}
485
+ </button>
486
+ </div>
487
+ </div>
488
+ </div>
489
+ </Transition>
490
+
491
+ <!-- 编辑产线弹窗 -->
492
+ <Transition name="modal">
493
+ <div
494
+ v-if="editOpen"
495
+ class="modal-mask"
496
+ @click.self="editOpen = false"
497
+ >
498
+ <div class="modal">
499
+ <h3 class="m-title">
500
+ {{ $t('dcw.k5tq8wc071') }}
501
+ </h3>
502
+ <label class="f">
503
+ <span>{{ $t('dcw.k1b2ioko019') }}<em>*</em></span>
504
+ <input
505
+ v-model="editForm.name"
506
+ class="inp"
507
+ :placeholder="$t('dcw.kru37i3004')"
508
+ >
509
+ </label>
510
+ <label class="f">
511
+ <span>{{ $t('dcw.k24dxcd020') }}</span>
512
+ <input
513
+ v-model="editForm.description"
514
+ class="inp"
515
+ :placeholder="$t('dcw.k1f2nwsp005')"
516
+ >
517
+ </label>
518
+ <div class="f">
519
+ <span>{{ $t('dcw.k1x7nubr021') }}</span>
520
+ <div class="color-row">
521
+ <button
522
+ v-for="c in DCW_LINE_COLORS"
523
+ :key="c"
524
+ class="color-dot"
525
+ :class="{ on: editForm.color === c }"
526
+ :style="{ background: c }"
527
+ @click="editForm.color = editForm.color === c ? '' : c"
528
+ />
529
+ <small class="dim">{{ editForm.color || $t('dcw.k1qidbpy061', { p0: nextColor }) }}</small>
530
+ </div>
531
+ </div>
532
+ <p
533
+ v-if="editError"
534
+ class="m-err"
535
+ >
536
+ {{ editError }}
537
+ </p>
538
+ <div class="m-actions">
539
+ <button
540
+ class="mini-btn"
541
+ @click="editOpen = false"
542
+ >
543
+ {{ $t('dcw.k3xdnn022') }}
544
+ </button>
545
+ <button
546
+ class="pill-btn"
547
+ :disabled="editSaving || !editForm.name.trim()"
548
+ @click="doEditLine"
549
+ >
550
+ {{ $t('common.save') }}
551
+ </button>
552
+ </div>
553
+ </div>
554
+ </div>
555
+ </Transition>
556
+
557
+ <!-- 删除产线弹窗(两步确认;purge 勾选决定是否级联清理) -->
558
+ <Transition name="modal">
559
+ <div
560
+ v-if="delOpen && delCard"
561
+ class="modal-mask"
562
+ @click.self="delOpen = false"
563
+ >
564
+ <div class="modal">
565
+ <h3 class="m-title danger-title">
566
+ <span class="i-tabler-alert-triangle" />
567
+ {{ $t('dcw.k7xq2mfd063', { p0: delCard.line.name }) }}
568
+ </h3>
569
+ <p class="del-summary">
570
+ {{ delPurge
571
+ ? $t('dcw.k4r7nbd073', { p0: delCard.line.name, p1: delCard.nodes, p2: delCard.products, p3: delCard.recipes })
572
+ : $t('dcw.k1gp649b062', { p0: delCard.line.name, p1: delCard.nodes, p2: delCard.products, p3: delCard.recipes }) }}
573
+ </p>
574
+ <label class="del-purge">
575
+ <input
576
+ v-model="delPurge"
577
+ type="checkbox"
578
+ >
579
+ <span>{{ $t('dcw.k9m2vxa072') }}</span>
580
+ </label>
581
+ <p
582
+ v-if="delErr"
583
+ class="m-err"
584
+ >
585
+ {{ delErr }}
586
+ </p>
587
+ <div class="m-actions">
588
+ <button
589
+ class="mini-btn"
590
+ @click="delOpen = false"
591
+ >
592
+ {{ $t('dcw.k3xdnn022') }}
593
+ </button>
594
+ <button
595
+ class="pill-btn danger"
596
+ :disabled="delBusy"
597
+ @click="doDeleteLine"
598
+ >
599
+ {{ $t('dcw.k3xakp026') }}
600
+ </button>
601
+ </div>
602
+ </div>
603
+ </div>
604
+ </Transition>
605
+
606
+ <!-- 控制模板管理弹窗 -->
607
+ <Transition name="modal">
608
+ <div
609
+ v-if="tplOpen"
610
+ class="modal-mask"
611
+ @click.self="tplOpen = false"
612
+ >
613
+ <div class="modal wide">
614
+ <h3 class="m-title">
615
+ {{ $t('dcw.k11oadmx024') }} <small class="dim mono">{{ $t('dcw.k3x23c054') }} {{ builtinCount }} · {{ $t('dcw.k3t616a058') }} {{ dcw.templates.length - builtinCount }}</small>
616
+ </h3>
617
+ <p class="dim tpl-hint">
618
+ {{ $t('dcw.k2hlav5025') }}
619
+ </p>
620
+ <div class="tpl-list">
621
+ <div
622
+ v-for="t in dcw.templates"
623
+ :key="t.key"
624
+ class="tpl-row"
625
+ :title="t.semantics ?? ''"
626
+ >
627
+ <b>{{ t.name }}</b>
628
+ <small class="mono dim">{{ t.code }} · {{ t.min }}~{{ t.max }} {{ t.unit }}</small>
629
+ <small
630
+ v-if="t.semantics"
631
+ class="tpl-sem"
632
+ >{{ t.semantics.slice(0, 40) }}{{ t.semantics.length > 40 ? '…' : '' }}</small>
633
+ <span
634
+ class="tpl-tag"
635
+ :class="{ builtin: t.builtin }"
636
+ >{{ t.builtin ? $t('dcw.k3x23c054') : $t('dcw.k3t616a058') }}</span>
637
+ <button
638
+ v-if="!t.builtin"
639
+ class="mini-btn danger"
640
+ @click="doRemoveTemplate(t.key)"
641
+ >
642
+ {{ $t('dcw.k3xakp026') }}
643
+ </button>
644
+ </div>
645
+ </div>
646
+ <p class="sec-label">
647
+ {{ $t('dcw.k169eb4s027') }}
648
+ </p>
649
+ <div class="tpl-form">
650
+ <label class="f">
651
+ <span>{{ $t('dcw.k3xhia028') }}<em>*</em></span>
652
+ <input
653
+ v-model="tplForm.name"
654
+ class="inp"
655
+ :placeholder="$t('dcw.k1tooi3o006')"
656
+ >
657
+ </label>
658
+ <label class="f">
659
+ <span>{{ $t('dcw.k1bqk219029') }}</span>
660
+ <input
661
+ v-model="tplForm.ch"
662
+ class="inp"
663
+ :placeholder="$t('dcw.k698qz0007')"
664
+ >
665
+ </label>
666
+ <label class="f">
667
+ <span>{{ $t('dcw.k1ayxrqb030') }}</span>
668
+ <input
669
+ v-model="tplForm.code"
670
+ class="inp"
671
+ placeholder="如 MOTOR · I"
672
+ >
673
+ </label>
674
+ <label class="f">
675
+ <span>{{ $t('dcw.k3x4ef031') }}</span>
676
+ <input
677
+ v-model="tplForm.unit"
678
+ class="inp"
679
+ placeholder="如 A"
680
+ >
681
+ </label>
682
+ <label class="f">
683
+ <span>{{ $t('dcw.k1l9jv5m032') }}<em>*</em></span>
684
+ <input
685
+ v-model.number="tplForm.min"
686
+ type="number"
687
+ class="inp"
688
+ >
689
+ </label>
690
+ <label class="f">
691
+ <span>{{ $t('dcw.k1l9jv4p033') }}<em>*</em></span>
692
+ <input
693
+ v-model.number="tplForm.max"
694
+ type="number"
695
+ class="inp"
696
+ >
697
+ </label>
698
+ <label class="f">
699
+ <span>{{ $t('dcw.k3mxmcx034') }}</span>
700
+ <input
701
+ v-model.number="tplForm.decimals"
702
+ type="number"
703
+ class="inp"
704
+ >
705
+ </label>
706
+ <label class="f">
707
+ <span>{{ $t('dcw.k3xx56035') }}</span>
708
+ <select
709
+ v-model="tplForm.icon"
710
+ class="inp"
711
+ >
712
+ <option
713
+ v-for="ic in tplIcons"
714
+ :key="ic.key"
715
+ :value="ic.key"
716
+ >
717
+ {{ ic.label }}
718
+ </option>
719
+ </select>
720
+ </label>
721
+ </div>
722
+ <label class="f">
723
+ <span>{{ $t('dcw.k1b6qorg036') }}</span>
724
+ <textarea
725
+ v-model="tplForm.semantics"
726
+ class="inp"
727
+ rows="3"
728
+ :placeholder="$t('dcw.k1qdnfzc008')"
729
+ />
730
+ </label>
731
+ <p
732
+ v-if="tplError"
733
+ class="m-err"
734
+ >
735
+ {{ tplError }}
736
+ </p>
737
+ <div class="m-actions">
738
+ <button
739
+ class="mini-btn"
740
+ @click="tplOpen = false"
741
+ >
742
+ {{ $t('dcw.k3x62t037') }}
743
+ </button>
744
+ <button
745
+ class="pill-btn"
746
+ :disabled="!tplForm.name.trim() || tplForm.min === '' || tplForm.max === ''"
747
+ @click="doCreateTemplate"
748
+ >
749
+ {{ $t('dcw.k1bg9nga038') }}
750
+ </button>
751
+ </div>
752
+ </div>
753
+ </div>
754
+ </Transition>
755
+
756
+ <p
757
+ v-if="dcw.error"
758
+ class="banner bad"
759
+ >
760
+ {{ dcw.error }}
761
+ </p>
762
+ </div>
763
+ </template>
764
+
765
+ <style scoped>
766
+ .page { display: flex; flex-direction: column; gap: 14px; }
767
+ .badges { display: flex; gap: 8px; }
768
+ /* 状态徽章 = 玻璃芯片:双主题令牌化,浅色不再发白发黄 */
769
+ .badge {
770
+ padding: 3px 10px;
771
+ font-size: 11px;
772
+ color: var(--ink-faint);
773
+ background: var(--glass-bg);
774
+ border: 1px solid var(--glass-line);
775
+ border-radius: 999px;
776
+ backdrop-filter: var(--frost-blur);
777
+ -webkit-backdrop-filter: var(--frost-blur);
778
+ }
779
+ .warn-badge { color: var(--tone-warning-dot); background: var(--tone-warning-bg); border-color: color-mix(in srgb, var(--tone-warning-dot) 32%, transparent); }
780
+ .tpl-btn { color: var(--tone-info-dot); background: var(--tone-info-bg); border-color: color-mix(in srgb, var(--tone-info-dot) 34%, transparent); cursor: pointer; }
781
+ .tpl-btn:hover { border-color: color-mix(in srgb, var(--tone-info-dot) 60%, transparent); }
782
+
783
+ /* 清单筛选条:状态分段 + 搜索(与页内控件同一语言) */
784
+ .fleet-filter {
785
+ display: flex;
786
+ flex-wrap: wrap;
787
+ gap: 12px;
788
+ align-items: center;
789
+ margin-bottom: 14px;
790
+ }
791
+ .fleet-filter .aw-seg button {
792
+ font-family: var(--font-mono);
793
+ font-size: 11.5px;
794
+ font-variant-numeric: tabular-nums;
795
+ }
796
+ .fleet-search {
797
+ width: 220px;
798
+ margin-left: auto;
799
+ height: 30px;
800
+ padding: 0 10px;
801
+ font-size: 11.5px;
802
+ color: var(--ink);
803
+ background: var(--paper-deep);
804
+ border: 1px solid var(--line-strong);
805
+ border-radius: var(--radius-panel-sm);
806
+ }
807
+ .fleet-search:focus { outline: none; border-color: var(--tone-success-dot); box-shadow: 0 0 0 3px var(--accent-soft); }
808
+ .line-grid {
809
+ display: grid;
810
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
811
+ gap: 14px;
812
+ }
813
+ /* 产线卡 = Aurora Glass 仪表砖:半透 surface + 深模糊 + 内缘折射;
814
+ 运行卡在玻璃顶缘叠一线产线色洗染做「图」,待机卡收敛为磨砂做「底」 */
815
+ .line-card {
816
+ position: relative;
817
+ display: flex;
818
+ flex-direction: column;
819
+ gap: 9px;
820
+ padding: 14px 16px;
821
+ text-align: left;
822
+ background: var(--surface-glass);
823
+ backdrop-filter: var(--aurora-blur);
824
+ -webkit-backdrop-filter: var(--aurora-blur);
825
+ border: 1px solid var(--glass-line);
826
+ border-radius: var(--radius-panel);
827
+ box-shadow: var(--glass-edge);
828
+ transition: border-color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base);
829
+ }
830
+ @media (hover: hover) and (prefers-reduced-motion: no-preference) {
831
+ .line-card:hover {
832
+ border-color: color-mix(in srgb, var(--lc, #3aa0ff) 55%, var(--line-strong));
833
+ transform: translateY(-2px);
834
+ box-shadow: var(--glass-edge), var(--shadow-float);
835
+ }
836
+ }
837
+ .line-card:not(.idle) {
838
+ border-color: color-mix(in srgb, var(--lc, #3aa0ff) 38%, var(--line-strong));
839
+ background:
840
+ linear-gradient(180deg, color-mix(in srgb, var(--lc, #3aa0ff) 9%, transparent), transparent 46%),
841
+ var(--surface-glass);
842
+ }
843
+ .line-card.idle {
844
+ background: var(--frost-bg);
845
+ }
846
+ .line-card.idle .lc-dot {
847
+ box-shadow: none;
848
+ opacity: 0.72;
849
+ }
850
+ .line-card.idle .lc-name {
851
+ color: var(--ink-faint);
852
+ }
853
+ .lc-head { display: flex; gap: 8px; align-items: center; }
854
+ .lc-dot {
855
+ width: 10px;
856
+ height: 10px;
857
+ flex: none;
858
+ background: var(--lc);
859
+ border-radius: 4px;
860
+ box-shadow: 0 0 10px color-mix(in srgb, var(--lc) 70%, transparent);
861
+ }
862
+ .lc-name {
863
+ overflow: hidden;
864
+ font-size: 14.5px;
865
+ color: var(--ink);
866
+ text-overflow: ellipsis;
867
+ white-space: nowrap;
868
+ }
869
+ .lc-state {
870
+ margin-left: auto;
871
+ flex: none;
872
+ font-family: var(--font-mono);
873
+ font-size: 10px;
874
+ color: var(--ink-fainter);
875
+ }
876
+ .lc-state.on { color: var(--tone-success-dot); }
877
+ /* 头部图标钮:编辑/删除,悬停语义色染色 */
878
+ .lc-act {
879
+ display: inline-flex;
880
+ flex: none;
881
+ align-items: center;
882
+ justify-content: center;
883
+ width: 22px;
884
+ height: 22px;
885
+ padding: 0;
886
+ font-size: 13px;
887
+ color: var(--ink-fainter);
888
+ background: transparent;
889
+ border: 0;
890
+ border-radius: 6px;
891
+ cursor: pointer;
892
+ opacity: 0;
893
+ transition:
894
+ color 0.15s ease,
895
+ background 0.15s ease,
896
+ opacity 0.15s ease,
897
+ transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
898
+ }
899
+ .line-card:hover .lc-act,
900
+ .line-card:focus-within .lc-act { opacity: 1; }
901
+ .lc-act:hover { color: var(--tone-info-dot); background: var(--tone-info-bg); }
902
+ .lc-act.danger:hover { color: var(--tone-danger-dot); background: var(--tone-danger-bg); }
903
+ .lc-act:active { transform: scale(0.9); }
904
+ .lc-run { font-size: 10.5px; color: var(--tone-info-dot); }
905
+ .lc-run.dim { color: var(--ink-fainter); }
906
+ /* 无描述时的兜底提示进一步退后(77 张卡同文反复出现即是噪音;不压到 0.55 以下,保浅色可读) */
907
+ .lc-run.dim.ph { opacity: 0.75; }
908
+ .lc-stats {
909
+ display: flex;
910
+ gap: 14px;
911
+ font-size: 10.5px;
912
+ color: var(--ink-faint);
913
+ }
914
+ .lc-stats b { color: var(--ink); font-weight: 600; }
915
+ .lc-ctl { display: flex; gap: 8px; }
916
+ .lc-ctl .inp {
917
+ flex: 1;
918
+ min-width: 0;
919
+ height: 30px;
920
+ padding: 0 9px;
921
+ font-size: 11.5px;
922
+ color: var(--ink);
923
+ background: var(--paper-deep);
924
+ border: 1px solid var(--line-strong);
925
+ border-radius: var(--radius-panel-sm);
926
+ }
927
+ .lc-ctl .inp:focus { outline: none; border-color: var(--tone-success-dot); box-shadow: 0 0 0 3px var(--accent-soft); }
928
+ .pill-btn {
929
+ flex: none;
930
+ height: 30px;
931
+ padding: 0 14px;
932
+ font-size: 11.5px;
933
+ font-weight: 600;
934
+ color: var(--on-accent);
935
+ background: var(--accent);
936
+ border: 0;
937
+ border-radius: var(--radius-panel-sm);
938
+ cursor: pointer;
939
+ transition: background 0.15s, box-shadow 0.15s;
940
+ }
941
+ .pill-btn:hover:not(:disabled) { background: var(--accent-strong); box-shadow: 0 0 14px var(--accent-soft); }
942
+ .pill-btn:disabled { opacity: 0.4; cursor: default; }
943
+ .pill-btn.danger,
944
+ .pill-btn.stop { background: var(--tone-danger-bg); color: var(--tone-danger-dot); }
945
+ .pill-btn.danger:hover:not(:disabled),
946
+ .pill-btn.stop:hover:not(:disabled) { background: color-mix(in srgb, var(--tone-danger-dot) 26%, transparent); box-shadow: none; }
947
+ .lc-manage {
948
+ font-size: 11.5px;
949
+ font-weight: 600;
950
+ color: var(--tone-info-dot);
951
+ text-decoration: none;
952
+ }
953
+ .lc-manage:hover { text-decoration: underline; }
954
+ .new-card {
955
+ align-items: center;
956
+ justify-content: center;
957
+ gap: 6px;
958
+ font-size: 13px;
959
+ font-weight: 600;
960
+ color: var(--ink-faint);
961
+ background: transparent;
962
+ border: 1px dashed var(--line-strong);
963
+ box-shadow: none;
964
+ backdrop-filter: none;
965
+ -webkit-backdrop-filter: none;
966
+ cursor: pointer;
967
+ }
968
+ .new-card:hover { color: var(--tone-success-dot); border-color: var(--tone-success-dot); transform: none; }
969
+ .new-card small { font-weight: 400; font-size: 10px; color: var(--ink-fainter); }
970
+
971
+ .banner { padding: 8px 12px; font-size: 12px; border-radius: 10px; }
972
+ .banner.bad { color: var(--tone-danger-dot); background: var(--tone-danger-bg); border: 1px solid color-mix(in srgb, var(--tone-danger-dot) 30%, transparent); }
973
+
974
+ .modal-mask {
975
+ position: fixed;
976
+ inset: 0;
977
+ z-index: 80;
978
+ display: grid;
979
+ place-items: center;
980
+ background: var(--scrim);
981
+ backdrop-filter: blur(3px);
982
+ }
983
+ /* 弹窗入退场(occasional 频次;防跳变):遮罩淡入淡出,卡片 fade+上浮 8px 入场;
984
+ * modal 不锚定触发器,transform-origin 居中豁免;退场对称快出 */
985
+ .modal-enter-active { transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1); }
986
+ .modal-leave-active { transition: opacity 130ms ease; }
987
+ .modal-enter-from,
988
+ .modal-leave-to { opacity: 0; }
989
+ .modal-enter-active .modal { transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1); }
990
+ .modal-enter-from .modal { transform: translateY(8px) scale(0.96); }
991
+ .modal {
992
+ display: flex;
993
+ flex-direction: column;
994
+ gap: 12px;
995
+ width: min(440px, calc(100vw - 40px));
996
+ max-height: 82vh;
997
+ padding: 18px;
998
+ overflow: auto;
999
+ background: var(--surface-glass-strong);
1000
+ backdrop-filter: var(--aurora-blur);
1001
+ -webkit-backdrop-filter: var(--aurora-blur);
1002
+ border: 1px solid var(--glass-line);
1003
+ border-radius: var(--radius-panel);
1004
+ box-shadow: var(--glass-edge), var(--shadow-float);
1005
+ }
1006
+ .modal.wide { width: min(640px, calc(100vw - 40px)); }
1007
+ .m-title { display: flex; gap: 10px; align-items: baseline; font-size: 15px; color: var(--ink); }
1008
+ .m-title.danger-title { align-items: center; color: var(--tone-danger-dot); }
1009
+ .f { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--ink-faint); }
1010
+ .f em { color: var(--tone-danger-dot); font-style: normal; }
1011
+ .f .inp {
1012
+ height: 30px;
1013
+ padding: 0 9px;
1014
+ font-size: 12px;
1015
+ color: var(--ink);
1016
+ background: var(--paper-deep);
1017
+ border: 1px solid var(--line-strong);
1018
+ border-radius: var(--radius-panel-sm);
1019
+ }
1020
+ .f .inp:focus { outline: none; border-color: var(--tone-success-dot); box-shadow: 0 0 0 3px var(--accent-soft); }
1021
+ .color-row { display: flex; gap: 7px; align-items: center; }
1022
+ .color-dot {
1023
+ width: 20px;
1024
+ height: 20px;
1025
+ border: 2px solid transparent;
1026
+ border-radius: 6px;
1027
+ cursor: pointer;
1028
+ }
1029
+ .color-dot.on { border-color: var(--ink); box-shadow: 0 0 10px currentColor; }
1030
+ .m-actions { display: flex; gap: 8px; justify-content: flex-end; }
1031
+ .m-err { font-size: 11px; color: var(--tone-danger-dot); }
1032
+ .dim { color: var(--ink-fainter); }
1033
+ .sec-label { font-size: 10px; font-weight: 700; color: var(--ink-fainter); letter-spacing: 0.16em; }
1034
+ /* 删除确认:摘要行 + 级联勾选 */
1035
+ .del-summary { font-size: 12px; line-height: 1.6; color: var(--ink-soft); }
1036
+ .del-purge { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--ink); cursor: pointer; }
1037
+ .del-purge input {
1038
+ width: 14px;
1039
+ height: 14px;
1040
+ accent-color: var(--tone-danger-dot);
1041
+ }
1042
+ .tpl-hint { font-size: 11px; }
1043
+ .tpl-list { display: flex; flex-direction: column; gap: 6px; }
1044
+ .tpl-row {
1045
+ display: flex;
1046
+ gap: 10px;
1047
+ align-items: center;
1048
+ padding: 7px 10px;
1049
+ font-size: 12px;
1050
+ color: var(--ink);
1051
+ background: var(--frost-bg);
1052
+ border: 1px solid var(--glass-line);
1053
+ border-radius: 9px;
1054
+ }
1055
+ .tpl-row small { flex: 1; }
1056
+ .tpl-tag {
1057
+ padding: 1px 8px;
1058
+ font-family: var(--font-mono);
1059
+ font-size: 9px;
1060
+ color: var(--tone-info-dot);
1061
+ border: 1px solid color-mix(in srgb, var(--tone-info-dot) 40%, transparent);
1062
+ border-radius: 5px;
1063
+ }
1064
+ .tpl-tag.builtin { color: var(--ink-fainter); border-color: var(--line-strong); }
1065
+ .tpl-form { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
1066
+ .tpl-sem { flex: 1; color: var(--ink-faint); }
1067
+ textarea.inp { height: auto; padding: 6px 9px; font-size: 11.5px; resize: vertical; }
1068
+ .mini-btn {
1069
+ padding: 4px 10px;
1070
+ font-size: 10.5px;
1071
+ color: var(--ink-faint);
1072
+ background: var(--paper-deep);
1073
+ border: 1px solid var(--line-strong);
1074
+ border-radius: 7px;
1075
+ cursor: pointer;
1076
+ }
1077
+ .mini-btn:hover { border-color: var(--ink-faint); color: var(--ink); }
1078
+ .mini-btn.danger { color: var(--tone-danger-dot); }
1079
+ </style>