fix: 子卷包装关系手动添加
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
964
packingAreaDashboard.html
Normal 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>
|
||||
Reference in New Issue
Block a user