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
@@ -106,3 +106,99 @@ export function patchContractStatus(id, body) {
export function listAuditEvents(params) {
return axios.get("/api/v1/audit-events", { params });
}
/**
* @param {{ page?: number, size?: number, projectId?: string | number, keyword?: string }} params
*/
export function listDeliveryBatches(params) {
return axios.get("/api/v1/delivery-batches", { params });
}
/**
* @param {Record<string, unknown>} body
*/
export function createDeliveryBatch(body) {
return axios.post("/api/v1/delivery-batches", body);
}
export function getDeliveryBatch(id) {
return axios.get(`/api/v1/delivery-batches/${id}`);
}
/**
* @param {string | number} id
* @param {Record<string, unknown>} body
*/
export function updateDeliveryBatch(id, body) {
return axios.put(`/api/v1/delivery-batches/${id}`, body);
}
/**
* @param {string | number} id
* @param {{ status: string }} body
*/
export function patchDeliveryBatchStatus(id, body) {
return axios.patch(`/api/v1/delivery-batches/${id}/status`, body);
}
/**
* @param {string | number} batchId
*/
export function listDeliveryLines(batchId) {
return axios.get(`/api/v1/delivery-batches/${batchId}/lines`);
}
/**
* @param {string | number} batchId
* @param {Record<string, unknown>} body
*/
export function addDeliveryLine(batchId, body) {
return axios.post(`/api/v1/delivery-batches/${batchId}/lines`, body);
}
/**
* @param {string | number} batchId
* @param {string | number} lineId
* @param {Record<string, unknown>} body
*/
export function updateDeliveryLine(batchId, lineId, body) {
return axios.put(`/api/v1/delivery-batches/${batchId}/lines/${lineId}`, body);
}
export function deleteDeliveryLine(batchId, lineId) {
return axios.delete(`/api/v1/delivery-batches/${batchId}/lines/${lineId}`);
}
/**
* @param {{ page?: number, size?: number, projectId?: string | number, keyword?: string }} params
*/
export function listLicenseSns(params) {
return axios.get("/api/v1/license-sns", { params });
}
/**
* @param {Record<string, unknown>} body
*/
export function createLicenseSn(body) {
return axios.post("/api/v1/license-sns", body);
}
export function getLicenseSn(id) {
return axios.get(`/api/v1/license-sns/${id}`);
}
/**
* @param {string | number} id
* @param {Record<string, unknown>} body
*/
export function updateLicenseSn(id, body) {
return axios.put(`/api/v1/license-sns/${id}`, body);
}
/**
* @param {string | number} id
* @param {{ status: string }} body
*/
export function patchLicenseSnStatus(id, body) {
return axios.patch(`/api/v1/license-sns/${id}/status`, body);
}
@@ -15,6 +15,12 @@
<el-menu-item index="/contracts">
<span>合同管理</span>
</el-menu-item>
<el-menu-item index="/deliveries">
<span>交付管理</span>
</el-menu-item>
<el-menu-item index="/licenses/sn">
<span>许可 SN</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
@@ -26,6 +26,42 @@ const routes = [
component: () => import("../views/ProjectsView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"] },
},
{
path: "deliveries/new",
name: "delivery-new",
component: () => import("../views/DeliveryBatchWizardView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "新建交付批次" },
},
{
path: "deliveries/:id",
name: "delivery-detail",
component: () => import("../views/DeliveryBatchDetailView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "交付批次详情" },
},
{
path: "deliveries",
name: "deliveries",
component: () => import("../views/DeliveriesView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "交付管理" },
},
{
path: "licenses/sn/new",
name: "license-sn-new",
component: () => import("../views/LicenseSnWizardView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "新建许可 SN" },
},
{
path: "licenses/sn/:id",
name: "license-sn-detail",
component: () => import("../views/LicenseSnDetailView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "许可 SN 详情" },
},
{
path: "licenses/sn",
name: "license-sn-list",
component: () => import("../views/LicenseSnListView.vue"),
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "许可 SN" },
},
{
path: "contracts/new",
name: "contract-new",
@@ -0,0 +1,211 @@
<template>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span class="title">交付管理</span>
<div class="actions">
<el-select
v-model="filterProjectId"
clearable
filterable
placeholder="按项目筛选"
class="filter"
style="width: 200px"
>
<el-option v-for="p in projectOptions" :key="p.id" :label="p.name || String(p.id)" :value="p.id" />
</el-select>
<el-input
v-model="keyword"
clearable
placeholder="按批次编码搜索"
class="search"
@keyup.enter="load"
/>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button type="success" @click="goNew">新建交付批次</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="rows" stripe style="width: 100%">
<el-table-column prop="batchCode" label="批次编码" min-width="140" show-overflow-tooltip />
<el-table-column label="项目" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.projectName ?? projectNameById(row.projectId) }}
</template>
</el-table-column>
<el-table-column label="合同 ID" width="100">
<template #default="{ row }">{{ row.contractId ?? "—" }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="batchStatusTagType(row.status)" size="small">{{ batchStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="计划交付日" width="130">
<template #default="{ row }">{{ formatDate(row.plannedDeliveryDate) }}</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="goDetail(row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="onSizeChange"
/>
</div>
</el-card>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { listDeliveryBatches, listProjects } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const rows = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref("");
const filterProjectId = ref(undefined);
const projectOptions = ref([]);
/** @type {import('vue').Ref<Map<string | number, string>>} */
const projectMap = ref(new Map());
onMounted(async () => {
auth.restoreAxiosAuth();
await loadProjects();
await load();
});
function onSizeChange() {
page.value = 1;
load();
}
function projectNameById(id) {
if (id == null) return "—";
return projectMap.value.get(id) ?? String(id);
}
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 loadProjects() {
try {
const { data } = await listProjects({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
const list = Array.isArray(body.content) ? body.content : [];
projectOptions.value = list;
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 (e) {
ElMessage.error(apiErrorMessage(e, "加载项目失败"));
projectOptions.value = [];
projectMap.value = new Map();
}
}
async function load() {
loading.value = true;
try {
const { data } = await listDeliveryBatches({
page: page.value - 1,
size: pageSize.value,
keyword: keyword.value?.trim() || undefined,
projectId: filterProjectId.value ?? undefined,
});
const body = data && typeof data === "object" ? data : {};
rows.value = Array.isArray(body.content) ? body.content : [];
total.value = Number(body.totalElements ?? body.total ?? 0);
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载交付批次失败"));
rows.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function goNew() {
router.push({ name: "delivery-new" });
}
function goDetail(id) {
router.push({ name: "delivery-detail", params: { id: String(id) } });
}
</script>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search {
width: 200px;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -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>
@@ -0,0 +1,214 @@
<template>
<el-card v-loading="pageLoading" shadow="never">
<template #header>
<div class="toolbar">
<span class="title">新建交付批次</span>
<el-button @click="goBack">返回列表</el-button>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" style="max-width: 560px">
<el-form-item label="项目" prop="projectId">
<el-select
v-model="form.projectId"
filterable
placeholder="请选择项目"
style="width: 100%"
:loading="projectsLoading"
@change="onProjectChange"
>
<el-option v-for="p in projectOptions" :key="p.id" :label="p.name || String(p.id)" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="合同">
<el-select
v-model="form.contractId"
clearable
filterable
placeholder="选填,可按项目筛选"
style="width: 100%"
:loading="contractsLoading"
:disabled="!form.projectId"
>
<el-option
v-for="c in contractsFiltered"
:key="c.id"
:label="contractOptionLabel(c)"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="批次编码" prop="batchCode">
<el-input v-model="form.batchCode" maxlength="64" show-word-limit placeholder="唯一批次编码" />
</el-form-item>
<el-form-item label="计划交付日">
<el-date-picker
v-model="plannedDateModel"
type="date"
value-format="YYYY-MM-DD"
placeholder="选填"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remarks" type="textarea" :rows="3" maxlength="4000" show-word-limit placeholder="选填" />
</el-form-item>
</el-form>
<div class="footer-actions">
<el-button :loading="submitting" @click="submit(false)">创建并返回列表</el-button>
<el-button type="primary" :loading="submitting" @click="submit(true)">创建并进入详情</el-button>
</div>
</el-card>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { listProjects, listContracts, createDeliveryBatch } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const router = useRouter();
const pageLoading = ref(false);
const projectsLoading = ref(false);
const contractsLoading = ref(false);
const submitting = ref(false);
const projectOptions = ref([]);
const contractOptions = ref([]);
const formRef = ref(null);
const plannedDateModel = ref("");
const form = reactive({
projectId: undefined,
contractId: undefined,
batchCode: "",
remarks: "",
});
const rules = {
projectId: [{ required: true, message: "请选择项目", trigger: "change" }],
batchCode: [{ required: true, message: "请输入批次编码", trigger: "blur" }],
};
const contractsFiltered = computed(() => {
const pid = form.projectId;
if (pid == null) return [];
return contractOptions.value.filter((c) => c.projectId == null || c.projectId === pid);
});
onMounted(async () => {
auth.restoreAxiosAuth();
pageLoading.value = true;
try {
await Promise.all([loadProjects(), loadContracts()]);
} finally {
pageLoading.value = false;
}
});
function goBack() {
router.push({ name: "deliveries" });
}
function onProjectChange() {
form.contractId = undefined;
}
function contractOptionLabel(c) {
const t = c.title ?? c.batchCode ?? String(c.id);
return `${t} (#${c.id})`;
}
async function loadProjects() {
projectsLoading.value = true;
try {
const { data } = await listProjects({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
projectOptions.value = Array.isArray(body.content) ? body.content : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载项目失败"));
projectOptions.value = [];
} finally {
projectsLoading.value = false;
}
}
async function loadContracts() {
contractsLoading.value = true;
try {
const { data } = await listContracts({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
contractOptions.value = Array.isArray(body.content) ? body.content : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载合同失败"));
contractOptions.value = [];
} finally {
contractsLoading.value = false;
}
}
/**
* @param {boolean} goDetail
*/
async function submit(goDetail) {
const f = formRef.value;
if (!f) return;
try {
await f.validate();
} catch {
return;
}
submitting.value = true;
const body = {
projectId: form.projectId,
contractId: form.contractId ?? undefined,
batchCode: form.batchCode.trim(),
plannedDeliveryDate: plannedDateModel.value || undefined,
remarks: form.remarks?.trim() || undefined,
};
try {
const { data } = await createDeliveryBatch(body);
const id = data?.id;
if (id == null) {
ElMessage.error("创建成功但未返回批次 ID");
router.push({ name: "deliveries" });
return;
}
ElMessage.success("已创建交付批次");
if (goDetail) {
router.push({ name: "delivery-detail", params: { id: String(id) } });
} else {
router.push({ name: "deliveries" });
}
} catch (e) {
ElMessage.error(apiErrorMessage(e, "创建交付批次失败"));
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,261 @@
<template>
<el-card v-loading="loading" shadow="never">
<template #header>
<div class="toolbar">
<div class="head-left">
<el-button link type="primary" @click="goList"> SN 列表</el-button>
<span class="title">许可 SN 详情</span>
<el-tag v-if="sn" :type="snStatusTagType(sn.status)" size="small">{{ snStatusLabel(sn.status) }}</el-tag>
</div>
</div>
</template>
<template v-if="sn">
<el-descriptions :column="2" border class="block">
<el-descriptions-item label="SN 编码">{{ sn.snCode ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(sn.createdAt) }}</el-descriptions-item>
</el-descriptions>
<h3 class="section-title">绑定与备注</h3>
<el-form label-width="120px" style="max-width: 520px">
<el-form-item label="项目">
<el-select
v-model="bindForm.projectId"
clearable
filterable
placeholder="选填"
style="width: 100%"
:loading="projectsLoading"
>
<el-option v-for="p in projectOptions" :key="p.id" :label="p.name || String(p.id)" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="合同行 ID">
<el-input-number
v-model="bindForm.contractLineId"
:min="1"
clearable
controls-position="right"
style="width: 100%"
placeholder="选填"
/>
</el-form-item>
<el-form-item label="激活备注">
<el-input v-model="bindForm.activationRemark" type="textarea" :rows="2" maxlength="512" show-word-limit />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="savingBind" @click="saveBind">保存绑定</el-button>
</el-form-item>
</el-form>
<h3 class="section-title">状态</h3>
<div class="status-row">
<el-select v-model="statusPick" placeholder="选择新状态" style="width: 220px">
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button type="primary" :loading="patchingStatus" :disabled="!statusPick" @click="applyStatus">更新状态</el-button>
</div>
</template>
<el-empty v-else-if="!loading" description="未加载到许可 SN" />
</el-card>
</template>
<script setup>
import { ref, reactive, watch, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { getLicenseSn, updateLicenseSn, patchLicenseSnStatus, listProjects } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const savingBind = ref(false);
const patchingStatus = ref(false);
const sn = ref(null);
const projectsLoading = ref(false);
const projectOptions = ref([]);
const bindForm = reactive({
projectId: undefined,
contractLineId: undefined,
activationRemark: "",
});
const statusPick = ref("");
const statusOptions = [
{ value: "REGISTERED", label: "已登记 (REGISTERED)" },
{ value: "ISSUED", label: "已发放 (ISSUED)" },
{ value: "ACTIVATED", label: "已激活 (ACTIVATED)" },
{ value: "SUSPENDED", label: "已暂停 (SUSPENDED)" },
{ value: "REVOKED", label: "已吊销 (REVOKED)" },
];
const snId = computed(() => route.params.id);
function snStatusLabel(s) {
const u = String(s ?? "").toUpperCase();
const map = {
REGISTERED: "已登记",
ISSUED: "已发放",
ACTIVATED: "已激活",
SUSPENDED: "已暂停",
REVOKED: "已吊销",
};
return map[u] ?? String(s ?? "—");
}
function snStatusTagType(s) {
const u = String(s ?? "").toUpperCase();
if (u === "REGISTERED") return "info";
if (u === "ISSUED") return "";
if (u === "ACTIVATED") return "success";
if (u === "SUSPENDED") return "warning";
if (u === "REVOKED") return "danger";
return "";
}
function formatDateTime(v) {
if (v == null || v === "") return "—";
if (typeof v === "string") return v.replace("T", " ").slice(0, 19);
return String(v);
}
watch(
() => sn.value,
(row) => {
if (!row) return;
bindForm.projectId = row.projectId ?? undefined;
bindForm.contractLineId = row.contractLineId ?? undefined;
bindForm.activationRemark = row.activationRemark ?? "";
statusPick.value = "";
},
{ immediate: true }
);
onMounted(async () => {
auth.restoreAxiosAuth();
await loadProjects();
await loadSn();
});
watch(
() => route.params.id,
async () => {
await loadSn();
}
);
function goList() {
router.push({ name: "license-sn-list" });
}
async function loadProjects() {
projectsLoading.value = true;
try {
const { data } = await listProjects({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
projectOptions.value = Array.isArray(body.content) ? body.content : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载项目失败"));
projectOptions.value = [];
} finally {
projectsLoading.value = false;
}
}
async function loadSn() {
const id = snId.value;
if (id == null || id === "") return;
loading.value = true;
try {
const { data } = await getLicenseSn(id);
sn.value = data && typeof data === "object" ? data : null;
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载许可 SN 失败"));
sn.value = null;
} finally {
loading.value = false;
}
}
async function saveBind() {
const id = snId.value;
if (id == null) return;
savingBind.value = true;
try {
await updateLicenseSn(id, {
projectId: bindForm.projectId ?? undefined,
contractLineId: bindForm.contractLineId ?? undefined,
activationRemark: bindForm.activationRemark?.trim() ?? "",
});
ElMessage.success("已保存");
await loadSn();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "保存失败"));
} finally {
savingBind.value = false;
}
}
async function applyStatus() {
const id = snId.value;
const st = statusPick.value;
if (id == null || !st) return;
patchingStatus.value = true;
try {
await patchLicenseSnStatus(id, { status: st });
ElMessage.success("状态已更新");
statusPick.value = "";
await loadSn();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "状态更新失败"));
} finally {
patchingStatus.value = false;
}
}
</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;
}
.status-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
</style>
@@ -0,0 +1,209 @@
<template>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span class="title">许可 SN</span>
<div class="actions">
<el-select
v-model="filterProjectId"
clearable
filterable
placeholder="按项目筛选"
style="width: 200px"
>
<el-option v-for="p in projectOptions" :key="p.id" :label="p.name || String(p.id)" :value="p.id" />
</el-select>
<el-input
v-model="keyword"
clearable
placeholder="按 SN 编码搜索"
class="search"
@keyup.enter="load"
/>
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button type="success" @click="goNew">新建许可 SN</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="rows" stripe style="width: 100%">
<el-table-column prop="snCode" label="SN 编码" min-width="160" show-overflow-tooltip />
<el-table-column label="项目" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.projectName ?? projectNameById(row.projectId) }}
</template>
</el-table-column>
<el-table-column label="合同行 ID" width="110">
<template #default="{ row }">{{ row.contractLineId ?? "—" }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="snStatusTagType(row.status)" size="small">{{ snStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="goDetail(row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="onSizeChange"
/>
</div>
</el-card>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { listLicenseSns, listProjects } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const rows = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref("");
const filterProjectId = ref(undefined);
const projectOptions = ref([]);
/** @type {import('vue').Ref<Map<string | number, string>>} */
const projectMap = ref(new Map());
onMounted(async () => {
auth.restoreAxiosAuth();
await loadProjects();
await load();
});
function onSizeChange() {
page.value = 1;
load();
}
function projectNameById(id) {
if (id == null) return "—";
return projectMap.value.get(id) ?? String(id);
}
function snStatusLabel(s) {
const u = String(s ?? "").toUpperCase();
const map = {
REGISTERED: "已登记",
ISSUED: "已发放",
ACTIVATED: "已激活",
SUSPENDED: "已暂停",
REVOKED: "已吊销",
};
return map[u] ?? String(s ?? "—");
}
function snStatusTagType(s) {
const u = String(s ?? "").toUpperCase();
if (u === "REGISTERED") return "info";
if (u === "ISSUED") return "";
if (u === "ACTIVATED") return "success";
if (u === "SUSPENDED") return "warning";
if (u === "REVOKED") return "danger";
return "";
}
function formatDateTime(v) {
if (v == null || v === "") return "—";
if (typeof v === "string") return v.replace("T", " ").slice(0, 19);
return String(v);
}
async function loadProjects() {
try {
const { data } = await listProjects({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
const list = Array.isArray(body.content) ? body.content : [];
projectOptions.value = list;
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 (e) {
ElMessage.error(apiErrorMessage(e, "加载项目失败"));
projectOptions.value = [];
projectMap.value = new Map();
}
}
async function load() {
loading.value = true;
try {
const { data } = await listLicenseSns({
page: page.value - 1,
size: pageSize.value,
keyword: keyword.value?.trim() || undefined,
projectId: filterProjectId.value ?? undefined,
});
const body = data && typeof data === "object" ? data : {};
rows.value = Array.isArray(body.content) ? body.content : [];
total.value = Number(body.totalElements ?? body.total ?? 0);
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载许可 SN 失败"));
rows.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function goNew() {
router.push({ name: "license-sn-new" });
}
function goDetail(id) {
router.push({ name: "license-sn-detail", params: { id: String(id) } });
}
</script>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.search {
width: 200px;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,162 @@
<template>
<el-card v-loading="pageLoading" shadow="never">
<template #header>
<div class="toolbar">
<span class="title">新建许可 SN</span>
<el-button @click="goBack">返回列表</el-button>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" style="max-width: 560px">
<el-form-item label="SN 编码" prop="snCode">
<el-input v-model="form.snCode" maxlength="128" show-word-limit placeholder="唯一 SN" />
</el-form-item>
<el-form-item label="项目">
<el-select
v-model="form.projectId"
clearable
filterable
placeholder="选填"
style="width: 100%"
:loading="projectsLoading"
>
<el-option v-for="p in projectOptions" :key="p.id" :label="p.name || String(p.id)" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="合同行 ID">
<el-input-number
v-model="form.contractLineId"
:min="1"
clearable
controls-position="right"
style="width: 100%"
placeholder="选填,MVP 手工录入"
/>
</el-form-item>
<el-form-item label="激活备注">
<el-input v-model="form.activationRemark" maxlength="512" show-word-limit type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-form>
<div class="footer-actions">
<el-button :loading="submitting" @click="submit(false)">创建并返回列表</el-button>
<el-button type="primary" :loading="submitting" @click="submit(true)">创建并进入详情</el-button>
</div>
</el-card>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { listProjects, createLicenseSn } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const router = useRouter();
const pageLoading = ref(false);
const projectsLoading = ref(false);
const submitting = ref(false);
const projectOptions = ref([]);
const formRef = ref(null);
const form = reactive({
snCode: "",
projectId: undefined,
contractLineId: undefined,
activationRemark: "",
});
const rules = {
snCode: [{ required: true, message: "请输入 SN 编码", trigger: "blur" }],
};
onMounted(async () => {
auth.restoreAxiosAuth();
pageLoading.value = true;
try {
await loadProjects();
} finally {
pageLoading.value = false;
}
});
function goBack() {
router.push({ name: "license-sn-list" });
}
async function loadProjects() {
projectsLoading.value = true;
try {
const { data } = await listProjects({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
projectOptions.value = Array.isArray(body.content) ? body.content : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载项目失败"));
projectOptions.value = [];
} finally {
projectsLoading.value = false;
}
}
/**
* @param {boolean} goDetail
*/
async function submit(goDetail) {
const f = formRef.value;
if (!f) return;
try {
await f.validate();
} catch {
return;
}
submitting.value = true;
const body = {
snCode: form.snCode.trim(),
projectId: form.projectId ?? undefined,
contractLineId: form.contractLineId ?? undefined,
activationRemark: form.activationRemark?.trim() || undefined,
};
try {
const { data } = await createLicenseSn(body);
const id = data?.id;
if (id == null) {
ElMessage.error("创建成功但未返回 SN ID");
router.push({ name: "license-sn-list" });
return;
}
ElMessage.success("已创建许可 SN");
if (goDetail) {
router.push({ name: "license-sn-detail", params: { id: String(id) } });
} else {
router.push({ name: "license-sn-list" });
}
} catch (e) {
ElMessage.error(apiErrorMessage(e, "创建许可 SN 失败"));
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 600;
font-size: 16px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>