fix: 子卷包装关系手动添加

This commit is contained in:
2026-08-18 09:48:50 +08:00
parent 98f573fab4
commit f22f832ad4
6 changed files with 1493 additions and 16 deletions

View File

@@ -5,13 +5,13 @@
## 核心规则
1. **收到任务时,先检查是否有匹配的 skill** — 哪怕只有 1% 的可能性也要检查
2. **设计先于编码** — 收到功能需求时,先用 brainstorming skill 做需求分析
3. **测试先于实现** — 写代码前先写测试TDD
4. **验证先于完成** — 声称完成前必须运行验证命令
5. **代码开发规则** — 遵守 `.Codex/skills/nl-java-style/SKILL.md` 技能的规范要求
6. 不需要频繁提交代码,除非用户说允许测试,否则优先不做测试
7. 如果用户说不需要使用 superpowers-zh 技能,就不需要使用 superpowers-zh 下的所有技能。
1. 默认不使用 superpowers-zh 技能,默认不需要使用 superpowers-zh 下的所有技能除非用户说使用superpower-zh
2. **收到任务时,先检查是否有匹配的 skill** — 哪怕只有 1% 的可能性也要检查
3. **设计先于编码** — 收到功能需求时,先用 brainstorming skill 做需求分析
4. **测试先于实现** — 写代码前先写测试TDD
5. **验证先于完成** — 声称完成前必须运行验证命令
6. **代码开发规则** — 遵守 `.Codex/skills/nl-java-style/SKILL.md` 技能的规范要求
7. 不需要频繁提交代码,除非用户说允许测试,否则优先不做测试
## 可用 Skills

View File

@@ -19,6 +19,7 @@ import {
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import BatchForm from './modules/batch-form.vue';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
@@ -26,6 +27,11 @@ const [FormModal, formModalApi] = useVbenModal({
destroyOnClose: true,
});
const [BatchFormModal, batchFormModalApi] = useVbenModal({
connectedComponent: BatchForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
@@ -36,6 +42,11 @@ function handleCreate() {
formModalApi.setData(null).open();
}
/** 批量创建子卷包装关系 */
function handleBatchCreate() {
batchFormModalApi.open();
}
/** 编辑子卷包装关系 */
function handleEdit(row: LmsSubPackageRelationApi.SubPackageRelation) {
formModalApi.setData(row).open();
@@ -127,6 +138,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<BatchFormModal @success="handleRefresh" />
<Grid table-title="子卷包装关系列表">
<template #toolbar-tools>
<TableAction
@@ -138,6 +150,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
auth: ['lms:sub-package-relation:create'],
onClick: handleCreate,
},
{
label: '批量新增',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['lms:sub-package-relation:create'],
onClick: handleBatchCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',

View File

@@ -121,12 +121,9 @@ describe('batch-form-model', () => {
});
expect(result.rows).toEqual([rows[0], rows[2]]);
expect(result.rowErrors).toEqual(
new Map([
[rows[2]!.clientKey, '第三行失败'],
[rows[0]!.clientKey, '第一行失败'],
]),
);
expect(result.safe).toBe(true);
expect(result.rowErrors.get(rows[0]!.clientKey)).toBe('第一行失败');
expect(result.rowErrors.get(rows[2]!.clientKey)).toBe('第三行失败');
});
it('falls back to one-based row index when the failure client key is unknown', () => {
@@ -145,6 +142,7 @@ describe('batch-form-model', () => {
});
expect(result.rows).toEqual([rows[1]]);
expect(result.safe).toBe(true);
expect(result.rowErrors).toEqual(
new Map([[rows[1]!.clientKey, '第二行失败']]),
);
@@ -167,6 +165,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -189,6 +188,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -212,6 +212,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -228,6 +229,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -249,6 +251,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -277,6 +280,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -299,6 +303,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -321,6 +326,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -343,6 +349,7 @@ describe('batch-form-model', () => {
const generalError = '批量保存结果无法匹配,请核对后重试';
expect(result.rows).toEqual(rows);
expect(result.safe).toBe(false);
expect(result.rowErrors).toEqual(
new Map(rows.map((row) => [row.clientKey, generalError])),
);
@@ -355,6 +362,6 @@ describe('batch-form-model', () => {
failures: [],
});
expect(result).toEqual({ rows: [], rowErrors: new Map() });
expect(result).toEqual({ rows: [], rowErrors: new Map(), safe: true });
});
});

View File

@@ -79,11 +79,16 @@ export function validateBatchRows(
export function applyBatchResult(
rows: BatchFormRow[],
result: LmsSubPackageRelationApi.BatchCreateResult,
): { rows: BatchFormRow[]; rowErrors: Map<string, string> } {
): {
rowErrors: Map<string, string>;
rows: BatchFormRow[];
safe: boolean;
} {
const generalError = '批量保存结果无法匹配,请核对后重试';
const unsafeResult = () => ({
rows,
rowErrors: new Map(rows.map((item) => [item.clientKey, generalError])),
safe: false,
});
const rowByClientKey = new Map<string, BatchFormRow>();
for (const row of rows) {
@@ -105,7 +110,7 @@ export function applyBatchResult(
}
if (result.failures.length === 0) {
return { rows: [], rowErrors: new Map() };
return { rows: [], rowErrors: new Map(), safe: true };
}
const failedClientKeys = new Set<string>();
@@ -142,5 +147,6 @@ export function applyBatchResult(
return {
rows: rows.filter((row) => failedClientKeys.has(row.clientKey)),
rowErrors,
safe: true,
};
}

View File

@@ -0,0 +1,481 @@
<script lang="ts" setup>
import type { BatchFieldErrors, BatchFormRow } from './batch-form-model';
import type { LmsBoxTypeApi } from '#/api/lms/boxtype';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
Alert,
Button,
Card,
FormItem,
Input,
message,
Segmented,
Select,
} from 'antdv-next';
import { batchCreateSubPackageRelation } from '#/api/lms/subpackagerelation';
import BoxTypeSelectModalComponent from '#/views/lms/boxtype/components/BoxTypeSelectModal.vue';
import {
applyBatchResult,
copyBatchRow,
createBatchRow,
validateBatchRows,
} from './batch-form-model';
const emit = defineEmits<{ success: [] }>();
const rows = ref<BatchFormRow[]>([createBatchRow()]);
const viewMode = ref<'card' | 'table'>('table');
const activeClientKey = ref<string>();
const fieldErrors = ref(new Map<number, BatchFieldErrors>());
const rowErrors = ref(new Map<string, string>());
const resultUncertain = ref(false);
const viewOptions = [
{ label: '▤ 卡片', value: 'card' },
{ label: '▦ 表格', value: 'table' },
];
const statusOptions = computed(() =>
getDictOptions(DICT_TYPE.SUB_PACKAGE_STATUS).map((item) => ({
...item,
value: String(item.value),
})),
);
function reset() {
rows.value = [createBatchRow()];
viewMode.value = 'table';
activeClientKey.value = undefined;
fieldErrors.value = new Map();
rowErrors.value = new Map();
resultUncertain.value = false;
}
function clearErrors() {
fieldErrors.value = new Map();
}
function appendRow() {
rows.value.push(createBatchRow());
clearErrors();
}
function duplicateRow(index: number) {
rows.value.splice(index + 1, 0, copyBatchRow(rows.value[index]!));
clearErrors();
}
function removeRow(index: number) {
const [removed] = rows.value.splice(index, 1);
if (rows.value.length === 0) rows.value.push(createBatchRow());
fieldErrors.value = new Map();
if (removed) rowErrors.value.delete(removed.clientKey);
}
function clearRows() {
rows.value = [createBatchRow()];
activeClientKey.value = undefined;
fieldErrors.value = new Map();
rowErrors.value = new Map();
}
function editField(index: number, field: keyof BatchFormRow) {
const errors = fieldErrors.value.get(index);
if (errors?.[field]) {
const next = { ...errors };
delete next[field];
const map = new Map(fieldErrors.value);
Object.keys(next).length ? map.set(index, next) : map.delete(index);
fieldErrors.value = map;
}
rowErrors.value.delete(rows.value[index]!.clientKey);
}
function fieldError(index: number, field: keyof BatchFormRow) {
return fieldErrors.value.get(index)?.[field];
}
const [BoxTypeSelectModal, boxTypeSelectModalApi] = useVbenModal({
connectedComponent: BoxTypeSelectModalComponent,
destroyOnClose: true,
});
function selectBoxType(clientKey: string) {
activeClientKey.value = clientKey;
boxTypeSelectModalApi.setData({ multiple: false }).open();
}
function handleBoxTypeSelect(selectedRows: LmsBoxTypeApi.BoxType[]) {
const index = rows.value.findIndex(
(item) => item.clientKey === activeClientKey.value,
);
const selected = selectedRows[0];
if (index < 0 || !selected) return;
const row = rows.value[index]!;
row.boxType = selected.boxType ?? '';
row.boxLength = selected.boxLength == null ? '' : String(selected.boxLength);
row.boxWidth = selected.boxWidth == null ? '' : String(selected.boxWidth);
row.boxHigh = selected.boxHigh == null ? '' : String(selected.boxHigh);
editField(index, 'boxType');
activeClientKey.value = undefined;
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (resultUncertain.value) {
message.error('上次保存结果无法确认,请刷新列表核对后关闭弹窗');
return;
}
const errors = validateBatchRows(rows.value);
fieldErrors.value = errors;
rowErrors.value = new Map();
if (errors.size > 0) {
message.warning('请检查必填项');
return;
}
modalApi.lock();
try {
const result = await batchCreateSubPackageRelation(rows.value);
const applied = applyBatchResult(rows.value, result);
if (result.successCount > 0) emit('success');
if (!applied.safe) {
rows.value = applied.rows;
rowErrors.value = applied.rowErrors;
fieldErrors.value = new Map();
resultUncertain.value = true;
message.error('保存结果无法确认,请刷新列表核对后关闭弹窗');
return;
}
if (result.failureCount === 0) {
await modalApi.close();
message.success(`成功新增 ${result.successCount} 条子卷包装关系`);
return;
}
rows.value = applied.rows;
rowErrors.value = applied.rowErrors;
fieldErrors.value = new Map();
message.warning(
`成功 ${result.successCount} 条,失败 ${result.failureCount}`,
);
} finally {
modalApi.unlock();
}
},
onOpenChange(isOpen) {
if (isOpen) reset();
},
});
</script>
<template>
<Modal title="自由切换批量新增" class="w-[1400px] max-w-[96vw]">
<BoxTypeSelectModal @select="handleBoxTypeSelect" />
<div class="batch-form">
<div class="toolbar">
<div class="toolbar-actions">
<Button type="primary" @click="appendRow">新增一行</Button>
<Button class="clear-button" @click="clearRows">清空全部</Button>
</div>
<Segmented
v-model:value="viewMode"
class="view-switch"
:options="viewOptions"
aria-label="录入视图"
/>
<span class="record-counter">
当前共 <strong>{{ rows.length }}</strong> 条记录
</span>
</div>
<Alert class="view-tip" type="info" show-icon>
<template #message>
{{
viewMode === 'table'
? '表格视图适合批量核对,可横向滚动查看全部字段。'
: '卡片视图适合逐条录入和检查,切换后数据自动保留。'
}}
</template>
<template #action>尺寸单位mm</template>
</Alert>
<div v-if="viewMode === 'table'" class="table-wrap">
<table class="batch-table">
<thead>
<tr>
<th>序号</th><th><i>*</i> 木箱唯一码</th><th><i>*</i> 木箱类型</th>
<th>木箱长</th><th>木箱宽</th><th>木箱高</th>
<th><i>*</i> 保质期</th><th><i>*</i> 入库日期</th><th><i>*</i> 子卷号</th>
<th><i>*</i> 状态</th><th>操作</th>
</tr>
</thead>
<tbody>
<template v-for="(row, index) in rows" :key="row.clientKey">
<tr>
<td class="sequence">{{ index + 1 }}</td>
<td><Input v-model:value="row.packageBoxSn" :class="{ invalid: fieldError(index, 'packageBoxSn') }" placeholder="请输入木箱唯一码" @update:value="editField(index, 'packageBoxSn')" /><small>{{ fieldError(index, 'packageBoxSn') }}</small></td>
<td><Input :value="row.boxType" readonly role="button" tabindex="0" aria-label="选择木箱类型" :class="{ invalid: fieldError(index, 'boxType') }" placeholder="请选择木箱类型" @click="selectBoxType(row.clientKey)" @keydown.enter="selectBoxType(row.clientKey)" @keydown.space.prevent="selectBoxType(row.clientKey)" /><small>{{ fieldError(index, 'boxType') }}</small></td>
<td><Input v-model:value="row.boxLength" readonly placeholder="自动带出" /></td>
<td><Input v-model:value="row.boxWidth" readonly placeholder="自动带出" /></td>
<td><Input v-model:value="row.boxHigh" readonly placeholder="自动带出" /></td>
<td><Input v-model:value="row.qualityGuaranPeriod" :class="{ invalid: fieldError(index, 'qualityGuaranPeriod') }" placeholder="请输入保质期" @update:value="editField(index, 'qualityGuaranPeriod')" /><small>{{ fieldError(index, 'qualityGuaranPeriod') }}</small></td>
<td><Input v-model:value="row.dateOfFgInbound" type="date" :class="{ invalid: fieldError(index, 'dateOfFgInbound') }" @update:value="editField(index, 'dateOfFgInbound')" /><small>{{ fieldError(index, 'dateOfFgInbound') }}</small></td>
<td><Input v-model:value="row.containerName" :class="{ invalid: fieldError(index, 'containerName') }" placeholder="请输入子卷号" @update:value="editField(index, 'containerName')" /><small>{{ fieldError(index, 'containerName') }}</small></td>
<td><Select v-model:value="row.status" :options="statusOptions" :class="{ invalid: fieldError(index, 'status') }" @change="editField(index, 'status')" /><small>{{ fieldError(index, 'status') }}</small></td>
<td class="operations"><Button type="link" @click="duplicateRow(index)">复制</Button><Button type="link" danger @click="removeRow(index)">删除</Button></td>
</tr>
<tr v-if="rowErrors.get(row.clientKey)" class="row-error"><td colspan="11"> {{ index + 1 }} {{ rowErrors.get(row.clientKey) }}</td></tr>
</template>
</tbody>
</table>
</div>
<div v-else class="card-list">
<Card v-for="(row, index) in rows" :key="row.clientKey" size="small">
<template #title> {{ index + 1 }} 条包装关系</template>
<template #extra><Button type="link" @click="duplicateRow(index)">复制</Button><Button type="link" danger @click="removeRow(index)">删除</Button></template>
<Alert v-if="rowErrors.get(row.clientKey)" type="error" show-icon :message="rowErrors.get(row.clientKey)" class="row-alert" />
<div class="card-grid">
<FormItem label="木箱唯一码" required :validate-status="fieldError(index, 'packageBoxSn') ? 'error' : undefined" :help="fieldError(index, 'packageBoxSn')"><Input v-model:value="row.packageBoxSn" @update:value="editField(index, 'packageBoxSn')" /></FormItem>
<FormItem label="木箱类型" required :validate-status="fieldError(index, 'boxType') ? 'error' : undefined" :help="fieldError(index, 'boxType')"><Input :value="row.boxType" readonly role="button" tabindex="0" aria-label="选择木箱类型" placeholder="请选择木箱类型" @click="selectBoxType(row.clientKey)" @keydown.enter="selectBoxType(row.clientKey)" @keydown.space.prevent="selectBoxType(row.clientKey)" /></FormItem>
<FormItem label="木箱长"><Input v-model:value="row.boxLength" readonly placeholder="自动带出" /></FormItem>
<FormItem label="木箱宽"><Input v-model:value="row.boxWidth" readonly placeholder="自动带出" /></FormItem>
<FormItem label="木箱高"><Input v-model:value="row.boxHigh" readonly placeholder="自动带出" /></FormItem>
<FormItem label="保质期" required :validate-status="fieldError(index, 'qualityGuaranPeriod') ? 'error' : undefined" :help="fieldError(index, 'qualityGuaranPeriod')"><Input v-model:value="row.qualityGuaranPeriod" @update:value="editField(index, 'qualityGuaranPeriod')" /></FormItem>
<FormItem label="入库日期" required :validate-status="fieldError(index, 'dateOfFgInbound') ? 'error' : undefined" :help="fieldError(index, 'dateOfFgInbound')"><Input v-model:value="row.dateOfFgInbound" type="date" @update:value="editField(index, 'dateOfFgInbound')" /></FormItem>
<FormItem label="子卷号" required :validate-status="fieldError(index, 'containerName') ? 'error' : undefined" :help="fieldError(index, 'containerName')"><Input v-model:value="row.containerName" @update:value="editField(index, 'containerName')" /></FormItem>
<FormItem label="状态" required :validate-status="fieldError(index, 'status') ? 'error' : undefined" :help="fieldError(index, 'status')"><Select v-model:value="row.status" :options="statusOptions" @change="editField(index, 'status')" /></FormItem>
</div>
</Card>
</div>
</div>
</Modal>
</template>
<style scoped>
.batch-form {
display: flex;
min-height: min(650px, calc(100vh - 230px));
padding: 0 20px 18px;
background: #f7f9fc;
flex-direction: column;
}
.toolbar {
display: grid;
align-items: center;
padding: 14px 6px;
margin: 0 -6px 18px;
background: hsl(var(--background));
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.toolbar-actions :deep(.ant-btn) {
height: 36px;
padding: 0 18px;
border-radius: 8px;
}
.clear-button {
color: #1677ff;
background: #eef4ff;
border-color: #cfe0ff;
}
.view-switch {
padding: 3px;
border-radius: 8px;
}
.view-switch :deep(.ant-segmented-item) {
min-width: 82px;
border-radius: 6px;
}
.view-switch :deep(.ant-segmented-item-selected) {
color: #1677ff;
font-weight: 600;
}
.record-counter {
color: hsl(var(--muted-foreground));
text-align: right;
}
.record-counter strong {
margin: 0 4px;
color: #1677ff;
font-size: 18px;
}
.view-tip {
margin-bottom: 16px;
border-color: #cfe0ff;
border-radius: 9px;
}
.table-wrap {
flex: 1;
min-height: 420px;
overflow: auto;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 10px;
}
.batch-table {
width: 100%;
min-width: 1780px;
border-collapse: separate;
border-spacing: 0;
}
.batch-table th,
.batch-table td {
padding: 10px;
vertical-align: top;
background: hsl(var(--background));
border-right: 1px solid hsl(var(--border));
border-bottom: 1px solid hsl(var(--border));
}
.batch-table th {
position: sticky;
top: 0;
z-index: 3;
height: 48px;
color: #4b5870;
font-weight: 600;
text-align: left;
white-space: nowrap;
background: #f3f6fb;
vertical-align: middle;
}
.batch-table th i {
color: #ff4d4f;
font-style: normal;
}
.batch-table td {
min-width: 145px;
}
.batch-table :deep(.ant-input),
.batch-table :deep(.ant-select-selector) {
min-height: 36px;
border-radius: 7px;
}
.batch-table :deep(.ant-input[readonly]) {
color: #667085;
background: #f2f4f7;
}
.batch-table .sequence {
position: sticky;
left: 0;
z-index: 2;
min-width: 64px;
color: #788196;
text-align: center;
}
.batch-table th:first-child {
left: 0;
z-index: 5;
min-width: 64px;
text-align: center;
}
.batch-table th:last-child,
.batch-table .operations {
position: sticky;
right: 0;
z-index: 2;
min-width: 128px;
text-align: center;
white-space: nowrap;
box-shadow: -5px 0 12px rgb(21 35 62 / 6%);
}
.batch-table th:last-child {
z-index: 5;
}
.batch-table small {
display: block;
min-height: 18px;
margin-top: 2px;
color: #ff4d4f;
}
.invalid {
border-color: #ff4d4f;
}
.row-error td {
color: #ff4d4f;
background: #fff2f0;
}
.card-list {
display: grid;
flex: 1;
align-content: start;
gap: 16px;
min-height: 420px;
padding: 2px;
overflow: auto;
}
.card-list :deep(.ant-card) {
border-radius: 10px;
box-shadow: 0 2px 8px rgb(23 32 51 / 4%);
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 24px;
}
.card-grid :deep(.ant-input[readonly]) {
color: #667085;
background: #f2f4f7;
}
.row-alert {
margin-bottom: 16px;
}
@media (max-width: 768px) {
.toolbar {
align-items: flex-start;
grid-template-columns: 1fr;
}
.record-counter {
text-align: left;
}
.card-grid {
grid-template-columns: 1fr;
}
}
</style>

964
packingAreaDashboard.html Normal file
View File

@@ -0,0 +1,964 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>装箱区信息看板</title>
<style>
:root {
color-scheme: dark;
--bg: #0d0d0d;
--panel: rgba(255, 255, 255, .03);
--panel-2: rgba(255, 255, 255, .025);
--line: rgba(255, 255, 255, .07);
--cyan: #31d7ed;
--blue: #2a91ff;
--green: #42df91;
--yellow: #ffd35a;
--red: #ff6574;
--purple: #b28cff;
--text: #eef8ff;
--muted: #898781;
--agv: rgba(242, 250, 255, .92);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
color: var(--text);
background: #0d0d0d;
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
}
button, input { font: inherit; }
#stage {
position: relative;
width: 1920px;
height: 1080px;
flex: 0 0 auto;
overflow: hidden;
padding: 20px 24px 16px;
transform-origin: center center;
background:
radial-gradient(1200px 500px at 50% -10%, rgba(25, 110, 161, .28), transparent 60%),
linear-gradient(180deg, #0a1424 0%, #0d1424 45%, #0d1220 100%);
display: flex;
flex-direction: column;
gap: 12px;
}
#stage::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(90, 140, 190, .05) 1px, transparent 1px),
linear-gradient(90deg, rgba(90, 140, 190, .05) 1px, transparent 1px);
background-size: 48px 48px;
}
#stage::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 100px rgba(0, 0, 0, .28);
}
#stage > * { position: relative; z-index: 1; }
.header {
height: 58px;
display: grid;
grid-template-columns: 350px 1fr 350px;
align-items: center;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #10aeff, #007aff);
box-shadow: none;
}
.brand-mark::before, .brand-mark::after {
content: "箱";
position: absolute;
inset: 0;
display: grid;
place-items: center;
border: 0;
color: #fff;
font-size: 18px;
font-weight: 800;
}
.brand-mark::after { display: none; }
.brand-cn { font-size: 17px; font-weight: 700; letter-spacing: 1px; }
.brand-en { margin-top: 2px; color: #898781; font-size: 10px; letter-spacing: 2.4px; }
h1 {
margin: 0;
text-align: center;
font-size: 30px;
font-weight: 700;
letter-spacing: 8px;
background: linear-gradient(180deg, #ffffff, #9ed0ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: none;
}
.clock { text-align: right; font-variant-numeric: tabular-nums; }
#clockTime { font-size: 25px; font-weight: 750; letter-spacing: 2px; }
#clockDate { margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 1px; }
.kpis { height: 70px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
.kpi, .toolbar, .map-panel, .side-panel {
border: 1px solid rgba(255, 255, 255, .07);
background: rgba(255, 255, 255, .03);
box-shadow: none;
}
.kpi {
position: relative;
overflow: hidden;
padding: 10px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 14px;
}
.kpi::before { display: none; }
.kpi-icon {
width: 10px;
height: 42px;
display: block;
flex: none;
overflow: hidden;
border: 0;
border-radius: 5px;
color: transparent;
background: var(--accent);
font-size: 0;
box-shadow: none;
}
.kpi-value { font-size: 25px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
.kpi-value small { margin-left: 3px; color: var(--muted); font-size: 12px; font-weight: 400; }
.kpi-label { margin-top: 5px; color: #9eb8ca; font-size: 12px; }
.toolbar {
min-height: 55px;
padding: 8px 12px;
border-radius: 9px;
display: flex;
align-items: center;
gap: 18px;
}
.legend { display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; }
.legend-title { color: #b7d6e9; font-size: 12px; font-weight: 700; }
.legend-item { display: inline-flex; align-items: center; gap: 5px; color: #aac2d4; font-size: 11px; white-space: nowrap; }
.legend-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); }
.legend-dot.alert { box-shadow: 0 0 8px var(--c); }
.toolbar-spacer { flex: 1; }
.control { display: flex; align-items: center; gap: 7px; color: #9db8cb; font-size: 12px; }
.control input {
height: 31px;
padding: 0 9px;
border: 1px solid #315a79;
border-radius: 5px;
outline: none;
color: #eaf9ff;
background: rgba(3, 14, 27, .72);
}
.control input:focus { border-color: var(--cyan); }
#searchInput { width: 190px; }
#thresholdInput, #zoomInput { width: 62px; text-align: center; }
.btn {
height: 31px;
padding: 0 13px;
border: 1px solid #287ca8;
border-radius: 5px;
color: #def8ff;
background: linear-gradient(180deg, #11618a, #0a4669);
cursor: pointer;
transition: .18s ease;
}
.btn:hover { border-color: #50dcf2; filter: brightness(1.13); }
.btn.secondary { border-color: #365b78; background: #102c45; }
#searchMessage { min-width: 105px; font-size: 11px; }
#searchMessage.ok { color: #55e89a; }
#searchMessage.error { color: #ff7c89; }
.content { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 342px; gap: 12px; }
.map-panel { min-width: 0; padding: 10px; border-radius: 10px; display: flex; flex-direction: column; }
.panel-head { height: 28px; display: flex; align-items: center; justify-content: space-between; padding: 0 4px 8px; }
.panel-title { color: #bcecff; font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.panel-hint { color: #668aa3; font-size: 10px; }
.map-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
border: 1px solid rgba(255, 255, 255, .06);
border-radius: 10px;
background:
linear-gradient(rgba(90, 140, 190, .04) 1px, transparent 1px),
linear-gradient(90deg, rgba(90, 140, 190, .04) 1px, transparent 1px),
rgba(4, 13, 25, .68);
background-size: 36px 36px;
}
.map-viewport.dragging { cursor: grabbing; }
.map-canvas {
position: absolute;
left: 0;
top: 0;
width: 1450px;
height: 1590px;
transform-origin: top left;
will-change: transform;
user-select: none;
}
.map-canvas::before {
content: "装箱车间 / PACKING WORKSHOP";
position: absolute;
left: 18px;
top: 415px;
color: rgba(90, 206, 238, .3);
font-size: 10px;
letter-spacing: 3px;
writing-mode: vertical-rl;
}
.north-arrow {
position: absolute;
z-index: 8;
right: 18px;
top: 16px;
width: 58px;
height: 72px;
display: flex;
flex-direction: column;
align-items: center;
color: #c8f6ff;
font-size: 10px;
letter-spacing: 1px;
text-shadow: none;
}
.north-arrow b { font-size: 32px; line-height: 32px; color: #ecfdff; }
.floor-zone {
position: absolute;
left: 65px;
width: 1320px;
padding: 14px 12px 10px;
border: 2px solid var(--zone-color);
border-radius: 8px;
background: color-mix(in srgb, var(--zone-color) 4%, rgba(3, 13, 25, .7));
box-shadow: inset 0 0 16px rgba(24, 118, 174, .06);
}
.floor-zone::before, .floor-zone::after {
content: "";
position: absolute;
width: 13px;
height: 13px;
border: 2px solid var(--zone-color);
background: #06111f;
}
.floor-zone::before { left: -8px; top: -8px; border-right: 0; border-bottom: 0; }
.floor-zone::after { right: -8px; bottom: -8px; border-left: 0; border-top: 0; }
.zone-label {
position: absolute;
z-index: 5;
left: 14px;
top: -13px;
height: 24px;
padding: 3px 12px;
border: 1px solid var(--zone-color);
border-radius: 12px;
color: #effcff;
background: color-mix(in srgb, var(--zone-color) 38%, #071526);
box-shadow: 0 3px 10px rgba(0, 0, 0, .4);
font-size: 11px;
font-weight: 800;
letter-spacing: 1px;
}
.zone-count { margin-left: 7px; color: rgba(255, 255, 255, .68); font-weight: 400; }
.zone-north { top: 28px; height: 182px; --zone-color: #2ca9c2; }
.zone-center { top: 265px; height: 950px; --zone-color: #3578b7; }
.zone-south { top: 1270px; height: 290px; --zone-color: #367e9d; }
.slot-grid {
height: 100%;
display: grid;
grid-template-columns: 150px 150px 54px 150px 150px 54px 150px 150px;
gap: 8px;
justify-content: center;
}
.zone-center .slot-grid { grid-template-rows: repeat(7, 124px); row-gap: 8px; }
.zone-north .slot-grid { grid-template-columns: repeat(3, 150px) 54px repeat(3, 150px); grid-template-rows: 150px; }
.agv-column {
position: relative;
grid-row: 1 / -1;
border-left: 2px solid var(--agv);
border-right: 2px solid var(--agv);
background: repeating-linear-gradient(180deg, transparent 0 14px, rgba(236, 250, 255, .08) 14px 28px);
box-shadow: inset 0 0 8px rgba(238, 252, 255, .05);
}
.agv-column::after {
content: "↑ AGV ↓";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: rgba(235, 250, 255, .75);
font-size: 9px;
letter-spacing: 4px;
writing-mode: vertical-rl;
}
.agv-main {
position: absolute;
left: 65px;
width: 1320px;
height: 30px;
border-top: 2px solid var(--agv);
border-bottom: 2px solid var(--agv);
background: repeating-linear-gradient(90deg, transparent 0 22px, rgba(236, 250, 255, .07) 22px 44px);
box-shadow: none;
color: rgba(238, 251, 255, .72);
font-size: 9px;
line-height: 26px;
text-align: center;
letter-spacing: 14px;
}
.agv-main.one { top: 220px; }
.agv-main.two { top: 1225px; }
.south-grid {
height: 100%;
display: grid;
grid-template-columns: repeat(8, 125px) 54px 158px;
grid-template-rows: repeat(2, 124px);
gap: 10px 8px;
justify-content: center;
}
.south-grid .agv-column { grid-column: 9; grid-row: 1 / 3; }
.slot {
position: relative;
min-width: 0;
overflow: hidden;
padding: 8px 9px 7px;
border: 1px solid var(--slot-border);
border-radius: 5px;
color: #eaf7ff;
background: linear-gradient(150deg, var(--slot-top), var(--slot-bottom));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .06), 0 3px 7px rgba(0, 0, 0, .25);
cursor: pointer;
transition: transform .16s ease, filter .16s ease, box-shadow .16s ease;
}
.slot::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--state-color);
box-shadow: none;
}
.slot:hover { z-index: 5; transform: translateY(-2px); filter: brightness(1.12); }
.slot.selected {
z-index: 6;
border-color: #fff;
outline: 2px solid rgba(221, 246, 255, .8);
outline-offset: 1px;
}
.slot.search-hit { animation: search-pulse .7s ease-in-out infinite alternate; }
@keyframes search-pulse {
from { outline-color: rgba(178, 225, 245, .65); }
to { outline-color: #ffffff; filter: brightness(1.12); }
}
.slot-head { min-height: 16px; padding-right: 14px; display: flex; align-items: center; }
.slot-code { overflow: hidden; color: #fff; font-size: 12px; font-weight: 800; letter-spacing: .3px; text-overflow: ellipsis; white-space: nowrap; }
.slot-state-dot {
position: absolute;
right: 8px;
top: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--state-color);
box-shadow: none;
}
.slot-name {
height: 17px;
margin-top: 3px;
padding: 2px 5px;
overflow: hidden;
border-radius: 3px;
color: #b8d8ed;
background: rgba(0, 0, 0, .2);
font-size: 8px;
line-height: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-box-panel {
min-height: 28px;
margin-top: 4px;
padding: 3px 5px;
overflow: hidden;
border: 1px solid rgba(142, 179, 205, .28);
border-radius: 4px;
background: rgba(0, 0, 0, .22);
}
.slot-box-panel.has-box { border-color: rgba(70, 151, 190, .7); background: rgba(20, 68, 94, .45); }
.slot-box-label { display: block; color: #8faec4; font-size: 7px; line-height: 9px; }
.slot-box-panel.has-box .slot-box-label { color: #87c9e5; }
.slot-box-value { display: block; margin-top: 1px; overflow: hidden; color: #dbeaf4; font-size: 9px; font-weight: 700; line-height: 11px; text-overflow: ellipsis; white-space: nowrap; }
.slot-box-panel.has-box .slot-box-value { color: #dcf4ff; }
.slot-info-row { min-height: 15px; display: flex; align-items: center; justify-content: space-between; gap: 4px; color: #91adc1; font-size: 8px; }
.slot-info-label { flex: none; }
.slot-info-value { min-width: 0; overflow: hidden; color: #f1f8fc; font-weight: 700; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.slot-info-value.roll-no { color: #b9dff7; }
.slot-stock {
flex: none;
padding: 1px 5px;
border: 1px solid var(--state-color);
border-radius: 8px;
color: var(--state-color);
background: color-mix(in srgb, var(--state-color) 10%, transparent);
font-size: 7px;
font-weight: 700;
line-height: 11px;
white-space: nowrap;
}
.slot-stock::before { content: ""; display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%; background: currentColor; vertical-align: 0; }
.slot.empty { --slot-top: rgba(23, 54, 79, .63); --slot-bottom: rgba(8, 28, 46, .62); --slot-border: #345974; opacity: .78; border-style: dashed; }
.slot.empty .slot-info-value.roll-no { color: #b9dff7; }
.slot.carrier { --slot-top: #153c51; --slot-bottom: #0b2636; --slot-border: #356b85; }
.slot.roll { --slot-top: #183d63; --slot-bottom: #0c2844; --slot-border: #426f9b; }
.slot.qualified { --slot-top: #16444d; --slot-bottom: #0b2b34; --slot-border: #3c747d; }
.slot.controlled { --slot-top: #762536; --slot-bottom: #3b111c; --slot-border: #f05a70; box-shadow: 0 0 12px rgba(255, 83, 100, .45); }
.slot.overdue { --slot-top: #8a2935; --slot-bottom: #430f18; --slot-border: #ff6574; animation: overdue-pulse 1.8s ease-in-out infinite; }
.slot.controlled::after, .slot.overdue::after { box-shadow: 0 0 8px #ff6574; }
.slot.controlled .slot-state-dot, .slot.overdue .slot-state-dot { box-shadow: 0 0 7px #ff6574; }
.slot.controlled .slot-stock::before, .slot.overdue .slot-stock::before { box-shadow: 0 0 4px #ff6574; }
@keyframes overdue-pulse { 50% { filter: brightness(1.18); box-shadow: 0 0 13px rgba(255, 83, 100, .55); } }
.dock-slot { padding: 8px 9px; border-width: 2px; }
.dock-slot .slot-code { color: #fff2c2; font-size: 11px; }
.side-panel { min-height: 0; padding: 12px; border-radius: 10px; display: flex; flex-direction: column; gap: 10px; }
.side-title { display: flex; align-items: center; justify-content: space-between; color: #c0ecff; font-size: 14px; font-weight: 750; }
.demo-tag { padding: 3px 8px; border: 1px solid #3f6f95; border-radius: 10px; color: #9fc9e5; background: rgba(45, 101, 142, .14); font-size: 9px; letter-spacing: 1px; }
.detail-card {
padding: 12px;
border: 1px solid rgba(74, 150, 204, .35);
border-radius: 8px;
background: rgba(5, 20, 36, .72);
}
.detail-code { color: #fff; font-size: 18px; font-weight: 800; letter-spacing: 1px; }
.detail-name { margin-top: 3px; color: #75b9df; font-size: 11px; }
.detail-status { margin: 10px 0 5px; display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--detail-color); border-radius: 11px; color: var(--detail-color); font-size: 10px; }
.detail-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--detail-color); }
.detail-card.alert .detail-status i { box-shadow: 0 0 7px #ff6574; }
.detail-rows { margin-top: 7px; border-top: 1px solid rgba(87, 143, 182, .2); }
.detail-row { min-height: 31px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(87, 143, 182, .13); font-size: 11px; }
.detail-row span:first-child { flex: none; color: #779ab3; }
.detail-row b { min-width: 0; color: #e9f7ff; text-align: right; overflow-wrap: anywhere; }
.detail-row b.em { color: #ffe594; }
.queue-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
.queue-group { flex: 1; min-height: 0; padding: 7px 8px 5px; border: 1px solid rgba(67, 131, 172, .28); border-radius: 7px; background: rgba(5, 20, 36, .5); display: flex; flex-direction: column; }
.queue-group.dock-02 { border-color: rgba(54, 126, 157, .34); }
.queue-head { margin: 0 0 6px; display: flex; justify-content: space-between; color: #91cce9; font-size: 11px; }
.queue-head b { color: #d9f2ff; font-weight: 750; }
.queue-head small { margin-left: 5px; color: #638ba5; font-size: 8px; font-weight: 400; }
.queue-list { flex: 1; min-height: 0; margin: 0; padding: 0; overflow: auto; list-style: none; }
.queue-list::-webkit-scrollbar { width: 5px; }
.queue-list::-webkit-scrollbar-thumb { border-radius: 3px; background: #27516d; }
.queue-item {
margin-bottom: 6px;
padding: 7px 8px;
border: 1px solid rgba(83, 137, 175, .25);
border-radius: 6px;
background: rgba(8, 27, 46, .72);
display: grid;
grid-template-columns: 25px 1fr auto;
align-items: center;
gap: 7px;
}
.queue-no { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 5px; color: #77d7eb; background: rgba(35, 213, 232, .11); font-size: 9px; font-weight: 800; }
.queue-box { color: #f3fbff; font-size: 10px; font-weight: 700; }
.queue-point { margin-top: 2px; color: #6f96ae; font-size: 9px; }
.queue-time { color: #ffdb73; font-size: 9px; }
.zone-mini { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.zone-mini-item { padding: 7px 5px; border: 1px solid rgba(74, 137, 179, .22); border-radius: 6px; background: rgba(6, 24, 41, .62); text-align: center; }
.zone-mini-item b { display: block; color: #ecf9ff; font-size: 15px; }
.zone-mini-item span { color: #7197af; font-size: 8px; }
.context-menu {
position: fixed;
z-index: 999;
display: none;
width: 162px;
padding: 6px;
border: 1px solid #3f7da3;
border-radius: 7px;
background: #081a2b;
box-shadow: 0 14px 34px rgba(0, 0, 0, .55);
}
.context-menu.open { display: block; }
.context-title { padding: 5px 8px 7px; border-bottom: 1px solid #24445d; color: #8bd8ef; font-size: 10px; }
.context-action { width: 100%; margin-top: 4px; padding: 7px 8px; border: 0; border-radius: 4px; color: #eaf7ff; background: transparent; text-align: left; cursor: pointer; font-size: 11px; }
.context-action:hover:not(:disabled) { background: #104463; }
.context-action:disabled { color: #526779; cursor: not-allowed; }
.toast { position: fixed; z-index: 1000; left: 50%; bottom: 28px; display: none; padding: 8px 14px; transform: translateX(-50%); border: 1px solid #3fba7e; border-radius: 6px; color: #dcffeb; background: #0f5b38; box-shadow: 0 6px 20px rgba(0, 0, 0, .45); font-size: 11px; }
</style>
</head>
<body>
<main id="stage">
<header class="header">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div><div class="brand-cn">智能制造物流中心</div><div class="brand-en">SMART LOGISTICS CENTER</div></div>
</div>
<h1>装箱区信息看板</h1>
<div class="clock"><div id="clockTime">--:--:--</div><div id="clockDate">----</div></div>
</header>
<section class="kpis" aria-label="汇总指标">
<article class="kpi" style="--accent:#37b8d1"><div class="kpi-icon">Σ</div><div><div id="kpiTotal" class="kpi-value">66<small></small></div><div class="kpi-label">规划点位总数</div></div></article>
<article class="kpi" style="--accent:#3f91c6"><div class="kpi-icon">IN</div><div><div id="kpiOccupied" class="kpi-value">0<small></small></div><div class="kpi-label">当前占用点位</div></div></article>
<article class="kpi" style="--accent:#668ba8"><div class="kpi-icon">E</div><div><div id="kpiEmpty" class="kpi-value">0<small></small></div><div class="kpi-label">空闲点位</div></div></article>
<article class="kpi" style="--accent:#ff6574"><div class="kpi-icon">!</div><div><div id="kpiOverdue" class="kpi-value">0<small></small></div><div class="kpi-label">超时滞留点位</div></div></article>
<article class="kpi" style="--accent:#4e7fb8"><div class="kpi-icon">%</div><div><div id="kpiRate" class="kpi-value">0<small>%</small></div><div class="kpi-label">综合点位占用率</div></div></article>
</section>
<section class="toolbar">
<div class="legend"><span class="legend-title">点位状态</span><span class="legend-item"><i class="legend-dot" style="--c:#71869a"></i>空闲</span><span class="legend-item"><i class="legend-dot" style="--c:#57a8c8"></i>空载具</span><span class="legend-item"><i class="legend-dot" style="--c:#5d92c9"></i>有子卷</span><span class="legend-item"><i class="legend-dot" style="--c:#4eb2b0"></i>合格品</span><span class="legend-item"><i class="legend-dot alert" style="--c:#ff6574"></i>管制/超时</span><span class="legend-item"><i class="legend-dot" style="--c:#f2faff;border-radius:1px"></i>AGV 通道</span></div>
<div class="toolbar-spacer"></div>
<div class="control"><label for="searchInput">卷号</label><input id="searchInput" type="text" placeholder="输入卷号或点位编码"><button id="searchBtn" class="btn" type="button">定位</button><span id="searchMessage"></span></div>
<div class="control"><label for="thresholdInput">超时</label><input id="thresholdInput" type="number" min="1" max="72" step="1"><span>h</span></div>
<div class="control"><label for="zoomInput">比例</label><input id="zoomInput" type="number" min="55" max="300" step="5"><span>%</span><button id="resetBtn" class="btn secondary" type="button">复位</button></div>
</section>
<section class="content">
<section class="map-panel">
<div class="panel-head"><span class="panel-title">装箱区域平面布置图</span><span class="panel-hint">滚轮缩放 · 按住左键拖动 · 双击恢复居中</span></div>
<div id="mapViewport" class="map-viewport">
<div id="mapCanvas" class="map-canvas">
<div class="north-arrow"><b></b><span>北 N</span></div>
<div class="floor-zone zone-north"><div class="zone-label">北区 · 装箱信息暂存 <span class="zone-count">6 点位</span></div><div id="northGrid" class="slot-grid"></div></div>
<div class="agv-main one">← AGV 主通道 →</div>
<div class="floor-zone zone-center"><div class="zone-label">中区 · 待检区 <span class="zone-count">7 排 × 6 列 / 42 点位</span></div><div id="centerGrid" class="slot-grid"></div></div>
<div class="agv-main two">← AGV 主通道 →</div>
<div class="floor-zone zone-south"><div class="zone-label">南区 · 装箱作业区 <span class="zone-count">16 点位 + 2 等待位</span></div><div id="southGrid" class="south-grid"></div></div>
</div>
</div>
</section>
<aside class="side-panel">
<div class="side-title"><span>点位实时信息</span><span class="demo-tag">DEMO DATA</span></div>
<section id="detailCard" class="detail-card" aria-live="polite"></section>
<div id="zoneMini" class="zone-mini"></div>
<div class="queue-wrap">
<section class="queue-group dock-01"><div class="queue-head"><span><b>ZXDJW_01</b><small>装箱区 1 队列</small></span><span id="queueCount01">0 项</span></div><ol id="queueList01" class="queue-list"></ol></section>
<section class="queue-group dock-02"><div class="queue-head"><span><b>ZXDJW_02</b><small>装箱区 2 队列</small></span><span id="queueCount02">0 项</span></div><ol id="queueList02" class="queue-list"></ol></section>
</div>
</aside>
</section>
</main>
<div id="contextMenu" class="context-menu"><div id="contextTitle" class="context-title">点位</div><button id="copyRollBtn" class="context-action" type="button">复制卷号</button><button id="copyBoxBtn" class="context-action" type="button">复制箱号</button></div>
<div id="toast" class="toast">复制成功</div>
<script>
(() => {
'use strict';
const STATUS = {
0: { text: '空闲', color: '#71869a', className: 'empty' },
1: { text: '空载具', color: '#57a8c8', className: 'carrier' },
2: { text: '有子卷', color: '#5d92c9', className: 'roll' },
3: { text: '合格品', color: '#4eb2b0', className: 'qualified' },
4: { text: '管制品', color: '#ff6574', className: 'controlled' }
};
const THRESHOLD_KEY = 'packingDashboardTechThreshold';
const ZOOM_KEY = 'packingDashboardTechZoom';
const COLUMN_MAP = [1, 2, 4, 5, 7, 8];
const NORTH_COLUMN_MAP = [1, 2, 3, 5, 6, 7];
const pad = value => String(value).padStart(2, '0');
const rangeCodes = (prefix, start, end) => Array.from({ length: end - start + 1 }, (_, index) => `${prefix}_${pad(start + index)}`);
const northCodes = rangeCodes('ZJXX', 1, 6);
const centerCodes = rangeCodes('DJQ', 1, 42);
const southCodes = rangeCodes('ZXQ', 1, 16);
const dockCodes = rangeCodes('ZXDJW', 1, 2);
const allCodes = [...northCodes, ...centerCodes, ...southCodes, ...dockCodes];
const zoneOf = code => code.startsWith('ZJXX') ? 'north' : code.startsWith('DJQ') ? 'center' : code.startsWith('ZXDJW') ? 'dock' : 'south';
const pointNameOf = code => code.startsWith('ZJXX') ? '装箱信息暂存位' : code.startsWith('DJQ') ? '待检缓存位' : code.startsWith('ZXDJW') ? '装箱等待位' : Number(code.slice(-2)) <= 8 ? '装箱区 1 作业位' : '装箱区 2 作业位';
const statusPattern = [2, 3, 0, 1, 2, 4, 3, 0, 2, 1, 3, 2];
function createMockPoint(code, index) {
const zone = zoneOf(code);
let status = statusPattern[(index * 5 + code.length) % statusPattern.length];
if (zone === 'dock') status = index % 2 ? 1 : 3;
const occupied = status >= 2;
const durationHours = occupied ? ((index * 7) % 19) + .5 : status === 1 ? ((index * 3) % 5) + .2 : 0;
const entryTime = new Date(Date.now() - durationHours * 3600000);
const rollNo = occupied ? `R260814${String(index + 1).padStart(4, '0')}` : '';
const hasBox = occupied && (zone === 'south' || zone === 'dock' || index % 3 === 0);
const boxNo = hasBox ? `BX2608${String((index % 28) + 1).padStart(3, '0')}` : '';
return {
code,
zone,
name: pointNameOf(code),
status,
rollNo,
boxNo,
width: occupied ? [980, 1120, 1250, 1380][index % 4] : null,
diameter: occupied ? [760, 820, 900][index % 3] : null,
durationHours,
entryTime
};
}
const points = allCodes.map(createMockPoint);
const pointMap = new Map(points.map(point => [point.code, point]));
let thresholdHours = Math.max(1, Number(localStorage.getItem(THRESHOLD_KEY)) || 12);
let fitScale = 1;
let mapScale = 1;
let panX = 0;
let panY = 0;
let selectedCode = '';
let contextPoint = null;
const $ = id => document.getElementById(id);
const formatDateTime = date => {
const d = new Date(date);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const durationText = hours => hours ? `${Math.floor(hours)}h ${Math.round((hours % 1) * 60)}m` : '--';
const isOverdue = point => point.status >= 2 && point.durationHours >= thresholdHours;
function slotElement(point, options = {}) {
const meta = STATUS[point.status];
const overdue = isOverdue(point);
const element = document.createElement('article');
element.className = `slot ${meta.className}${overdue ? ' overdue' : ''}${options.dock ? ' dock-slot' : ''}`;
element.style.setProperty('--state-color', overdue ? '#ff6574' : meta.color);
element.dataset.code = point.code;
element.dataset.roll = point.rollNo;
element.dataset.box = point.boxNo;
element.title = `${point.code}${point.name}\n库存状态:${overdue ? '超时滞留' : meta.text}\n卷号:${point.rollNo || '空闲'}\n箱号:${point.boxNo || '未组盘'}\n停留:${durationText(point.durationHours)}`;
const statusText = overdue ? '超时滞留' : meta.text;
const specification = point.width ? `${point.width} × ${point.diameter}` : '- × -';
element.innerHTML = `<i class="slot-state-dot" aria-hidden="true"></i>
<div class="slot-head"><span class="slot-code">${point.code}</span></div>
<div class="slot-name" title="${point.name}">${point.name}</div>
<div class="slot-box-panel ${point.boxNo ? 'has-box' : ''}"><span class="slot-box-label">组盘木箱</span><span class="slot-box-value">${point.boxNo || '未组盘'}</span></div>
<div class="slot-info-row"><span class="slot-info-label">库存状态</span><span class="slot-stock">${statusText}</span></div>
<div class="slot-info-row"><span class="slot-info-label">卷号</span><span class="slot-info-value roll-no" title="${point.rollNo}">${point.rollNo || '空闲'}</span></div>
<div class="slot-info-row"><span class="slot-info-label">规格</span><span class="slot-info-value">${specification}</span></div>`;
element.addEventListener('click', event => {
event.stopPropagation();
selectPoint(point.code);
});
element.addEventListener('contextmenu', event => showContextMenu(event, point));
return element;
}
function addAisles(grid, rowCount, columns = [3, 6]) {
columns.forEach(column => {
const aisle = document.createElement('div');
aisle.className = 'agv-column';
aisle.style.gridColumn = column;
aisle.style.gridRow = `1 / ${rowCount + 1}`;
aisle.setAttribute('aria-label', 'AGV 通道');
grid.appendChild(aisle);
});
}
function renderMap() {
const northGrid = $('northGrid');
const centerGrid = $('centerGrid');
const southGrid = $('southGrid');
northGrid.innerHTML = '';
centerGrid.innerHTML = '';
southGrid.innerHTML = '';
addAisles(northGrid, 1, [4]);
addAisles(centerGrid, 7);
northCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code));
slot.style.gridColumn = NORTH_COLUMN_MAP[index];
slot.style.gridRow = 1;
northGrid.appendChild(slot);
});
centerCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code));
slot.style.gridColumn = COLUMN_MAP[index % 6];
slot.style.gridRow = Math.floor(index / 6) + 1;
centerGrid.appendChild(slot);
});
const southAisle = document.createElement('div');
southAisle.className = 'agv-column';
southAisle.setAttribute('aria-label', 'AGV 通道');
southGrid.appendChild(southAisle);
southCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code));
slot.style.gridColumn = (index % 8) + 1;
slot.style.gridRow = Math.floor(index / 8) + 1;
southGrid.appendChild(slot);
});
dockCodes.forEach((code, index) => {
const slot = slotElement(pointMap.get(code), { dock: true });
slot.style.gridColumn = 10;
slot.style.gridRow = index + 1;
southGrid.appendChild(slot);
});
if (selectedCode) selectPoint(selectedCode);
updateDashboard();
}
function updateDashboard() {
const occupied = points.filter(point => point.status >= 2);
const overdue = occupied.filter(isOverdue);
$('kpiTotal').innerHTML = `${points.length}<small>个</small>`;
$('kpiOccupied').innerHTML = `${occupied.length}<small>个</small>`;
$('kpiEmpty').innerHTML = `${points.length - occupied.length}<small>个</small>`;
$('kpiOverdue').innerHTML = `${overdue.length}<small>个</small>`;
$('kpiRate').innerHTML = `${Math.round(occupied.length / points.length * 100)}<small>%</small>`;
const zones = [
['北区', northCodes],
['中区', centerCodes],
['南区', [...southCodes, ...dockCodes]]
];
$('zoneMini').innerHTML = zones.map(([name, codes]) => {
const used = codes.filter(code => pointMap.get(code).status >= 2).length;
return `<div class="zone-mini-item"><b>${used}/${codes.length}</b><span>${name}占用</span></div>`;
}).join('');
renderQueueGroup('queueList01', 'queueCount01', southCodes.slice(0, 8));
renderQueueGroup('queueList02', 'queueCount02', southCodes.slice(8, 16));
document.querySelectorAll('.queue-item[data-code]').forEach(item => item.addEventListener('click', () => selectPoint(item.dataset.code)));
}
function renderQueueGroup(listId, countId, codes) {
const queue = codes.map(code => pointMap.get(code)).filter(point => point.boxNo).sort((a, b) => b.durationHours - a.durationHours);
$(countId).textContent = `${queue.length}`;
$(listId).innerHTML = queue.length ? queue.map((point, index) => `<li class="queue-item" data-code="${point.code}"><span class="queue-no">${pad(index + 1)}</span><div><div class="queue-box">${point.boxNo}</div><div class="queue-point">${point.code} · ${point.rollNo}</div></div><span class="queue-time">${durationText(point.durationHours)}</span></li>`).join('') : '<li class="queue-item">当前队列为空</li>';
}
function selectPoint(code) {
const point = pointMap.get(code);
if (!point) return;
selectedCode = code;
document.querySelectorAll('.slot.selected').forEach(element => element.classList.remove('selected'));
const element = document.querySelector(`.slot[data-code="${code}"]`);
if (element) element.classList.add('selected');
const meta = STATUS[point.status];
const overdue = isOverdue(point);
const statusColor = overdue ? '#ff6574' : meta.color;
$('detailCard').classList.toggle('alert', overdue || point.status === 4);
$('detailCard').style.setProperty('--detail-color', statusColor);
$('detailCard').innerHTML = `<div class="detail-code">${point.code}</div><div class="detail-name">${point.name}</div><div class="detail-status"><i></i>${overdue ? '超时滞留' : meta.text}</div><div class="detail-rows"><div class="detail-row"><span>卷号</span><b class="em">${point.rollNo || '空闲'}</b></div><div class="detail-row"><span>组盘木箱</span><b class="em">${point.boxNo || '未组盘'}</b></div><div class="detail-row"><span>产品规格</span><b>${point.width ? `${point.width} × ${point.diameter} mm` : '--'}</b></div><div class="detail-row"><span>进入时间</span><b>${point.durationHours ? formatDateTime(point.entryTime) : '--'}</b></div><div class="detail-row"><span>停留时长</span><b>${durationText(point.durationHours)}</b></div></div>`;
}
function locateRoll() {
const query = $('searchInput').value.trim().toUpperCase();
const message = $('searchMessage');
document.querySelectorAll('.search-hit').forEach(element => element.classList.remove('search-hit'));
message.className = '';
if (!query) {
message.textContent = '请输入查询内容';
message.classList.add('error');
return;
}
const point = points.find(item => item.rollNo.toUpperCase().includes(query) || item.code.includes(query));
if (!point) {
message.textContent = '未找到匹配点位';
message.classList.add('error');
return;
}
const target = document.querySelector(`.slot[data-code="${point.code}"]`);
if (!target) return;
target.classList.add('search-hit');
selectPoint(point.code);
focusElement(target);
message.textContent = `已定位 ${point.code}`;
message.classList.add('ok');
}
function applyTransform() {
$('mapCanvas').style.transform = `translate(${panX}px, ${panY}px) scale(${mapScale})`;
$('zoomInput').value = fitScale ? Math.round(mapScale / fitScale * 100) : 100;
}
function fitMap() {
const viewport = $('mapViewport');
const canvas = $('mapCanvas');
fitScale = Math.min(viewport.clientWidth / canvas.offsetWidth, viewport.clientHeight / canvas.offsetHeight) * .96;
const saved = Number(localStorage.getItem(ZOOM_KEY));
const ratio = Number.isFinite(saved) && saved >= .55 && saved <= 3 ? saved : 1;
mapScale = fitScale * ratio;
panX = (viewport.clientWidth - canvas.offsetWidth * mapScale) / 2;
panY = (viewport.clientHeight - canvas.offsetHeight * mapScale) / 2;
applyTransform();
}
function focusElement(element) {
const viewport = $('mapViewport');
const canvasRect = $('mapCanvas').getBoundingClientRect();
const targetRect = element.getBoundingClientRect();
const worldX = (targetRect.left + targetRect.width / 2 - canvasRect.left) / mapScale;
const worldY = (targetRect.top + targetRect.height / 2 - canvasRect.top) / mapScale;
mapScale = Math.max(mapScale, fitScale * 2);
panX = viewport.clientWidth / 2 - worldX * mapScale;
panY = viewport.clientHeight / 2 - worldY * mapScale;
localStorage.setItem(ZOOM_KEY, String(mapScale / fitScale));
applyTransform();
}
function resetMap() {
localStorage.setItem(ZOOM_KEY, '1');
fitMap();
}
function showContextMenu(event, point) {
event.preventDefault();
event.stopPropagation();
contextPoint = point;
selectPoint(point.code);
const menu = $('contextMenu');
$('contextTitle').textContent = point.code;
$('copyRollBtn').disabled = !point.rollNo;
$('copyBoxBtn').disabled = !point.boxNo;
menu.classList.add('open');
const x = Math.min(event.clientX, window.innerWidth - 174);
const y = Math.min(event.clientY, window.innerHeight - 120);
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
}
function hideContextMenu() { $('contextMenu').classList.remove('open'); }
async function copyValue(type) {
const value = type === 'roll' ? contextPoint?.rollNo : contextPoint?.boxNo;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
} catch (error) {
const area = document.createElement('textarea');
area.value = value;
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
document.execCommand('copy');
area.remove();
}
hideContextMenu();
const toast = $('toast');
toast.textContent = `已复制${type === 'roll' ? '卷号' : '箱号'}${value}`;
toast.style.display = 'block';
window.setTimeout(() => { toast.style.display = 'none'; }, 1800);
}
function updateClock() {
const now = new Date();
const week = ['日', '一', '二', '三', '四', '五', '六'];
$('clockTime').textContent = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
$('clockDate').textContent = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())} 日 星期${week[now.getDay()]}`;
}
function scaleStage() {
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
$('stage').style.transform = `scale(${scale})`;
}
function bindEvents() {
$('searchBtn').addEventListener('click', locateRoll);
$('searchInput').addEventListener('keydown', event => { if (event.key === 'Enter') locateRoll(); });
$('thresholdInput').value = thresholdHours;
$('thresholdInput').addEventListener('change', () => {
const value = Math.max(1, Math.min(72, Number($('thresholdInput').value) || 12));
thresholdHours = value;
$('thresholdInput').value = value;
localStorage.setItem(THRESHOLD_KEY, String(value));
renderMap();
});
$('zoomInput').addEventListener('change', () => {
const ratio = Math.max(.55, Math.min(3, (Number($('zoomInput').value) || 100) / 100));
const viewport = $('mapViewport');
const canvas = $('mapCanvas');
mapScale = fitScale * ratio;
panX = (viewport.clientWidth - canvas.offsetWidth * mapScale) / 2;
panY = (viewport.clientHeight - canvas.offsetHeight * mapScale) / 2;
localStorage.setItem(ZOOM_KEY, String(ratio));
applyTransform();
});
$('resetBtn').addEventListener('click', resetMap);
$('mapViewport').addEventListener('dblclick', resetMap);
$('copyRollBtn').addEventListener('click', () => copyValue('roll'));
$('copyBoxBtn').addEventListener('click', () => copyValue('box'));
document.addEventListener('click', event => { if (!event.target.closest('#contextMenu')) hideContextMenu(); });
const viewport = $('mapViewport');
viewport.addEventListener('wheel', event => {
event.preventDefault();
const rect = viewport.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const worldX = (mouseX - panX) / mapScale;
const worldY = (mouseY - panY) / mapScale;
const next = Math.max(fitScale * .55, Math.min(fitScale * 3, mapScale * (event.deltaY < 0 ? 1.11 : .9)));
panX = mouseX - worldX * next;
panY = mouseY - worldY * next;
mapScale = next;
localStorage.setItem(ZOOM_KEY, String(mapScale / fitScale));
applyTransform();
}, { passive: false });
let dragging = false;
let startX = 0;
let startY = 0;
let startPanX = 0;
let startPanY = 0;
viewport.addEventListener('mousedown', event => {
if (event.button !== 0 || event.target.closest('.slot')) return;
dragging = true;
startX = event.clientX;
startY = event.clientY;
startPanX = panX;
startPanY = panY;
viewport.classList.add('dragging');
});
window.addEventListener('mousemove', event => {
if (!dragging) return;
panX = startPanX + event.clientX - startX;
panY = startPanY + event.clientY - startY;
applyTransform();
});
window.addEventListener('mouseup', () => { dragging = false; viewport.classList.remove('dragging'); });
window.addEventListener('resize', () => { scaleStage(); fitMap(); });
}
renderMap();
selectPoint('DJQ_01');
bindEvents();
scaleStage();
requestAnimationFrame(fitMap);
updateClock();
window.setInterval(updateClock, 1000);
})();
</script>
</body>
</html>