mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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:
@@ -41,3 +41,68 @@ export function deleteProject(id) {
|
||||
export function getProjectPhaseDictionary() {
|
||||
return axios.get("/api/v1/dictionaries/PROJECT_PHASE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同列表(分页)。后端就绪后路径以 OpenAPI 为准。
|
||||
* @param {{ page?: number, size?: number, customerId?: string | number, projectId?: string | number, keyword?: string }} params
|
||||
*/
|
||||
export function listContracts(params) {
|
||||
return axios.get("/api/v1/contracts", { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} body
|
||||
*/
|
||||
export function createContract(body) {
|
||||
return axios.post("/api/v1/contracts", body);
|
||||
}
|
||||
|
||||
export function getContract(id) {
|
||||
return axios.get(`/api/v1/contracts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number} id
|
||||
* @param {Record<string, unknown>} body
|
||||
*/
|
||||
export function updateContract(id, body) {
|
||||
return axios.put(`/api/v1/contracts/${id}`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number} contractId
|
||||
* @param {Record<string, unknown>} body
|
||||
*/
|
||||
export function addLine(contractId, body) {
|
||||
return axios.post(`/api/v1/contracts/${contractId}/lines`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number} contractId
|
||||
* @param {string | number} lineId
|
||||
* @param {Record<string, unknown>} body
|
||||
*/
|
||||
export function updateLine(contractId, lineId, body) {
|
||||
return axios.put(`/api/v1/contracts/${contractId}/lines/${lineId}`, body);
|
||||
}
|
||||
|
||||
export function deleteLine(contractId, lineId) {
|
||||
return axios.delete(`/api/v1/contracts/${contractId}/lines/${lineId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态迁移:后端 `PATCH /api/v1/contracts/{id}/status`,body `{ status: "PENDING_EFFECTIVE" }` 等。
|
||||
* @param {string | number} id
|
||||
* @param {{ status: string }} body
|
||||
*/
|
||||
export function patchContractStatus(id, body) {
|
||||
return axios.patch(`/api/v1/contracts/${id}/status`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* M10-F01 审计分页:`GET /api/v1/audit-events`。
|
||||
* @param {{ entityType: string, entityId: string | number, page?: number, size?: number }} params
|
||||
*/
|
||||
export function listAuditEvents(params) {
|
||||
return axios.get("/api/v1/audit-events", { params });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<el-menu-item index="/projects">
|
||||
<span>项目管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/contracts">
|
||||
<span>合同管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
|
||||
@@ -26,6 +26,24 @@ const routes = [
|
||||
component: () => import("../views/ProjectsView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||
},
|
||||
{
|
||||
path: "contracts/new",
|
||||
name: "contract-new",
|
||||
component: () => import("../views/ContractWizardView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "新建合同" },
|
||||
},
|
||||
{
|
||||
path: "contracts/:id",
|
||||
name: "contract-detail",
|
||||
component: () => import("../views/ContractDetailView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "合同详情" },
|
||||
},
|
||||
{
|
||||
path: "contracts",
|
||||
name: "contracts",
|
||||
component: () => import("../views/ContractsView.vue"),
|
||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "合同管理" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "/403", name: "forbidden", component: () => import("../views/ForbiddenView.vue") },
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user