增加招投标模块

This commit is contained in:
2026-09-24 17:06:46 +08:00
parent bc9e32d62a
commit eafa5e1cf4
51 changed files with 13453 additions and 5 deletions
@@ -0,0 +1,260 @@
<template>
<div class="tender-announce" v-loading="loading">
<div class="section-header">
<h3 class="section-title">招标公告</h3>
<el-button type="primary" :icon="Plus" :disabled="disabled" @click="openCreate">
新建公告
</el-button>
</div>
<el-empty v-if="list.length === 0" description="暂无公告" :image-size="80" />
<el-table v-else :data="list" stripe border>
<el-table-column label="公告标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openPreview(row)">
{{ row.title }}
</el-link>
</template>
</el-table-column>
<el-table-column label="类型" width="120" align="center">
<template #default="{ row }">
<el-tag size="small">{{ announceTypeText(row.announce_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.publish_status === 1 ? 'success' : 'info'" size="small">
{{ row.publish_status === 1 ? "已发布" : "草稿" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.publish_time) || "-" }}</template>
</el-table-column>
<el-table-column label="浏览" prop="view_count" width="80" align="center" />
<el-table-column label="创建人" prop="create_user_name" width="100" align="center" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.publish_status !== 1"
link
type="success"
size="small"
:disabled="disabled"
@click="handlePublish(row)"
>
发布
</el-button>
<el-button link type="primary" size="small" @click="openPreview(row)">预览</el-button>
<el-button
link
type="danger"
size="small"
:disabled="disabled || row.publish_status === 1"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建公告 -->
<el-dialog
v-model="createVisible"
title="新建招标公告"
width="760px"
:close-on-click-modal="false"
>
<el-form :model="form" label-width="90px">
<el-form-item label="公告标题" required>
<el-input v-model="form.title" placeholder="请输入公告标题" />
</el-form-item>
<el-form-item label="公告类型">
<el-select v-model="form.announce_type" style="width: 100%">
<el-option
v-for="opt in tenderAnnounceTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="公告内容">
<el-input
v-model="form.content"
type="textarea"
:rows="10"
placeholder="支持纯文本,可粘贴公告正文"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 公告预览 -->
<el-dialog v-model="previewVisible" :title="previewRow?.title || '公告预览'" width="760px">
<div class="preview-meta">
<el-tag size="small">{{ announceTypeText(previewRow?.announce_type) }}</el-tag>
<span>{{ formatDateTime(previewRow?.publish_time) || "未发布" }}</span>
</div>
<el-divider />
<div class="preview-content">{{ previewRow?.content || "(空)" }}</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import {
getTenderAnnounceList,
createTenderAnnounce,
publishTenderAnnounce,
deleteTenderAnnounce,
} from "@/api/crmBidding";
import { tenderAnnounceTypeOptions, formatDateTime } from "@/views/apps/crm/dict";
const props = defineProps({
tenderId: { type: [Number, String], required: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["count", "published"]);
const loading = ref(false);
const list = ref([]);
const createVisible = ref(false);
const previewVisible = ref(false);
const previewRow = ref(null);
const submitting = ref(false);
const form = reactive({ title: "", announce_type: "1", content: "" });
defineExpose({ reload: loadList });
onMounted(loadList);
async function loadList() {
if (!props.tenderId) return;
loading.value = true;
try {
const res = await getTenderAnnounceList({ tender_id: props.tenderId });
list.value = res?.data?.list || [];
emit("count", list.value.length);
} catch {
list.value = [];
emit("count", 0);
} finally {
loading.value = false;
}
}
function openCreate() {
form.title = "";
form.announce_type = "1";
form.content = "";
createVisible.value = true;
}
async function handleSubmit() {
if (!form.title.trim()) {
ElMessage.warning("请输入公告标题");
return;
}
submitting.value = true;
try {
await createTenderAnnounce({
tender_id: props.tenderId,
title: form.title,
announce_type: form.announce_type,
content: form.content,
});
ElMessage.success("已创建");
createVisible.value = false;
loadList();
} catch (e) {
ElMessage.error(e?.message || "创建失败");
} finally {
submitting.value = false;
}
}
async function handlePublish(row) {
try {
await ElMessageBox.confirm(
`发布后招标状态将转为「公告中」,确认发布「${row.title}」?`,
"发布确认",
{ type: "warning" }
);
} catch {
return;
}
try {
await publishTenderAnnounce(row.id);
ElMessage.success("已发布");
loadList();
emit("published");
} catch (e) {
ElMessage.error(e?.message || "发布失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除公告「${row.title}」?`, "提示", { type: "warning" });
} catch {
return;
}
try {
await deleteTenderAnnounce(row.id);
ElMessage.success("删除成功");
loadList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
function openPreview(row) {
previewRow.value = row;
previewVisible.value = true;
}
function announceTypeText(t) {
const hit = tenderAnnounceTypeOptions.find((i) => String(i.value) === String(t));
return hit ? hit.label : t || "-";
}
</script>
<style scoped>
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.preview-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.preview-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
font-size: 14px;
min-height: 120px;
}
</style>
@@ -0,0 +1,176 @@
<template>
<div class="tender-basic">
<div class="section-header">
<h3 class="section-title">基本信息</h3>
<div class="section-actions">
<el-button
v-if="canEdit"
type="primary"
link
:icon="EditPen"
@click="emit('action', { type: 'edit', row: tender })"
>
编辑
</el-button>
<el-button
v-if="canAward"
type="success"
link
:icon="Trophy"
@click="emit('action', { type: 'award', row: tender })"
>
定标
</el-button>
<el-button
v-if="canToContract"
type="warning"
link
:icon="Document"
@click="emit('action', { type: 'contract', row: tender })"
>
转合同
</el-button>
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="招标编号">{{ tender.tender_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="招标标题">{{ tender.title || "-" }}</el-descriptions-item>
<el-descriptions-item label="招标类型">
<el-tag size="small" :type="tenderTypeTag(tender.tender_type)">
{{ tenderTypeText(tender.tender_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="招标状态">
<el-tag size="small" :type="tenderStatusTag(tender.status)">
{{ tenderStatusText(tender.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="评标方法">
{{ evaluationMethodText(tender.evaluation_method) }}
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ tender.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="招标方">{{ tender.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="发布单位">{{ tender.publish_org || "-" }}</el-descriptions-item>
<el-descriptions-item label="预算金额">
{{ formatMoney(tender.budget_amount) }}
</el-descriptions-item>
<el-descriptions-item label="投标保证金">
{{ formatMoney(tender.bid_deposit) }}
</el-descriptions-item>
<el-descriptions-item label="公告发布日期">
{{ formatDateOnly(tender.publish_date) }}
</el-descriptions-item>
<el-descriptions-item label="报名起止">
{{ formatBidTime(tender.register_start_date) || "-" }} ~
{{ formatBidTime(tender.register_end_date) || "-" }}
</el-descriptions-item>
<el-descriptions-item label="投标起止">
{{ formatBidTime(tender.bid_start_date) || "-" }} ~
{{ formatBidTime(tender.bid_end_date) || "-" }}
</el-descriptions-item>
<el-descriptions-item label="开标时间">
{{ formatBidTime(tender.open_bid_date) || "-" }}
</el-descriptions-item>
<el-descriptions-item label="联系人">{{ tender.contact_person || "-" }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ tender.contact_phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="联系邮箱">{{ tender.contact_email || "-" }}</el-descriptions-item>
<el-descriptions-item label="投标地点">{{ tender.bid_address || "-" }}</el-descriptions-item>
<el-descriptions-item label="开标地点">{{ tender.open_address || "-" }}</el-descriptions-item>
<el-descriptions-item label="中标单位">
<el-tag v-if="tender.winner_supplier_name" type="success" size="small">
{{ tender.winner_supplier_name }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="中标金额">
{{ formatMoney(tender.winner_amount) }}
</el-descriptions-item>
<el-descriptions-item label="定标日期">
{{ formatBidTime(tender.winner_date) || "-" }}
</el-descriptions-item>
<el-descriptions-item label="关联合同">
<el-link v-if="tender.contract_id" type="primary" :underline="false">
合同 #{{ tender.contract_id }}
</el-link>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(tender.create_time) }}
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h4 class="block-title">项目描述</h4>
<div class="content-box">{{ tender.description || "暂无描述" }}</div>
<h4 class="block-title">资质要求</h4>
<div class="content-box">{{ tender.requirement || "—" }}</div>
<h4 class="block-title">备注</h4>
<div class="content-box">{{ tender.remark || "—" }}</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { EditPen, Trophy, Document } from "@element-plus/icons-vue";
import {
tenderStatusText,
tenderStatusTag,
tenderTypeText,
tenderTypeTag,
evaluationMethodText,
formatMoney,
formatDateOnly,
formatDateTime,
formatBidTime,
} from "@/views/apps/crm/dict";
const props = defineProps({
tender: { type: Object, required: true },
});
const emit = defineEmits(["action"]);
const canEdit = computed(() => props.tender.locked !== 1);
// 已开标(4)之后才允许定标
const canAward = computed(() => {
const s = Number(props.tender.status);
return props.tender.locked !== 1 && s >= 4 && s < 6;
});
const canToContract = computed(
() => props.tender.status === 6 && props.tender.winner_supplier_name && !props.tender.contract_id
);
</script>
<style scoped>
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.block-title {
margin: 16px 0 8px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.content-box {
background: var(--el-bg-color-page);
border-radius: 6px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-primary);
min-height: 40px;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,346 @@
<template>
<div class="tender-evaluation" v-loading="loading">
<div class="section-header">
<h3 class="section-title">开标评标</h3>
<div class="header-actions">
<el-button :icon="Setting" @click="weightVisible = true">权重设置</el-button>
<el-button type="primary" :icon="Plus" :disabled="disabled" @click="openCreate">
新增评分
</el-button>
</div>
</div>
<el-alert
v-if="weightSum > 0 && Math.abs(weightSum - 100) > 0.01"
type="warning"
:closable="false"
show-icon
:title="`当前权重合计 ${weightSum.toFixed(2)}%,不等于 100%,请检查权重设置`"
style="margin-bottom: 12px"
/>
<el-empty v-if="list.length === 0" description="暂无评分记录" :image-size="80" />
<el-table v-else :data="list" stripe border>
<el-table-column label="排名" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.ranking === 1" type="success" size="small">1</el-tag>
<span v-else>{{ row.ranking || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="投标方" min-width="160" show-overflow-tooltip prop="supplier_name" />
<el-table-column label="技术分" width="90" align="center" prop="technical_score" />
<el-table-column label="商务分" width="90" align="center" prop="business_score" />
<el-table-column label="价格分" width="90" align="center" prop="price_score" />
<el-table-column label="服务分" width="90" align="center" prop="service_score" />
<el-table-column label="总分" width="100" align="center">
<template #default="{ row }">
<strong>{{ Number(row.total_score || 0).toFixed(2) }}</strong>
</template>
</el-table-column>
<el-table-column label="结论" width="100" align="center">
<template #default="{ row }">
<el-tag :type="resultTag(row.result)" size="small">{{ resultText(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="评标人" prop="evaluation_user_name" width="100" align="center" />
<el-table-column label="评标时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.evaluation_time) || "-" }}</template>
</el-table-column>
<el-table-column label="评价" prop="comment" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="danger"
size="small"
:disabled="disabled"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增评分 -->
<el-dialog v-model="dialogVisible" title="新增评标打分" width="620px" :close-on-click-modal="false">
<el-form :model="form" label-width="100px">
<el-form-item label="投标方" required>
<el-select
v-model="form.supplier_name"
filterable
allow-create
default-first-option
placeholder="选择或输入投标方"
style="width: 100%"
>
<el-option
v-for="name in supplierNames"
:key="name"
:label="name"
:value="name"
/>
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="技术分">
<el-input-number v-model="form.technical_score" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商务分">
<el-input-number v-model="form.business_score" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="价格分">
<el-input-number v-model="form.price_score" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务分">
<el-input-number v-model="form.service_score" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="折算总分">
<el-input :model-value="previewTotal" readonly>
<template #append>分</template>
</el-input>
</el-form-item>
<el-form-item label="结论">
<el-select v-model="form.result" style="width: 100%">
<el-option
v-for="opt in evaluationResultOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="评价意见">
<el-input v-model="form.comment" type="textarea" :rows="3" placeholder="评标意见(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 权重设置 -->
<el-dialog v-model="weightVisible" title="评标权重设置" width="460px" :close-on-click-modal="false">
<el-alert
type="info"
:closable="false"
show-icon
title="权重为百分比数值,建议四项合计 100。留空或合计为 0 时,总分按四项直接求和。"
style="margin-bottom: 12px"
/>
<el-form label-width="110px">
<el-form-item label="技术权重(%)">
<el-input-number v-model="weights.technical" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="商务权重(%)">
<el-input-number v-model="weights.business" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="价格权重(%)">
<el-input-number v-model="weights.price" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="服务权重(%)">
<el-input-number v-model="weights.service" :min="0" :max="100" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="合计">
<el-input :model-value="weightSum.toFixed(2)" readonly>
<template #append>%</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="weightVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Setting } from "@element-plus/icons-vue";
import {
getTenderEvaluationList,
createTenderEvaluation,
deleteTenderEvaluation,
getTenderSupplierList,
} from "@/api/crmBidding";
import { evaluationResultOptions, formatDateTime } from "@/views/apps/crm/dict";
const props = defineProps({
tenderId: { type: [Number, String], required: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["count"]);
const loading = ref(false);
const list = ref([]);
const supplierNames = ref([]);
const dialogVisible = ref(false);
const weightVisible = ref(false);
const submitting = ref(false);
const emptyForm = () => ({
supplier_name: "",
technical_score: 0,
business_score: 0,
price_score: 0,
service_score: 0,
result: 0,
comment: "",
});
const form = reactive(emptyForm());
const weights = reactive({ technical: 25, business: 25, price: 30, service: 20 });
const weightSum = computed(
() =>
Number(weights.technical || 0) +
Number(weights.business || 0) +
Number(weights.price || 0) +
Number(weights.service || 0)
);
const previewTotal = computed(() => {
const s = weightSum.value;
if (s <= 0) {
return (
Number(form.technical_score || 0) +
Number(form.business_score || 0) +
Number(form.price_score || 0) +
Number(form.service_score || 0)
).toFixed(2);
}
return (
(Number(form.technical_score || 0) * Number(weights.technical || 0)) / 100 +
(Number(form.business_score || 0) * Number(weights.business || 0)) / 100 +
(Number(form.price_score || 0) * Number(weights.price || 0)) / 100 +
(Number(form.service_score || 0) * Number(weights.service || 0)) / 100
).toFixed(2);
});
defineExpose({ reload: loadList });
onMounted(() => {
loadList();
loadSuppliers();
});
async function loadList() {
if (!props.tenderId) return;
loading.value = true;
try {
const res = await getTenderEvaluationList({ tender_id: props.tenderId });
list.value = res?.data?.list || [];
emit("count", list.value.length);
} catch {
list.value = [];
emit("count", 0);
} finally {
loading.value = false;
}
}
async function loadSuppliers() {
if (!props.tenderId) return;
try {
const res = await getTenderSupplierList({ tender_id: props.tenderId });
supplierNames.value = (res?.data?.list || [])
.map((i) => i.supplier_name)
.filter(Boolean);
} catch {
supplierNames.value = [];
}
}
function openCreate() {
Object.assign(form, emptyForm());
dialogVisible.value = true;
}
async function handleSubmit() {
if (!form.supplier_name.trim()) {
ElMessage.warning("请选择或输入投标方");
return;
}
submitting.value = true;
try {
await createTenderEvaluation({
tender_id: props.tenderId,
supplier_name: form.supplier_name,
technical_score: form.technical_score,
business_score: form.business_score,
price_score: form.price_score,
service_score: form.service_score,
weight_config: JSON.stringify(weights),
comment: form.comment,
result: form.result,
});
ElMessage.success("评分已保存,排名已自动重排");
dialogVisible.value = false;
loadList();
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除「${row.supplier_name}」的评分记录?`, "提示", {
type: "warning",
});
} catch {
return;
}
try {
await deleteTenderEvaluation(row.id);
ElMessage.success("删除成功");
loadList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
function resultText(v) {
const hit = evaluationResultOptions.find((i) => Number(i.value) === Number(v));
return hit ? hit.label : "-";
}
function resultTag(v) {
const map = { 0: "info", 1: "success", 2: "danger", 3: "warning" };
return map[Number(v)] || "info";
}
</script>
<style scoped>
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
</style>
@@ -0,0 +1,200 @@
<template>
<div class="tender-file-list" v-loading="loading">
<div class="section-header">
<h3 class="section-title">招标文件</h3>
<div class="header-actions">
<el-select v-model="uploadType" size="small" style="width: 130px">
<el-option
v-for="opt in TENDER_FILE_TYPE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-upload
:http-request="handleUpload"
:show-file-list="false"
:before-upload="beforeUpload"
:disabled="disabled"
>
<el-button type="primary" :icon="Plus" :disabled="disabled">上传文件</el-button>
</el-upload>
</div>
</div>
<el-empty v-if="list.length === 0" description="暂无招标文件" :image-size="80" />
<el-table v-else :data="list" stripe border>
<el-table-column label="文件名" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-icon><Document /></el-icon>
<span style="margin-left: 8px">{{ row.file_name }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="120" align="center">
<template #default="{ row }">
<el-tag size="small">{{ fileTypeText(row.file_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="100" align="center">
<template #default="{ row }">{{ formatFileSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="上传人" prop="upload_user_name" width="100" align="center" />
<el-table-column label="备注" prop="remark" min-width="140" show-overflow-tooltip />
<el-table-column label="上传时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleDownload(row)">下载</el-button>
<el-button
link
type="danger"
size="small"
:disabled="disabled"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Document } from "@element-plus/icons-vue";
import { getTenderFileList, createTenderFile, deleteTenderFile } from "@/api/crmBidding";
import { uploadFile } from "@/api/file";
import { formatDateTime } from "@/views/apps/crm/dict";
// 招标文件类型:1招标文件 2技术规格书 3工程量清单 4合同模板 5答疑澄清 6其他
const TENDER_FILE_TYPE_OPTIONS = [
{ label: "招标文件", value: "1" },
{ label: "技术规格书", value: "2" },
{ label: "工程量清单", value: "3" },
{ label: "合同模板", value: "4" },
{ label: "答疑澄清", value: "5" },
{ label: "其他", value: "6" },
];
const props = defineProps({
tenderId: { type: [Number, String], required: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["count"]);
const loading = ref(false);
const list = ref([]);
const uploadType = ref("1");
defineExpose({ reload: loadList });
onMounted(loadList);
async function loadList() {
if (!props.tenderId) return;
loading.value = true;
try {
const res = await getTenderFileList({ tender_id: props.tenderId });
list.value = res?.data?.list || [];
emit("count", list.value.length);
} catch {
list.value = [];
emit("count", 0);
} finally {
loading.value = false;
}
}
function beforeUpload(file) {
if (file.size > 100 * 1024 * 1024) {
ElMessage.warning("文件大小不能超过 100MB");
return false;
}
return true;
}
async function handleUpload(option) {
const file = option.file;
const fd = new FormData();
fd.append("file", file);
try {
const upRes = await uploadFile(fd);
// 秒传命中时返回 code=201,同样携带 url/id/name
if (upRes?.code !== 200 && upRes?.code !== 201) {
throw new Error(upRes?.msg || "上传失败");
}
const data = upRes?.data || {};
const name = data.name || file.name;
await createTenderFile({
tender_id: props.tenderId,
file_name: name,
file_url: data.url || "",
file_size: file.size || 0,
file_ext: name.split(".").pop() || "",
file_type: uploadType.value,
});
ElMessage.success("上传成功");
loadList();
} catch (e) {
ElMessage.error(e?.message || "上传失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除文件「${row.file_name}」?`, "提示", { type: "warning" });
} catch {
return;
}
try {
await deleteTenderFile(row.id);
ElMessage.success("删除成功");
loadList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
function handleDownload(row) {
if (!row.file_url) {
ElMessage.warning("文件 URL 为空");
return;
}
window.open(row.file_url, "_blank");
}
function fileTypeText(t) {
const hit = TENDER_FILE_TYPE_OPTIONS.find((i) => String(i.value) === String(t));
return hit ? hit.label : t || "-";
}
function formatFileSize(size) {
if (!size) return "-";
if (size < 1024) return size + " B";
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + " KB";
return (size / 1024 / 1024).toFixed(2) + " MB";
}
</script>
<style scoped>
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,305 @@
<template>
<div class="tender-result" v-loading="loading">
<div class="section-header">
<h3 class="section-title">定标结果</h3>
<el-button
v-if="canAward"
type="success"
:icon="Trophy"
@click="openAward"
>
定标
</el-button>
</div>
<el-empty v-if="list.length === 0" description="尚未定标" :image-size="80" />
<div v-else>
<el-table :data="list" stripe border>
<el-table-column label="结果类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="resultTypeTag(row.result_type)" size="small">
{{ resultTypeText(row.result_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="中标单位" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.winner_supplier_name || "-" }}</template>
</el-table-column>
<el-table-column label="中标金额" width="140" align="right">
<template #default="{ row }">{{ formatMoney(row.winner_amount) }}</template>
</el-table-column>
<el-table-column label="定标日期" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.result_date) || "-" }}</template>
</el-table-column>
<el-table-column label="关联合同" width="110" align="center">
<template #default="{ row }">
<el-link v-if="row.contract_id" type="primary" :underline="false">
合同 #{{ row.contract_id }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 定标 -->
<el-dialog v-model="awardVisible" title="定标" width="620px" :close-on-click-modal="false">
<el-alert
type="warning"
:closable="false"
show-icon
title="定标后招标状态将转为「已定标」并锁定,不可重复定标。"
style="margin-bottom: 12px"
/>
<el-form :model="awardForm" label-width="110px">
<el-form-item label="结果类型">
<el-radio-group v-model="awardForm.result_type">
<el-radio :value="1">定标</el-radio>
<el-radio :value="2">流标</el-radio>
<el-radio :value="3">废标</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="awardForm.result_type === 1">
<el-form-item label="中标单位" required>
<el-select
v-model="awardForm.winner_supplier_name"
filterable
allow-create
default-first-option
placeholder="选择或输入中标单位"
style="width: 100%"
@change="onWinnerChange"
>
<el-option
v-for="s in suppliers"
:key="s.id"
:label="s.supplier_name"
:value="s.supplier_name"
/>
</el-select>
</el-form-item>
<el-form-item label="中标金额">
<el-input v-model.number="awardForm.winner_amount" placeholder="0.00">
<template #append>元</template>
</el-input>
</el-form-item>
</template>
<el-form-item label="结果公告">
<el-input v-model="awardForm.announcement" type="textarea" :rows="4" placeholder="定标公告内容(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="awardVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleAward">确认定标</el-button>
</template>
</el-dialog>
<!-- 结果详情 -->
<el-dialog v-model="detailVisible" title="定标结果详情" width="680px">
<el-descriptions v-if="detailRow" :column="2" border>
<el-descriptions-item label="结果类型">
<el-tag :type="resultTypeTag(detailRow.result_type)" size="small">
{{ resultTypeText(detailRow.result_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="中标单位">
{{ detailRow.winner_supplier_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="中标金额">
{{ formatMoney(detailRow.winner_amount) }}
</el-descriptions-item>
<el-descriptions-item label="定标日期">
{{ formatDateTime(detailRow.result_date) || "-" }}
</el-descriptions-item>
<el-descriptions-item label="关联合同">
{{ detailRow.contract_id ? `合同 #${detailRow.contract_id}` : "-" }}
</el-descriptions-item>
<el-descriptions-item label="关联项目">
{{ detailRow.project_id ? `项目 #${detailRow.project_id}` : "-" }}
</el-descriptions-item>
<el-descriptions-item label="记录人">
{{ detailRow.create_user_name || "-" }}
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h4 class="block-title">结果公告</h4>
<div class="content-box">{{ detailRow?.announcement || "—" }}</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Trophy } from "@element-plus/icons-vue";
import {
getTenderResultList,
getTenderResultDetail,
awardTender,
getTenderSupplierList,
} from "@/api/crmBidding";
import {
tenderResultTypeOptions,
formatMoney,
formatDateTime,
} from "@/views/apps/crm/dict";
const props = defineProps({
tenderId: { type: [Number, String], required: true },
tender: { type: Object, default: null },
});
const emit = defineEmits(["count", "awarded"]);
const loading = ref(false);
const list = ref([]);
const suppliers = ref([]);
const awardVisible = ref(false);
const detailVisible = ref(false);
const detailRow = ref(null);
const submitting = ref(false);
const awardForm = reactive({
result_type: 1,
winner_supplier_name: "",
winner_supplier_id: 0,
winner_amount: 0,
announcement: "",
});
const canAward = computed(() => {
if (!props.tender) return true;
const s = Number(props.tender.status);
return props.tender.locked !== 1 && s >= 4 && s < 6;
});
defineExpose({ reload: loadList, openAward });
onMounted(() => {
loadList();
loadSuppliers();
});
async function loadList() {
if (!props.tenderId) return;
loading.value = true;
try {
const res = await getTenderResultList({ tender_id: props.tenderId });
list.value = res?.data?.list || [];
emit("count", list.value.length);
} catch {
list.value = [];
emit("count", 0);
} finally {
loading.value = false;
}
}
async function loadSuppliers() {
if (!props.tenderId) return;
try {
const res = await getTenderSupplierList({ tender_id: props.tenderId });
suppliers.value = res?.data?.list || [];
} catch {
suppliers.value = [];
}
}
function onWinnerChange(name) {
const hit = suppliers.value.find((s) => s.supplier_name === name);
awardForm.winner_supplier_id = hit?.supplier_id || hit?.id || 0;
if (hit && !awardForm.winner_amount) {
awardForm.winner_amount = Number(hit.bid_amount) || 0;
}
}
async function openAward() {
awardForm.result_type = 1;
awardForm.winner_supplier_name = props.tender?.winner_supplier_name || "";
awardForm.winner_supplier_id = props.tender?.winner_supplier_id || 0;
awardForm.winner_amount = Number(props.tender?.budget_amount) || 0;
awardForm.announcement = "";
await loadSuppliers();
awardVisible.value = true;
}
async function handleAward() {
if (awardForm.result_type === 1 && !awardForm.winner_supplier_name.trim()) {
ElMessage.warning("请选择中标单位");
return;
}
try {
await ElMessageBox.confirm("定标后不可撤销,确认继续?", "定标确认", { type: "warning" });
} catch {
return;
}
submitting.value = true;
try {
await awardTender(props.tenderId, { ...awardForm });
ElMessage.success("定标成功");
awardVisible.value = false;
loadList();
emit("awarded");
} catch (e) {
ElMessage.error(e?.message || "定标失败");
} finally {
submitting.value = false;
}
}
async function openDetail(row) {
try {
const res = await getTenderResultDetail(row.id);
detailRow.value = res?.data || row;
} catch {
detailRow.value = row;
}
detailVisible.value = true;
}
function resultTypeText(v) {
const hit = tenderResultTypeOptions.find((i) => Number(i.value) === Number(v));
return hit ? hit.label : "-";
}
function resultTypeTag(v) {
const map = { 1: "success", 2: "warning", 3: "danger" };
return map[Number(v)] || "info";
}
</script>
<style scoped>
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.block-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.content-box {
background: var(--el-bg-color-page);
border-radius: 6px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
min-height: 40px;
}
</style>
@@ -0,0 +1,292 @@
<template>
<div class="tender-supplier" v-loading="loading">
<div class="section-header">
<h3 class="section-title">投标方报名</h3>
<el-button type="primary" :icon="Plus" :disabled="disabled" @click="openCreate">
登记投标方
</el-button>
</div>
<el-empty v-if="list.length === 0" description="暂无投标方报名" :image-size="80" />
<el-table v-else :data="list" stripe border>
<el-table-column label="投标方名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.supplier_name }}</span>
<el-tag v-if="row.is_winner === 1" type="success" size="small" style="margin-left: 6px">
中标
</el-tag>
</template>
</el-table-column>
<el-table-column label="报名状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="registerStatusTag(row.register_status)" size="small">
{{ registerStatusText(row.register_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="投标报价" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.bid_amount) }}</template>
</el-table-column>
<el-table-column label="联系人" prop="contact_person" width="100" align="center" />
<el-table-column label="联系电话" prop="contact_phone" width="130" align="center" />
<el-table-column label="排名" prop="ranking" width="70" align="center" />
<el-table-column label="报名时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.register_time) || "-" }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
:disabled="disabled"
@click="openEdit(row)"
>
编辑
</el-button>
<el-button
v-if="row.is_winner !== 1"
link
type="success"
size="small"
:disabled="disabled || row.register_status !== 1"
@click="handleSetWinner(row)"
>
设为中标
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="disabled"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
:title="editing ? '编辑投标方' : '登记投标方'"
width="620px"
:close-on-click-modal="false"
>
<el-form :model="form" label-width="100px">
<el-form-item label="投标方名称" required>
<el-input v-model="form.supplier_name" placeholder="请输入投标方单位名称" />
</el-form-item>
<el-form-item label="报名状态">
<el-select v-model="form.register_status" style="width: 100%">
<el-option
v-for="opt in tenderSupplierStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="投标报价">
<el-input v-model.number="form.bid_amount" placeholder="0.00">
<template #append>元</template>
</el-input>
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="form.contact_person" placeholder="联系人" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="form.contact_phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import {
getTenderSupplierList,
createTenderSupplier,
updateTenderSupplier,
deleteTenderSupplier,
} from "@/api/crmBidding";
import {
tenderSupplierStatusOptions,
formatMoney,
formatDateTime,
} from "@/views/apps/crm/dict";
const props = defineProps({
tenderId: { type: [Number, String], required: true },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["count"]);
const loading = ref(false);
const list = ref([]);
const dialogVisible = ref(false);
const editing = ref(null);
const submitting = ref(false);
const emptyForm = () => ({
supplier_name: "",
register_status: 1,
bid_amount: 0,
contact_person: "",
contact_phone: "",
remark: "",
});
const form = reactive(emptyForm());
defineExpose({ reload: loadList });
onMounted(loadList);
async function loadList() {
if (!props.tenderId) return;
loading.value = true;
try {
const res = await getTenderSupplierList({ tender_id: props.tenderId });
list.value = res?.data?.list || [];
emit("count", list.value.length);
} catch {
list.value = [];
emit("count", 0);
} finally {
loading.value = false;
}
}
function openCreate() {
editing.value = null;
Object.assign(form, emptyForm());
dialogVisible.value = true;
}
function openEdit(row) {
editing.value = row;
Object.assign(form, {
supplier_name: row.supplier_name || "",
register_status: Number(row.register_status) || 1,
bid_amount: Number(row.bid_amount) || 0,
contact_person: row.contact_person || "",
contact_phone: row.contact_phone || "",
remark: row.remark || "",
});
dialogVisible.value = true;
}
async function handleSubmit() {
if (!form.supplier_name.trim()) {
ElMessage.warning("请输入投标方名称");
return;
}
submitting.value = true;
try {
const payload = {
tender_id: props.tenderId,
supplier_name: form.supplier_name,
register_status: form.register_status,
bid_amount: form.bid_amount,
contact_person: form.contact_person,
contact_phone: form.contact_phone,
remark: form.remark,
};
if (editing.value?.id) {
await updateTenderSupplier(editing.value.id, payload);
} else {
await createTenderSupplier(payload);
}
ElMessage.success("保存成功");
dialogVisible.value = false;
loadList();
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
async function handleSetWinner(row) {
try {
await ElMessageBox.confirm(
`确认将「${row.supplier_name}」标记为本招标的中标单位?`,
"中标确认",
{ type: "warning" }
);
} catch {
return;
}
try {
await updateTenderSupplier(row.id, {
tender_id: props.tenderId,
supplier_name: row.supplier_name,
register_status: row.register_status,
bid_amount: row.bid_amount,
contact_person: row.contact_person,
contact_phone: row.contact_phone,
remark: row.remark,
is_winner: 1,
ranking: 1,
});
ElMessage.success("已标记中标");
loadList();
emit("count", list.value.length);
} catch (e) {
ElMessage.error(e?.message || "操作失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除投标方「${row.supplier_name}」?`, "提示", {
type: "warning",
});
} catch {
return;
}
try {
await deleteTenderSupplier(row.id);
ElMessage.success("删除成功");
loadList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
function registerStatusText(v) {
const hit = tenderSupplierStatusOptions.find((i) => Number(i.value) === Number(v));
return hit ? hit.label : "未知";
}
function registerStatusTag(v) {
const map = { 1: "success", 2: "danger", 3: "info" };
return map[Number(v)] || "info";
}
</script>
<style scoped>
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
</style>
@@ -0,0 +1,322 @@
<template>
<el-dialog
:model-value="visible"
title="编辑招标"
width="860px"
:close-on-click-modal="false"
@update:model-value="handleClose"
>
<el-alert
v-if="locked"
type="warning"
:closable="false"
show-icon
title="该招标已定标归档,仅允许修改备注类信息。"
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招标标题" prop="title">
<el-input v-model="form.title" placeholder="请输入招标标题" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="招标编号">
<el-input v-model="form.tender_no" placeholder="招标编号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招标类型">
<el-select v-model="form.tender_type" style="width: 100%">
<el-option
v-for="opt in tenderTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="评标方法">
<el-select v-model="form.evaluation_method" style="width: 100%">
<el-option
v-for="opt in evaluationMethodOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招标方" prop="customer_name">
<el-input v-model="form.customer_name" placeholder="请输入招标方名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发布单位">
<el-input v-model="form.publish_org" placeholder="发布单位" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="预算金额">
<el-input v-model.number="form.budget_amount" placeholder="0.00">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="投标保证金">
<el-input v-model.number="form.bid_deposit" placeholder="0.00">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="公告发布日期">
<el-date-picker
v-model="form.publish_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开标时间">
<el-date-picker
v-model="form.open_bid_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="报名开始">
<el-date-picker
v-model="form.register_start_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报名截止">
<el-date-picker
v-model="form.register_end_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="投标开始">
<el-date-picker
v-model="form.bid_start_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="投标截止">
<el-date-picker
v-model="form.bid_end_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="联系人">
<el-input v-model="form.contact_person" placeholder="联系人" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话">
<el-input v-model="form.contact_phone" placeholder="联系电话" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系邮箱">
<el-input v-model="form.contact_email" placeholder="联系邮箱" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="投标地点">
<el-input v-model="form.bid_address" placeholder="递交投标文件的地点" />
</el-form-item>
<el-form-item label="开标地点">
<el-input v-model="form.open_address" placeholder="开标地点" />
</el-form-item>
<el-form-item label="项目描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="项目描述" />
</el-form-item>
<el-form-item label="资质要求">
<el-input v-model="form.requirement" type="textarea" :rows="3" placeholder="资质要求" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { updateTender } from "@/api/crmBidding";
import { tenderTypeOptions, evaluationMethodOptions } from "@/views/apps/crm/dict";
const props = defineProps({
visible: { type: Boolean, default: false },
tender: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref();
const submitting = ref(false);
const locked = computed(() => props.tender?.locked === 1);
const form = reactive({
title: "",
tender_no: "",
tender_type: "1",
evaluation_method: "1",
customer_name: "",
publish_org: "",
budget_amount: 0,
bid_deposit: 0,
publish_date: "",
register_start_date: "",
register_end_date: "",
bid_start_date: "",
bid_end_date: "",
open_bid_date: "",
contact_person: "",
contact_phone: "",
contact_email: "",
bid_address: "",
open_address: "",
description: "",
requirement: "",
remark: "",
});
const rules = {
title: [{ required: true, message: "请输入招标标题", trigger: "blur" }],
customer_name: [{ required: true, message: "请输入招标方名称", trigger: "blur" }],
};
// 后端返回的时间字段可能是 "2026-01-01T00:00:00+08:00" 形式,统一裁剪
function trimTime(val, len) {
if (!val) return "";
const s = String(val).replace("T", " ");
return s.length > len ? s.slice(0, len) : s;
}
watch(
() => [props.visible, props.tender],
([val, tender]) => {
if (!val || !tender) return;
Object.assign(form, {
title: tender.title || "",
tender_no: tender.tender_no || "",
tender_type: String(tender.tender_type ?? "1"),
evaluation_method: String(tender.evaluation_method ?? "1"),
customer_name: tender.customer_name || "",
publish_org: tender.publish_org || "",
budget_amount: Number(tender.budget_amount) || 0,
bid_deposit: Number(tender.bid_deposit) || 0,
publish_date: trimTime(tender.publish_date, 10),
register_start_date: trimTime(tender.register_start_date, 19),
register_end_date: trimTime(tender.register_end_date, 19),
bid_start_date: trimTime(tender.bid_start_date, 19),
bid_end_date: trimTime(tender.bid_end_date, 19),
open_bid_date: trimTime(tender.open_bid_date, 19),
contact_person: tender.contact_person || "",
contact_phone: tender.contact_phone || "",
contact_email: tender.contact_email || "",
bid_address: tender.bid_address || "",
open_address: tender.open_address || "",
description: tender.description || "",
requirement: tender.requirement || "",
remark: tender.remark || "",
});
},
{ immediate: true, deep: true }
);
function handleClose() {
emit("update:visible", false);
}
async function handleSubmit() {
if (!formRef.value || !props.tender?.id) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
await updateTender(props.tender.id, { ...form });
ElMessage.success("保存成功");
emit("success");
handleClose();
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.el-date-picker {
width: 100%;
}
</style>
@@ -0,0 +1,292 @@
<template>
<el-dialog
:model-value="visible"
title="新建招标"
width="860px"
:close-on-click-modal="false"
@update:model-value="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招标标题" prop="title">
<el-input v-model="form.title" placeholder="请输入招标标题" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="招标编号">
<el-input v-model="form.tender_no" placeholder="可留空自动生成" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招标类型">
<el-select v-model="form.tender_type" style="width: 100%">
<el-option
v-for="opt in tenderTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="评标方法">
<el-select v-model="form.evaluation_method" style="width: 100%">
<el-option
v-for="opt in evaluationMethodOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招标方" prop="customer_name">
<el-input v-model="form.customer_name" placeholder="请输入招标方名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发布单位">
<el-input v-model="form.publish_org" placeholder="留空时默认取招标方" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="预算金额">
<el-input v-model.number="form.budget_amount" placeholder="0.00">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="投标保证金">
<el-input v-model.number="form.bid_deposit" placeholder="0.00">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="公告发布日期">
<el-date-picker
v-model="form.publish_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开标时间">
<el-date-picker
v-model="form.open_bid_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="报名开始">
<el-date-picker
v-model="form.register_start_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报名截止">
<el-date-picker
v-model="form.register_end_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="投标开始">
<el-date-picker
v-model="form.bid_start_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="投标截止">
<el-date-picker
v-model="form.bid_end_date"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="联系人">
<el-input v-model="form.contact_person" placeholder="联系人" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话">
<el-input v-model="form.contact_phone" placeholder="联系电话" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系邮箱">
<el-input v-model="form.contact_email" placeholder="联系邮箱" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="投标地点">
<el-input v-model="form.bid_address" placeholder="递交投标文件的地点" />
</el-form-item>
<el-form-item label="开标地点">
<el-input v-model="form.open_address" placeholder="开标地点" />
</el-form-item>
<el-form-item label="项目描述">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="项目背景与整体说明"
/>
</el-form-item>
<el-form-item label="资质要求">
<el-input
v-model="form.requirement"
type="textarea"
:rows="3"
placeholder="对投标方的资质、业绩、技术要求"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="其他备注(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存为草稿</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { createTender } from "@/api/crmBidding";
import { tenderTypeOptions, evaluationMethodOptions } from "@/views/apps/crm/dict";
const props = defineProps({
visible: { type: Boolean, default: false },
});
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref();
const submitting = ref(false);
const emptyForm = () => ({
title: "",
tender_no: "",
tender_type: "1",
evaluation_method: "1",
customer_name: "",
publish_org: "",
budget_amount: 0,
bid_deposit: 0,
publish_date: "",
register_start_date: "",
register_end_date: "",
bid_start_date: "",
bid_end_date: "",
open_bid_date: "",
contact_person: "",
contact_phone: "",
contact_email: "",
bid_address: "",
open_address: "",
description: "",
requirement: "",
remark: "",
});
const form = reactive(emptyForm());
const rules = {
title: [{ required: true, message: "请输入招标标题", trigger: "blur" }],
customer_name: [{ required: true, message: "请输入招标方名称", trigger: "blur" }],
};
watch(
() => props.visible,
(val) => {
if (val) {
Object.assign(form, emptyForm());
}
}
);
function handleClose() {
emit("update:visible", false);
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
const res = await createTender({ ...form });
ElMessage.success("创建成功");
emit("success", res?.data);
handleClose();
} catch (e) {
ElMessage.error(e?.message || "创建失败");
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.el-date-picker {
width: 100%;
}
</style>
@@ -0,0 +1,188 @@
<template>
<el-drawer
:model-value="visible"
:title="tender?.title || '招标详情'"
direction="rtl"
size="1000px"
@update:model-value="handleClose"
@opened="loadAll"
>
<div v-if="tender" class="tender-detail">
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<el-tab-pane label="基本信息" name="basic">
<TenderBaseInfo :tender="tender" @action="handleAction" />
<el-divider />
<h4 class="block-title">流程进度</h4>
<BidTimeline kind="tender" :status="tender.status" :times="tender" />
</el-tab-pane>
<el-tab-pane :label="`招标公告 (${counts.announce})`" name="announce">
<TenderAnnounceList
ref="announceRef"
:tender-id="tender.id"
:disabled="locked"
@count="counts.announce = $event"
@published="refreshSelf"
/>
</el-tab-pane>
<el-tab-pane :label="`招标文件 (${counts.file})`" name="file">
<TenderFileList
ref="fileRef"
:tender-id="tender.id"
:disabled="locked"
@count="counts.file = $event"
/>
</el-tab-pane>
<el-tab-pane :label="`投标方 (${counts.supplier})`" name="supplier">
<TenderSupplierList
ref="supplierRef"
:tender-id="tender.id"
:disabled="locked"
@count="counts.supplier = $event"
/>
</el-tab-pane>
<el-tab-pane :label="`开标评标 (${counts.evaluation})`" name="evaluation">
<TenderEvaluation
ref="evaluationRef"
:tender-id="tender.id"
:disabled="locked"
@count="counts.evaluation = $event"
/>
</el-tab-pane>
<el-tab-pane :label="`定标结果 (${counts.result})`" name="result">
<TenderResult
ref="resultRef"
:tender-id="tender.id"
:tender="tender"
@count="counts.result = $event"
@awarded="refreshSelf"
/>
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
</template>
<script setup>
import { ref, reactive, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getTenderDetail, awardTenderToContract } from "@/api/crmBidding";
import BidTimeline from "../components/BidTimeline.vue";
import TenderBaseInfo from "./components/TenderBaseInfo.vue";
import TenderAnnounceList from "./components/TenderAnnounceList.vue";
import TenderFileList from "./components/TenderFileList.vue";
import TenderSupplierList from "./components/TenderSupplierList.vue";
import TenderEvaluation from "./components/TenderEvaluation.vue";
import TenderResult from "./components/TenderResult.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
tender: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "refresh", "action"]);
const activeTab = ref("basic");
const counts = reactive({
announce: 0,
file: 0,
supplier: 0,
evaluation: 0,
result: 0,
});
const announceRef = ref();
const fileRef = ref();
const supplierRef = ref();
const evaluationRef = ref();
const resultRef = ref();
const locked = computed(() => props.tender?.locked === 1);
function handleClose() {
emit("update:visible", false);
}
async function refreshSelf() {
if (!props.tender?.id) return;
try {
const res = await getTenderDetail(props.tender.id);
if (res?.data) {
Object.assign(props.tender, res.data);
}
} catch {
// ignore
}
emit("refresh");
}
async function loadAll() {
if (!props.tender?.id) return;
await refreshSelf();
counts.announce = 0;
counts.file = 0;
counts.supplier = 0;
counts.evaluation = 0;
counts.result = 0;
announceRef.value?.reload?.();
fileRef.value?.reload?.();
supplierRef.value?.reload?.();
evaluationRef.value?.reload?.();
resultRef.value?.reload?.();
}
async function handleAction({ type, row }) {
if (type === "edit") {
emit("action", { type: "edit", row });
return;
}
if (type === "award") {
activeTab.value = "result";
resultRef.value?.openAward?.();
return;
}
if (type === "contract") {
await handleToContract(row);
}
}
async function handleToContract(row) {
try {
await ElMessageBox.confirm(
`确认将「${row.title}」的中标结果转为合同吗?`,
"转合同确认",
{ type: "warning" }
);
} catch {
return;
}
try {
await awardTenderToContract(row.id, {});
ElMessage.success("已转合同");
await refreshSelf();
} catch (e) {
ElMessage.error(e?.message || "转合同失败");
}
}
defineExpose({ reload: loadAll });
</script>
<style scoped>
.tender-detail {
padding: 0 16px 16px;
}
.detail-tabs {
min-height: 640px;
}
.block-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
}
</style>
@@ -0,0 +1,274 @@
<template>
<div class="crm-page">
<div class="page-header">
<div>
<h2>我要招标</h2>
<p>作为甲方发布招标、收集投标、评标定标,支持定标后一键转合同</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<el-button :icon="DataAnalysis" @click="goStatistics">统计</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建招标</el-button>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索招标标题 / 编号 / 招标方"
:prefix-icon="Search"
style="width: 260px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-select v-model="filters.status" clearable placeholder="状态" style="width: 140px">
<el-option
v-for="opt in tenderStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-select v-model="filters.tender_type" clearable placeholder="招标类型" style="width: 140px">
<el-option
v-for="opt in tenderTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="RefreshLeft" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-wrap" v-loading="loading">
<el-table :data="tableData" stripe border>
<el-table-column label="招标编号" prop="tender_no" width="140" show-overflow-tooltip />
<el-table-column label="招标标题" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openDetail(row)">
{{ row.title }}
</el-link>
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }"><TenderTypeTag :type="row.tender_type" /></template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }"><StatusTag kind="tender" :status="row.status" /></template>
</el-table-column>
<el-table-column label="预算金额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.budget_amount) }}</template>
</el-table-column>
<el-table-column label="投标截止" width="160" align="center">
<template #default="{ row }">{{ formatBidTime(row.bid_end_date) || "-" }}</template>
</el-table-column>
<el-table-column label="中标单位" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.winner_supplier_name || "-" }}</template>
</el-table-column>
<el-table-column label="负责人" width="100" align="center">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<el-button
link type="success" size="small"
:disabled="row.status !== 6 || !!row.contract_id"
@click="handleToContract(row)"
>
转合同
</el-button>
<el-button link type="warning" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无招标项目" :image-size="80" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="fetchList"
/>
</div>
</div>
<TenderCreate v-model:visible="createVisible" @success="handleSaved" />
<TenderEdit v-model:visible="editVisible" :tender="currentRow" @success="handleSaved" />
<TenderDetail
v-model:visible="detailVisible"
:tender="currentRow"
@refresh="fetchList"
@action="handleDetailAction"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, RefreshLeft, DataAnalysis } from "@element-plus/icons-vue";
import { getTenderList, deleteTender, awardTenderToContract } from "@/api/crmBidding";
import {
tenderStatusOptions,
tenderTypeOptions,
formatMoney,
formatBidTime
} from "@/views/apps/crm/dict";
import StatusTag from "../components/StatusTag.vue";
import TenderTypeTag from "../components/TenderTypeTag.vue";
import TenderCreate from "./create.vue";
import TenderEdit from "./components/edit.vue";
import TenderDetail from "./detail.vue";
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const tableData = ref([]);
const filters = reactive({ keyword: "", status: "", tender_type: "" });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
const createVisible = ref(false);
const editVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
onMounted(() => {
fetchList();
// 从到期提醒跳转:携带 id 直接打开详情
const qid = route.query.id;
if (qid) {
openDetail({ id: Number(qid) });
}
});
async function fetchList() {
loading.value = true;
try {
const res = await getTenderList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
} catch (e) {
tableData.value = [];
pagination.total = 0;
ElMessage.error(e?.message || "查询失败");
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.page = 1;
fetchList();
}
function resetFilters() {
filters.keyword = "";
filters.status = "";
filters.tender_type = "";
handleSearch();
}
function openCreate() {
currentRow.value = null;
createVisible.value = true;
}
function openEdit(row) {
currentRow.value = row;
editVisible.value = true;
}
function openDetail(row) {
currentRow.value = row;
detailVisible.value = true;
}
function goStatistics() {
router.push("/apps/crm/bidding/statistics/tender");
}
function handleSaved() {
fetchList();
}
function handleDetailAction({ type, row }) {
if (type === "edit" && row) {
openEdit(row);
}
fetchList();
}
async function handleToContract(row) {
try {
await ElMessageBox.confirm(
`确认将招标「${row.title}」的中标结果转为合同吗?`,
"转合同确认",
{ type: "warning" }
);
} catch {
return;
}
try {
await awardTenderToContract(row.id, {});
ElMessage.success("已转合同");
fetchList();
} catch (e) {
ElMessage.error(e?.message || "转合同失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除招标「${row.title}」?`, "删除确认", {
type: "warning"
});
} catch {
return;
}
try {
await deleteTender(row.id);
ElMessage.success("删除成功");
fetchList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
</script>
<style lang="less" scoped src="../../styles/crm-page.less"></style>
<style scoped>
.filter-bar {
margin-bottom: 16px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>