统一仓库纸质单据体验

This commit is contained in:
汤学会
2026-06-14 16:23:27 +08:00
parent fc39d81564
commit 453b19399b
19 changed files with 3945 additions and 344 deletions
@@ -0,0 +1,53 @@
<template>
<span ref="anchorRef" class="document-action-anchor" aria-hidden="true"></span>
<Teleport :to="actionHost" :disabled="!actionHost">
<div
class="document-action-bar"
:class="[
$attrs.class,
{ 'document-action-bar--with-state': $slots.state }
]"
@click.capture="handleActionClick"
>
<div v-if="$slots.state" class="document-action-state">
<slot name="state" />
</div>
<div class="document-action-buttons">
<slot />
</div>
</div>
</Teleport>
</template>
<script setup>
import { nextTick, onMounted, ref } from "vue";
defineOptions({
inheritAttrs: false
});
const anchorRef = ref(null);
const actionHost = ref(null);
onMounted(async () => {
await nextTick();
const form = anchorRef.value?.closest?.("form");
actionHost.value = form?.querySelector?.("[data-document-action-host]") || form?.querySelector?.("[data-warehouse-document-action-host]") || null;
});
function handleActionClick(event) {
const target = event.target;
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
if (!submitter || submitter.disabled) {
return;
}
const form = anchorRef.value?.closest?.("form");
if (!form) {
return;
}
event.preventDefault();
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
}
</script>
@@ -0,0 +1,55 @@
<template>
<component
:is="as"
class="document-paper-field"
:class="[
spanClass,
{
'document-paper-field-readonly': readonly,
'document-paper-field-upload': upload
}
]"
>
<span>{{ label }}</span>
<slot>
<p class="document-paper-readonly-value">{{ value || "-" }}</p>
</slot>
</component>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
label: {
type: String,
required: true
},
value: {
type: [String, Number],
default: ""
},
span: {
type: [Number, String],
default: 1
},
as: {
type: String,
default: "label"
},
readonly: {
type: Boolean,
default: false
},
upload: {
type: Boolean,
default: false
}
});
const spanClass = computed(() => {
if (String(props.span) === "full") return "document-paper-field-full";
if (Number(props.span) === 2) return "document-paper-field-wide";
return "";
});
</script>
@@ -0,0 +1,69 @@
<template>
<form class="document-form-shell" @submit.prevent="emit('submit')">
<DocumentPaper
:title="title"
:document-no="documentNo"
:company-name="companyName"
:tone="tone"
:signature-labels="signatureLabels"
>
<div class="document-form-meta" :aria-label="metaAriaLabel">
<span>{{ metaLabel }}</span>
<strong>{{ companyName }}</strong>
<span>{{ dateLabel }}</span>
<strong>{{ displayBusinessDate }}</strong>
</div>
<main class="document-form-body">
<slot />
</main>
</DocumentPaper>
</form>
</template>
<script setup>
import { computed } from "vue";
import DocumentPaper from "./DocumentPaper.vue";
import { formatChineseDate } from "../../utils/formatters";
const props = defineProps({
title: {
type: String,
required: true
},
documentNo: {
type: String,
default: ""
},
businessDate: {
type: [String, Date],
default: ""
},
tone: {
type: String,
default: "green"
},
companyName: {
type: String,
default: "宁波嘉恒智能科技有限公司"
},
metaLabel: {
type: String,
default: "业务联单"
},
dateLabel: {
type: String,
default: "业务日期"
},
signatureLabels: {
type: Array,
default: () => ["制单", "经办", "审核", "财务"]
}
});
const emit = defineEmits(["submit"]);
const displayBusinessDate = computed(() => formatChineseDate(props.businessDate));
const metaAriaLabel = computed(() => `${props.metaLabel}基础信息`);
</script>
@@ -0,0 +1,18 @@
<template>
<div class="document-inline-summary" :class="toneClass">
<slot />
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
tone: {
type: String,
default: "green"
}
});
const toneClass = computed(() => `document-inline-summary-${props.tone}`);
</script>
@@ -43,7 +43,11 @@
</div>
</footer>
<div class="document-paper-action-host" data-warehouse-document-action-host></div>
<div
class="document-paper-action-host"
data-document-action-host
data-warehouse-document-action-host
></div>
</article>
</section>
</template>
@@ -0,0 +1,25 @@
<template>
<section class="document-section">
<header class="document-section-head">
<span class="document-section-index">{{ index }}</span>
<strong>{{ title }}</strong>
</header>
<div class="document-section-grid">
<slot />
</div>
</section>
</template>
<script setup>
defineProps({
index: {
type: [Number, String],
required: true
},
title: {
type: String,
required: true
}
});
</script>
@@ -0,0 +1,24 @@
<template>
<section class="document-selector-panel">
<div class="document-selector-heading">
<span>{{ label }}</span>
<strong>{{ hint }}</strong>
</div>
<div class="document-selector-body">
<slot />
</div>
</section>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true
},
hint: {
type: String,
default: "请选择"
}
});
</script>
@@ -0,0 +1,5 @@
<template>
<section class="document-settle-panel">
<slot />
</section>
</template>
@@ -1,47 +1,12 @@
<template>
<span ref="anchorRef" class="warehouse-document-action-anchor" aria-hidden="true"></span>
<Teleport :to="actionHost" :disabled="!actionHost">
<div
class="warehouse-document-action-bar warehouse-document-action-bar--shared"
:class="{ 'warehouse-document-action-bar--with-state': $slots.state }"
@click.capture="handleActionClick"
>
<div v-if="$slots.state" class="warehouse-document-action-state">
<slot name="state" />
</div>
<div class="warehouse-document-action-buttons">
<slot />
</div>
</div>
</Teleport>
<DocumentActionBar class="warehouse-document-action-bar warehouse-document-action-bar--shared">
<template v-if="$slots.state" #state>
<slot name="state" />
</template>
<slot />
</DocumentActionBar>
</template>
<script setup>
import { nextTick, onMounted, ref } from "vue";
const anchorRef = ref(null);
const actionHost = ref(null);
onMounted(async () => {
await nextTick();
actionHost.value = anchorRef.value
?.closest?.("form")
?.querySelector?.("[data-warehouse-document-action-host]") || null;
});
function handleActionClick(event) {
const target = event.target;
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
if (!submitter || submitter.disabled) {
return;
}
const form = anchorRef.value?.closest?.("form");
if (!form) {
return;
}
event.preventDefault();
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
}
import DocumentActionBar from "./DocumentActionBar.vue";
</script>
@@ -1,32 +1,23 @@
<template>
<form class="warehouse-document-form" @submit.prevent="emit('submit')">
<DocumentPaper
:title="title"
:document-no="documentNo"
:company-name="companyName"
:tone="tone"
>
<div class="warehouse-document-form-meta" aria-label="仓库单据基础信息">
<span>仓库联单</span>
<strong>{{ companyName }}</strong>
<span>业务日期</span>
<strong>{{ displayBusinessDate }}</strong>
</div>
<main class="warehouse-document-body">
<slot />
</main>
</DocumentPaper>
</form>
<DocumentFormShell
class="warehouse-document-form"
:title="title"
:document-no="documentNo"
:business-date="businessDate"
:tone="tone"
:company-name="companyName"
meta-label="仓库联单"
:signature-labels="signatureLabels"
@submit="emit('submit')"
>
<slot />
</DocumentFormShell>
</template>
<script setup>
import { computed } from "vue";
import DocumentFormShell from "./DocumentFormShell.vue";
import DocumentPaper from "./DocumentPaper.vue";
import { formatChineseDate } from "../../utils/formatters";
const props = defineProps({
defineProps({
title: {
type: String,
required: true
@@ -46,9 +37,12 @@ const props = defineProps({
companyName: {
type: String,
default: "嘉恒仓库"
},
signatureLabels: {
type: Array,
default: () => ["制单", "审核", "仓库", "财务"]
}
});
const emit = defineEmits(["submit"]);
const displayBusinessDate = computed(() => formatChineseDate(props.businessDate));
</script>
@@ -1,17 +1,16 @@
<template>
<section class="warehouse-document-section">
<header class="warehouse-document-section-head">
<span class="warehouse-document-section-index">{{ index }}</span>
<strong>{{ title }}</strong>
</header>
<div class="warehouse-document-section-grid">
<slot />
</div>
</section>
<DocumentSection
class="warehouse-document-section"
:index="index"
:title="title"
>
<slot />
</DocumentSection>
</template>
<script setup>
import DocumentSection from "./DocumentSection.vue";
defineProps({
index: {
type: [Number, String],
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function readComponent(name) {
return readFileSync(join(__dirname, name), "utf8");
}
const mainCss = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
describe("document form foundation", () => {
it("provides generic system-wide paper form components", () => {
[
"DocumentFormShell.vue",
"DocumentSection.vue",
"DocumentActionBar.vue",
"DocumentField.vue",
"DocumentSelectorPanel.vue",
"DocumentInlineSummary.vue",
"DocumentSettlePanel.vue"
].forEach((name) => {
const source = readComponent(name);
assert.match(source, /document-/);
});
});
it("keeps warehouse wrappers as compatibility adapters over generic document components", () => {
assert.match(readComponent("WarehouseDocumentFormShell.vue"), /import DocumentFormShell/);
assert.match(readComponent("WarehouseDocumentSection.vue"), /import DocumentSection/);
assert.match(readComponent("WarehouseDocumentActionBar.vue"), /import DocumentActionBar/);
});
it("exposes a generic action host without removing the existing warehouse host", () => {
const paper = readComponent("DocumentPaper.vue");
assert.match(paper, /data-document-action-host/);
assert.match(paper, /data-warehouse-document-action-host/);
});
it("defines generic document paper controls and keeps warehouse aliases compatible", () => {
assert.match(mainCss, /\.document-form-shell/);
assert.match(mainCss, /\.document-section-grid/);
assert.match(mainCss, /\.document-paper-field/);
assert.match(mainCss, /\.document-selector-panel/);
assert.match(mainCss, /\.document-inline-summary/);
assert.match(mainCss, /\.document-settle-panel/);
assert.match(mainCss, /\.warehouse-paper-control-grid/);
});
});
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,14 @@ function functionBody(name) {
return match[0];
}
function drawerBlock(openBinding, nextMarker) {
const start = source.indexOf(`:open="${openBinding}"`);
assert.ok(start >= 0, `expected drawer ${openBinding} to exist`);
const end = source.indexOf(nextMarker, start + openBinding.length);
assert.ok(end > start, `expected drawer ${openBinding} to end before ${nextMarker}`);
return source.slice(start, end);
}
describe("InventoryLedgerView warehouse action layout", () => {
it("keeps stocktake inside the tool action group instead of a standalone rack", () => {
assert.equal(source.includes("warehouse-stocktake-rack"), false);
@@ -77,6 +85,134 @@ describe("InventoryLedgerView warehouse action layout", () => {
assert.match(mainCss, /\.warehouse-paper-summary-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
});
it("keeps customer supplied inbound paper fields full width instead of squeezed into a side column", () => {
assert.match(source, /title="客料入库单"/);
assert.match(source, /class="warehouse-document-meta-strip"/);
assert.match(source, /class="warehouse-paper-grid"/);
assert.match(mainCss, /\.warehouse-document-operation-form\s*\{[\s\S]*width: 100%;/);
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-document-meta-strip\s*\{[\s\S]*grid-column: 1 \/ -1;/);
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
assert.match(mainCss, /\.warehouse-paper-field > span\s*\{[\s\S]*font-size: 13px;/);
assert.match(mainCss, /\.warehouse-paper-field input,[\s\S]*?\.warehouse-paper-field textarea\s*\{[\s\S]*font-size: 15px;/);
assert.match(mainCss, /@media \(max-width: 520px\)[\s\S]*\.document-paper-binder,[\s\S]*\.document-retained-mark\s*\{[\s\S]*display: none;/);
assert.match(mainCss, /@media \(max-width: 520px\)[\s\S]*\.document-paper-sheet\s*\{[\s\S]*padding: 18px 10px 22px;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\)\s*\{[\s\S]*width: min\(1280px, calc\(100vw - 24px\)\) !important;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.form-drawer-body\s*\{[\s\S]*overflow-x: hidden !important;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*font-size: clamp\(22px, 7\.2vw, 28px\) !important;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*white-space: normal !important;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*overflow-wrap: anywhere !important;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-number-stamp\s*\{[\s\S]*width: min\(176px, 100%\) !important;/);
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.form-drawer-body\s*\{[\s\S]*padding: 10px !important;/);
});
it("wraps production-ledger controlled inbound forms in the shared paper control language", () => {
assert.match(source, /warehouse-paper-control-grid/);
assert.match(source, /warehouse-paper-source-lot-panel/);
assert.match(source, /warehouse-paper-source-lot-table/);
assert.match(source, /warehouse-paper-inline-summary/);
assert.match(source, /warehouse-paper-settle-panel/);
assert.match(source, /warehouse-paper-settle-toggle/);
assert.match(source, /warehouse-paper-settle-grid/);
assert.match(source, /warehouse-paper-settle-remark/);
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-control-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
assert.match(mainCss, /\.warehouse-paper-control-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
assert.match(mainCss, /\.warehouse-paper-source-lot-table/);
assert.match(mainCss, /\.warehouse-paper-settle-toggle/);
});
it("keeps special adjustment documents inside the paper document language", () => {
const specialAdjustmentBlock = source.slice(
source.indexOf(':open="specialAdjustmentDrawerOpen"'),
source.indexOf(':open="productionDrawerOpen"')
);
assert.match(source, /special-adjustment-paper-grid/);
assert.match(source, /special-adjustment-paper-warning/);
assert.match(source, /special-adjustment-paper-lines/);
assert.match(source, /special-adjustment-paper-line-card/);
assert.doesNotMatch(specialAdjustmentBlock, /<div class="double-field">[\s\S]*?specialAdjustmentForm\.reason/);
assert.match(mainCss, /\.special-adjustment-paper-grid/);
assert.match(mainCss, /\.special-adjustment-paper-line-card/);
});
it("keeps production outbound document controls paper-native", () => {
assert.match(source, /production-out-paper-grid/);
assert.match(source, /production-out-source-lot-panel/);
assert.match(source, /production-out-source-lot-table/);
assert.doesNotMatch(source, /<div class="workflow-tip">选择原材料库存批次出库/);
assert.match(mainCss, /\.production-out-source-lot-panel/);
});
it("keeps production ledger inbound settlement controls paper-native", () => {
const productionLedgerInboundBlock = source.slice(
source.indexOf(':open="productionWorkOrderInboundDrawerOpen"'),
source.indexOf("<StocktakeDialog")
);
assert.match(source, /production-ledger-inbound-paper-grid/);
assert.match(source, /production-ledger-inbound-summary/);
assert.match(source, /production-ledger-inbound-metrics/);
assert.doesNotMatch(productionLedgerInboundBlock, /<div class="triple-field">[\s\S]*?productionWorkOrderInboundForm\.finished_qty/);
assert.match(mainCss, /\.production-ledger-inbound-metrics/);
});
it("keeps raw material return outbound documents paper-native", () => {
const block = drawerBlock("rawReturnDrawerOpen", ':open="returnInboundDrawerOpen"');
assert.match(block, /raw-return-paper-grid/);
assert.match(block, /raw-return-logistics-grid/);
assert.doesNotMatch(block, /class="double-field"/);
assert.doesNotMatch(block, /class="triple-field"/);
assert.doesNotMatch(block, /class="form-field/);
assert.doesNotMatch(block, /modern-upload/);
assert.match(mainCss, /\.raw-return-paper-grid/);
});
it("keeps return inbound and return scrap inbound documents paper-native", () => {
const returnBlock = drawerBlock("returnInboundDrawerOpen", ':open="returnScrapInboundDrawerOpen"');
const scrapBlock = drawerBlock("returnScrapInboundDrawerOpen", ':open="openingImportDrawerOpen"');
[returnBlock, scrapBlock].forEach((block) => {
assert.match(block, /return-paper-grid/);
assert.match(block, /return-trace-summary/);
assert.doesNotMatch(block, /class="double-field"/);
assert.doesNotMatch(block, /class="triple-field"/);
assert.doesNotMatch(block, /class="form-field/);
});
assert.match(mainCss, /\.return-paper-grid/);
});
it("keeps finished inbound and sales outbound documents paper-native", () => {
const completionBlock = drawerBlock("completionDrawerOpen", ':open="deliveryDrawerOpen"');
const deliveryBlock = drawerBlock("deliveryDrawerOpen", ':open="returnReworkDrawerOpen"');
assert.match(completionBlock, /completion-paper-grid/);
assert.match(completionBlock, /completion-settle-panel/);
assert.match(deliveryBlock, /sales-delivery-paper-grid/);
assert.match(deliveryBlock, /sales-delivery-allocation-table/);
[completionBlock, deliveryBlock].forEach((block) => {
assert.doesNotMatch(block, /class="double-field"/);
assert.doesNotMatch(block, /class="triple-field"/);
assert.doesNotMatch(block, /class="form-field/);
assert.doesNotMatch(block, /modern-upload/);
});
assert.match(mainCss, /\.completion-paper-grid/);
assert.match(mainCss, /\.sales-delivery-paper-grid/);
});
it("keeps return rework and production ledger closeout tails paper-native", () => {
const reworkBlock = drawerBlock("returnReworkDrawerOpen", ':open="warehouseLedgerDrawerOpen"');
const productionBlock = drawerBlock("productionDrawerOpen", ':open="completionDrawerOpen"');
const ledgerInboundBlock = drawerBlock("productionWorkOrderInboundDrawerOpen", "<StocktakeDialog");
assert.match(reworkBlock, /return-rework-paper-grid/);
assert.match(reworkBlock, /return-rework-trace-summary/);
assert.match(productionBlock, /production-out-paper-remark/);
assert.match(ledgerInboundBlock, /production-ledger-inbound-settle-panel/);
assert.match(ledgerInboundBlock, /production-ledger-inbound-remark/);
[reworkBlock, productionBlock, ledgerInboundBlock].forEach((block) => {
assert.doesNotMatch(block, /class="double-field"/);
assert.doesNotMatch(block, /class="triple-field"/);
assert.doesNotMatch(block, /class="form-field/);
});
assert.match(mainCss, /\.return-rework-paper-grid/);
assert.match(mainCss, /\.production-ledger-inbound-settle-panel/);
});
it("surfaces generated PDF archive feedback after independent warehouse saves", () => {
[
["submitRawReturnOutbound", "退货出库"],
File diff suppressed because it is too large Load Diff