feat(web): I3 contract list, wizard, and detail

Add routes and menu, platform API helpers (patch status, audit-events),
and Vue views aligned to platform contract DTOs and state transitions.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:29:28 +08:00
parent 69f7ee11df
commit 7f8e7b7e7c
6 changed files with 1097 additions and 0 deletions
@@ -0,0 +1,504 @@
<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="contract" :type="statusTagType(contract.status)" size="small">
{{ statusLabel(contract.status) }}
</el-tag>
</div>
<div v-if="contract && isDraft" class="head-actions">
<el-button type="primary" :loading="saving" @click="saveHeader">保存</el-button>
</div>
</div>
</template>
<template v-if="contract">
<el-descriptions :column="2" border class="block">
<el-descriptions-item label="合同标题/编号">
<template v-if="isDraft">
<el-input v-model="form.title" maxlength="256" />
</template>
<template v-else>{{ contract.title ?? "" }}</template>
</el-descriptions-item>
<el-descriptions-item label="客户">{{ displayCustomer }}</el-descriptions-item>
<el-descriptions-item label="项目">{{ displayProject }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
<template v-if="isDraft">
<el-input v-model="form.remarks" type="textarea" :rows="2" maxlength="4000" />
</template>
<template v-else>{{ contract.remarks ?? "" }}</template>
</el-descriptions-item>
</el-descriptions>
<div v-if="transitionButtons.length" class="transition-bar">
<span class="label">状态操作</span>
<el-button
v-for="btn in transitionButtons"
:key="btn.status"
:type="btn.danger ? 'danger' : 'primary'"
:loading="transitionLoading === btn.status"
@click="onTransition(btn)"
>
{{ btn.label }}
</el-button>
</div>
<h3 class="section-title">合同明细</h3>
<div v-if="isDraft" 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="itemName" label="标的/行项" min-width="200" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="unit" label="单位" width="90" show-overflow-tooltip />
<el-table-column v-if="isDraft" 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>
<h3 class="section-title">最近审计</h3>
<el-table v-loading="auditLoading" :data="auditRows" border stripe size="small" style="width: 100%">
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatAuditTime(row) }}</template>
</el-table-column>
<el-table-column prop="actor" label="操作者" width="140" show-overflow-tooltip />
<el-table-column prop="action" label="动作" width="140" show-overflow-tooltip />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
</el-table>
</template>
<el-empty v-else-if="!loading" description="未加载到合同数据" />
<el-dialog v-model="lineDialogVisible" :title="lineEditingId ? '编辑明细' : '添加明细'" width="480px" destroy-on-close @closed="resetLineForm">
<el-form ref="lineFormRef" :model="lineForm" :rules="lineRules" label-width="120px">
<el-form-item label="行项名称" prop="itemName">
<el-input v-model="lineForm.itemName" maxlength="256" placeholder="itemName" />
</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="单位">
<el-input v-model="lineForm.unit" maxlength="32" 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, reactive, computed, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAuthStore } from "../stores/auth";
import {
getContract,
updateContract,
addLine,
updateLine,
deleteLine,
patchContractStatus,
listAuditEvents,
listCustomers,
listProjects,
} from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const contract = ref(null);
const customerMap = ref(new Map());
const projectMap = ref(new Map());
const form = reactive({
title: "",
remarks: "",
});
const auditLoading = ref(false);
const auditRows = ref([]);
const lineDialogVisible = ref(false);
const lineEditingId = ref(null);
const lineFormRef = ref(null);
const lineSaving = ref(false);
const lineForm = reactive({
itemName: "",
quantity: 1,
unit: "",
});
const lineRules = {
itemName: [{ required: true, message: "请输入行项名称", trigger: "blur" }],
quantity: [{ required: true, message: "请输入数量", trigger: "change" }],
};
const transitionLoading = ref("");
const contractId = computed(() => route.params.id);
const isDraft = computed(() => String(contract.value?.status ?? "").toUpperCase() === "DRAFT");
const lineRows = computed(() => {
const c = contract.value;
if (!c) return [];
const raw = c.lines ?? c.lineItems ?? c.items ?? [];
return Array.isArray(raw) ? raw : [];
});
const displayCustomer = computed(() => {
const c = contract.value;
if (!c) return "—";
if (c.customerName) return c.customerName;
const id = c.customerId;
if (id == null) return "—";
return customerMap.value.get(id) ?? String(id);
});
const displayProject = computed(() => {
const c = contract.value;
if (!c) return "—";
if (c.projectName) return c.projectName;
const id = c.projectId;
if (id == null) return "—";
return projectMap.value.get(id) ?? String(id);
});
const transitionButtons = computed(() => {
const c = contract.value;
if (!c) return [];
return fallbackTransitions(c.status);
});
watch(
() => contract.value,
(c) => {
if (!c) return;
form.title = c.title ?? "";
form.remarks = c.remarks ?? "";
},
{ immediate: true }
);
onMounted(async () => {
auth.restoreAxiosAuth();
await loadNameMaps();
await refreshAll();
});
watch(
() => route.params.id,
async () => {
await refreshAll();
}
);
function goList() {
router.push({ name: "contracts" });
}
function statusLabel(s) {
if (s == null || s === "") return "—";
const u = String(s).toUpperCase();
const map = {
DRAFT: "草稿",
PENDING_EFFECTIVE: "待生效",
EFFECTIVE: "生效",
CHANGING: "变更中",
TERMINATED: "已终止",
};
return map[u] ?? String(s);
}
function statusTagType(s) {
const u = String(s ?? "").toUpperCase();
if (u === "DRAFT") return "info";
if (u === "PENDING_EFFECTIVE") return "warning";
if (u === "EFFECTIVE") return "success";
if (u === "CHANGING") return "warning";
if (u === "TERMINATED") return "danger";
return "";
}
function fallbackTransitions(status) {
const u = String(status ?? "").toUpperCase();
if (u === "DRAFT") {
return [{ status: "PENDING_EFFECTIVE", label: "提交待生效", danger: false }];
}
if (u === "PENDING_EFFECTIVE") {
return [{ status: "EFFECTIVE", label: "确认生效", danger: false }];
}
if (u === "EFFECTIVE") {
return [
{ status: "CHANGING", label: "发起变更", danger: false },
{ status: "TERMINATED", label: "终止合同", danger: true },
];
}
if (u === "CHANGING") {
return [{ status: "EFFECTIVE", label: "完成变更", danger: false }];
}
return [];
}
function formatAuditTime(row) {
const v = row.occurredAt ?? row.createdAt ?? row.timestamp ?? row.time;
if (v == null) return "—";
if (typeof v === "string") return v.replace("T", " ").slice(0, 19);
return String(v);
}
async function loadNameMaps() {
try {
const [cRes, pRes] = await Promise.all([
listCustomers({ page: 0, size: 500 }),
listProjects({ page: 0, size: 500 }),
]);
const cBody = cRes.data && typeof cRes.data === "object" ? cRes.data : {};
const pBody = pRes.data && typeof pRes.data === "object" ? pRes.data : {};
const customers = Array.isArray(cBody.content) ? cBody.content : [];
const projects = Array.isArray(pBody.content) ? pBody.content : [];
const cm = new Map();
const pm = new Map();
for (const x of customers) {
if (x?.id != null) cm.set(x.id, x.name ?? String(x.id));
}
for (const x of projects) {
if (x?.id != null) pm.set(x.id, x.name ?? String(x.id));
}
customerMap.value = cm;
projectMap.value = pm;
} catch {
customerMap.value = new Map();
projectMap.value = new Map();
}
}
async function refreshAll() {
await Promise.all([loadContract(), loadAudit()]);
}
async function loadContract() {
const id = contractId.value;
if (id == null || id === "") return;
loading.value = true;
try {
const { data } = await getContract(id);
contract.value = data && typeof data === "object" ? data : null;
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载合同失败"));
contract.value = null;
} finally {
loading.value = false;
}
}
async function loadAudit() {
const id = contractId.value;
if (id == null || id === "") return;
auditLoading.value = true;
try {
const { data } = await listAuditEvents({
entityType: "CONTRACT",
entityId: id,
page: 0,
size: 30,
});
const body = data && typeof data === "object" ? data : {};
const raw = Array.isArray(body.content) ? body.content : Array.isArray(data) ? data : [];
auditRows.value = raw.map(normalizeAuditRow);
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载审计记录失败"));
auditRows.value = [];
} finally {
auditLoading.value = false;
}
}
function normalizeAuditRow(row) {
if (!row || typeof row !== "object") return { actor: "", action: "", summary: "" };
const ov = row.oldValue != null ? String(row.oldValue) : "";
const nv = row.newValue != null ? String(row.newValue) : "";
const summary =
row.fieldName != null
? `${row.fieldName}: ${ov || "—"}${nv || "—"}`
: [ov, nv].filter(Boolean).join(" → ") || "—";
return {
occurredAt: row.occurredAt ?? row.createdAt,
actor: row.actorUserId ?? row.actor ?? row.actorName ?? "—",
action: row.action ?? row.event ?? row.type ?? "—",
summary,
};
}
async function saveHeader() {
const id = contractId.value;
if (id == null) return;
saving.value = true;
try {
await updateContract(id, {
title: form.title?.trim(),
remarks: form.remarks?.trim() ?? "",
});
ElMessage.success("已保存");
await loadContract();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "保存失败"));
} finally {
saving.value = false;
}
}
function lineIdOf(row) {
return row.id ?? row.lineId;
}
function openLineDialog(row) {
if (row) {
lineEditingId.value = lineIdOf(row);
lineForm.itemName = row.itemName ?? "";
lineForm.quantity = Number(row.quantity ?? 1);
lineForm.unit = row.unit ?? "";
} else {
lineEditingId.value = null;
lineForm.itemName = "";
lineForm.quantity = 1;
lineForm.unit = "";
}
lineDialogVisible.value = true;
}
function resetLineForm() {
lineEditingId.value = null;
lineForm.itemName = "";
lineForm.quantity = 1;
lineForm.unit = "";
lineFormRef.value?.resetFields?.();
}
async function submitLine() {
const f = lineFormRef.value;
if (!f) return;
try {
await f.validate();
} catch {
return;
}
const id = contractId.value;
if (id == null) return;
const payload = {
itemName: lineForm.itemName.trim(),
quantity: lineForm.quantity,
unit: lineForm.unit?.trim() || undefined,
};
lineSaving.value = true;
try {
if (lineEditingId.value != null) {
await updateLine(id, lineEditingId.value, payload);
ElMessage.success("已更新明细");
} else {
await addLine(id, payload);
ElMessage.success("已添加明细");
}
lineDialogVisible.value = false;
await loadContract();
} 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 deleteLine(contractId.value, lid);
ElMessage.success("已删除");
await loadContract();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "删除失败"));
}
})
.catch(() => {});
}
function onTransition(btn) {
ElMessageBox.confirm(`确定执行「${btn.label}」吗?`, "状态变更", {
type: btn.danger ? "warning" : "info",
})
.then(async () => {
const id = contractId.value;
if (id == null) return;
transitionLoading.value = btn.status;
try {
await patchContractStatus(id, { status: btn.status });
ElMessage.success("状态已更新");
await refreshAll();
} catch (e) {
ElMessage.error(apiErrorMessage(e, "状态变更失败"));
} finally {
transitionLoading.value = "";
}
})
.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;
}
.transition-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.transition-bar .label {
color: var(--el-text-color-secondary);
font-size: 14px;
}
.section-title {
margin: 20px 0 12px;
font-size: 15px;
font-weight: 600;
}
.line-toolbar {
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,288 @@
<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-steps :active="step" finish-status="success" align-center class="steps">
<el-step title="客户与项目" />
<el-step title="合同基本信息" />
<el-step title="明细行" />
</el-steps>
<div v-show="step === 0" class="panel">
<el-form label-width="100px" style="max-width: 520px">
<el-form-item label="客户" required>
<el-select
v-model="customerId"
filterable
placeholder="请选择客户"
style="width: 100%"
:loading="customersLoading"
@change="onCustomerChange"
>
<el-option
v-for="c in customerOptions"
:key="c.id"
:label="c.name || String(c.id)"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="项目" required>
<el-select
v-model="projectId"
filterable
placeholder="请选择项目"
style="width: 100%"
:loading="projectsLoading"
:disabled="!customerId"
>
<el-option
v-for="p in projectOptionsFiltered"
:key="p.id"
:label="p.name || String(p.id)"
:value="p.id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<div v-show="step === 1" class="panel">
<el-form ref="headerFormRef" :model="header" :rules="headerRules" label-width="120px" style="max-width: 560px">
<el-form-item label="合同标题/编号" prop="title">
<el-input v-model="header.title" maxlength="256" show-word-limit placeholder="对应后端 title 字段" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="header.remarks" type="textarea" :rows="3" maxlength="4000" show-word-limit placeholder="选填" />
</el-form-item>
</el-form>
</div>
<div v-show="step === 2" class="panel">
<div class="line-toolbar">
<el-button type="primary" @click="addLineRow">添加明细</el-button>
</div>
<el-table :data="lines" border stripe style="width: 100%">
<el-table-column label="标的/行项名称" min-width="200">
<template #default="{ row }">
<el-input v-model="row.itemName" placeholder="itemName,对应 M2 行项" maxlength="256" />
</template>
</el-table-column>
<el-table-column label="数量" width="140">
<template #default="{ row }">
<el-input-number v-model="row.quantity" :min="0.0001" :precision="4" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="单位" width="120">
<template #default="{ row }">
<el-input v-model="row.unit" maxlength="32" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link :disabled="lines.length <= 1" @click="removeLine($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="footer-actions">
<el-button v-if="step > 0" @click="step -= 1">上一步</el-button>
<el-button v-if="step < 2" type="primary" @click="nextStep">下一步</el-button>
<el-button v-if="step === 2" type="primary" :loading="submitting" @click="submit">提交创建草稿</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 { listCustomers, listProjects, createContract, addLine } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore();
const router = useRouter();
const pageLoading = ref(false);
const customersLoading = ref(false);
const projectsLoading = ref(false);
const submitting = ref(false);
const step = ref(0);
const customerId = ref(undefined);
const projectId = ref(undefined);
const customerOptions = ref([]);
const projectOptions = ref([]);
const headerFormRef = ref(null);
const header = reactive({
title: "",
remarks: "",
});
const headerRules = {
title: [{ required: true, message: "请输入合同标题或编号", trigger: "blur" }],
};
const lines = ref([{ itemName: "", quantity: 1, unit: "" }]);
const projectOptionsFiltered = computed(() => {
const cid = customerId.value;
if (cid == null) return [];
return projectOptions.value.filter((p) => (p.customerId == null ? false : p.customerId == cid));
});
onMounted(async () => {
auth.restoreAxiosAuth();
pageLoading.value = true;
try {
await Promise.all([loadCustomers(), loadAllProjects()]);
} finally {
pageLoading.value = false;
}
});
function goBack() {
router.push({ name: "contracts" });
}
function onCustomerChange() {
projectId.value = undefined;
}
async function loadCustomers() {
customersLoading.value = true;
try {
const { data } = await listCustomers({ page: 0, size: 500 });
const body = data && typeof data === "object" ? data : {};
customerOptions.value = Array.isArray(body.content) ? body.content : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载客户失败"));
customerOptions.value = [];
} finally {
customersLoading.value = false;
}
}
async function loadAllProjects() {
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;
}
}
function addLineRow() {
lines.value.push({ itemName: "", quantity: 1, unit: "" });
}
function removeLine(index) {
lines.value.splice(index, 1);
}
async function nextStep() {
if (step.value === 0) {
if (customerId.value == null || projectId.value == null) {
ElMessage.warning("请选择客户与项目");
return;
}
step.value = 1;
return;
}
if (step.value === 1) {
const f = headerFormRef.value;
if (!f) return;
try {
await f.validate();
} catch {
return;
}
step.value = 2;
}
}
async function submit() {
for (const row of lines.value) {
if (!row.itemName?.trim()) {
ElMessage.warning("请填写每行的标的/行项名称(itemName");
return;
}
if (row.quantity == null || Number(row.quantity) <= 0) {
ElMessage.warning("数量须大于 0");
return;
}
}
submitting.value = true;
try {
const { data } = await createContract({
customerId: customerId.value,
projectId: projectId.value,
title: header.title.trim(),
remarks: header.remarks?.trim() || undefined,
});
const id = data?.id;
if (id == null) {
ElMessage.error("创建成功但未返回合同 ID");
router.push({ name: "contracts" });
return;
}
for (const r of lines.value) {
await addLine(id, {
itemName: r.itemName.trim(),
quantity: r.quantity,
unit: r.unit?.trim() || undefined,
});
}
ElMessage.success("已创建草稿");
router.push({ name: "contract-detail", params: { id: String(id) } });
} 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;
}
.steps {
margin: 24px 0 32px;
}
.panel {
min-height: 200px;
margin-bottom: 24px;
}
.line-toolbar {
margin-bottom: 12px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,219 @@
<template>
<el-card shadow="never">
<template #header>
<div class="toolbar">
<span class="title">合同管理</span>
<div class="actions">
<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="title" label="合同标题/编号" min-width="160" show-overflow-tooltip />
<el-table-column label="客户" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.customerName ?? customerNameById(row.customerId) }}
</template>
</el-table-column>
<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="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(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, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth";
import { listContracts, listCustomers, 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 customerOptions = ref([]);
const projectOptions = ref([]);
const customerMap = computed(() => {
const m = new Map();
for (const c of customerOptions.value) {
if (c && c.id != null) m.set(c.id, c.name ?? String(c.id));
}
return m;
});
const projectMap = computed(() => {
const m = new Map();
for (const p of projectOptions.value) {
if (p && p.id != null) m.set(p.id, p.name ?? String(p.id));
}
return m;
});
onMounted(async () => {
auth.restoreAxiosAuth();
await Promise.all([loadCustomerProjectMaps(), load()]);
});
function onSizeChange() {
page.value = 1;
load();
}
function customerNameById(id) {
if (id == null) return "—";
return customerMap.value.get(id) ?? String(id);
}
function projectNameById(id) {
if (id == null) return "—";
return projectMap.value.get(id) ?? String(id);
}
function statusLabel(s) {
if (s == null || s === "") return "—";
const u = String(s).toUpperCase();
const map = {
DRAFT: "草稿",
PENDING_EFFECTIVE: "待生效",
EFFECTIVE: "生效",
CHANGING: "变更中",
TERMINATED: "已终止",
};
return map[u] ?? String(s);
}
function statusTagType(s) {
const u = String(s ?? "").toUpperCase();
if (u === "DRAFT") return "info";
if (u === "PENDING_EFFECTIVE") return "warning";
if (u === "EFFECTIVE") return "success";
if (u === "CHANGING") return "warning";
if (u === "TERMINATED") 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 loadCustomerProjectMaps() {
try {
const [cRes, pRes] = await Promise.all([
listCustomers({ page: 0, size: 500 }),
listProjects({ page: 0, size: 500 }),
]);
const cBody = cRes.data && typeof cRes.data === "object" ? cRes.data : {};
const pBody = pRes.data && typeof pRes.data === "object" ? pRes.data : {};
customerOptions.value = Array.isArray(cBody.content) ? cBody.content : [];
projectOptions.value = Array.isArray(pBody.content) ? pBody.content : [];
} catch (e) {
ElMessage.error(apiErrorMessage(e, "加载客户/项目用于展示失败"));
customerOptions.value = [];
projectOptions.value = [];
}
}
async function load() {
loading.value = true;
try {
const { data } = await listContracts({
page: page.value - 1,
size: pageSize.value,
keyword: keyword.value?.trim() || 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: "contract-new" });
}
function goDetail(id) {
router.push({ name: "contract-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: 220px;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>