feat(web): I4 delivery and license SN UI

Add routes, menu entries, platform API helpers, and views for delivery
batches and license SN management.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:49:10 +08:00
parent 9df6f60a17
commit 00411a5e74
9 changed files with 1590 additions and 0 deletions
@@ -0,0 +1,395 @@
<template>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="toolbar">
<div class="head-left">
<el-button link type="primary" @click="goList"> 交付列表</el-button>
<span class="title">交付批次详情</span>
<el-tag v-if="batch" :type="batchStatusTagType(batch.status)" size="small">
{{ batchStatusLabel(batch.status) }}
</el-tag>
</div>
<div v-if="batch && isPending" class="head-actions">
<el-button type="primary" :loading="savingHeader" @click="saveHeader">保存抬头</el-button>
<el-button type="success" :loading="markingDelivered" @click="onMarkDelivered">标记已交付</el-button>
</div>
</div>
</template>
<template v-if="batch">
<el-descriptions :column="2" border class="block">
<el-descriptions-item label="批次编码">{{ batch.batchCode ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="项目">{{ displayProject }}</el-descriptions-item>
<el-descriptions-item label="合同 ID">{{ batch.contractId ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ formatDateTime(batch.finishedAt) }}</el-descriptions-item>
<el-descriptions-item label="计划交付日" :span="2">
<template v-if="isPending">
<el-date-picker
v-model="headerPlannedDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选填"
style="width: 240px"
/>
</template>
<template v-else>{{ formatDate(batch.plannedDeliveryDate) }}</template>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
<template v-if="isPending">
<el-input v-model="headerRemarks" type="textarea" :rows="2" maxlength="4000" show-word-limit />
</template>
<template v-else>{{ batch.remarks ?? "" }}</template>
</el-descriptions-item>
</el-descriptions>
<h3 class="section-title">交付明细</h3>
<div v-if="isPending" class="line-toolbar">
<el-button type="primary" @click="openLineDialog()">添加明细</el-button>
</div>
<el-table :data="lineRows" border stripe style="width: 100%">
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="contractLineId" label="合同行 ID" width="110">
<template #default="{ row }">{{ row.contractLineId ?? "—" }}</template>
</el-table-column>
<el-table-column v-if="isPending" label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="openLineDialog(row)">编辑</el-button>
<el-button type="danger" link @click="onDeleteLine(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<el-empty v-else-if="!loading" description="未加载到交付批次" />
<el-dialog v-model="lineDialogVisible" :title="lineEditingId ? '编辑明细' : '添加明细'" width="520px" destroy-on-close @closed="resetLineForm">
<el-form ref="lineFormRef" :model="lineForm" :rules="lineRules" label-width="120px">
<el-form-item label="排序">
<el-input-number v-model="lineForm.sortOrder" :min="0" :max="999999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="说明" prop="description">
<el-input v-model="lineForm.description" maxlength="512" show-word-limit placeholder="description" />
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number v-model="lineForm.quantity" :min="0.0001" :precision="4" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="合同行 ID">
<el-input-number
v-model="lineForm.contractLineId"
:min="1"
clearable
controls-position="right"
style="width: 100%"
placeholder="选填"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="lineDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="lineSaving" @click="submitLine">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../stores/auth";
import {
getDeliveryBatch,
updateDeliveryBatch,
patchDeliveryBatchStatus,
addDeliveryLine,
updateDeliveryLine,
deleteDeliveryLine,
listProjects,
} from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const savingHeader = ref(false);
const markingDelivered = ref(false);
const batch = ref(null);
const projectMap = ref(new Map());
const headerPlannedDate = ref("");
const headerRemarks = ref("");
const lineDialogVisible = ref(false);
const lineEditingId = ref(null);
const lineFormRef = ref(null);
const lineSaving = ref(false);
const lineForm = ref({
sortOrder: 0,
description: "",
quantity: 1,
contractLineId: undefined,
});
const lineRules = {
description: [{ required: true, message: "请输入说明", trigger: "blur" }],
quantity: [{ required: true, message: "请输入数量", trigger: "change" }],
};
const batchId = computed(() => route.params.id);
const isPending = computed(() => String(batch.value?.status ?? "").toUpperCase() === "PENDING");
const lineRows = computed(() => {
const b = batch.value;
if (!b) return [];
const raw = b.lines ?? [];
return Array.isArray(raw) ? raw : [];
});
const displayProject = computed(() => {
const b = batch.value;
if (!b) return "—";
if (b.projectName) return b.projectName;
const id = b.projectId;
if (id == null) return "—";
return projectMap.value.get(id) ?? String(id);
});
watch(
() => batch.value,
(b) => {
if (!b) return;
const d = b.plannedDeliveryDate;
headerPlannedDate.value = d == null ? "" : typeof d === "string" ? d.slice(0, 10) : String(d).slice(0, 10);
headerRemarks.value = b.remarks ?? "";
},
{ immediate: true }
);
onMounted(async () => {
auth.restoreAxiosAuth();
await loadProjectMap();
await loadBatch();
});
watch(
() => route.params.id,
async () => {
await loadBatch();
}
);
function goList() {
router.push({ name: "deliveries" });
}
function batchStatusLabel(s) {
const u = String(s ?? "").toUpperCase();
const map = { PENDING: "待交付", DELIVERED: "已交付", CANCELLED: "已取消" };
return map[u] ?? String(s ?? "—");
}
function batchStatusTagType(s) {
const u = String(s ?? "").toUpperCase();
if (u === "PENDING") return "warning";
if (u === "DELIVERED") return "success";
if (u === "CANCELLED") return "info";
return "";
}
function formatDate(v) {
if (v == null || v === "") return "—";
if (typeof v === "string") return v.slice(0, 10);
return String(v);
}
function formatDateTime(v) {
if (v == null || v === "") return "—";
if (typeof v === "string") return v.replace("T", " ").slice(0, 19);
return String(v);
}
async function loadProjectMap() {
try {
const { data } = await listProjects({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
const list = Array.isArray(body.content) ? body.content : [];
const m = new Map();
for (const p of list) {
if (p?.id != null) m.set(p.id, p.name ?? String(p.id));
}
projectMap.value = m;
} catch {
projectMap.value = new Map();
}
}
async function loadBatch() {
const id = batchId.value;
if (id == null || id === "") return;
loading.value = true;
try {
const { data } = await getDeliveryBatch(id);
batch.value = data && typeof data === "object" ? data : null;
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载交付批次失败"));
batch.value = null;
} finally {
loading.value = false;
}
}
async function saveHeader() {
const id = batchId.value;
if (id == null) return;
savingHeader.value = true;
try {
await updateDeliveryBatch(id, {
plannedDeliveryDate: headerPlannedDate.value || undefined,
remarks: headerRemarks.value?.trim() ?? "",
});
ElMessage.success("已保存");
await loadBatch();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "保存失败"));
} finally {
savingHeader.value = false;
}
}
function onMarkDelivered() {
ElMessageBox.confirm("确认将该批次标记为已交付(DELIVERED)?", "标记已交付", { type: "info" })
.then(async () => {
const id = batchId.value;
if (id == null) return;
markingDelivered.value = true;
try {
await patchDeliveryBatchStatus(id, { status: "DELIVERED" });
ElMessage.success("状态已更新");
await loadBatch();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "状态更新失败"));
} finally {
markingDelivered.value = false;
}
})
.catch(() => {});
}
function lineIdOf(row) {
return row.id;
}
function openLineDialog(row) {
if (row) {
lineEditingId.value = lineIdOf(row);
lineForm.value = {
sortOrder: row.sortOrder ?? 0,
description: row.description ?? "",
quantity: Number(row.quantity ?? 1),
contractLineId: row.contractLineId ?? undefined,
};
} else {
lineEditingId.value = null;
lineForm.value = {
sortOrder: 0,
description: "",
quantity: 1,
contractLineId: undefined,
};
}
lineDialogVisible.value = true;
}
function resetLineForm() {
lineEditingId.value = null;
lineFormRef.value?.resetFields?.();
}
async function submitLine() {
const f = lineFormRef.value;
if (!f) return;
try {
await f.validate();
} catch {
return;
}
const id = batchId.value;
if (id == null) return;
const payload = {
sortOrder: lineForm.value.sortOrder ?? 0,
description: lineForm.value.description.trim(),
quantity: lineForm.value.quantity,
contractLineId: lineForm.value.contractLineId ?? undefined,
};
lineSaving.value = true;
try {
if (lineEditingId.value != null) {
await updateDeliveryLine(id, lineEditingId.value, payload);
ElMessage.success("已更新明细");
} else {
await addDeliveryLine(id, payload);
ElMessage.success("已添加明细");
}
lineDialogVisible.value = false;
await loadBatch();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "保存明细失败"));
} finally {
lineSaving.value = false;
}
}
function onDeleteLine(row) {
const lid = lineIdOf(row);
if (lid == null) {
ElMessage.warning("该行缺少 ID,无法删除");
return;
}
ElMessageBox.confirm("确定删除该交付明细吗?", "提示", { type: "warning" })
.then(async () => {
try {
await deleteDeliveryLine(batchId.value, lid);
ElMessage.success("已删除");
await loadBatch();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "删除失败"));
}
})
.catch(() => {});
}
</script>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.head-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.block {
margin-bottom: 20px;
}
.section-title {
margin: 20px 0 12px;
font-size: 15px;
font-weight: 600;
}
.line-toolbar {
margin-bottom: 12px;
}
</style>