chore: 补提交工作区未提交改动(2026-10-30 交接快照)——补录管理/服务费/AI能力管理/企业等级页面等已上生产功能源码(含新文件)
This commit is contained in:
parent
dd000053a2
commit
4377d39b79
@ -48,18 +48,22 @@ async function uploadFile(file: File): Promise<string> {
|
||||
throw new Error(data.msg || "图片上传失败")
|
||||
}
|
||||
|
||||
// === 外链图片转存: 公众号图片有防盗链, 下载后上传平台存储 ===
|
||||
// === 外链图片转存: 微信等 CDN 有防盗链且无 CORS 头, 前端 fetch 必败 —— 走后端服务端拉取转存 ===
|
||||
async function remoteToLocal(src: string): Promise<string> {
|
||||
try {
|
||||
const resp = await fetch(src, { referrerPolicy: "no-referrer", mode: "cors" })
|
||||
if (!resp.ok) return src
|
||||
const blob = await resp.blob()
|
||||
if (!blob.type.startsWith("image/")) return src
|
||||
const file = new File([blob], "paste-image." + (blob.type.split("/")[1] || "png"), { type: blob.type })
|
||||
return await uploadFile(file)
|
||||
} catch {
|
||||
return src
|
||||
const token = localStorage.getItem("access_token") || ""
|
||||
const resp = await fetch("/api/sys/file/uploadByUrl", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "" },
|
||||
body: JSON.stringify({ url: src }),
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (data.code === 0 && data.data?.url) return data.data.url
|
||||
console.warn("图片转存失败:", src, data.msg)
|
||||
} catch (e: any) {
|
||||
console.warn("图片转存异常:", src, e?.message)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// === 处理粘贴 HTML: 转存外链图 -> 清洗危险/不兼容标签 -> 返回 [原始版, 编辑器版] ===
|
||||
@ -84,13 +88,27 @@ async function processPasteHtml(html: string): Promise<{ raw: string; editorSafe
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器版: 移除 section/iframe/script/style/class 等 slate 不兼容或危险标签, 保留排版
|
||||
// 编辑器版: 移除 section/iframe/script/style/svg/自定义标签(mp-xxx 等)/class —— 这些会导致
|
||||
// wangeditor(slate) HTML 解析丢节点(用户反馈"部分文本未粘贴成功"的根因), 保留文本与常规排版
|
||||
editorSafe = editorSafe
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<iframe[\s\S]*?<\/iframe>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<svg[\s\S]*?<\/svg>/gi, "")
|
||||
.replace(/<section\b/gi, "<div")
|
||||
.replace(/<\/section>/gi, "</div>")
|
||||
// 公众号自定义标签 (mp-common-*, mpprofile 等) 整块移除
|
||||
.replace(/<mp-[a-z-]+[\s\S]*?<\/mp-[a-z-]+>/gi, "")
|
||||
.replace(/<\/?mp-[a-z-]+[^>]*>/gi, "")
|
||||
// 其余非 HTML 标准标签转为 div 保留内部文本
|
||||
.replace(/<([a-z][a-z0-9-]*)((?:\s[^>]*)?)>/gi, (m0, tag, attrs) => {
|
||||
if (/^(div|p|span|br|img|strong|b|em|i|u|s|ul|ol|li|h[1-6]|blockquote|table|thead|tbody|tr|td|th|a|hr|pre|code|font|center|section)$/i.test(tag)) return m0
|
||||
return "<div" + attrs + ">"
|
||||
})
|
||||
.replace(/<\/([a-z][a-z0-9-]*)>/gi, (m0, tag) => {
|
||||
if (/^(div|p|span|br|img|strong|b|em|i|u|s|ul|ol|li|h[1-6]|blockquote|table|thead|tbody|tr|td|th|a|hr|pre|code|font|center|section)$/i.test(tag)) return m0
|
||||
return "</div>"
|
||||
})
|
||||
.replace(/\sclass=["\x27][^"\x27]*["\x27]/gi, "")
|
||||
.replace(/<br\s*\/?\s*>/gi, "<br>")
|
||||
|
||||
@ -138,7 +156,14 @@ const editorConfig = {
|
||||
// 保存原始 HTML: 提交时用 (公众号排版完整保留)
|
||||
rawPasteHtml = raw
|
||||
programmaticSet = true
|
||||
editor.dangerouslyInsertHtml(editorSafe)
|
||||
try {
|
||||
editor.dangerouslyInsertHtml(editorSafe)
|
||||
} catch (insertErr: any) {
|
||||
// slate 解析失败兜底: 插入纯文本, 保证文本内容不丢
|
||||
console.error("insert html failed, fallback to text:", insertErr)
|
||||
const text = editorSafe.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||||
editor.insertText(text)
|
||||
}
|
||||
setTimeout(() => { programmaticSet = false }, 100)
|
||||
} catch (e: any) {
|
||||
console.error("paste html error:", e)
|
||||
@ -222,14 +247,18 @@ onBeforeUnmount(() => {
|
||||
.rich-editor {
|
||||
border: 1px solid var(--el-border-color, #dcdfe6);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rich-editor__toolbar {
|
||||
/* 工具栏吸顶: 页面滚动编辑长内容时始终固定在可视区顶部 (2026-09-21) */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--el-bg-color, #fff);
|
||||
border-radius: 6px 6px 0 0;
|
||||
border-bottom: 1px solid var(--el-border-color, #dcdfe6);
|
||||
}
|
||||
.rich-editor__body {
|
||||
min-height: 320px;
|
||||
z-index: 100;
|
||||
}
|
||||
.rich-editor__body :deep(.w-e-text-container) {
|
||||
min-height: 320px;
|
||||
|
||||
@ -74,6 +74,10 @@ export const MENU_LABELS: MenuLabel[] = [
|
||||
{ url: 'order/serviceorder-commission', label: '服务订单分佣统计', icon: 'Coin', path: '/order/serviceorder/commission', sort: 221 },
|
||||
{ url: 'order/orderbase-price-analysis', label: '基础价格分析表', icon: 'TrendCharts', path: '/order/base-price-analysis', sort: 230 },
|
||||
{ url: 'order/orderpay', label: '订单支付回调', icon: 'Wallet', path: '/order/orderpay', sort: 240 },
|
||||
{ url: 'order/servicefee', label: '平台服务费管理', icon: 'Money', path: '/order/servicefee', sort: 245 },
|
||||
{ url: 'order/backfill-manage', label: '补录管理', icon: 'DocumentAdd', path: '/order/backfill-manage', sort: 246 },
|
||||
{ url: 'user/enterprise-score', label: '企业评分与等级', icon: 'Trophy', path: '/user/enterprise-score', sort: 150 },
|
||||
{ url: 'sys/ai-manage', label: 'AI 能力管理', icon: 'Cpu', path: '/sys/ai-manage', sort: 155 },
|
||||
{ url: 'order/refund', label: '订单退款管理', icon: 'Refresh', path: '/order/refund', sort: 250 },
|
||||
{ url: 'order/aftersale', label: '售后订单', icon: 'Service', path: '/order/aftersale', sort: 260 },
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import qs from 'qs'
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { View, Hide, Lock } from '@element-plus/icons-vue'
|
||||
import { computed } from 'vue'
|
||||
import logoUrl from '@/assets/logo-jubian.png'
|
||||
import logoLightUrl from '@/assets/logo-jubian-light.png'
|
||||
@ -27,6 +28,7 @@ const userStore = useUserStore()
|
||||
const themeStore = useThemeStore()
|
||||
const logoSrc = computed(() => themeStore.current === 'dark' ? logoLightUrl : logoUrl)
|
||||
|
||||
const showPwd = ref(false)
|
||||
const form = reactive({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
@ -150,7 +152,8 @@ async function onLogin() {
|
||||
<label class="rr-field-label">密码</label>
|
||||
<div class="rr-input-wrap">
|
||||
<el-icon class="rr-input-icon"><Lock /></el-icon>
|
||||
<input v-model="form.password" type="password" class="rr-input" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<input v-model="form.password" :type="showPwd ? 'text' : 'password'" class="rr-input has-eye" placeholder="请输入密码" autocomplete="current-password" />
|
||||
<el-icon class="rr-input-eye" @click="showPwd = !showPwd" :title="showPwd ? '隐藏密码' : '查看密码'"><View v-if="showPwd" /><Hide v-else /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -312,6 +315,20 @@ async function onLogin() {
|
||||
color: var(--ink-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
.rr-input-eye {
|
||||
position: absolute;
|
||||
right: 10px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
color: var(--ink-3);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&:hover { color: var(--brand); }
|
||||
}
|
||||
.rr-input.has-eye {
|
||||
padding-right: 36px;
|
||||
}
|
||||
.rr-input {
|
||||
width: 100%; height: 38px;
|
||||
padding: 0 12px 0 34px;
|
||||
|
||||
@ -13,6 +13,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
|
||||
interface Inquiry {
|
||||
id: string
|
||||
@ -105,7 +106,17 @@ const inquiryId = computed(() => String(route.params.id || ''))
|
||||
|
||||
const PROC_TYPE: Record<number, string> = { 1: '定量采购', 2: '框架采购' }
|
||||
const DEMAND: Record<number, string> = { 1: '不允许部分报价', 2: '允许部分报价' }
|
||||
const INVOICE: Record<number, string> = { 1: '普通发票', 2: '增值税专用发票', 3: '无' }
|
||||
// 发票要求展示 (2026-10-16 票种化)
|
||||
function invoiceTypeLabel(v: any): string {
|
||||
const n = Number(v)
|
||||
if (v === null || v === undefined || v === '' || isNaN(n)) return '—'
|
||||
if (n === 0) return '无'
|
||||
if (n === 2) return '增值税专用发票'
|
||||
if (n >= 200) return `增值税专用发票 ${n - 200}%`
|
||||
if (n >= 100) return `增值税普通发票 ${n - 100}%`
|
||||
if ([1, 3, 6, 9, 13].includes(n)) return `${n}% 发票`
|
||||
return String(v)
|
||||
}
|
||||
const QUOTE_TYPE: Record<number, string> = { 1: '公开报价', 2: '邀请报价' }
|
||||
const mapLabel = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v))
|
||||
const mapLabel1 = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v))
|
||||
@ -131,7 +142,7 @@ async function load() {
|
||||
quotationTypeName: mapLabel(QUOTE_TYPE, resp.quotationType),
|
||||
quotationDemandName: mapLabel(DEMAND, resp.quotationDemand),
|
||||
paymentMethodName: resp.paymentMethodName || '--',
|
||||
invoiceTypeName: mapLabel(INVOICE, resp.invoiceType),
|
||||
invoiceTypeName: invoiceTypeLabel(resp.invoiceType),
|
||||
inquiryValidDate: resp.inquiryValidDate || '--',
|
||||
note: resp.note || '',
|
||||
detailNote: '', // 清除 FALLBACK 占位文案,备注一律取真实数据
|
||||
@ -152,15 +163,28 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// 附件:发布时 images 为 URL 数组 + imageNames 名称数组
|
||||
// 附件:兼容两种返回形态——① images=URL 字符串数组 + imageNames ② images=[{url, imagesName}] 对象数组
|
||||
function resetFiles(resp: any): any[] {
|
||||
const urls: string[] = resp.images || []
|
||||
const names: string[] = resp.imageNames || []
|
||||
return urls.map((u: string, i: number) => ({ url: u, imagesName: names[i] || ('附件' + (i + 1)) }))
|
||||
const raw: any[] = resp.images || []
|
||||
const names: string[] = Array.isArray(resp.imageNames) ? resp.imageNames : String(resp.imageNames || '').split(',')
|
||||
const out: any[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const item = raw[i]
|
||||
if (item && typeof item === 'object') {
|
||||
if (!item.url) continue
|
||||
out.push({ url: String(item.url), imagesName: item.imagesName || names[i] || ('附件' + (i + 1)) })
|
||||
} else if (typeof item === 'string' && item.trim()) {
|
||||
out.push({ url: item.trim(), imagesName: names[i] || ('附件' + (i + 1)) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const quoteGroups = ref<any[]>([])
|
||||
const quoteLoading = ref(false)
|
||||
// 报价列表卡片展开索引(-1=全部收起)
|
||||
const openSupIndex = ref(-1)
|
||||
function toggleSup(i: number) { openSupIndex.value = openSupIndex.value === i ? -1 : i }
|
||||
|
||||
// 供应商维度报价列表:受邀供应商(含报价状态)+ 主动报价供应商补充
|
||||
const supplierRows = computed(() => {
|
||||
@ -189,8 +213,8 @@ const supplierRows = computed(() => {
|
||||
const existing = rows.find(r => r.name === name)
|
||||
const quoted = Number(inv.quotationStatus) === 1
|
||||
if (existing) {
|
||||
// 已有真实报价行 = 已报价,状态不被邀请表状态覆盖(邀请表可能仍标记未响应)
|
||||
existing.invited = true
|
||||
if (!quoted) existing.status = 0
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
@ -225,10 +249,18 @@ async function loadQuotes(orderId: any) {
|
||||
let g = groups.find(x => x.name === name)
|
||||
if (!g) { g = { name, rows: [], count: 0, total: 0, date: '', list: [] }; groups.push(g) }
|
||||
g.rows.push(q)
|
||||
g.count++
|
||||
g.total += Number(q.unitPriceTaxIncluded || q.unitPrice || 0) * Number(q.quantity || 0)
|
||||
if (q.quotationDate && !g.date) g.date = q.quotationDate
|
||||
}
|
||||
// 每企业仅保留最新报价轮:明细行 quotation_id 为 19 位雪花 id(超出 Number 安全整数,必须 BigInt 比较)
|
||||
for (const g of groups) {
|
||||
const withQid = g.rows.filter((r: any) => r.quotationId)
|
||||
if (withQid.length) {
|
||||
const latestQid = withQid.reduce((m: string, r: any) => (BigInt(String(r.quotationId)) > BigInt(m) ? String(r.quotationId) : m), String(withQid[0].quotationId))
|
||||
g.rows = g.rows.filter((r: any) => String(r.quotationId) === latestQid)
|
||||
}
|
||||
g.count = g.rows.length
|
||||
g.total = g.rows.reduce((s: number, r: any) => s + Number(r.unitPriceTaxIncluded || r.unitPrice || 0) * Number(r.quantity || 0), 0)
|
||||
}
|
||||
quoteGroups.value = groups
|
||||
} catch { quoteGroups.value = [] } finally { quoteLoading.value = false }
|
||||
}
|
||||
@ -268,18 +300,35 @@ function quotationInfo(s: number) { return QUOTATION_STATUS_MAP[s] || { label: `
|
||||
function inquiryInfo(s: number) { return INQUIRY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
||||
|
||||
function onBack() { router.push('/inquiry') }
|
||||
// 平台 OSS 域名白名单:响应头为 attachment,走预览代理按扩展名 inline 输出
|
||||
const OSS_PREVIEW_PREFIX = 'https://jubianwangluo.oss-cn-wulanchabu.aliyuncs.com/'
|
||||
// 页内预览弹窗(不再开新窗口,避免弹窗拦截/新标签空白)
|
||||
const previewOpen = ref(false)
|
||||
const previewItem = ref<{ url: string; name: string; kind: 'image' | 'pdf' | 'external' } | null>(null)
|
||||
function fileKind(url: string): 'image' | 'pdf' | 'external' {
|
||||
const lower = (url || '').toLowerCase()
|
||||
if (/\.(png|jpe?g|gif|webp|bmp)(\?|$)/.test(lower)) return 'image'
|
||||
if (/\.pdf(\?|$)/.test(lower)) return 'pdf'
|
||||
return 'external'
|
||||
}
|
||||
function previewSrc(url: string): string {
|
||||
return url.startsWith(OSS_PREVIEW_PREFIX) ? '/other/file/preview?url=' + encodeURIComponent(url) : url
|
||||
}
|
||||
function openExternal(url?: string) { if (url) window.open(url, '_blank') }
|
||||
function onPreviewFile(url: string, name?: string) {
|
||||
if (!url) return
|
||||
previewItem.value = { url: previewSrc(url), name: name || '附件预览', kind: fileKind(url) }
|
||||
previewOpen.value = true
|
||||
}
|
||||
function onDownloadFile(url: string, name: string) {
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.href = previewSrc(url)
|
||||
if (url.startsWith(OSS_PREVIEW_PREFIX)) a.href = url // OSS 直接下载原始文件
|
||||
a.download = name || ''
|
||||
a.target = '_blank'
|
||||
a.click()
|
||||
}
|
||||
function onPreviewFile(url: string) {
|
||||
// 走预览代理(inline 输出,浏览器原生预览图片/PDF)
|
||||
window.open('/other/file/preview?url=' + encodeURIComponent(url), '_blank')
|
||||
}
|
||||
function onExportPriceComparison() { ElMessage.info('导出功能开发中') }
|
||||
function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (请前往询价管理)`) }
|
||||
function onQuote(row: Quotation) { ElMessage.info('请前往前台发起报价') }
|
||||
@ -356,73 +405,94 @@ watch(inquiryId, load)
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 询价附件 -->
|
||||
<!-- 询价附件:图片缩略图网格 + 文件行,预览=页内弹窗 -->
|
||||
<div class="rr-card" v-if="detail.fileList.length > 0">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">询价附件</h3></div>
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">询价附件 ({{ detail.fileList.length }})</h3></div>
|
||||
<div class="rr-files">
|
||||
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-file-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="rr-file-name">{{ f.imagesName }}</span>
|
||||
<el-button text type="primary" size="small" @click="onPreviewFile(f.url)">预览</el-button>
|
||||
<el-button text size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
|
||||
<div class="rr-attach-grid">
|
||||
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-attach-item" @click="onPreviewFile(f.url, f.imagesName)">
|
||||
<img v-if="fileKind(f.url) === 'image'" :src="previewSrc(f.url)" class="rr-attach-thumb" loading="lazy" />
|
||||
<div v-else class="rr-attach-icon"><el-icon :size="26"><Document /></el-icon><span>{{ fileKind(f.url) === 'pdf' ? 'PDF' : '链接' }}</span></div>
|
||||
<div class="rr-attach-name" :title="f.imagesName">{{ f.imagesName }}</div>
|
||||
<div class="rr-attach-actions" @click.stop>
|
||||
<el-button text type="primary" size="small" @click="onPreviewFile(f.url, f.imagesName)">预览</el-button>
|
||||
<el-button text size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报价列表(供应商维度:受邀未报价 / 已报价,展开查看商品清单) -->
|
||||
<!-- 附件预览弹窗(页内) -->
|
||||
<el-dialog v-model="previewOpen" :title="previewItem?.name || '附件预览'" width="96%" top="2vh" destroy-on-close append-to-body>
|
||||
<div class="rr-preview-body">
|
||||
<img v-if="previewItem?.kind === 'image'" :src="previewItem.url" class="rr-preview-img" />
|
||||
<iframe v-else-if="previewItem?.kind === 'pdf'" :src="previewItem.url" class="rr-preview-frame"></iframe>
|
||||
<div v-else class="rr-preview-ext">
|
||||
<p>该附件为外部链接,平台无法在页内预览:</p>
|
||||
<p class="rr-preview-link">{{ previewItem?.url }}</p>
|
||||
<el-button type="primary" @click="openExternal(previewItem?.url)">在新窗口打开</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 报价列表:供应商卡片分区(默认折叠,点击展开全宽明细表,无横向滚动) -->
|
||||
<div class="rr-card">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ supplierRows.length }} 家供应商)</h3></div>
|
||||
<div v-if="quoteLoading" class="rr-empty">加载报价中...</div>
|
||||
<div v-else-if="!supplierRows.length" class="rr-empty">暂无受邀供应商与报价</div>
|
||||
<el-table v-else :data="supplierRows" border size="small" row-key="name">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.rows.length" class="rr-expand">
|
||||
<el-table :data="row.rows" border size="small" class="rr-expand-table">
|
||||
<el-table-column type="index" label="#" width="46" align="center" />
|
||||
<el-table-column prop="productName" label="商品" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="specification" label="规格" min-width="120">
|
||||
<div v-else class="rr-sup-list">
|
||||
<div v-for="(row, ri) in supplierRows" :key="row.name" class="rr-sup-card" :class="{ 'is-open': openSupIndex === ri }">
|
||||
<div class="rr-sup-head" @click="toggleSup(ri)">
|
||||
<div class="rr-sup-title">
|
||||
<el-icon class="rr-sup-arrow"><ArrowRight /></el-icon>
|
||||
<span class="rr-sup-name">{{ row.name }}</span>
|
||||
<span v-if="Number(row.status) === 1" class="rr-tag rr-tag-green">已报价</span>
|
||||
<span v-else class="rr-tag rr-tag-amber">{{ row.invited ? '受邀未报价' : '未报价' }}</span>
|
||||
</div>
|
||||
<div class="rr-sup-meta">
|
||||
<span v-if="row.date" class="rr-sup-meta-item">报价时间:{{ row.date }}</span>
|
||||
<span class="rr-sup-meta-item">商品 {{ row.count || 0 }} 项</span>
|
||||
<span class="rr-sup-meta-item rr-sup-total">合计 <b>{{ row.total > 0 ? ('¥ ' + Number(row.total).toLocaleString()) : '--' }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="openSupIndex === ri" class="rr-sup-body">
|
||||
<template v-if="row.rows.length">
|
||||
<el-table :data="row.rows" border size="small">
|
||||
<el-table-column type="index" label="#" width="48" align="center" />
|
||||
<el-table-column prop="productName" label="商品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="规格" min-width="110">
|
||||
<template #default="{ row: r }">{{ r.specification || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价单价(含税)" width="130" align="right">
|
||||
<el-table-column label="报价品牌" min-width="100">
|
||||
<template #default="{ row: r }">{{ r.quotationProductBrand || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价单价(含税)" width="120" align="right">
|
||||
<template #default="{ row: r }">¥ {{ Number(r.unitPriceTaxIncluded || r.unitPrice || 0).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="80" align="center" />
|
||||
<el-table-column prop="unit" label="单位" width="70" align="center">
|
||||
<el-table-column label="税率" width="70" align="center">
|
||||
<template #default="{ row: r }">{{ r.taxRate != null ? Number(r.taxRate) + '%' : '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="76" align="center" />
|
||||
<el-table-column label="单位" width="62" align="center">
|
||||
<template #default="{ row: r }">{{ r.unit || '件' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生产周期" min-width="110">
|
||||
<el-table-column label="小计" width="110" align="right">
|
||||
<template #default="{ row: r }">¥ {{ (Number(r.unitPriceTaxIncluded || r.unitPrice || 0) * Number(r.quantity || 0)).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生产周期" width="100">
|
||||
<template #default="{ row: r }">{{ r.productionCycle || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quotationNote" label="备注" min-width="130">
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row: r }">{{ r.quotationNote || '--' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else class="rr-tip">该供应商尚未提交报价</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="供应商" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="报价状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="Number(row.status) === 1" class="rr-tag rr-tag-green">已报价</span>
|
||||
<span v-else class="rr-tag rr-tag-amber">{{ row.invited ? '受邀未报价' : '未报价' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价时间" width="180">
|
||||
<template #default="{ row }">{{ row.date || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品项数" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.count || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价合计" width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.total > 0">¥ {{ Number(row.total).toLocaleString() }}</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="rr-tip mt10">受邀未报价的供应商将不展示报价清单,点击行展开可查看已报价企业的逐商品报价详情</div>
|
||||
</template>
|
||||
<div v-else class="rr-tip rr-sup-empty">该供应商尚未提交报价</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-tip mt10">点击供应商卡片展开/收起该企业的逐商品报价明细(仅显示当前最新一轮报价)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -441,9 +511,34 @@ watch(inquiryId, load)
|
||||
.rr-title { font-size: 20px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
|
||||
.rr-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-header-actions { display: flex; gap: 8px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--border); }
|
||||
.rr-files { padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rr-file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--bg-soft); border-radius: var(--radius); }
|
||||
.rr-file-name { flex: 1; color: var(--ink); font-size: 13px; }
|
||||
.rr-files { padding: 16px 20px; }
|
||||
.rr-attach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
||||
.rr-attach-item { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--bg-soft); cursor: pointer; transition: box-shadow .15s, border-color .15s; }
|
||||
.rr-attach-item:hover { border-color: var(--el-color-primary-light-5); box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||
.rr-attach-thumb { width: 100%; height: 150px; object-fit: cover; display: block; background: #fff; }
|
||||
.rr-attach-icon { height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.rr-attach-icon .el-icon { font-size: 34px; }
|
||||
.rr-attach-name { padding: 8px 10px 2px; font-size: 12px; color: var(--ink); line-height: 1.5; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; word-break: break-all; }
|
||||
.rr-attach-actions { display: flex; justify-content: flex-end; padding: 0 6px 6px; }
|
||||
.rr-preview-body { min-height: 200px; max-height: 84vh; overflow: auto; text-align: center; }
|
||||
.rr-preview-img { max-width: 100%; max-height: 84vh; object-fit: contain; }
|
||||
.rr-preview-frame { width: 100%; height: 82vh; border: 0; }
|
||||
.rr-preview-ext { padding: 30px 20px; }
|
||||
.rr-preview-link { color: var(--el-color-primary); word-break: break-all; margin: 10px 0 16px; }
|
||||
|
||||
.rr-sup-list { padding: 12px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rr-sup-card { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; transition: border-color .15s; }
|
||||
.rr-sup-card.is-open { border-color: var(--el-color-primary-light-5); }
|
||||
.rr-sup-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; background: var(--bg-soft); cursor: pointer; flex-wrap: wrap; }
|
||||
.rr-sup-head:hover { background: var(--el-fill-color-light); }
|
||||
.rr-sup-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.rr-sup-arrow { transition: transform .2s; color: var(--el-text-color-secondary); }
|
||||
.rr-sup-card.is-open .rr-sup-arrow { transform: rotate(90deg); }
|
||||
.rr-sup-name { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||
.rr-sup-meta { display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
|
||||
.rr-sup-total b { color: var(--el-color-primary); font-size: 14px; }
|
||||
.rr-sup-body { padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||
.rr-sup-empty { padding: 20px; text-align: center; }
|
||||
|
||||
.rr-quote-groups { display: flex; flex-direction: column; gap: 12px; }
|
||||
.rr-quote-group { border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden; }
|
||||
|
||||
@ -9,19 +9,20 @@
|
||||
*/
|
||||
import { onMounted, computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||
import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
|
||||
interface InquiryRow {
|
||||
id: string
|
||||
customer: string
|
||||
product: string
|
||||
qty: string
|
||||
hidden?: number
|
||||
status: 'pending' | 'quoting' | 'done' | 'cancelled' | string
|
||||
time: string | number
|
||||
raw: any
|
||||
@ -78,6 +79,7 @@ async function load() {
|
||||
const resp = await httpGet<any>('/supplier/inquiry/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
includeHidden: 1,
|
||||
...(filters.value.apprStatus ? { apprStatus: filters.value.apprStatus } : {})
|
||||
})
|
||||
const arr = Array.isArray(resp) ? resp : (resp?.list || [])
|
||||
@ -89,6 +91,7 @@ async function load() {
|
||||
qty: it.purchaseQuantity || it.qty || '--',
|
||||
status: it.status || (it.apprStatus != null ? (['pending','quoting','done','cancelled'][it.apprStatus] || 'pending') : 'pending'),
|
||||
apprStatus: it.apprStatus,
|
||||
hidden: it.hidden,
|
||||
time: it.publishDate || it.createDate || it.time,
|
||||
raw: it
|
||||
}))
|
||||
@ -122,6 +125,68 @@ function onStatusTab(v: string) {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
// ===== 运营后台 隐藏/删除询价单 =====
|
||||
function reload() { load() }
|
||||
|
||||
async function onHide(row: InquiryRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'隐藏后,该询价单将在平台前端(招采广场、结果公示、供应商邀请列表及详情页)完全不可见,运营后台仍可查看。',
|
||||
'隐藏询价单',
|
||||
{ confirmButtonText: '确认隐藏', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/supplier/inquiry/adminHide', { id: row.id, hidden: 1 })
|
||||
ElMessage.success('已隐藏,平台前端不再展示')
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnhide(row: InquiryRow) {
|
||||
try {
|
||||
await httpPost('/supplier/inquiry/adminHide', { id: row.id, hidden: 0 })
|
||||
ElMessage.success('已取消隐藏,恢复前端展示')
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: InquiryRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`<div style="line-height:1.9;font-size:13px">
|
||||
<div style="background:#fef0f0;border:1px solid #fbc4c4;color:#c45656;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-weight:600">⚠️ 高危操作,删除后数据不可恢复</div>
|
||||
<div style="font-weight:600;margin-bottom:4px">将彻底删除该询价单及其全部关联数据:</div>
|
||||
<div>① 询价单与全部商品明细 —— 即前端展示的<b>询价公告</b></div>
|
||||
<div>② 全部供应商<b>报价记录</b>与报价明细</div>
|
||||
<div>③ <b>邀请报价记录</b></div>
|
||||
<div style="color:#909399;margin-top:8px">一般场景建议仅做「隐藏」处理;确需删除(测试数据 / 错误录入 / 信息下架)请谨慎确认。</div>
|
||||
</div>`,
|
||||
'删除询价单',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消,仅隐藏即可',
|
||||
type: 'error',
|
||||
confirmButtonClass: 'inq-del-confirm-btn',
|
||||
dangerouslyUseHTMLString: true,
|
||||
customStyle: { maxWidth: '520px' } as any
|
||||
}
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
const res: any = await httpPost('/supplier/inquiry/adminDelete', { id: row.id })
|
||||
const d = res || {}
|
||||
ElMessage.success(`已删除:报价记录 ${d.quotation ?? 0} 条、报价明细 ${d.quotationDetail ?? 0} 条、邀请记录 ${d.quotationInvited ?? 0} 条、询价明细 ${d.inquiryDetail ?? 0} 条`)
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
||||
function onView(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
||||
|
||||
@ -130,9 +195,9 @@ const columns = computed(() => [
|
||||
{ prop: 'customer', label: '客户', minWidth: 180 },
|
||||
{ prop: 'product', label: '商品', minWidth: 180 },
|
||||
{ prop: 'qty', label: '数量', width: 120 },
|
||||
{ prop: 'apprStatus', label: '审核状态', width: 100, slot: 'apprStatus' },
|
||||
{ prop: 'apprStatus', label: '审核状态', width: 175, slot: 'apprStatus' },
|
||||
{ prop: 'time', label: '时间', width: 170, slot: 'time' },
|
||||
{ label: '操作', width: 100, slot: 'action' }
|
||||
{ label: '操作', width: 265, slot: 'action' }
|
||||
])
|
||||
|
||||
onMounted(() => { load() })
|
||||
@ -174,12 +239,20 @@ onMounted(() => { load() })
|
||||
<el-tag :type="row.apprStatus === 1 ? 'warning' : (row.apprStatus === 2 ? 'success' : (row.apprStatus === 3 ? 'danger' : 'info'))" size="small">
|
||||
{{ row.apprStatus === 1 ? '待审核' : (row.apprStatus === 2 ? '已通过' : (row.apprStatus === 3 ? '已驳回' : '--')) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.hidden === 1" type="info" size="small" effect="dark" style="margin-left: 4px">已隐藏</el-tag>
|
||||
</template>
|
||||
<template #time="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.time) }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<div class="rr-action-cell">
|
||||
<el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button>
|
||||
<el-button v-if="row.hidden !== 1" text type="warning" size="small" @click.stop="onHide(row)">隐藏</el-button>
|
||||
<template v-else>
|
||||
<el-button text type="primary" size="small" @click.stop="onUnhide(row)">取消隐藏</el-button>
|
||||
<el-button text type="danger" size="small" @click.stop="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@ -191,6 +264,12 @@ onMounted(() => { load() })
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-action-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
@ -217,3 +296,15 @@ onMounted(() => { load() })
|
||||
.rr-badge-done { background: var(--green-soft); color: var(--green); }
|
||||
.rr-badge-cancelled { background: var(--red-soft); color: var(--red); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 删除询价单确认弹窗: 确认按钮红色(高危语义) */
|
||||
.inq-del-confirm-btn {
|
||||
background: var(--el-color-danger, #f56c6c) !important;
|
||||
border-color: var(--el-color-danger, #f56c6c) !important;
|
||||
}
|
||||
.inq-del-confirm-btn:hover {
|
||||
background: #e25c5c !important;
|
||||
border-color: #e25c5c !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
312
src/modules/order/BackfillManageListView.vue
Normal file
312
src/modules/order/BackfillManageListView.vue
Normal file
@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 补录管理 — admin(订单管理组)
|
||||
*
|
||||
* 板块一 补录企业: 白名单管理(添加=搜索已认证企业开通 /order/user/enterprise/backfillEnabled, 移除=同接口切换)
|
||||
* 板块二 补录订单: /order/orderbackfill/page 全量补录单, 可作废(软删)
|
||||
* 白名单查询: GET /user/enterprise/backfillList (user-server)
|
||||
*/
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||
|
||||
const activeTab = ref('ent')
|
||||
const tabItems = [
|
||||
{ key: 'ent', label: '补录企业' },
|
||||
{ key: 'order', label: '补录订单' }
|
||||
]
|
||||
|
||||
// ===== 板块一: 补录企业白名单 =====
|
||||
const entList = ref<any[]>([])
|
||||
const entTotal = ref(0)
|
||||
const entLoading = ref(false)
|
||||
const entPage = ref(1)
|
||||
const entLimit = ref(10)
|
||||
const entFilters = reactive({ enterpriseName: '' })
|
||||
|
||||
const entColumns: any = [
|
||||
{ prop: 'enterprise_name', label: '企业名称', minWidth: 240, slot: 'name' },
|
||||
{ prop: 'current_score', label: '履约评分', width: 110, align: 'center', slot: 'score' },
|
||||
{ prop: 'enabled_time', label: '开通时间', width: 170, slot: 'time' },
|
||||
{ label: '操作', width: 120, align: 'center', slot: 'action' }
|
||||
]
|
||||
|
||||
async function loadEnt() {
|
||||
entLoading.value = true
|
||||
try {
|
||||
const params: any = { page: entPage.value, limit: entLimit.value }
|
||||
if (entFilters.enterpriseName) params.enterpriseName = entFilters.enterpriseName
|
||||
const r: any = await httpGet('/user/enterprise/backfillList', params)
|
||||
entList.value = r?.list || []
|
||||
const t = r?.total
|
||||
entTotal.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally { entLoading.value = false }
|
||||
}
|
||||
|
||||
function onEntSearch() { entPage.value = 1; loadEnt() }
|
||||
function onEntPage(p: number) { entPage.value = p; loadEnt() }
|
||||
function onEntLimit(l: number) { entLimit.value = l; loadEnt() }
|
||||
|
||||
async function onRemove(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认关闭【${row.enterprise_name}】的订单补录权限? 关闭后该企业将无法继续补录`, '关闭权限', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost(`/user/enterprise/backfillEnabled?id=${row.id}`)
|
||||
ElMessage.success('已关闭补录权限')
|
||||
loadEnt()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||
}
|
||||
|
||||
// ===== 添加企业弹窗(搜索选择) =====
|
||||
const addDialog = ref(false)
|
||||
const addKeyword = ref('')
|
||||
const addSearching = ref(false)
|
||||
const addCandidates = ref<any[]>([])
|
||||
const addSelected = ref<any>(null)
|
||||
|
||||
function openAdd() {
|
||||
addDialog.value = true
|
||||
addKeyword.value = ''
|
||||
addCandidates.value = []
|
||||
addSelected.value = null
|
||||
}
|
||||
|
||||
async function searchCandidates() {
|
||||
if (!addKeyword.value.trim()) { ElMessage.warning('请输入企业名称关键词'); return }
|
||||
addSearching.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/user/enterprise/page', { page: 1, limit: 20, status: 2, enterpriseName: addKeyword.value.trim() })
|
||||
addCandidates.value = (r?.list || []).map((it: any) => ({
|
||||
id: String(it.id), name: it.enterpriseName, backfillEnabled: it.backfillEnabled ?? 0
|
||||
}))
|
||||
if (!addCandidates.value.length) ElMessage.info('未搜到已认证企业')
|
||||
} finally { addSearching.value = false }
|
||||
}
|
||||
|
||||
async function confirmAdd() {
|
||||
if (!addSelected.value) { ElMessage.warning('请先选择企业'); return }
|
||||
if (addSelected.value.backfillEnabled === 1) { ElMessage.warning('该企业已开通补录权限'); return }
|
||||
try {
|
||||
await httpPost(`/user/enterprise/backfillEnabled?id=${addSelected.value.id}`)
|
||||
ElMessage.success(`已开通【${addSelected.value.name}】的订单补录权限`)
|
||||
addDialog.value = false
|
||||
loadEnt()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '开通失败') }
|
||||
}
|
||||
|
||||
// ===== 板块二: 补录订单列表 =====
|
||||
const ordList = ref<any[]>([])
|
||||
const ordTotal = ref(0)
|
||||
const ordLoading = ref(false)
|
||||
const ordPage = ref(1)
|
||||
const ordLimit = ref(10)
|
||||
const ordFilters = reactive({ keyword: '' })
|
||||
|
||||
const ordColumns: any = [
|
||||
{ prop: 'supplier_order_code', label: '供应商订单号', width: 215, slot: 'ocode' },
|
||||
{ prop: 'enterprise_name', label: '卖方(供应商)', minWidth: 180 },
|
||||
{ prop: 'buyer', label: '买方', minWidth: 180, slot: 'buyer' },
|
||||
{ prop: 'order_money', label: '订单金额', width: 120, align: 'right', slot: 'money' },
|
||||
{ prop: 'backfill_role', label: '补录方', width: 90, align: 'center', slot: 'role' },
|
||||
{ prop: 'backfill_by', label: '操作人', width: 110, slot: 'by' },
|
||||
{ prop: 'invoice_count', label: '发票', width: 70, align: 'center', slot: 'inv' },
|
||||
{ prop: 'create_date', label: '补录时间', width: 160, slot: 'time' },
|
||||
{ label: '操作', width: 130, align: 'center', slot: 'action' }
|
||||
]
|
||||
|
||||
async function loadOrd() {
|
||||
ordLoading.value = true
|
||||
try {
|
||||
const params: any = { page: ordPage.value, limit: ordLimit.value }
|
||||
if (ordFilters.keyword) params.keyword = ordFilters.keyword
|
||||
const r: any = await httpGet('/order/orderbackfill/page', params)
|
||||
ordList.value = (r?.list || []).map((it: any) => ({
|
||||
...it,
|
||||
buyer: buyerNameCache.value[it.current_enterprise_id] || `企业#${it.current_enterprise_id}`
|
||||
}))
|
||||
fillBuyerNames()
|
||||
const t = r?.total
|
||||
ordTotal.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || '加载失败')
|
||||
} finally { ordLoading.value = false }
|
||||
}
|
||||
|
||||
// 买方企业名后端未带(跨库), 用 /user/enterprise/getById 批量补显
|
||||
const buyerNameCache = ref<Record<string, string>>({})
|
||||
async function fillBuyerNames() {
|
||||
for (const row of ordList.value) {
|
||||
const bid = String(row.current_enterprise_id)
|
||||
if (!buyerNameCache.value[bid]) {
|
||||
try {
|
||||
const r: any = await httpGet('/user/enterprise/getById', { id: bid })
|
||||
buyerNameCache.value[bid] = r?.enterpriseName || `企业#${bid}`
|
||||
row.buyer = buyerNameCache.value[bid]
|
||||
} catch { /* 保持占位 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOrdSearch() { ordPage.value = 1; loadOrd() }
|
||||
function onOrdPage(p: number) { ordPage.value = p; loadOrd() }
|
||||
function onOrdLimit(l: number) { ordLimit.value = l; loadOrd() }
|
||||
|
||||
async function onVoid(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认作废补录单 ${row.supplier_order_code}? 作废后买卖双方均不可见(软删除)`, '作废确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/order/orderbackfill/void', { supplierOrderCode: row.supplier_order_code })
|
||||
ElMessage.success('已作废')
|
||||
loadOrd()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '作废失败') }
|
||||
}
|
||||
|
||||
function fmtTime(v: any) { return String(v || '').replace('T', ' ').slice(0, 19) }
|
||||
function money(v: any) { return Number(v || 0).toFixed(2) }
|
||||
|
||||
onMounted(() => { loadEnt() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<PageHeader title="补录管理" sub="控制哪些企业可使用订单补录功能,并查看全部补录订单">
|
||||
<el-button type="primary" :icon="Plus" @click="openAdd">添加补录企业</el-button>
|
||||
</PageHeader>
|
||||
|
||||
<ListTabs v-model="activeTab" :items="tabItems" @change="(v: string) => { activeTab = v }" />
|
||||
|
||||
<!-- 板块一: 补录企业 -->
|
||||
<template v-if="activeTab === 'ent'">
|
||||
<SearchBar
|
||||
v-model:filters="entFilters"
|
||||
:fields="[{ key: 'enterpriseName', label: '企业名称', placeholder: '请输入企业名称' }]"
|
||||
@search="onEntSearch"
|
||||
@reset="() => { entFilters.enterpriseName = ''; onEntSearch() }"
|
||||
/>
|
||||
<DataTable
|
||||
:data="entList"
|
||||
:columns="entColumns"
|
||||
:loading="entLoading"
|
||||
:total="entTotal"
|
||||
:page="entPage"
|
||||
:limit="entLimit"
|
||||
row-key="id"
|
||||
@page-change="onEntPage"
|
||||
@limit-change="onEntLimit"
|
||||
>
|
||||
<template #name="{ row }">
|
||||
<span style="font-weight:600">{{ row.enterprise_name }}</span>
|
||||
<el-tag v-if="Number(row.status) === 2" type="success" size="small" style="margin-left:6px">已认证</el-tag>
|
||||
</template>
|
||||
<template #score="{ row }">
|
||||
<span style="font-weight:700">{{ Number(row.current_score ?? 60) }}</span>
|
||||
<span class="rr-muted" style="font-size:11px"> 分</span>
|
||||
</template>
|
||||
<template #time="{ row }"><span class="rr-time">{{ fmtTime(row.enabled_time) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<TableActions :row="row" :actions="[{ label: '关闭权限', type: 'danger', onClick: (r: any) => onRemove(r) }]" />
|
||||
</template>
|
||||
</DataTable>
|
||||
<div v-if="!entLoading && !entList.length" class="empty-tip">
|
||||
暂无企业开通补录权限,点击右上角「添加补录企业」开通
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 板块二: 补录订单 -->
|
||||
<template v-else>
|
||||
<SearchBar
|
||||
v-model:filters="ordFilters"
|
||||
:fields="[{ key: 'keyword', label: '关键词', placeholder: '订单号 / 商品名' }]"
|
||||
@search="onOrdSearch"
|
||||
@reset="() => { ordFilters.keyword = ''; onOrdSearch() }"
|
||||
/>
|
||||
<DataTable
|
||||
:data="ordList"
|
||||
:columns="ordColumns"
|
||||
:loading="ordLoading"
|
||||
:total="ordTotal"
|
||||
:page="ordPage"
|
||||
:limit="ordLimit"
|
||||
row-key="supplier_order_code"
|
||||
@page-change="onOrdPage"
|
||||
@limit-change="onOrdLimit"
|
||||
>
|
||||
<template #ocode="{ row }"><span class="rr-time">{{ row.supplier_order_code }}</span></template>
|
||||
<template #buyer="{ row }">{{ row.buyer }}</template>
|
||||
<template #money="{ row }"><b style="color:var(--primary)">¥{{ money(row.order_money) }}</b></template>
|
||||
<template #role="{ row }">
|
||||
<el-tag :type="Number(row.backfill_role) === 1 ? 'warning' : 'primary'" size="small">
|
||||
{{ Number(row.backfill_role) === 1 ? '卖方补录' : '买方补录' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #by="{ row }"><span class="rr-muted">{{ row.backfill_by || '—' }}</span></template>
|
||||
<template #inv="{ row }">
|
||||
<el-tag v-if="Number(row.invoice_count) > 0" type="success" size="small">已开票</el-tag>
|
||||
<span v-else class="rr-muted">—</span>
|
||||
</template>
|
||||
<template #time="{ row }"><span class="rr-time">{{ fmtTime(row.create_date) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<TableActions :row="row" :actions="[{ label: '作废', type: 'danger', onClick: (r: any) => onVoid(r) }]" />
|
||||
</template>
|
||||
</DataTable>
|
||||
<div v-if="!ordLoading && !ordList.length" class="empty-tip">暂无补录订单</div>
|
||||
</template>
|
||||
|
||||
<!-- 添加补录企业弹窗 -->
|
||||
<el-dialog v-model="addDialog" title="添加补录企业" width="520px" destroy-on-close>
|
||||
<div class="add-search">
|
||||
<el-input v-model="addKeyword" placeholder="输入企业名称搜索(仅已认证企业)" clearable @keyup.enter="searchCandidates">
|
||||
<template #append><el-button :icon="Search" :loading="addSearching" @click="searchCandidates">搜索</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="add-list">
|
||||
<div v-for="c in addCandidates" :key="c.id"
|
||||
:class="['add-item', { active: addSelected?.id === c.id }]" @click="addSelected = c">
|
||||
<span class="add-name">{{ c.name }}</span>
|
||||
<el-tag v-if="c.backfillEnabled === 1" type="info" size="small">已开通</el-tag>
|
||||
<span v-else class="add-check">✓</span>
|
||||
</div>
|
||||
<div v-if="!addCandidates.length && !addSearching" class="rr-muted" style="text-align:center;padding:24px 0">
|
||||
输入关键词搜索并选择要开通的企业
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="addDialog = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!addSelected || addSelected.backfillEnabled === 1" @click="confirmAdd">开通补录权限</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-muted { color: var(--ink-3); }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
.empty-tip { text-align: center; color: var(--ink-3); padding: 28px 0; font-size: 13px; }
|
||||
.add-search { margin-bottom: 12px; }
|
||||
.add-list { max-height: 320px; overflow-y: auto; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; }
|
||||
.add-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 11px 14px; cursor: pointer; border-bottom: 1px solid var(--border-1, #e5e7eb);
|
||||
transition: background .12s;
|
||||
}
|
||||
.add-item:last-child { border-bottom: none; }
|
||||
.add-item:hover { background: var(--bg-2, #f8fafc); }
|
||||
.add-item.active { background: var(--primary-soft, #eff6ff); box-shadow: inset 0 0 0 1px var(--primary); }
|
||||
.add-name { font-size: 13.5px; font-weight: 600; color: var(--ink); }
|
||||
.add-check {
|
||||
width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff;
|
||||
font-size: 11px; text-align: center; line-height: 18px; display: none;
|
||||
}
|
||||
.add-item.active .add-check { display: block; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 订单管理列表 - W9 A3 标准化示范
|
||||
* 订单管理列表(商品订单) — 真实业务数据版
|
||||
*
|
||||
* 沿用老 vue order/order-good.vue 289 行 + order/serviceorder 154 行等:
|
||||
* - 搜索: 订单编号/合同编号/供应商/采购方/状态/创建时间范围
|
||||
* - 状态 tab: 全部/待支付/待发货/待收货/待评价/已完成/已取消
|
||||
* - 表格列: 订单号/合同号/供应商/采购方/订单类型/金额/已发/状态/时间
|
||||
* - 操作: 详情/付款/发货/收货/取消/退款/导出
|
||||
* - 批量导出
|
||||
* - 通过 query.type 区分 good/service/group
|
||||
* 数据源: GET /order/ordergood/adminPageList(order-server 后台管理分页, 全量订单, 无企业隔离)
|
||||
* - 过滤: status / orderCode(OP号) / enterpriseName(供应商) / currentEnterpriseName(采购方) / startDate / endDate
|
||||
* - orderGoodType 必传: 1=普通/秒杀 2=拼团
|
||||
* 详情: 跳转 /order/{supplierOrderCode} → OrderDetailView(只读, 信息维度与工作台订单详情一致)
|
||||
*
|
||||
* 字段: id/orderNo/contractCode/enterpriseName/orderUserName/orderType/totalAmount/totalNum/shippedNum/status/createDate/payDate/payType/pickupType
|
||||
* 端点: /order/ordergood/page 200 (按 type 区分 good/service/group)
|
||||
*
|
||||
* W9 A3: 用 PageHeader / SearchBar 组件替换硬编码, 用 DataTable type:'selection' 替换裸表
|
||||
* 历史: W12 版本错挂平台助手 MCP 端点 /sys/openapi/order/list, 供应商/采购方/合同号全空,
|
||||
* 详情页更用 FALLBACK 模拟数据兜底(公司=深圳科技、物流=顺丰均为假数据)。
|
||||
*/
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Clock } from '@element-plus/icons-vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||
@ -27,20 +22,17 @@ import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
orderNo: string
|
||||
supplierOrderCode: string
|
||||
contractCode: string
|
||||
enterpriseName: string
|
||||
orderUserName: string
|
||||
orderType: number
|
||||
supplierName: string
|
||||
buyerName: string
|
||||
goodType: number
|
||||
totalAmount: number
|
||||
totalNum: number
|
||||
shippedNum: number
|
||||
status: number
|
||||
createDate: string
|
||||
payDate: string | null
|
||||
payType: number
|
||||
pickupType: number
|
||||
raw: any
|
||||
}
|
||||
|
||||
@ -67,17 +59,6 @@ const STATUS_TABS = [
|
||||
{ value: '7', label: '已取消' }
|
||||
]
|
||||
|
||||
const FALLBACK: Order[] = [
|
||||
{ id: 'O001', orderNo: 'O20260705001', contractCode: 'HT-2026-07001', enterpriseName: '深圳科技股份有限公司', orderUserName: '深圳科技股份有限公司', orderType: 1, totalAmount: 12500, totalNum: 5, shippedNum: 5, status: 3, createDate: '2026-07-05 10:24:33', payDate: '2026-07-05 10:35:12', payType: 1, pickupType: 1, raw: {} },
|
||||
{ id: 'O002', orderNo: 'O20260705002', contractCode: 'HT-2026-07002', enterpriseName: '上海制造企业', orderUserName: '中建八局', orderType: 1, totalAmount: 158500, totalNum: 100, shippedNum: 60, status: 2, createDate: '2026-07-05 09:15:00', payDate: '2026-07-05 09:30:00', payType: 2, pickupType: 1, raw: {} },
|
||||
{ id: 'O003', orderNo: 'O20260705003', contractCode: '', enterpriseName: '北京电子有限公司', orderUserName: '北京城建', orderType: 2, totalAmount: 45800, totalNum: 20, shippedNum: 0, status: 1, createDate: '2026-07-04 16:42:00', payDate: null, payType: 0, pickupType: 1, raw: {} },
|
||||
{ id: 'O004', orderNo: 'O20260705004', contractCode: 'HT-2026-07004', enterpriseName: '广州工厂', orderUserName: '广州建筑', orderType: 1, totalAmount: 89000, totalNum: 1, shippedNum: 0, status: 4, createDate: '2026-07-04 14:30:00', payDate: '2026-07-04 14:35:00', payType: 3, pickupType: 1, raw: {} },
|
||||
{ id: 'O005', orderNo: 'O20260705005', contractCode: '', enterpriseName: '杭州电子', orderUserName: '杭州电子', orderType: 1, totalAmount: 320000, totalNum: 1, shippedNum: 1, status: 5, createDate: '2026-07-04 11:08:00', payDate: '2026-07-04 11:10:00', payType: 1, pickupType: 2, raw: {} },
|
||||
{ id: 'O006', orderNo: 'O20260705006', contractCode: 'HT-2026-07006', enterpriseName: '深圳科技股份有限公司', orderUserName: '上海建工', orderType: 1, totalAmount: 45000, totalNum: 10, shippedNum: 0, status: 6, createDate: '2026-07-04 10:00:00', payDate: '2026-07-04 10:05:00', payType: 1, pickupType: 1, raw: {} },
|
||||
{ id: 'O007', orderNo: 'O20260705007', contractCode: '', enterpriseName: '上海制造企业', orderUserName: '上海建工', orderType: 1, totalAmount: 56000, totalNum: 3, shippedNum: 0, status: 7, createDate: '2026-07-04 09:00:00', payDate: null, payType: 0, pickupType: 1, raw: {} },
|
||||
{ id: 'O008', orderNo: 'O20260705008', contractCode: 'HT-2026-07008', enterpriseName: '北京电子有限公司', orderUserName: '北京城建', orderType: 2, totalAmount: 120000, totalNum: 1, shippedNum: 1, status: 3, createDate: '2026-07-04 08:30:00', payDate: '2026-07-04 08:35:00', payType: 2, pickupType: 1, raw: {} }
|
||||
]
|
||||
|
||||
const list = ref<Order[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
@ -85,9 +66,9 @@ const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({
|
||||
orderNo: '',
|
||||
contractCode: '',
|
||||
orderCode: '',
|
||||
enterpriseName: '',
|
||||
currentEnterpriseName: '',
|
||||
status: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
@ -95,23 +76,18 @@ const filters = reactive({
|
||||
const selection = ref<Order[]>([])
|
||||
const columns: any = computed(() => [
|
||||
{ type: 'selection', label: '', width: 50 },
|
||||
{ prop: 'orderNo', label: '订单号', width: 180 },
|
||||
{ prop: 'contractCode', label: '合同号', width: 160, slot: 'contractCode' },
|
||||
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, slot: 'enterpriseName' },
|
||||
{ prop: 'orderUserName', label: '采购方', minWidth: 160, slot: 'orderUserName' },
|
||||
{ prop: 'totalAmount', label: '金额', width: 130, align: 'right', slot: 'amount' },
|
||||
{ prop: 'totalNum', label: '数量', width: 80, align: 'center' },
|
||||
{ prop: 'shippedNum', label: '已发', width: 80, align: 'center' },
|
||||
{ prop: 'status', label: '状态', width: 110, align: 'center', slot: 'status' },
|
||||
{ prop: 'createDate', label: '下单时间', width: 170, slot: 'time' },
|
||||
{ prop: 'supplierOrderCode', label: '订单号', width: 200, slot: 'orderNo' },
|
||||
{ prop: 'contractCode', label: '合同号', width: 150, slot: 'contractCode' },
|
||||
{ prop: 'supplierName', label: '供应商', minWidth: 170, slot: 'supplier' },
|
||||
{ prop: 'buyerName', label: '采购方', minWidth: 160, slot: 'buyer' },
|
||||
{ prop: 'totalAmount', label: '金额', width: 120, align: 'right', slot: 'amount' },
|
||||
{ prop: 'totalNum', label: '数量', width: 70, align: 'center' },
|
||||
{ prop: 'shippedNum', label: '已发', width: 70, align: 'center' },
|
||||
{ prop: 'status', label: '状态', width: 100, align: 'center', slot: 'status' },
|
||||
{ prop: 'createDate', label: '下单时间', width: 180, slot: 'time' },
|
||||
{ label: '操作', width: 140, align: 'center', slot: 'action' }
|
||||
])
|
||||
const tabItems = computed(() => STATUS_TABS.map(t => ({
|
||||
key: t.value,
|
||||
label: t.label,
|
||||
count: t.value === '' ? stats.all : (stats[Number(t.value)] || 0)
|
||||
})))
|
||||
const stats = reactive<Record<number|string, number>>({ 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, all: 0 })
|
||||
const tabItems = computed(() => STATUS_TABS.map(t => ({ key: t.value, label: t.label })))
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -121,50 +97,51 @@ const listType = computed(() => {
|
||||
if (path.includes('/group-supplier') || path.includes('/groupsupplierorder')) return 'groupsupplier'
|
||||
if (path.includes('/group-service') || path.includes('/groupserviceorder')) return 'groupservice'
|
||||
if (path.includes('/after')) return 'aftersale'
|
||||
if (path.includes('/commission')) return 'commission'
|
||||
return 'good'
|
||||
})
|
||||
|
||||
// W12.2 修正: 后端 OpenApiOrderController 只暴露 /openapi/order/list 一个端点,
|
||||
// 旧路径 (/order/ordergood/page 等) 404。所有 5 种订单类型都走统一端点。
|
||||
const endpoint = computed(() => '/sys/openapi/order/list')
|
||||
// 后端后台分页: orderGoodType 必传 (1=商品订单 2=拼团订单)
|
||||
const endpoint = computed(() => '/order/ordergood/adminPageList')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// W12.3 修复: httpGet() 已解开 res.data.data, 所以 r 直接是 {total, list, page, limit}
|
||||
const r: any = await httpGet(endpoint.value, { page: page.value, limit: limit.value })
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
orderGoodType: listType.value === 'groupsupplier' ? 2 : 1
|
||||
}
|
||||
if (filters.status) params.status = filters.status
|
||||
if (filters.orderCode) params.orderCode = filters.orderCode
|
||||
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
|
||||
if (filters.currentEnterpriseName) params.currentEnterpriseName = filters.currentEnterpriseName
|
||||
if (filters.startDate) params.startDate = filters.startDate
|
||||
if (filters.endDate) params.endDate = filters.endDate
|
||||
const r: any = await httpGet(endpoint.value, params)
|
||||
const respList: any[] = Array.isArray(r?.list) ? r.list : []
|
||||
const rows: Order[] = respList.map((it: any) => ({
|
||||
id: it.orderId || '',
|
||||
orderNo: it.orderId || '',
|
||||
contractCode: '',
|
||||
enterpriseName: '',
|
||||
orderUserName: '',
|
||||
orderType: 1,
|
||||
totalAmount: Number(it.totalAmount ?? 0),
|
||||
totalNum: Number(it.goodsQuantity ?? 0),
|
||||
shippedNum: 0,
|
||||
supplierOrderCode: it.supplierOrderCode || it.orderCode || '',
|
||||
contractCode: it.contractCode || '',
|
||||
supplierName: it.enterpriseName || '',
|
||||
buyerName: it.currentEnterpriseName || it.orderUserName || (it.nickName ? `${it.nickName}${it.mobile ? ' (' + it.mobile + ')' : ''}` : ''),
|
||||
goodType: Number(it.goodType ?? 1),
|
||||
totalAmount: Number(it.orderMoney ?? it.sum ?? 0),
|
||||
totalNum: Number(it.totalNum ?? 0),
|
||||
shippedNum: Number(it.shippedNum ?? 0),
|
||||
status: Number(it.status ?? 0),
|
||||
createDate: it.createDate ? String(it.createDate) : '',
|
||||
payDate: null,
|
||||
payType: 0,
|
||||
pickupType: 1,
|
||||
payDate: it.payDate ? String(it.payDate) : null,
|
||||
raw: it
|
||||
}))
|
||||
list.value = rows
|
||||
// 后端 total 是字符串 "1356", parseInt 兜底
|
||||
const t = r?.total
|
||||
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||
// 各 status 计数
|
||||
Object.keys(stats).forEach(k => (stats as any)[k] = 0)
|
||||
stats.all = rows.length
|
||||
rows.forEach(r => { stats[r.status] = ((stats[r.status] as number) || 0) + 1 })
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = FALLBACK
|
||||
total.value = FALLBACK.length
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -187,9 +164,9 @@ function fmtDate(ts?: string | number | null) {
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
filters.orderNo = ''
|
||||
filters.contractCode = ''
|
||||
filters.orderCode = ''
|
||||
filters.enterpriseName = ''
|
||||
filters.currentEnterpriseName = ''
|
||||
filters.status = ''
|
||||
filters.startDate = ''
|
||||
filters.endDate = ''
|
||||
@ -200,18 +177,77 @@ function onLimitChange(l: number) { limit.value = l; load() }
|
||||
function onSelectionChange(rows: Order[]) { selection.value = rows }
|
||||
|
||||
function onStatusTabChange(status: string) { filters.status = status; onSearch() }
|
||||
function onViewDetail(row: Order) { router.push(`/order/${row.id}`) }
|
||||
function onPay(row: Order) { ElMessage.info('请前往前台完成支付') }
|
||||
function onShip(row: Order) { ElMessage.info('请前往供应商端发货') }
|
||||
function onConfirmReceipt(row: Order) { ElMessage.info('请前往前台确认收货') }
|
||||
function onRefund(row: Order) { ElMessage.info('请前往售后管理处理退款') }
|
||||
function onCancel(row: Order) { ElMessage.info('请前往前台取消订单') }
|
||||
function onPrint(row: Order) { window.print() }
|
||||
function onViewDetail(row: Order) { router.push(`/order/${row.supplierOrderCode}`) }
|
||||
function onExportSelected() {
|
||||
if (!selection.value.length) { ElMessage.warning('请先选择订单'); return }
|
||||
ElMessage.info('导出功能开发中')
|
||||
}
|
||||
|
||||
// ===== 平台服务费(居间费)设置 =====
|
||||
const feeDialog = ref(false)
|
||||
const feeRow = ref<Order | null>(null)
|
||||
const feeLoading = ref(false)
|
||||
const feeExist = ref<any>(null) // 已有服务费详情
|
||||
const feeForm = reactive({ feeType: 1, feeRate: '1', fixedMoney: '' })
|
||||
const feeSubmitting = ref(false)
|
||||
|
||||
async function onServiceFee(row: Order) {
|
||||
feeRow.value = row
|
||||
feeExist.value = null
|
||||
feeForm.feeType = 1
|
||||
feeForm.feeRate = '1'
|
||||
feeForm.fixedMoney = ''
|
||||
feeDialog.value = true
|
||||
feeLoading.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/order/platformservicefee/detail', { orderCode: row.supplierOrderCode })
|
||||
feeExist.value = r || null
|
||||
} catch { feeExist.value = null }
|
||||
feeLoading.value = false
|
||||
}
|
||||
|
||||
const feePreview = computed(() => {
|
||||
if (feeExist.value) return ''
|
||||
if (feeForm.feeType === 1) {
|
||||
const r = Number(feeForm.feeRate) || 0
|
||||
return ((feeRow.value?.totalAmount || 0) * r / 100).toFixed(2)
|
||||
}
|
||||
return (Number(feeForm.fixedMoney) || 0).toFixed(2)
|
||||
})
|
||||
|
||||
const FEE_STATUS: Record<number, string> = { 1: '待付款', 2: '部分已付', 3: '已付清', 4: '已作废' }
|
||||
const PERIOD_STATUS: Record<number, string> = { 1: '待支付', 2: '待确认', 3: '已支付' }
|
||||
|
||||
async function onFeeSubmit() {
|
||||
if (!feeRow.value) return
|
||||
const v = feeForm.feeType === 1 ? feeForm.feeRate : feeForm.fixedMoney
|
||||
if (!v || Number(v) <= 0) { ElMessage.warning(feeForm.feeType === 1 ? '请输入比例' : '请输入固定金额'); return }
|
||||
feeSubmitting.value = true
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/set', {
|
||||
orderCode: feeRow.value.supplierOrderCode,
|
||||
feeType: feeForm.feeType,
|
||||
feeRate: feeForm.feeType === 1 ? v : v
|
||||
})
|
||||
ElMessage.success('服务费设置成功, 已生成供应商应付款')
|
||||
feeDialog.value = false
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '设置失败')
|
||||
} finally { feeSubmitting.value = false }
|
||||
}
|
||||
|
||||
async function onFeeCancelExist() {
|
||||
if (!feeExist.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认作废该订单的服务费? 作废后可重新设置', '作废确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/cancel', { id: feeExist.value.id })
|
||||
ElMessage.success('已作废')
|
||||
onServiceFee(feeRow.value!)
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '作废失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@ -228,9 +264,9 @@ onMounted(load)
|
||||
<SearchBar
|
||||
v-model:filters="filters"
|
||||
:fields="[
|
||||
{ key: 'orderNo', label: '订单号', placeholder: '请输入订单号' },
|
||||
{ key: 'contractCode', label: '合同号', placeholder: '请输入合同号' },
|
||||
{ key: 'orderCode', label: '订单号', placeholder: '请输入订单号' },
|
||||
{ key: 'enterpriseName', label: '供应商', placeholder: '请输入供应商名称' },
|
||||
{ key: 'currentEnterpriseName', label: '采购方', placeholder: '请输入采购方企业名称' },
|
||||
{ key: 'startDate', label: '开始日期', type: 'date', placeholder: '选择开始日期' },
|
||||
{ key: 'endDate', label: '结束日期', type: 'date', placeholder: '选择结束日期' }
|
||||
]"
|
||||
@ -250,21 +286,24 @@ onMounted(load)
|
||||
:error="error"
|
||||
empty-text="暂无订单"
|
||||
:borderless="true"
|
||||
row-key="id"
|
||||
row-key="supplierOrderCode"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #orderNo="{ row }">
|
||||
<span class="rr-order-no">{{ row.supplierOrderCode }}</span>
|
||||
</template>
|
||||
<template #contractCode="{ row }">
|
||||
<span v-if="row.contractCode">{{ row.contractCode }}</span>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
<template #enterpriseName="{ row }">
|
||||
<span v-if="row.enterpriseName">{{ row.enterpriseName }}</span>
|
||||
<template #supplier="{ row }">
|
||||
<span v-if="row.supplierName">{{ row.supplierName }}</span>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
<template #orderUserName="{ row }">
|
||||
<span v-if="row.orderUserName">{{ row.orderUserName }}</span>
|
||||
<template #buyer="{ row }">
|
||||
<span v-if="row.buyerName">{{ row.buyerName }}</span>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
<template #time="{ row }">
|
||||
@ -283,22 +322,203 @@ onMounted(load)
|
||||
:row="row"
|
||||
:actions="[
|
||||
{ label: '详情', type: 'primary', onClick: onViewDetail },
|
||||
{ label: '付款', type: 'primary', when: (r: any) => r.status === 1, onClick: onPay },
|
||||
{ label: '发货', type: 'primary', when: (r: any) => r.status === 2, onClick: onShip },
|
||||
{ label: '收货', type: 'primary', when: (r: any) => r.status === 3, onClick: onConfirmReceipt },
|
||||
{ label: '退款', type: 'warning', when: (r: any) => r.status === 4, onClick: onRefund },
|
||||
{ label: '取消', type: 'danger', when: (r: any) => [1,2].includes(r.status), onClick: onCancel },
|
||||
{ label: '打印', type: 'primary', onClick: onPrint }
|
||||
{ label: '服务费', type: 'primary', onClick: onServiceFee }
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- 平台服务费设置弹窗 -->
|
||||
<el-dialog v-model="feeDialog" title="平台服务费(居间费)" width="660px" destroy-on-close class="fee-dialog">
|
||||
<div v-if="feeLoading" style="padding:40px 0;text-align:center;color:var(--ink-3)">加载中...</div>
|
||||
<template v-else-if="feeExist && feeRow">
|
||||
<!-- 已设置: 概览卡 -->
|
||||
<div class="fee-info-card">
|
||||
<div class="fee-grid">
|
||||
<div class="fee-field"><span class="f-label">服务费单号</span><span class="f-value mono">{{ feeExist.fee_code }}</span></div>
|
||||
<div class="fee-field"><span class="f-label">状态</span>
|
||||
<span class="f-value"><span class="st-pill" :style="{ color: statusPill(feeExist.status).color, background: statusPill(feeExist.status).bg }"><span class="st-dot"></span>{{ FEE_STATUS[feeExist.status] || feeExist.status }}</span></span>
|
||||
</div>
|
||||
<div class="fee-field"><span class="f-label">计费方式</span><span class="f-value">{{ feeExist.fee_type === 1 ? '按订单金额比例 ' + feeExist.fee_rate + '%' : '固定金额' }}</span></div>
|
||||
<div class="fee-field"><span class="f-label">设置时间</span><span class="f-value">{{ String(feeExist.create_time || '').replace('T', ' ').slice(0, 19) }}</span></div>
|
||||
</div>
|
||||
<div class="fee-progress">
|
||||
<div class="fp-row">
|
||||
<span>已收款</span>
|
||||
<b>¥{{ Number(feeExist.paid_money || 0).toFixed(2) }}</b>
|
||||
<span class="fp-sep">/</span>
|
||||
<span>服务费总额</span>
|
||||
<b class="fp-total">¥{{ Number(feeExist.fee_money).toFixed(2) }}</b>
|
||||
</div>
|
||||
<div class="fp-bar"><div class="fp-bar-in" :style="{ width: Math.min(100, Number(feeExist.fee_money) ? Number(feeExist.paid_money || 0) / Number(feeExist.fee_money) * 100 : 0) + '%' }"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-section-title">付款期次<span class="fee-section-sub">跟随订单付款方式拆分,供应商向平台主体转账后由运营确认</span></div>
|
||||
<el-table :data="feeExist.periods || []" size="small" border>
|
||||
<el-table-column prop="period_num" label="期次" width="55" align="center" />
|
||||
<el-table-column label="付款节点" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:600">{{ row.period_name || '第' + row.period_num + '期' }}</span>
|
||||
<span v-if="row.period_ratio != null" class="fee-ratio">{{ Number(row.period_ratio) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="110" align="right">
|
||||
<template #default="{ row }">¥{{ Number(row.pay_money).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.pay_status === 1 && row.node_unlocked === 0" type="info" size="small" effect="plain">🔒 未到节点</el-tag>
|
||||
<el-tag v-else :type="row.pay_status === 3 ? 'success' : row.pay_status === 2 ? 'warning' : 'info'" size="small">{{ PERIOD_STATUS[row.pay_status] || row.pay_status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="凭证/实收" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.pay_status === 3">实收 ¥{{ Number(row.received_money).toFixed(2) }}({{ (row.pay_date || '').slice(0, 10) }})</span>
|
||||
<el-image v-else-if="row.images" :src="'/api/other/file/preview?url=' + encodeURIComponent(String(row.images).split(',')[0])" :preview-src-list="String(row.images).split(',').map((u: string) => '/api/other/file/preview?url=' + encodeURIComponent(u))" fit="cover" style="width:36px;height:36px;border-radius:4px" />
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-else-if="feeRow">
|
||||
<!-- 未设置: 订单信息卡 -->
|
||||
<div class="fee-info-card">
|
||||
<div class="fee-grid">
|
||||
<div class="fee-field"><span class="f-label">订单号</span><span class="f-value mono">{{ feeRow.supplierOrderCode }}</span></div>
|
||||
<div class="fee-field"><span class="f-label">供应商(付款方)</span><span class="f-value">{{ feeRow.supplierName }}</span></div>
|
||||
<div class="fee-field"><span class="f-label">订单金额</span><span class="f-value f-strong">¥{{ Number(feeRow.totalAmount).toFixed(2) }}</span></div>
|
||||
<div class="fee-field"><span class="f-label">收款方</span><span class="f-value">郑州巨变网络科技有限公司<span class="f-tag">平台主体</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计费方式选择卡 -->
|
||||
<div class="fee-section-title">计费方式</div>
|
||||
<div class="fee-type-cards">
|
||||
<div :class="['fee-type-card', { active: feeForm.feeType === 1 }]" @click="feeForm.feeType = 1">
|
||||
<div class="ftc-head"><span class="ftc-icon">%</span><span class="ftc-check">✓</span></div>
|
||||
<div class="ftc-title">按订单金额比例</div>
|
||||
<div class="ftc-desc">订单金额 × 比例,随订单规模浮动</div>
|
||||
</div>
|
||||
<div :class="['fee-type-card', { active: feeForm.feeType === 2 }]" @click="feeForm.feeType = 2">
|
||||
<div class="ftc-head"><span class="ftc-icon">¥</span><span class="ftc-check">✓</span></div>
|
||||
<div class="ftc-title">固定金额</div>
|
||||
<div class="ftc-desc">一次性定额,与订单金额无关</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入 -->
|
||||
<div class="fee-section-title">{{ feeForm.feeType === 1 ? '收费比例' : '服务费金额' }}</div>
|
||||
<div class="fee-input-row">
|
||||
<template v-if="feeForm.feeType === 1">
|
||||
<el-input-number v-model="feeForm.feeRate" :min="0.01" :max="100" :precision="2" :step="0.5" controls-position="right" style="width:170px" />
|
||||
<span class="fee-unit">%</span>
|
||||
<span class="fee-hint">订单金额 ¥{{ Number(feeRow.totalAmount).toFixed(2) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input-number v-model="feeForm.fixedMoney" :min="0.01" :precision="2" :step="100" controls-position="right" style="width:200px" />
|
||||
<span class="fee-unit">元</span>
|
||||
<span class="fee-hint">不可超过订单金额</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 结果卡 -->
|
||||
<div class="fee-result">
|
||||
<div class="fr-left">
|
||||
<div class="fr-cap">服务费金额</div>
|
||||
<div class="fr-val">¥{{ feePreview }}</div>
|
||||
<div class="fr-calc">{{ feeForm.feeType === 1 ? `¥${Number(feeRow.totalAmount).toFixed(2)} × ${Number(feeForm.feeRate || 0)}%` : '一次性定额收取' }}</div>
|
||||
</div>
|
||||
<div class="fr-right">
|
||||
<div class="fr-step" v-for="(st, si) in ['生成应付款(按订单期次拆分)', '供应商向平台主体转账并上传凭证', '运营确认收款,期次逐笔核销'] as any" :key="si">
|
||||
<span class="frs-dot">{{ si + 1 }}</span>{{ st }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="feeExist">
|
||||
<el-button @click="feeDialog = false">关闭</el-button>
|
||||
<el-button v-if="feeExist.status !== 3 && feeExist.status !== 4" type="danger" plain @click="onFeeCancelExist">作废重设</el-button>
|
||||
<el-button type="primary" @click="router.push('/order/servicefee')">前往服务费管理</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click="feeDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="feeSubmitting" @click="onFeeSubmit">生成应付款</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1500px; }
|
||||
/* 表头与数据统一居中 (覆盖 DataTable inline 的表头居中/数据左对齐错位, 2026-10-16) */
|
||||
.rr-list-card :deep(.el-table th.el-table__cell),
|
||||
.rr-list-card :deep(.el-table td.el-table__cell) {
|
||||
text-align: center !important;
|
||||
}
|
||||
/* 操作列链接不换行不贴边 */
|
||||
.rr-list-card :deep(.el-table td:last-child .el-button + .el-button) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.rr-muted { color: var(--ink-3); }
|
||||
.rr-order-no { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12.5px; color: var(--ink); }
|
||||
.rr-amount { font-weight: 600; color: var(--primary); }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
/* ===== 平台服务费弹窗 ===== */
|
||||
.fee-info-card {
|
||||
background: var(--bg-2, #f8fafc);
|
||||
border: 1px solid var(--border-1, #e5e7eb);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.fee-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; }
|
||||
.fee-field { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.f-label { flex-shrink: 0; font-size: 12px; color: var(--ink-3); }
|
||||
.f-value { font-size: 13px; color: var(--ink); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.f-value.mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; }
|
||||
.f-strong { font-weight: 700; color: var(--primary); }
|
||||
.f-tag { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; }
|
||||
.fee-progress { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-1, #e5e7eb); }
|
||||
.fp-row { display: flex; align-items: baseline; gap: 6px; font-size: 12px; color: var(--ink-3); margin-bottom: 6px; }
|
||||
.fp-row b { color: var(--ink); font-size: 14px; }
|
||||
.fp-total { color: var(--primary) !important; }
|
||||
.fp-sep { margin: 0 4px; }
|
||||
.fp-bar { height: 8px; border-radius: 999px; background: rgba(0,0,0,.06); overflow: hidden; }
|
||||
.fp-bar-in { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--primary), var(--primary-dark, #1d4ed8)); transition: width .3s; }
|
||||
.fee-section-title { font-size: 13px; font-weight: 700; color: var(--ink); margin: 16px 0 8px; }
|
||||
.fee-section-sub { margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--ink-3); }
|
||||
.fee-type-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.fee-type-card {
|
||||
position: relative; border: 1.5px solid var(--border-1, #e5e7eb); border-radius: 10px;
|
||||
padding: 12px 14px; cursor: pointer; transition: all .15s; background: var(--bg-1, #fff);
|
||||
}
|
||||
.fee-type-card:hover { border-color: var(--primary); }
|
||||
.fee-type-card.active { border-color: var(--primary); background: var(--primary-soft, #eff6ff); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||
.ftc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.ftc-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background: var(--primary-soft, #dbeafe); color: var(--primary); font-size: 16px; font-weight: 800; }
|
||||
.ftc-check { display: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; }
|
||||
.fee-type-card.active .ftc-check { display: block; }
|
||||
.ftc-title { font-size: 13.5px; font-weight: 700; color: var(--ink); }
|
||||
.ftc-desc { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||
.fee-input-row { display: flex; align-items: center; gap: 8px; padding: 4px 0 2px; }
|
||||
.fee-unit { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||
.fee-hint { font-size: 12px; color: var(--ink-3); margin-left: 6px; }
|
||||
.fee-result {
|
||||
margin-top: 14px; border-radius: 10px; padding: 14px 16px;
|
||||
background: linear-gradient(135deg, var(--primary-soft, #eff6ff), rgba(255,255,255,0));
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
display: flex; gap: 20px; align-items: center;
|
||||
}
|
||||
.fr-left { flex-shrink: 0; min-width: 160px; }
|
||||
.fr-cap { font-size: 12px; color: var(--ink-3); }
|
||||
.fr-val { font-size: 26px; font-weight: 800; color: var(--primary); line-height: 1.2; }
|
||||
.fr-calc { font-size: 12px; color: var(--ink-2); margin-top: 2px; }
|
||||
.fr-right { flex: 1; display: flex; flex-direction: column; gap: 7px; border-left: 1px dashed var(--border-1, #e5e7eb); padding-left: 18px; }
|
||||
.fr-step { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||
.frs-dot { flex-shrink: 0; width: 16px; height: 16px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 10px; font-weight: 700; text-align: center; line-height: 16px; }
|
||||
|
||||
.fee-ratio { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; }
|
||||
</style>
|
||||
677
src/modules/order/ServiceFeeListView.vue
Normal file
677
src/modules/order/ServiceFeeListView.vue
Normal file
@ -0,0 +1,677 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 平台服务费(居间费)管理 — admin
|
||||
*
|
||||
* 数据源: /order/platformservicefee/page (order-server)
|
||||
* 流程: 订单管理设置服务费 → 供应商工作台上传转账凭证 → 此处确认收款/驳回
|
||||
* 操作: 确认收款(录入实收+查看凭证) / 驳回凭证 / 作废 / 期次明细展开
|
||||
*/
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, http } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||
|
||||
interface FeeRow {
|
||||
id: number
|
||||
fee_code: string
|
||||
order_code: string
|
||||
buyer_enterprise_name: string
|
||||
supplier_enterprise_name: string
|
||||
order_money: number
|
||||
fee_type: number
|
||||
fee_rate: number | null
|
||||
fee_money: number
|
||||
paid_money: number
|
||||
status: number
|
||||
period_count: number
|
||||
paid_period_count: number
|
||||
create_time: string
|
||||
periods: any[]
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<number, string> = { 1: '待付款', 2: '部分已付', 3: '已付清', 4: '已作废' }
|
||||
const STATUS_PILL: Record<number, { color: string; bg: string }> = {
|
||||
3: { color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||
4: { color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
|
||||
}
|
||||
const PERIOD_LABEL: Record<number, string> = { 1: '待支付', 2: '待确认', 3: '已支付' }
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '待付款' },
|
||||
{ value: '2', label: '部分已付' },
|
||||
{ value: '3', label: '已付清' },
|
||||
{ value: '4', label: '已作废' }
|
||||
]
|
||||
|
||||
const list = ref<FeeRow[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ orderCode: '', supplierName: '', status: '' })
|
||||
const expandedRows = ref<number[]>([])
|
||||
|
||||
// 表格改原生 el-table 行展开 (2026-10-16: 期次清单原渲染在页面底部, 低分辨率屏看不到以为无反应)
|
||||
const feeTableRef = ref()
|
||||
const tabItems = STATUS_TABS.map(t => ({ key: t.value, label: t.label }))
|
||||
|
||||
function statusPill(status: number) {
|
||||
return STATUS_PILL[status] || { color: 'var(--primary)', bg: 'var(--primary-soft, #dbeafe)' }
|
||||
}
|
||||
function feeTypeText(row: FeeRow) {
|
||||
return row.fee_type === 1 ? `比例 ${row.fee_rate}%` : '固定金额'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params: any = { page: page.value, limit: limit.value }
|
||||
if (filters.orderCode) params.orderCode = filters.orderCode
|
||||
if (filters.supplierName) params.supplierName = filters.supplierName
|
||||
if (filters.status) params.status = filters.status
|
||||
const r: any = await httpGet('/order/platformservicefee/page', params)
|
||||
if (r?.sum) sum.value = r.sum
|
||||
list.value = (r?.list || []).map((it: any) => ({ ...it, periods: [] }))
|
||||
const t = r?.total
|
||||
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePeriods(row: FeeRow) {
|
||||
if (!row.periods.length) {
|
||||
try {
|
||||
const d: any = await httpGet('/order/platformservicefee/detail', { orderCode: row.order_code })
|
||||
row.periods = d?.periods || []
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '期次加载失败') }
|
||||
}
|
||||
}
|
||||
// 点期次列/期次明细: 懒加载后触发行展开箭头
|
||||
async function toggleExpand(row: FeeRow) {
|
||||
await ensurePeriods(row)
|
||||
feeTableRef.value?.toggleRowExpansion(row)
|
||||
}
|
||||
// 用户直接点行首展开箭头: 展开时懒加载期次
|
||||
async function onExpandChange(row: FeeRow, expanded: FeeRow[]) {
|
||||
expandedRows.value = expanded.map((r: any) => r.id)
|
||||
if (expanded.includes(row)) await ensurePeriods(row)
|
||||
}
|
||||
|
||||
// ===== 汇总统计 =====
|
||||
const sum = ref<any>({ total_fee: 0, total_paid: 0, total_pending: 0, fee_count: 0 })
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.orderCode = ''; filters.supplierName = ''; filters.status = ''; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; load() }
|
||||
function onStatusTabChange(v: string) { filters.status = v; onSearch() }
|
||||
|
||||
// ===== 主 tab: 服务费列表 / 开票申请 =====
|
||||
const mainTab = ref<'fee' | 'invoice'>('fee')
|
||||
|
||||
// ===== 开票申请(admin 处理) =====
|
||||
const invList = ref<any[]>([])
|
||||
const invTotal = ref(0)
|
||||
const invPage = ref(1)
|
||||
const invLimit = ref(10)
|
||||
const invLoading = ref(false)
|
||||
const invStatus = ref('')
|
||||
const INV_LABEL: Record<number, string> = { 1: '待开票', 2: '已开票', 3: '已驳回' }
|
||||
const invTabItems = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '待开票' },
|
||||
{ value: '2', label: '已开票' },
|
||||
{ value: '3', label: '已驳回' }
|
||||
]
|
||||
|
||||
async function loadInvoices() {
|
||||
invLoading.value = true
|
||||
try {
|
||||
const params: any = { page: invPage.value, limit: invLimit.value }
|
||||
if (invStatus.value) params.status = invStatus.value
|
||||
const r: any = await httpGet('/order/platformservicefee/invoiceAdminList', params)
|
||||
invList.value = r?.list || []
|
||||
invTotal.value = Number(r?.total || 0)
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '开票申请加载失败') } finally { invLoading.value = false }
|
||||
}
|
||||
function onInvSearch() { invPage.value = 1; loadInvoices() }
|
||||
function onInvPageChange(p: number) { invPage.value = p; loadInvoices() }
|
||||
function onInvLimitChange(l: number) { invLimit.value = l; invPage.value = 1; loadInvoices() }
|
||||
|
||||
const invDialog = ref(false)
|
||||
const invRow = ref<any>(null)
|
||||
const invAction = ref<'approve' | 'reject'>('approve')
|
||||
const invFile = ref<string[]>([])
|
||||
const invUploading = ref(false)
|
||||
const invDate = ref('')
|
||||
const invRejectReason = ref('')
|
||||
const invSubmitting = ref(false)
|
||||
|
||||
function openProcess(row: any, action: 'approve' | 'reject') {
|
||||
invRow.value = row
|
||||
invAction.value = action
|
||||
invFile.value = []
|
||||
invDate.value = new Date().toISOString().slice(0, 10)
|
||||
invRejectReason.value = ''
|
||||
invDialog.value = true
|
||||
}
|
||||
|
||||
async function onInvFileChange(e: any) {
|
||||
const files = Array.from(e.target?.files || [])
|
||||
if (e.target) e.target.value = ''
|
||||
for (const f of files) {
|
||||
invUploading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', f as File)
|
||||
const res: any = await http.post('/sys/file/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
if (res?.data?.url) invFile.value.push(res.data.url)
|
||||
else ElMessage.error(res?.msg || '上传失败')
|
||||
} catch (e: any) { ElMessage.error(e?.message || '上传失败') } finally { invUploading.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProcess() {
|
||||
if (!invRow.value) return
|
||||
invSubmitting.value = true
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/invoiceProcess', {
|
||||
id: invRow.value.id,
|
||||
action: invAction.value,
|
||||
invoiceImage: invFile.value.join(','),
|
||||
invoiceDate: invDate.value,
|
||||
rejectReason: invRejectReason.value
|
||||
})
|
||||
ElMessage.success(invAction.value === 'approve' ? '已开票并上传发票' : '已驳回')
|
||||
invDialog.value = false
|
||||
loadInvoices()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') } finally { invSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ===== 确认收款 =====
|
||||
const confirmDialog = ref(false)
|
||||
const confirmPeriod = ref<any>(null)
|
||||
const confirmRow = ref<FeeRow | null>(null)
|
||||
const receivedMoney = ref('')
|
||||
const confirmSubmitting = ref(false)
|
||||
|
||||
function openConfirm(row: FeeRow, period: any) {
|
||||
confirmRow.value = row
|
||||
confirmPeriod.value = period
|
||||
receivedMoney.value = String(period.pay_money)
|
||||
confirmDialog.value = true
|
||||
}
|
||||
|
||||
const payWayLabel = (p: any) => (Number(p?.pay_way) === 2 ? '线上支付' : Number(p?.pay_way) === 1 ? '线下转账' : '—')
|
||||
|
||||
async function onRejectFromDialog() {
|
||||
if (!confirmRow.value || !confirmPeriod.value) return
|
||||
let reason = ''
|
||||
try {
|
||||
const r = await ElMessageBox.prompt('驳回后该期次回到待支付, 凭证将被清除', '驳回凭证', {
|
||||
inputPlaceholder: '驳回原因(选填)', type: 'warning'
|
||||
})
|
||||
reason = r.value || ''
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/reject', { payId: confirmPeriod.value.id, reason })
|
||||
ElMessage.success('已驳回, 该期次已回到待支付')
|
||||
confirmDialog.value = false
|
||||
await load()
|
||||
expandedRows.value = []
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '驳回失败') }
|
||||
}
|
||||
|
||||
function fillFullAmount() {
|
||||
if (confirmPeriod.value) receivedMoney.value = String(confirmPeriod.value.pay_money)
|
||||
}
|
||||
|
||||
async function submitConfirm() {
|
||||
const m = Number(receivedMoney.value)
|
||||
if (!(m > 0)) { ElMessage.warning('请输入实收金额'); return }
|
||||
confirmSubmitting.value = true
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/confirm', { payId: confirmPeriod.value.id, receivedMoney: m })
|
||||
ElMessage.success('确认收款成功')
|
||||
confirmDialog.value = false
|
||||
await load()
|
||||
expandedRows.value = []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
} finally { confirmSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ===== 驳回 =====
|
||||
async function onReject(row: FeeRow, period: any) {
|
||||
let reason = ''
|
||||
try {
|
||||
const r = await ElMessageBox.prompt('驳回后该期次回到待支付, 凭证将被清除', '驳回凭证', {
|
||||
inputPlaceholder: '驳回原因(选填)', type: 'warning'
|
||||
})
|
||||
reason = r.value || ''
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/reject', { payId: period.id, reason })
|
||||
ElMessage.success('已驳回')
|
||||
row.periods = []
|
||||
expandedRows.value = []
|
||||
await load()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '驳回失败') }
|
||||
}
|
||||
|
||||
// ===== 作废 =====
|
||||
async function onCancel(row: FeeRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认作废 ${row.fee_code}? 仅允许作废无已收款期次的服务费`, '作废确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/order/platformservicefee/cancel', { id: row.id })
|
||||
ElMessage.success('已作废')
|
||||
load()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '作废失败') }
|
||||
}
|
||||
|
||||
function previewUrl(u: string) {
|
||||
return '/api/other/file/preview?url=' + encodeURIComponent(u)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<PageHeader title="平台服务费管理" :sub="`供应商应付平台居间费 · 共 ${total} 条`" />
|
||||
|
||||
<div class="sum-grid">
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">应付服务费总额</div>
|
||||
<div class="sum-value" style="color:var(--primary)">¥{{ Number(sum.total_fee || 0).toFixed(2) }}</div>
|
||||
<div class="sum-sub">共 {{ Number(sum.fee_count || 0) }} 笔有效服务费</div>
|
||||
</div>
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">已确认收款</div>
|
||||
<div class="sum-value" style="color:var(--el-color-success,#67c23a)">¥{{ Number(sum.total_paid || 0).toFixed(2) }}</div>
|
||||
<div class="sum-sub">含部分确认期次</div>
|
||||
</div>
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">待收款</div>
|
||||
<div class="sum-value" style="color:#e6a23c">¥{{ Number(sum.total_pending || 0).toFixed(2) }}</div>
|
||||
<div class="sum-sub">总额 − 已确认收款</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-tabs">
|
||||
<button class="main-tab" :class="{ active: mainTab === 'fee' }" @click="mainTab = 'fee'">服务费列表</button>
|
||||
<button class="main-tab" :class="{ active: mainTab === 'invoice' }" @click="mainTab = 'invoice'; loadInvoices()">开票申请</button>
|
||||
</div>
|
||||
|
||||
<template v-if="mainTab === 'fee'">
|
||||
<ListTabs v-model="filters.status" :items="tabItems" @change="onStatusTabChange" />
|
||||
|
||||
<SearchBar
|
||||
:fields="[
|
||||
{ key: 'orderCode', label: '订单号', placeholder: 'OSP 订单号' },
|
||||
{ key: 'supplierName', label: '供应商', placeholder: '供应商企业名' }
|
||||
]"
|
||||
v-model:filters="filters"
|
||||
@search="onSearch"
|
||||
@reset="onReset"
|
||||
/>
|
||||
|
||||
<div v-if="error" class="rr-empty-tip" style="color:#ef4444">{{ error }} <el-link type="primary" @click="load">重试</el-link></div>
|
||||
<el-table ref="feeTableRef" :data="list" row-key="id" v-loading="loading" border
|
||||
@expand-change="onExpandChange" style="width:100%">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="period-block" style="margin:0 12px 12px">
|
||||
<div class="period-title">{{ row.fee_code }} · 付款期次(跟随订单付款方式拆分)</div>
|
||||
<el-table :data="row.periods" size="small" border>
|
||||
<el-table-column prop="period_num" label="期次" width="60" align="center" />
|
||||
<el-table-column label="付款节点" min-width="150">
|
||||
<template #default="{ row: p }">
|
||||
<span style="font-weight:600">{{ p.period_name || '第' + p.period_num + '期' }}</span>
|
||||
<span v-if="p.period_ratio != null" class="fee-ratio">{{ Number(p.period_ratio) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应付金额" width="110" align="right">
|
||||
<template #default="{ row: p }">¥{{ Number(p.pay_money).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row: p }">
|
||||
<el-tag v-if="p.pay_status === 1 && Number(p.node_unlocked) === 0" type="info" size="small" effect="plain">🔒 未到节点</el-tag>
|
||||
<el-tag v-else-if="p.pay_status === 2" type="warning" size="small" effect="dark">待确认收款</el-tag>
|
||||
<el-tag v-else :type="p.pay_status === 3 ? 'success' : 'info'" size="small">{{ PERIOD_LABEL[p.pay_status] }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="转账凭证" min-width="150">
|
||||
<template #default="{ row: p }">
|
||||
<template v-if="p.images">
|
||||
<el-image
|
||||
v-for="(u, i) in String(p.images).split(',').filter(Boolean)"
|
||||
:key="i"
|
||||
:src="previewUrl(u)"
|
||||
:preview-src-list="String(p.images).split(',').filter(Boolean).map(previewUrl)"
|
||||
fit="cover"
|
||||
style="width:38px;height:38px;border-radius:4px;margin-right:4px"
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="rr-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="付款方式/填报" min-width="120" align="center">
|
||||
<template #default="{ row: p }">
|
||||
<div v-if="p.pay_way" style="font-size:12px;font-weight:600">{{ p.pay_way === 1 ? '线下转账' : '线上支付' }}</div>
|
||||
<div v-if="p.submit_money != null" class="rr-muted" style="font-size:11px">填报 ¥{{ Number(p.submit_money).toFixed(2) }}</div>
|
||||
<span v-if="!p.pay_way && p.submit_money == null" class="rr-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实收/驳回" min-width="140">
|
||||
<template #default="{ row: p }">
|
||||
<div v-if="p.pay_status === 3">实收 ¥{{ Number(p.received_money).toFixed(2) }} · {{ String(p.pay_date || '').slice(0, 10) }}</div>
|
||||
<div v-else-if="p.reject_reason" class="rr-muted">驳回: {{ p.reject_reason }}</div>
|
||||
<span v-else class="rr-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row: p }">
|
||||
<template v-if="p.pay_status === 2">
|
||||
<el-button type="primary" size="small" @click="openConfirm(row, p)">确认收款</el-button>
|
||||
</template>
|
||||
<span v-else-if="p.pay_status === 1 && Number(p.node_unlocked) === 0" class="rr-muted" style="font-size:11px">待订单进入该节点</span>
|
||||
<span v-else class="rr-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fee_code" label="服务费单号" width="210">
|
||||
<template #default="{ row }"><span class="rr-order-no">{{ row.fee_code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="order_code" label="订单号" width="210">
|
||||
<template #default="{ row }"><span class="rr-order-no">{{ row.order_code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="supplier_enterprise_name" label="供应商(付款方)" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="订单金额" width="110" align="right">
|
||||
<template #default="{ row }">¥{{ Number(row.order_money).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="服务费" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="rr-fee">¥{{ Number(row.fee_money).toFixed(2) }}</span>
|
||||
<div class="rr-muted" style="font-size:11px">{{ feeTypeText(row) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已收款" width="110" align="right">
|
||||
<template #default="{ row }">¥{{ Number(row.paid_money || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="期次" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
|
||||
{{ row.paid_period_count }}/{{ row.period_count }} 期
|
||||
<span style="font-size:11px">{{ expandedRows.includes(row.id) ? '▲' : '▼' }}</span>
|
||||
</el-link>
|
||||
<div v-if="Number(row.pending_period_count) > 0" style="margin-top:2px">
|
||||
<el-link type="danger" :underline="false" style="font-size:11px;font-weight:600" @click="toggleExpand(row)">⚠ {{ row.pending_period_count }} 期待确认收款</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="st-pill" :style="{ color: statusPill(row.status).color, background: statusPill(row.status).bg }">
|
||||
<span class="st-dot"></span>{{ STATUS_LABEL[row.status] || row.status }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设置时间" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="rr-time">{{ String(row.create_time || '').replace('T', ' ').slice(0, 19) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<TableActions
|
||||
:row="row"
|
||||
:actions="[
|
||||
{ label: '期次明细', type: 'primary', onClick: (r: any) => toggleExpand(r) },
|
||||
...(row.status === 1 || row.status === 2 ? [{ label: '作废', type: 'danger' as const, onClick: (r: any) => onCancel(r) }] : [])
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="display:flex;justify-content:flex-end;padding:14px 4px 4px">
|
||||
<el-pagination background layout="total, sizes, prev, pager, next, jumper" :total="total"
|
||||
:current-page="page" :page-size="limit" :page-sizes="[10, 20, 50]"
|
||||
@current-change="(p: number) => { page = p; load() }"
|
||||
@size-change="(l: number) => { limit = l; page = 1; load() }" />
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="main-tabs" style="margin-bottom:0">
|
||||
<button v-for="t in invTabItems" :key="t.value" class="main-tab" :class="{ active: invStatus === t.value }" @click="invStatus = t.value; onInvSearch()">{{ t.label }}</button>
|
||||
</div>
|
||||
<el-table :data="invList" v-loading="invLoading" size="default" border style="margin-top:12px">
|
||||
<el-table-column prop="create_time" label="申请时间" width="150">
|
||||
<template #default="{ row: r }">{{ String(r.create_time || '').slice(0, 19).replace('T', ' ') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fee_code" label="服务费单号" width="185" />
|
||||
<el-table-column prop="order_code" label="订单号" width="185" />
|
||||
<el-table-column prop="supplier_enterprise_name" label="供应商(付款方)" min-width="150" />
|
||||
<el-table-column label="开票金额" width="110" align="right">
|
||||
<template #default="{ row: r }">¥{{ Number(r.apply_money).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="invoice_body" label="发票抬头" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="invoice_tax" label="税号" width="150" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row: r }">
|
||||
<el-tag :type="r.status === 2 ? 'success' : r.status === 3 ? 'danger' : 'warning'" size="small">{{ INV_LABEL[r.status] || r.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发票" width="120">
|
||||
<template #default="{ row: r }">
|
||||
<template v-if="r.status === 2 && r.invoice_image">
|
||||
<template v-for="(u, i) in String(r.invoice_image).split(',').filter(Boolean)" :key="i">
|
||||
<el-image v-if="/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u)" :src="previewUrl(u)" :preview-src-list="String(r.invoice_image).split(',').filter(Boolean).map(previewUrl)" fit="cover" style="width:34px;height:34px;border-radius:4px;margin-right:4px" />
|
||||
<el-link v-else type="primary" :underline="false" :href="previewUrl(u)" target="_blank" style="font-size:12px;margin-right:6px">发票PDF</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="rr-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template #default="{ row: r }">
|
||||
<template v-if="r.status === 1">
|
||||
<el-button type="primary" size="small" @click="openProcess(r, 'approve')">开票</el-button>
|
||||
<el-button type="danger" plain size="small" @click="openProcess(r, 'reject')">驳回</el-button>
|
||||
</template>
|
||||
<span v-else class="rr-muted">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="display:flex;justify-content:flex-end;padding:12px 0">
|
||||
<el-pagination background layout="total, prev, pager, next, sizes" :total="invTotal" :page-size="invLimit" :current-page="invPage" @current-change="onInvPageChange" @size-change="onInvLimitChange" />
|
||||
</div>
|
||||
|
||||
<!-- 开票处理弹窗 -->
|
||||
<el-dialog v-model="invDialog" :title="invAction === 'approve' ? '开票并上传发票' : '驳回开票申请'" width="480px" destroy-on-close>
|
||||
<div v-if="invRow" style="margin-bottom:12px;color:var(--ink-2);font-size:13px">
|
||||
{{ invRow.supplier_enterprise_name }} · 服务费单 {{ invRow.fee_code }} · 申请开票 <b style="color:var(--primary)">¥{{ Number(invRow.apply_money).toFixed(2) }}</b>
|
||||
<div style="margin-top:4px">抬头: {{ invRow.invoice_body }}<template v-if="invRow.invoice_tax">(税号 {{ invRow.invoice_tax }})</template></div>
|
||||
</div>
|
||||
<template v-if="invAction === 'approve'">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="发票文件">
|
||||
<label style="display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:12px;font-weight:600;border:1px dashed var(--border-1,#dcdfe6);border-radius:6px;cursor:pointer">
|
||||
{{ invUploading ? '上传中...' : '选择文件' }}
|
||||
<input type="file" accept="image/*,.pdf" multiple class="hidden-input" @change="onInvFileChange" />
|
||||
</label>
|
||||
<div v-if="invFile.length" style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
<template v-for="(u, i) in invFile" :key="i">
|
||||
<el-image v-if="/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u)" :src="previewUrl(u)" :preview-src-list="invFile.map(previewUrl)" fit="cover" style="width:56px;height:56px;border-radius:6px" />
|
||||
<el-tag v-else type="danger" size="small">PDF已上传</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="发票日期">
|
||||
<el-date-picker v-model="invDate" type="date" value-format="YYYY-MM-DD" style="width:180px" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input v-model="invRejectReason" type="textarea" :rows="3" placeholder="驳回原因(必填)" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="invDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="invSubmitting" @click="submitProcess">{{ invAction === 'approve' ? '确认开票' : '确认驳回' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<!-- 确认收款弹窗 -->
|
||||
<el-dialog v-model="confirmDialog" title="确认收款" width="560px" destroy-on-close>
|
||||
<div v-if="confirmPeriod" class="confirm-wrap">
|
||||
<!-- 期次信息卡 -->
|
||||
<div class="cfm-head">
|
||||
<div class="cfm-row1">
|
||||
<span class="cfm-ent">{{ confirmRow?.supplier_enterprise_name }}</span>
|
||||
<el-tag type="warning" size="small" effect="dark">待确认收款</el-tag>
|
||||
</div>
|
||||
<div class="cfm-row2">
|
||||
<span>第 {{ confirmPeriod.period_num }} 期 · {{ confirmPeriod.period_name || '第' + confirmPeriod.period_num + '期' }}</span>
|
||||
<span v-if="confirmPeriod.period_ratio != null" class="fee-ratio">占{{ Number(confirmPeriod.period_ratio) }}%</span>
|
||||
<span class="cfm-amount">应付 <b>¥{{ Number(confirmPeriod.pay_money).toFixed(2) }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 付款信息 -->
|
||||
<div class="cfm-payinfo">
|
||||
<div class="cfm-payitem">
|
||||
<div class="cfm-paylabel">付款方式</div>
|
||||
<div class="cfm-payval">{{ payWayLabel(confirmPeriod) }}</div>
|
||||
</div>
|
||||
<div class="cfm-payitem">
|
||||
<div class="cfm-paylabel">供应商填报金额</div>
|
||||
<div class="cfm-payval">{{ confirmPeriod.submit_money != null ? '¥' + Number(confirmPeriod.submit_money).toFixed(2) : '—' }}</div>
|
||||
</div>
|
||||
<div class="cfm-payitem">
|
||||
<div class="cfm-paylabel">服务费单</div>
|
||||
<div class="cfm-payval" style="font-size:12px">{{ confirmRow?.fee_code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 凭证 -->
|
||||
<div v-if="confirmPeriod?.images" class="cfm-vouchers">
|
||||
<div class="cfm-voulabel">供应商转账凭证(点击放大)</div>
|
||||
<div class="cfm-vougrid">
|
||||
<template v-for="(u, i) in String(confirmPeriod.images).split(',').filter(Boolean)" :key="i">
|
||||
<el-image
|
||||
v-if="/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u)"
|
||||
:src="previewUrl(u)"
|
||||
:preview-src-list="String(confirmPeriod.images).split(',').filter(Boolean).map(previewUrl)"
|
||||
fit="cover"
|
||||
class="cfm-vouimg"
|
||||
/>
|
||||
<el-link v-else type="danger" :underline="false" :href="previewUrl(u)" target="_blank" class="cfm-voupdf">凭证PDF</el-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 实收金额 -->
|
||||
<div class="cfm-money">
|
||||
<span class="cfm-paylabel">实收金额</span>
|
||||
<el-input-number v-model="receivedMoney" :min="0.01" :precision="2" :step="100" style="width:180px" />
|
||||
<span style="color:var(--ink-3)">元</span>
|
||||
<el-button size="small" text type="primary" @click="fillFullAmount">按应付全额 ¥{{ Number(confirmPeriod.pay_money).toFixed(2) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="confirmDialog = false">取消</el-button>
|
||||
<el-button type="danger" plain :loading="confirmSubmitting" @click="onRejectFromDialog">驳回凭证</el-button>
|
||||
<el-button type="primary" :loading="confirmSubmitting" @click="submitConfirm">确认收款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1500px; }
|
||||
|
||||
/* ===== 顶部汇总卡 ===== */
|
||||
.sum-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||
.sum-card {
|
||||
background: #fff; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px;
|
||||
padding: 14px 18px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||
}
|
||||
.sum-label { font-size: 12px; color: var(--ink-3); margin-bottom: 6px; }
|
||||
.sum-value { font-size: 24px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||||
.sum-sub { font-size: 11px; color: var(--ink-3); margin-top: 4px; }
|
||||
|
||||
/* ===== 确认收款弹窗 ===== */
|
||||
.confirm-wrap { padding: 0 2px; }
|
||||
.cfm-head {
|
||||
background: linear-gradient(135deg, rgba(37,99,235,.06), rgba(37,99,235,.02));
|
||||
border: 1px solid rgba(37,99,235,.18); border-radius: 10px; padding: 12px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.cfm-row1 { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.cfm-ent { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||
.cfm-row2 { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||
.cfm-amount { margin-left: auto; font-size: 13px; color: var(--ink-2); }
|
||||
.cfm-amount b { font-size: 18px; color: var(--primary); font-variant-numeric: tabular-nums; }
|
||||
.cfm-payinfo { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||
.cfm-payitem {
|
||||
flex: 1; background: var(--bg-1, #fafafa); border: 1px solid var(--border-1, #ececec);
|
||||
border-radius: 8px; padding: 8px 12px;
|
||||
}
|
||||
.cfm-paylabel { font-size: 11px; color: var(--ink-3); margin-bottom: 3px; }
|
||||
.cfm-payval { font-size: 13px; font-weight: 600; color: var(--ink); }
|
||||
.cfm-vouchers { margin-bottom: 12px; }
|
||||
.cfm-voulabel { font-size: 12px; color: var(--ink-3); margin-bottom: 8px; }
|
||||
.cfm-vougrid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.cfm-vouimg { width: 84px; height: 84px; border-radius: 8px; border: 1px solid var(--border-1, #ececec); cursor: zoom-in; }
|
||||
.cfm-voupdf {
|
||||
width: 84px; height: 84px; display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px dashed var(--el-color-danger,#f56c6c); border-radius: 8px; font-size: 12px; background: #fef0f0;
|
||||
}
|
||||
.cfm-money { display: flex; align-items: center; gap: 10px; padding: 12px 0 2px; border-top: 1px solid var(--border-1, #f0f0f0); }
|
||||
.cfm-money .cfm-paylabel { margin-bottom: 0; }
|
||||
|
||||
.main-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.main-tab {
|
||||
padding: 7px 18px; font-size: 13px; font-weight: 600; border-radius: 18px;
|
||||
border: 1px solid var(--border-1, #dcdfe6); background: #fff; color: var(--ink-2);
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.main-tab:hover { color: var(--primary); border-color: var(--primary); }
|
||||
.main-tab.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.hidden-input { display: none; }
|
||||
.rr-muted { color: var(--ink-3); }
|
||||
.rr-order-no { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12.5px; color: var(--ink); }
|
||||
.rr-fee { font-weight: 600; color: var(--primary); }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
.period-block {
|
||||
border: 1px solid var(--border-1, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin: -6px 0 14px;
|
||||
background: var(--bg-1, #fafafa);
|
||||
}
|
||||
.period-title { font-weight: 600; margin-bottom: 8px; font-size: 13px; }
|
||||
.st-pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.st-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.fee-ratio { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; }
|
||||
</style>
|
||||
@ -10,7 +10,7 @@
|
||||
* 图片: POST /sys/file/upload (FormData, 返 data.url) — 参考旧系统 information-add-or-update.vue
|
||||
*/
|
||||
import { onMounted, ref, reactive, computed } from 'vue'
|
||||
import { httpGet, httpPost, httpDelete } from '@/utils/request'
|
||||
import { httpGet, httpPost, httpDelete, http } from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
@ -62,10 +62,12 @@ const columns = [
|
||||
]
|
||||
|
||||
const imgStyle = computed(() => ({
|
||||
maxWidth: '140px',
|
||||
maxHeight: '48px',
|
||||
// 固定宽高 + cover 裁切: 封面列所有图统一尺寸 (原 max 约束按原图比例缩放导致大小不一)
|
||||
width: '140px',
|
||||
height: '48px',
|
||||
objectFit: 'cover' as const,
|
||||
borderRadius: '4px',
|
||||
display: 'block' as const,
|
||||
}))
|
||||
|
||||
async function load() {
|
||||
@ -147,7 +149,8 @@ async function submit(status: number) {
|
||||
async function onDelete(row: Info) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除资讯「${row.title}」吗?`, '提示', { type: 'warning' })
|
||||
await httpDelete('/other/information/delete', { id: row.id })
|
||||
// 后端 delete 接口是 @RequestBody Long[] ids (JSON 数组), httpDelete 只支持 query 参数故直接用 http 实例
|
||||
await http.delete('/other/information/delete', { data: [row.id] })
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
|
||||
@ -121,12 +121,14 @@ const filters = reactive({
|
||||
checkStatus: ''
|
||||
})
|
||||
|
||||
// 弹窗
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref<Good | null>(null)
|
||||
const checkDialogVisible = ref(false)
|
||||
const checkData = ref<Good | null>(null)
|
||||
const checkForm = reactive({ checkStatus: 2, checkOpinion: '' })
|
||||
// 商品详情/审核抽屉 (2026-09-18 重设计: 大抽屉分区展示 完整信息 + 审核操作条)
|
||||
const drawerVisible = ref(false)
|
||||
const drawerLoading = ref(false)
|
||||
const drawerRow = ref<Good | null>(null)
|
||||
const drawerInfo = ref<any>(null)
|
||||
const drawerFocusAudit = ref(false)
|
||||
const drawerAuditForm = reactive({ checkStatus: 2, checkOpinion: '' })
|
||||
const galleryActive = ref(0)
|
||||
const categoryDialogVisible = ref(false)
|
||||
const categoryData = ref<Good | null>(null)
|
||||
const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' })
|
||||
@ -194,13 +196,17 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// W13.1: /supplier/good/pageList 返完整字段 (16133 条) — 比 search 端点更全
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', {
|
||||
// W13.1: /supplier/good/pageList 返完整字段 — 比 search 端点更全
|
||||
// 2026-09-20: 筛选条件下推到后端 (原仅前端过滤当前页, 导致 tab 计数 42 与列表 16106 条不一致)
|
||||
const queryParams: Record<string, any> = {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
type: 1, // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
|
||||
...(filters.title ? { title: filters.title } : {})
|
||||
})
|
||||
type: 1 // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
|
||||
}
|
||||
if (filters.title) queryParams.title = filters.title
|
||||
if (filters.checkStatus) queryParams.checkStatus = Number(filters.checkStatus)
|
||||
if (filters.enterpriseName) queryParams.enterpriseName = filters.enterpriseName
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', queryParams)
|
||||
let rows = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
supplierGoodId: it.supplierGoodId || String(it.id),
|
||||
@ -218,8 +224,7 @@ async function load() {
|
||||
createDate: it.createDate || '',
|
||||
raw: it
|
||||
}))
|
||||
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
|
||||
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
|
||||
// 筛选已由后端条件完成 (checkStatus/enterpriseName/title 下推), rows 即当前页数据
|
||||
list.value = rows
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
@ -247,25 +252,50 @@ const tabItems = computed(() => [
|
||||
|
||||
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
|
||||
|
||||
function onShowDetail(row: Good) { detailData.value = row; detailVisible.value = true }
|
||||
function onShowCheckDialog(row: Good) { checkData.value = row; checkForm.checkStatus = 2; checkForm.checkOpinion = ''; checkDialogVisible.value = true }
|
||||
async function onSubmitCheck() {
|
||||
if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return }
|
||||
// W14.3: 真实调 /supplier/good/checkGood?checkStatus=&checkOpinion=
|
||||
function splitImages(v: any): string[] {
|
||||
return String(v || '').split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
/** 打开商品详情/审核抽屉 (focusAudit=true 时审核入口进入并定位操作条) */
|
||||
async function openGoodDrawer(row: Good, focusAudit = false) {
|
||||
drawerRow.value = row
|
||||
drawerInfo.value = null
|
||||
drawerFocusAudit.value = focusAudit
|
||||
drawerAuditForm.checkStatus = 2
|
||||
drawerAuditForm.checkOpinion = ''
|
||||
galleryActive.value = 0
|
||||
drawerVisible.value = true
|
||||
drawerLoading.value = true
|
||||
try {
|
||||
// 列表行 id 是 tb_good(铺货表) 的 id; 详情接口要 tb_supplier_good.id -> 用行的 supplierGoodId
|
||||
const detailId = Number(row.raw?.supplierGoodId) || Number(row.raw?.supplier_good_id) || Number(row.id)
|
||||
const r: any = await httpGet('/supplier/supplierGood/info', { id: detailId })
|
||||
// httpGet 已解包: r 即 SupplierGoodDTO (含 images/content/skuList/skuDetailList)
|
||||
drawerInfo.value = r && (r.id || r.title) ? r : (row.raw || row)
|
||||
} catch {
|
||||
drawerInfo.value = row.raw || row
|
||||
} finally { drawerLoading.value = false }
|
||||
if (focusAudit) {
|
||||
setTimeout(() => document.querySelector('.rr-gd-audit')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 400)
|
||||
}
|
||||
}
|
||||
const drawerImages = computed(() => splitImages(drawerInfo.value?.images))
|
||||
const drawerSkuTotalStore = computed(() => (drawerInfo.value?.skuDetailList || []).reduce((s: number, it: any) => s + (Number(it.store) || 0), 0))
|
||||
const drawerCategoryPath = computed(() => [drawerInfo.value?.firstCategoryName, drawerInfo.value?.secondCategoryName, drawerInfo.value?.thirdCategoryName].filter(Boolean).join(' / '))
|
||||
function onShowDetail(row: Good) { openGoodDrawer(row, false) }
|
||||
function onShowCheckDialog(row: Good) { openGoodDrawer(row, true) }
|
||||
async function onSubmitDrawerAudit() {
|
||||
const f = drawerAuditForm
|
||||
if (f.checkStatus === 3 && !f.checkOpinion.trim()) { ElMessage.warning('驳回时请填写审核意见'); return }
|
||||
const id = drawerRow.value?.id || drawerInfo.value?.id
|
||||
try {
|
||||
const id = checkData.value?.id || checkData.value?.raw?.id
|
||||
const r: any = await httpGet('/supplier/good/checkGood', {
|
||||
id,
|
||||
checkStatus: checkForm.checkStatus,
|
||||
checkOpinion: checkForm.checkOpinion || ''
|
||||
id, checkStatus: f.checkStatus, checkOpinion: f.checkOpinion.trim()
|
||||
})
|
||||
if (isOk(r)) {
|
||||
ElMessage.success(checkForm.checkStatus === 2 ? '审核通过' : '已驳回')
|
||||
checkDialogVisible.value = false
|
||||
ElMessage.success(f.checkStatus === 2 ? '审核通过' : '已驳回')
|
||||
drawerVisible.value = false
|
||||
load()
|
||||
} else {
|
||||
ElMessage.error(r?.msg || '操作失败')
|
||||
}
|
||||
} else { ElMessage.error(r?.msg || '操作失败') }
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||
}
|
||||
async function onOnShelf(row: Good) {
|
||||
@ -477,74 +507,141 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 (含 SKU 列表, 沿用老 vue 弹窗布局) -->
|
||||
<el-dialog v-model="detailVisible" title="商品详情" width="960px" :close-on-click-modal="false">
|
||||
<div v-if="detailData">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="商品ID">{{ detailData.supplierGoodId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称" :span="2">{{ detailData.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">{{ detailData.enterpriseName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="品牌">{{ detailData.brandName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类" :span="2">
|
||||
{{ [detailData.firstCategoryName, detailData.secondCategoryName, detailData.thirdCategoryName].filter(Boolean).join(' / ') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[detailData.checkStatus]?.color, background: CHECK_STATUS_MAP[detailData.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="铺货">
|
||||
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[detailData.onShelf]?.color, background: ONSHELF_STATUS_MAP[detailData.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<!-- ===== 商品详情 / 审核抽屉 (2026-09-18 重设计: 分区式完整信息 + 底部审核操作条) ===== -->
|
||||
<el-drawer v-model="drawerVisible" size="min(940px, 94vw)" :with-header="true"
|
||||
:title="drawerRow?.title ? `商品详情 · ${drawerRow.title}` : '商品详情'"
|
||||
destroy-on-close :close-on-click-modal="false" class="rr-gd-drawer">
|
||||
<div v-loading="drawerLoading" class="rr-gd-wrap">
|
||||
<template v-if="drawerInfo">
|
||||
<!-- 头部: 主图 + 标题/标签 + 元信息 + 价格 -->
|
||||
<div class="rr-gd-head">
|
||||
<el-image :src="drawerInfo.icon || drawerImages[0]" fit="cover"
|
||||
:preview-src-list="[drawerInfo.icon || drawerImages[0]].filter(Boolean)" preview-teleported
|
||||
class="rr-gd-mainimg">
|
||||
<template #error><div class="rr-gd-imgerr">无图</div></template>
|
||||
</el-image>
|
||||
<div class="rr-gd-headmain">
|
||||
<div class="rr-gd-titleline">
|
||||
<span class="rr-gd-title">{{ drawerInfo.title || drawerRow?.title }}
|
||||
<span v-if="Number(drawerInfo.sourceType) === 2" class="px-1.5 py-0.5 bg-amber-50 text-amber-600 border border-amber-200 rounded text-[10px] font-bold align-middle" style="margin-left:6px">共享商品</span>
|
||||
</span>
|
||||
<span class="rr-gd-code">{{ drawerInfo.goodCode || drawerInfo.id }}</span>
|
||||
</div>
|
||||
<div class="rr-gd-tags">
|
||||
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[Number(drawerRow?.checkStatus ?? -1)]?.color, background: CHECK_STATUS_MAP[Number(drawerRow?.checkStatus ?? -1)]?.bg }"><span class="st-dot"></span>{{ (drawerRow ? CHECK_STATUS_MAP[drawerRow.checkStatus] : undefined)?.label || '--' }}</span>
|
||||
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.color, background: ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.label || '--' }}</span>
|
||||
</div>
|
||||
<div class="rr-gd-price">
|
||||
<span class="rr-gd-price-now">¥ {{ drawerInfo.newPrice ?? drawerRow?.raw?.newPrice ?? '--' }}</span>
|
||||
<span v-if="drawerInfo.oldPrice" class="rr-gd-price-old">¥ {{ drawerInfo.oldPrice }}</span>
|
||||
<span class="rr-gd-price-unit">/ {{ drawerInfo.unitName || drawerRow?.unitName || '件' }}</span>
|
||||
</div>
|
||||
<div class="rr-gd-meta">
|
||||
<span><label>供应商</label>{{ drawerInfo.enterpriseName || drawerRow?.enterpriseName || '--' }}</span>
|
||||
<span><label>品牌</label>{{ drawerInfo.brandName || drawerRow?.brandName || '--' }}</span>
|
||||
<span><label>分类</label>{{ drawerCategoryPath || '--' }}</span>
|
||||
<span><label>申请时间</label>{{ drawerInfo.createDate || drawerRow?.createDate || '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="rr-sku-title">SKU 列表 ({{ detailData.skuList?.length || 0 }})</h4>
|
||||
<el-table v-if="detailData.skuList && detailData.skuList.length > 0" :data="detailData.skuList" border>
|
||||
<el-table-column label="SKU编码" prop="skuCode" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="SKU组合" prop="propertyNames" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="原价" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.oldPrice?.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="现价" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.newPrice?.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="秒杀价" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.seckillPrice">¥ {{ row.seckillPrice.toLocaleString() }}</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="拼团价" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.groupPrice">¥ {{ row.groupPrice.toLocaleString() }}</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" prop="store" width="80" align="center" />
|
||||
<el-table-column label="最小购买" prop="buyMin" width="100" align="center" />
|
||||
</el-table>
|
||||
<div v-else class="rr-empty-tip">暂无 SKU</div>
|
||||
<!-- 商品图片画廊 -->
|
||||
<div class="rr-gd-section" v-if="drawerImages.length">
|
||||
<h4 class="rr-gd-sectitle">商品图片 <span class="rr-gd-seccount">{{ drawerImages.length }} 张</span></h4>
|
||||
<div class="rr-gd-gallery">
|
||||
<el-image v-for="(img, gi) in drawerImages" :key="gi" :src="img" fit="cover"
|
||||
:preview-src-list="drawerImages" :initial-index="gi" preview-teleported hide-on-click-modal
|
||||
class="rr-gd-thumb">
|
||||
<template #error><div class="rr-gd-imgerr">加载失败</div></template>
|
||||
</el-image>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 介绍视频 -->
|
||||
<div class="rr-gd-section" v-if="drawerInfo.videoUrl">
|
||||
<h4 class="rr-gd-sectitle">介绍视频</h4>
|
||||
<video :src="drawerInfo.videoUrl" controls preload="metadata" class="rr-gd-video"></video>
|
||||
</div>
|
||||
|
||||
<!-- 规格与 SKU -->
|
||||
<div class="rr-gd-section" v-if="(drawerInfo.skuList?.length || drawerInfo.skuDetailList?.length)">
|
||||
<h4 class="rr-gd-sectitle">规格与 SKU <span class="rr-gd-seccount">{{ drawerInfo.skuDetailList?.length || 0 }} 个 SKU · 库存合计 {{ drawerSkuTotalStore }}</span></h4>
|
||||
<div v-if="drawerInfo.skuList?.length" class="rr-gd-props">
|
||||
<div v-for="grp in drawerInfo.skuList" :key="grp.id" class="rr-gd-prop">
|
||||
<label>{{ grp.name }}</label>
|
||||
<span class="rr-gd-propchip" v-for="pv in grp.propertyList" :key="pv.id">{{ pv.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-if="drawerInfo.skuDetailList?.length" :data="drawerInfo.skuDetailList" border size="small"
|
||||
max-height="320" class="rr-gd-skutable">
|
||||
<el-table-column label="规格组合" prop="propertyNames" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="图片" width="64" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.icon" :src="row.icon" fit="cover" :preview-src-list="[row.icon]" preview-teleported class="rr-gd-skuimg" />
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="SKU编码" prop="skuCode" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="现价" width="96" align="right">
|
||||
<template #default="{ row }">¥ {{ row.newPrice ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原价" width="96" align="right">
|
||||
<template #default="{ row }">{{ row.oldPrice ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="秒杀价" width="96" align="right">
|
||||
<template #default="{ row }">{{ row.seckillPrice ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="拼团价" width="96" align="right">
|
||||
<template #default="{ row }">{{ row.groupPrice ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" width="72" align="center" prop="store" />
|
||||
<el-table-column label="起购" width="64" align="center" prop="buyMin" />
|
||||
<el-table-column label="限购" width="64" align="center">
|
||||
<template #default="{ row }">{{ row.limitNum ?? '--' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 商品详情 (富文本) -->
|
||||
<div class="rr-gd-section">
|
||||
<h4 class="rr-gd-sectitle">商品详情</h4>
|
||||
<div v-if="drawerInfo.content" class="rr-gd-content" v-html="drawerInfo.content"></div>
|
||||
<div v-else-if="drawerInfo.remark" class="rr-gd-content">{{ drawerInfo.remark }}</div>
|
||||
<div v-else class="rr-gd-emptypar">商家未填写图文详情</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核信息 -->
|
||||
<div class="rr-gd-section rr-gd-auditinfo">
|
||||
<h4 class="rr-gd-sectitle">审核信息</h4>
|
||||
<div class="rr-gd-auditline">
|
||||
<span><label>当前状态</label>{{ (drawerRow ? CHECK_STATUS_MAP[drawerRow.checkStatus] : undefined)?.label || '--' }}</span>
|
||||
<span><label>审核意见</label>{{ drawerRow?.checkOpinion || drawerInfo?.checkOpinion || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<el-dialog v-model="checkDialogVisible" title="商品审核" width="500px">
|
||||
<el-form v-if="checkData" label-width="100px">
|
||||
<el-form-item label="商品名称">{{ checkData.title }}</el-form-item>
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="checkForm.checkStatus">
|
||||
<el-radio :value="2">通过</el-radio>
|
||||
<el-radio :value="3">驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见" required>
|
||||
<el-input v-model="checkForm.checkOpinion" type="textarea" :rows="4" placeholder="请填写审核意见" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 底部固定审核操作条 -->
|
||||
<template #footer>
|
||||
<el-button @click="checkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitCheck">确定</el-button>
|
||||
<!-- 待审核判断以列表行状态为准 (审核入口数据源; 个别脏数据 info 返回值可能不一致) -->
|
||||
<div v-if="drawerRow && Number(drawerRow.checkStatus) === 1" class="rr-gd-audit">
|
||||
<el-radio-group v-model="drawerAuditForm.checkStatus" class="rr-gd-auditradio">
|
||||
<el-radio :value="2">审核通过</el-radio>
|
||||
<el-radio :value="3">审核驳回</el-radio>
|
||||
</el-radio-group>
|
||||
<!-- 通过无需填写审核意见; 驳回时必填 -->
|
||||
<el-input v-if="drawerAuditForm.checkStatus === 3" v-model="drawerAuditForm.checkOpinion" type="textarea"
|
||||
:rows="2" maxlength="200" placeholder="请填写驳回原因(必填),将展示给商家" class="rr-gd-auditinput" />
|
||||
<div class="rr-gd-auditbtns">
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitDrawerAudit">提交审核结果</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rr-gd-auditdone">
|
||||
<span v-if="drawerRow">该商品已完成审核({{ CHECK_STATUS_MAP[drawerRow.checkStatus]?.label }}<template v-if="drawerInfo?.checkOpinion">:{{ drawerInfo.checkOpinion }}</template>),如需修改请使用列表「⋯ → 上架/下架」操作。</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 修改分类弹窗 (单个) -->
|
||||
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px">
|
||||
@ -603,4 +700,41 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
||||
.rr-good-title { font-size: 13.5px; color: var(--ink); }
|
||||
.rr-sku-title { font-size: 14px; font-weight: 600; color: var(--ink); margin: 24px 0 12px 0; }
|
||||
.rr-empty-tip { text-align: center; padding: 30px 0; color: var(--ink-3); }
|
||||
|
||||
/* ===== 商品详情/审核抽屉 ===== */
|
||||
.rr-gd-wrap { padding: 0 2px; }
|
||||
.rr-gd-head { display: flex; gap: 16px; padding: 4px 0 14px; border-bottom: 1px solid var(--border); }
|
||||
.rr-gd-mainimg { flex: 0 0 96px; width: 96px; height: 96px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg, #f6f8fb); }
|
||||
.rr-gd-imgerr { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 12px; color: var(--ink-3); }
|
||||
.rr-gd-headmain { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rr-gd-titleline { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||
.rr-gd-title { font-size: 16px; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rr-gd-code { flex: 0 0 auto; font-size: 11px; font-family: 'SF Mono', Menlo, Consolas, monospace; color: var(--ink-3); background: var(--bg, #f6f8fb); border: 1px solid var(--border); border-radius: 4px; padding: 1px 8px; }
|
||||
.rr-gd-tags { display: flex; gap: 8px; }
|
||||
.rr-gd-price { display: flex; align-items: baseline; gap: 8px; }
|
||||
.rr-gd-price-now { font-size: 22px; font-weight: 800; color: #dc2626; }
|
||||
.rr-gd-price-old { font-size: 12px; color: var(--ink-3); text-decoration: line-through; }
|
||||
.rr-gd-price-unit { font-size: 12px; color: var(--ink-3); }
|
||||
.rr-gd-meta { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--ink-2); }
|
||||
.rr-gd-meta label { color: var(--ink-3); margin-right: 4px; }
|
||||
.rr-gd-section { margin-top: 18px; }
|
||||
.rr-gd-sectitle { font-size: 13px; font-weight: 700; color: var(--ink); margin-bottom: 10px; padding-left: 8px; border-left: 3px solid var(--primary); line-height: 1.2; }
|
||||
.rr-gd-seccount { font-weight: 500; font-size: 11px; color: var(--ink-3); margin-left: 6px; }
|
||||
.rr-gd-gallery { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.rr-gd-thumb { width: 92px; height: 92px; border-radius: 10px; border: 1px solid var(--border); cursor: zoom-in; background: var(--bg, #f6f8fb); }
|
||||
.rr-gd-video { max-width: 360px; width: 100%; border-radius: 10px; border: 1px solid var(--border); background: #000; }
|
||||
.rr-gd-props { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||
.rr-gd-prop { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rr-gd-prop label { flex: 0 0 88px; font-size: 12px; color: var(--ink-3); }
|
||||
.rr-gd-propchip { font-size: 11px; color: var(--ink-2); background: var(--bg, #f6f8fb); border: 1px solid var(--border); border-radius: 20px; padding: 2px 10px; }
|
||||
.rr-gd-skuimg { width: 40px; height: 40px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }
|
||||
.rr-gd-content { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--surface); max-height: 460px; overflow-y: auto; }
|
||||
.rr-gd-content :deep(img) { max-width: 100%; height: auto; display: block; margin: 6px 0; border-radius: 6px; }
|
||||
.rr-gd-content :deep(p) { margin: 6px 0; line-height: 1.8; font-size: 13px; color: var(--ink); }
|
||||
.rr-gd-emptypar { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); border-radius: 8px; padding: 14px; }
|
||||
.rr-gd-auditinfo .rr-gd-auditline { display: flex; gap: 28px; font-size: 13px; color: var(--ink-2); }
|
||||
.rr-gd-auditinfo label { color: var(--ink-3); margin-right: 6px; }
|
||||
.rr-gd-audit { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rr-gd-auditradio { margin-bottom: 2px; }
|
||||
.rr-gd-auditdone { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); border-radius: 8px; padding: 10px 12px; line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 价格公式变量管理
|
||||
* 走 /supplier/supplierGoodPriceFormulaVariable/page (2026-08-08 探测: 200, total=5)
|
||||
* CRUD: save/update/delete + updateValue
|
||||
* 字段: id/var_key/var_name/var_unit/current_value/source_type/source_code/formula/sort/status/create_date
|
||||
* 价格公式变量管理 (运营后台)
|
||||
*
|
||||
* 数据源: /supplier/supplierGoodPriceFormulaVariable/*
|
||||
* 来源类型: 1手动输入 / 2自动采集(HTTP) / 3公式计算 / 4脚本获取(JS, Nashorn)
|
||||
* 能力:
|
||||
* - 新增/编辑/删除 (编辑走 save 全字段更新)
|
||||
* - 行内快捷改值: 点击"当前值"直接输入新值 → updateValue → 引用商品公式自动重算
|
||||
* - 立即刷新: 采集/脚本来源点击立即执行 → refresh → 值更新 → 引用商品重算 (不需改商品公式)
|
||||
* - 自动刷新: refresh_interval_minutes 分钟间隔后台定时执行
|
||||
*/
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@ -21,6 +26,10 @@ interface FormulaVar {
|
||||
source_type: number
|
||||
source_code: string
|
||||
formula: string
|
||||
refresh_script: string
|
||||
refresh_interval_minutes: number | null
|
||||
last_refresh_time: any
|
||||
last_refresh_msg: string | null
|
||||
sort: number
|
||||
status: number
|
||||
create_date: any
|
||||
@ -30,12 +39,22 @@ interface FormulaVar {
|
||||
const SOURCE_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '手动输入', type: 'info' },
|
||||
2: { label: '自动采集', type: 'success' },
|
||||
3: { label: '公式计算', type: 'warning' }
|
||||
3: { label: '公式计算', type: 'warning' },
|
||||
4: { label: '脚本获取', type: 'danger' }
|
||||
}
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '禁用', type: 'danger' },
|
||||
1: { label: '启用', type: 'success' }
|
||||
}
|
||||
// 自动刷新间隔选项 (分钟)
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 0, label: '不自动刷新' },
|
||||
{ value: 10, label: '每 10 分钟' },
|
||||
{ value: 30, label: '每 30 分钟' },
|
||||
{ value: 60, label: '每小时' },
|
||||
{ value: 240, label: '每 4 小时' },
|
||||
{ value: 1440, label: '每天' }
|
||||
]
|
||||
|
||||
const list = ref<FormulaVar[]>([])
|
||||
const total = ref(0)
|
||||
@ -45,19 +64,19 @@ const filters = reactive({ var_name: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'add' | 'edit'>('add')
|
||||
const editForm = reactive<FormulaVar>({ id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', sort: 0, status: 1, create_date: null, raw: {} })
|
||||
const editForm = reactive<FormulaVar>({ id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', refresh_script: '', refresh_interval_minutes: 0, last_refresh_time: null, last_refresh_msg: null, sort: 0, status: 1, create_date: null, raw: {} })
|
||||
|
||||
const columns = [
|
||||
{ prop: 'id', label: 'ID', width: 60 },
|
||||
{ prop: 'var_name', label: '变量名称', minWidth: 120 },
|
||||
{ prop: 'var_key', label: '变量标识', width: 120 },
|
||||
{ prop: 'var_unit', label: '单位', width: 80 },
|
||||
{ prop: 'current_value', label: '当前值', width: 120, formatter: (r: FormulaVar) => r.current_value?.toLocaleString() || '0' },
|
||||
{ prop: 'var_name', label: '变量名称', minWidth: 110 },
|
||||
{ prop: 'var_unit', label: '单位', width: 70 },
|
||||
{ prop: 'current_value', label: '当前值', width: 110, slot: 'value' },
|
||||
{ prop: 'source_type', label: '数据来源', width: 100, slot: 'source' },
|
||||
{ prop: 'formula', label: '计算公式', minWidth: 150, formatter: (r: FormulaVar) => r.formula || '--' },
|
||||
{ prop: 'sort', label: '排序', width: 60 },
|
||||
{ prop: 'refresh_interval_minutes', label: '自动刷新', width: 100, slot: 'interval' },
|
||||
{ prop: 'last_refresh_time', label: '最近刷新', width: 170, slot: 'lastRefresh' },
|
||||
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
||||
{ prop: 'create_date', label: '创建时间', width: 160, formatter: (r: FormulaVar) => fmtDate(r.create_date) }
|
||||
{ prop: 'create_date', label: '创建时间', width: 160, formatter: (r: FormulaVar) => fmtDate(r.create_date) },
|
||||
{ label: '操作', width: 180, align: 'center' as const, slot: 'action' }
|
||||
]
|
||||
|
||||
function fmtDate(d: any): string {
|
||||
@ -65,6 +84,10 @@ function fmtDate(d: any): string {
|
||||
if (typeof d === 'string') return d.slice(0, 19)
|
||||
try { return `${d.year}-${String(d.monthValue).padStart(2,'0')}-${String(d.dayOfMonth).padStart(2,'0')} ${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}` } catch { return '--' }
|
||||
}
|
||||
function intervalName(min: any): string {
|
||||
const v = Number(min || 0)
|
||||
return INTERVAL_OPTIONS.find(o => o.value === v)?.label || (v > 0 ? `每 ${v} 分钟` : '不自动')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@ -81,22 +104,46 @@ function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
||||
|
||||
function onAdd() {
|
||||
editMode.value = 'add'
|
||||
Object.assign(editForm, { id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', sort: 0, status: 1, create_date: null, raw: {} })
|
||||
Object.assign(editForm, { id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', refresh_script: '', refresh_interval_minutes: 0, last_refresh_time: null, last_refresh_msg: null, sort: 0, status: 1, create_date: null, raw: {} })
|
||||
editVisible.value = true
|
||||
}
|
||||
function onEdit(row: FormulaVar) {
|
||||
editMode.value = 'edit'
|
||||
Object.assign(editForm, JSON.parse(JSON.stringify(row)))
|
||||
Object.assign(editForm, JSON.parse(JSON.stringify(row)), { refresh_interval_minutes: row.refresh_interval_minutes || 0 })
|
||||
editVisible.value = true
|
||||
}
|
||||
function onSubmitEdit() {
|
||||
if (!editForm.var_name.trim() || !editForm.var_key.trim()) { ElMessage.warning('请填写变量名称和标识'); return }
|
||||
const ep = editMode.value === 'add' ? '/supplier/supplierGoodPriceFormulaVariable/save' : '/supplier/supplierGoodPriceFormulaVariable/updateValue'
|
||||
httpPost(ep, { ...editForm }).then(() => {
|
||||
ElMessage.success(editMode.value === 'add' ? '新增成功' : '修改成功')
|
||||
if (!editForm.var_name.trim()) { ElMessage.warning('请填写变量名称'); return }
|
||||
// 变量标识未填时自动取变量名称 (系统公式按变量名称引用, 标识仅作唯一性用途)
|
||||
const payload = { ...editForm, var_key: editForm.var_key.trim() || editForm.var_name.trim() }
|
||||
// 新增和编辑都走 save (全字段保存; 编辑不再走 updateValue 以免丢失名称/单位/来源等修改)
|
||||
httpPost('/supplier/supplierGoodPriceFormulaVariable/save', payload).then(() => {
|
||||
ElMessage.success(editMode.value === 'add' ? '新增成功' : '保存成功')
|
||||
editVisible.value = false; load()
|
||||
}).catch((e: any) => ElMessage.error(e?.msg || '操作失败'))
|
||||
}
|
||||
// 行内快捷改值 (只更新当前值, 触发引用商品公式重算)
|
||||
function onQuickEditValue(row: FormulaVar) {
|
||||
ElMessageBox.prompt(`请输入「${row.var_name}」的新当前值`, '修改当前值', {
|
||||
inputValue: String(row.current_value ?? 0),
|
||||
inputPattern: /^-?\d+(\.\d+)?$/,
|
||||
inputErrorMessage: '请输入数字'
|
||||
}).then(({ value }) => {
|
||||
return httpPost('/supplier/supplierGoodPriceFormulaVariable/updateValue', { id: row.id, current_value: Number(value) })
|
||||
}).then(() => {
|
||||
ElMessage.success('已更新,引用该变量的商品价格已自动重算')
|
||||
load()
|
||||
}).catch((e: any) => {
|
||||
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.msg || '更新失败')
|
||||
})
|
||||
}
|
||||
// 立即执行采集/脚本刷新
|
||||
function onRefreshRow(row: FormulaVar) {
|
||||
httpPost('/supplier/supplierGoodPriceFormulaVariable/refresh', { id: row.id }).then((r: any) => {
|
||||
ElMessage.success(`刷新成功${r?.value != null ? ',新值 ' + r.value : ''},引用该变量的商品价格已自动重算`)
|
||||
load()
|
||||
}).catch((e: any) => ElMessage.error(e?.msg || '刷新失败'))
|
||||
}
|
||||
function onDelete(row: FormulaVar) {
|
||||
ElMessageBox.confirm(`确认删除「${row.var_name}」?`, '提示', { type: 'warning' }).then(async () => {
|
||||
try { await httpPost('/supplier/supplierGoodPriceFormulaVariable/delete', { id: row.id }); ElMessage.success('已删除'); load() }
|
||||
@ -118,9 +165,25 @@ onMounted(load)
|
||||
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total"
|
||||
:page="query.page" :limit="query.limit" :show-pagination="true" :page-sizes="[10, 20, 50, 100]" row-key="id"
|
||||
empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
|
||||
<template #value="{ row }">
|
||||
<el-link type="primary" @click="onQuickEditValue(row)" class="rr-value-link">
|
||||
{{ Number(row.current_value ?? 0).toLocaleString() }}
|
||||
</el-link>
|
||||
<div class="rr-value-tip">点击改值</div>
|
||||
</template>
|
||||
<template #source="{ row }">
|
||||
<el-tag :type="(SOURCE_MAP[row.source_type]?.type as any) || 'info'">{{ SOURCE_MAP[row.source_type]?.label || row.source_type }}</el-tag>
|
||||
</template>
|
||||
<template #interval="{ row }">
|
||||
<span class="rr-muted">{{ intervalName(row.refresh_interval_minutes) }}</span>
|
||||
</template>
|
||||
<template #lastRefresh="{ row }">
|
||||
<template v-if="row.last_refresh_time">
|
||||
<div class="rr-refresh-time">{{ fmtDate(row.last_refresh_time) }}</div>
|
||||
<div v-if="row.last_refresh_msg" class="rr-refresh-msg" :class="{ 'is-err': String(row.last_refresh_msg).startsWith('失败') }">{{ row.last_refresh_msg }}</div>
|
||||
</template>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
|
||||
</template>
|
||||
@ -128,16 +191,21 @@ onMounted(load)
|
||||
<span class="rr-actions">
|
||||
<el-link type="primary" @click="onEdit(row)">编辑</el-link>
|
||||
<span class="rr-actions__sep">|</span>
|
||||
<el-link v-if="[2, 4].includes(row.source_type)" type="success" @click="onRefreshRow(row)">立即刷新</el-link>
|
||||
<span v-if="[2, 4].includes(row.source_type)" class="rr-actions__sep">|</span>
|
||||
<el-link type="danger" @click="onDelete(row)">删除</el-link>
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增变量' : '编辑变量'" width="500px">
|
||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增变量' : '编辑变量'" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="变量名称" required><el-input v-model="editForm.var_name" placeholder="如: 铜价" /></el-form-item>
|
||||
<el-form-item label="变量标识" required><el-input v-model="editForm.var_key" placeholder="如: copper_price" /></el-form-item>
|
||||
<el-form-item label="变量标识">
|
||||
<el-input v-model="editForm.var_key" placeholder="留空自动生成(默认与变量名称相同)" />
|
||||
<div class="rr-form-tip">系统公式按变量名称引用,标识留空时自动取变量名称,无需手动填写</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="editForm.var_unit" placeholder="如: 元/吨" /></el-form-item>
|
||||
<el-form-item label="当前值"><el-input-number v-model="editForm.current_value" :precision="2" :step="100" /></el-form-item>
|
||||
<el-form-item label="数据来源">
|
||||
@ -145,10 +213,25 @@ onMounted(load)
|
||||
<el-radio :value="1">手动输入</el-radio>
|
||||
<el-radio :value="2">自动采集</el-radio>
|
||||
<el-radio :value="3">公式计算</el-radio>
|
||||
<el-radio :value="4">脚本获取</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="rr-form-tip">自动采集=请求接口获取值;脚本获取=运行 JS 脚本计算值;两者都可自动刷新并联动重算引用商品价格</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="采集地址" v-if="editForm.source_type === 2">
|
||||
<el-input v-model="editForm.source_code" placeholder='https://xxx/api/value (返回 {"value": 12.5} 或纯数字)' />
|
||||
</el-form-item>
|
||||
<el-form-item label="脚本内容" v-if="editForm.source_type === 4">
|
||||
<el-input v-model="editForm.refresh_script" type="textarea" :rows="5"
|
||||
placeholder="JS 脚本,例:\nvar copper = Number(ctx.getValue('铜价'));\nctx.setResult(copper * 1.05); // 或直接返回数字" />
|
||||
<div class="rr-form-tip">ctx.getValue("变量名") 读取其它变量当前值;ctx.setResult(数值) 或直接 return 数字</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="计算公式" v-if="editForm.source_type === 3"><el-input v-model="editForm.formula" placeholder="如: 铜价 * 1.05" /></el-form-item>
|
||||
<el-form-item label="自动刷新" v-if="[2, 4].includes(editForm.source_type)">
|
||||
<el-select v-model="editForm.refresh_interval_minutes" style="width: 200px">
|
||||
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
<div class="rr-form-tip">到时间自动执行采集/脚本更新当前值,并自动重算引用该变量的商品公式价格</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="采集编码" v-if="editForm.source_type === 2"><el-input v-model="editForm.source_code" placeholder="如: copper" /></el-form-item>
|
||||
<el-form-item label="计算公式" v-if="editForm.source_type === 3"><el-input v-model="editForm.formula" placeholder="如: copper_price * 1.05" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="editForm.sort" :min="0" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
@ -166,6 +249,14 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1400px; }
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.rr-form-tip { font-size: 12px; color: var(--ink-3); line-height: 1.5; margin-top: 4px; }
|
||||
.rr-value-link { font-weight: 600; }
|
||||
.rr-value-tip { font-size: 11px; color: var(--ink-3); line-height: 1; }
|
||||
.rr-muted { color: var(--ink-3); font-size: 12.5px; }
|
||||
.rr-refresh-time { font-size: 12.5px; color: var(--ink-2); }
|
||||
.rr-refresh-msg { font-size: 12px; color: var(--st-ok-fg, #16a34a); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rr-refresh-msg.is-err { color: var(--st-lv4-fg, #dc2626); }
|
||||
.rr-actions__sep { color: var(--border); margin: 0 4px; }
|
||||
</style>
|
||||
366
src/modules/sys/AiManageListView.vue
Normal file
366
src/modules/sys/AiManageListView.vue
Normal file
@ -0,0 +1,366 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 能力管理 — admin(系统管理组)
|
||||
*
|
||||
* 模型配置: OpenAI兼容渠道 CRUD + 全局启用(互斥) + 连通性测试 (api_key 脱敏回显/留空不改)
|
||||
* 功能授权: AI 功能清单(总开关/绑定模型) × 企业白名单(搜索添加/移除)
|
||||
* 数据源: /other/aimodel/* (other-server, tb_ai_model_config / tb_ai_feature / tb_ai_feature_auth)
|
||||
*/
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search, Connection } from '@element-plus/icons-vue'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||
|
||||
const activeTab = ref('model')
|
||||
const tabItems = [
|
||||
{ key: 'model', label: '模型配置' },
|
||||
{ key: 'feature', label: '功能授权' }
|
||||
]
|
||||
|
||||
// ===== 模型配置 =====
|
||||
const models = ref<any[]>([])
|
||||
const modelTotal = ref(0)
|
||||
const modelLoading = ref(false)
|
||||
const modelPage = ref(1)
|
||||
const modelLimit = ref(20)
|
||||
|
||||
const modelColumns: any = [
|
||||
{ prop: 'name', label: '配置名称', minWidth: 130, slot: 'name' },
|
||||
{ prop: 'base_url', label: '接口地址', minWidth: 220, slot: 'url' },
|
||||
{ prop: 'model', label: '模型', width: 150 },
|
||||
{ prop: 'api_key_masked', label: 'API Key', width: 110 },
|
||||
{ prop: 'max_chars', label: '最大字符', width: 85, align: 'center' },
|
||||
{ prop: 'timeout_seconds', label: '超时(秒)', width: 80, align: 'center' },
|
||||
{ prop: 'enabled', label: '状态', width: 90, align: 'center', slot: 'enabled' },
|
||||
{ prop: 'remark', label: '备注', minWidth: 120, slot: 'remark' },
|
||||
{ label: '操作', width: 230, align: 'center', slot: 'action' }
|
||||
]
|
||||
|
||||
async function loadModels() {
|
||||
modelLoading.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/other/aimodel/modelPage', { page: modelPage.value, limit: modelLimit.value })
|
||||
models.value = r?.list || []
|
||||
modelTotal.value = Number(r?.total || 0)
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '加载失败') } finally { modelLoading.value = false }
|
||||
}
|
||||
function onModelPage(p: number) { modelPage.value = p; loadModels() }
|
||||
|
||||
const testing = ref(-1)
|
||||
async function onTest(row: any) {
|
||||
testing.value = row.id
|
||||
try {
|
||||
const r: any = await httpPost('/other/aimodel/test', { id: row.id })
|
||||
if (r?.code === 0 || (r as any)?.reply != null) {
|
||||
ElMessage.success(`连通正常(${Math.round(r.costMs / 100) / 10}s): ${r.reply || 'OK'}`)
|
||||
} else {
|
||||
ElMessage.error(r?.msg || '连通失败')
|
||||
}
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '连通失败') } finally { testing.value = -1 }
|
||||
}
|
||||
|
||||
async function onEnable(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将【${row.name}】设为全局启用的AI模型? 所有未指定模型的功能将使用它`, '启用确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/other/aimodel/modelEnable', { id: row.id })
|
||||
ElMessage.success('已设为全局启用')
|
||||
loadModels()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '操作失败') }
|
||||
}
|
||||
|
||||
async function onDeleteModel(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除模型配置【${row.name}】?`, '删除确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/other/aimodel/modelDelete', { id: row.id })
|
||||
ElMessage.success('已删除')
|
||||
loadModels()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '删除失败') }
|
||||
}
|
||||
|
||||
// 模型编辑弹窗
|
||||
const modelDialog = ref(false)
|
||||
const modelSaving = ref(false)
|
||||
const modelForm = reactive({ id: null as any, name: '', baseUrl: '', apiKey: '', model: '', maxChars: 60000, timeoutSeconds: 180, remark: '' })
|
||||
function openModelDialog(row?: any) {
|
||||
if (row) {
|
||||
Object.assign(modelForm, { id: row.id, name: row.name, baseUrl: row.base_url, apiKey: '', model: row.model, maxChars: Number(row.max_chars), timeoutSeconds: Number(row.timeout_seconds), remark: row.remark || '' })
|
||||
} else {
|
||||
Object.assign(modelForm, { id: null, name: '', baseUrl: '', apiKey: '', model: '', maxChars: 60000, timeoutSeconds: 180, remark: '' })
|
||||
}
|
||||
modelDialog.value = true
|
||||
}
|
||||
async function saveModel() {
|
||||
if (!modelForm.name.trim() || !modelForm.baseUrl.trim() || !modelForm.model.trim()) { ElMessage.warning('名称/接口地址/模型名不能为空'); return }
|
||||
if (!modelForm.id && !modelForm.apiKey.trim()) { ElMessage.warning('新增时 API Key 不能为空'); return }
|
||||
modelSaving.value = true
|
||||
try {
|
||||
await httpPost('/other/aimodel/modelSave', { ...modelForm })
|
||||
ElMessage.success('已保存')
|
||||
modelDialog.value = false
|
||||
loadModels()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '保存失败') } finally { modelSaving.value = false }
|
||||
}
|
||||
|
||||
// ===== 功能授权 =====
|
||||
const features = ref<any[]>([])
|
||||
const featureLoading = ref(false)
|
||||
const expandedFeature = ref<string>('')
|
||||
|
||||
const featureColumns: any = [
|
||||
{ prop: 'code', label: '功能编码', width: 160, slot: 'code' },
|
||||
{ prop: 'name', label: '功能名称', minWidth: 140 },
|
||||
{ prop: 'model_name', label: '指定模型', width: 130, slot: 'fmodel' },
|
||||
{ prop: 'enabled', label: '总开关', width: 80, align: 'center', slot: 'fenabled' },
|
||||
{ prop: 'auth_count', label: '授权企业', width: 85, align: 'center', slot: 'authcount' },
|
||||
{ prop: 'description', label: '说明', minWidth: 200, slot: 'fdesc' },
|
||||
{ label: '操作', width: 170, align: 'center', slot: 'faction' }
|
||||
]
|
||||
|
||||
async function loadFeatures() {
|
||||
featureLoading.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/other/aimodel/featurePage')
|
||||
features.value = r || []
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '加载失败') } finally { featureLoading.value = false }
|
||||
}
|
||||
|
||||
// 功能编辑
|
||||
const featureDialog = ref(false)
|
||||
const featureForm = reactive({ id: null as any, code: '', name: '', modelConfigId: null as any, enabled: 1, description: '' })
|
||||
function openFeatureDialog(row?: any) {
|
||||
if (row) {
|
||||
Object.assign(featureForm, { id: row.id, code: row.code, name: row.name, modelConfigId: row.model_config_id, enabled: Number(row.enabled), description: row.description || '' })
|
||||
} else {
|
||||
Object.assign(featureForm, { id: null, code: '', name: '', modelConfigId: null, enabled: 1, description: '' })
|
||||
}
|
||||
featureDialog.value = true
|
||||
}
|
||||
async function saveFeature() {
|
||||
if (!featureForm.code.trim() || !featureForm.name.trim()) { ElMessage.warning('编码/名称不能为空'); return }
|
||||
try {
|
||||
await httpPost('/other/aimodel/featureSave', { ...featureForm })
|
||||
ElMessage.success('已保存')
|
||||
featureDialog.value = false
|
||||
loadFeatures()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '保存失败') }
|
||||
}
|
||||
|
||||
// 授权管理
|
||||
const authFeature = ref<any>(null)
|
||||
const authList = ref<any[]>([])
|
||||
const authLoading = ref(false)
|
||||
const addDialog = ref(false)
|
||||
const addKeyword = ref('')
|
||||
const addSearching = ref(false)
|
||||
const addCandidates = ref<any[]>([])
|
||||
const addSelected = ref<any>(null)
|
||||
|
||||
function openAuth(row: any) {
|
||||
authFeature.value = row
|
||||
addKeyword.value = ''
|
||||
addCandidates.value = []
|
||||
addSelected.value = null
|
||||
loadAuth()
|
||||
}
|
||||
async function loadAuth() {
|
||||
if (!authFeature.value) return
|
||||
authLoading.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/other/aimodel/authList', { featureCode: authFeature.value.code })
|
||||
authList.value = r || []
|
||||
} catch { authList.value = [] } finally { authLoading.value = false }
|
||||
}
|
||||
async function removeAuth(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认移除【${row.enterprise_name}】的「${authFeature.value.name}」授权?`, '移除确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost('/other/aimodel/authRemove', { id: row.id })
|
||||
ElMessage.success('已移除')
|
||||
loadAuth(); loadFeatures()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '移除失败') }
|
||||
}
|
||||
function openAdd() { addDialog.value = true; addKeyword.value = ''; addCandidates.value = []; addSelected.value = null }
|
||||
async function searchEnt() {
|
||||
if (!addKeyword.value.trim()) { ElMessage.warning('请输入企业名称关键词'); return }
|
||||
addSearching.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/user/enterprise/page', { page: 1, limit: 20, status: 2, enterpriseName: addKeyword.value.trim() })
|
||||
addCandidates.value = (r?.list || []).map((it: any) => ({ id: String(it.id), name: it.enterpriseName }))
|
||||
} finally { addSearching.value = false }
|
||||
}
|
||||
async function confirmAdd() {
|
||||
if (!addSelected.value) { ElMessage.warning('请先选择企业'); return }
|
||||
try {
|
||||
await httpPost('/other/aimodel/authAdd', { featureCode: authFeature.value.code, enterpriseId: addSelected.value.id, enterpriseName: addSelected.value.name })
|
||||
ElMessage.success('已授权')
|
||||
addDialog.value = false
|
||||
loadAuth(); loadFeatures()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '授权失败') }
|
||||
}
|
||||
|
||||
onMounted(() => { loadModels(); loadFeatures() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<PageHeader title="AI 能力管理" sub="大模型渠道配置与 AI 功能的企业授权管理">
|
||||
<el-button type="primary" :icon="Plus" @click="openModelDialog()">新增模型配置</el-button>
|
||||
<el-button :icon="Plus" @click="openFeatureDialog()">注册AI功能</el-button>
|
||||
</PageHeader>
|
||||
|
||||
<ListTabs v-model="activeTab" :items="tabItems" @change="(v: string) => { activeTab = v }" />
|
||||
|
||||
<!-- 模型配置 -->
|
||||
<template v-if="activeTab === 'model'">
|
||||
<DataTable :data="models" :columns="modelColumns" :loading="modelLoading" :total="modelTotal" :page="modelPage" :limit="modelLimit" row-key="id" @page-change="onModelPage">
|
||||
<template #name="{ row }">
|
||||
<span style="font-weight:600">{{ row.name }}</span>
|
||||
</template>
|
||||
<template #url="{ row }"><span class="rr-mono">{{ row.base_url }}</span></template>
|
||||
<template #enabled="{ row }">
|
||||
<el-tag :type="Number(row.enabled) === 1 ? 'success' : 'info'" size="small">{{ Number(row.enabled) === 1 ? '全局启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
<template #remark="{ row }"><span class="rr-muted">{{ row.remark || '—' }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<TableActions :row="row" :actions="[
|
||||
{ label: '测试连通', type: 'primary', onClick: (r: any) => onTest(r) },
|
||||
{ label: '编辑', type: 'primary', onClick: (r: any) => openModelDialog(r) },
|
||||
...(Number(row.enabled) !== 1 ? [{ label: '设为启用', type: 'success' as const, onClick: (r: any) => onEnable(r) }] : []),
|
||||
...(Number(row.enabled) !== 1 ? [{ label: '删除', type: 'danger' as const, onClick: (r: any) => onDeleteModel(r) }] : [])
|
||||
]" />
|
||||
</template>
|
||||
</DataTable>
|
||||
<div v-if="!modelLoading && !models.length" class="empty-tip">暂无模型配置——点击右上角「新增模型配置」接入 OpenAI 兼容接口(智谱/DeepSeek/通义/自建中转均可)</div>
|
||||
</template>
|
||||
|
||||
<!-- 功能授权 -->
|
||||
<template v-else>
|
||||
<DataTable :data="features" :columns="featureColumns" :loading="featureLoading" row-key="id">
|
||||
<template #code="{ row }"><span class="rr-mono">{{ row.code }}</span></template>
|
||||
<template #fmodel="{ row }">
|
||||
<span v-if="row.model_name">{{ row.model_name }}</span>
|
||||
<span v-else class="rr-muted">跟随全局</span>
|
||||
</template>
|
||||
<template #fenabled="{ row }">
|
||||
<el-tag :type="Number(row.enabled) === 1 ? 'success' : 'danger'" size="small">{{ Number(row.enabled) === 1 ? '开启' : '停用' }}</el-tag>
|
||||
</template>
|
||||
<template #authcount="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openAuth(row)">{{ row.auth_count }} 家</el-link>
|
||||
</template>
|
||||
<template #fdesc="{ row }"><span class="rr-muted">{{ row.description || '—' }}</span></template>
|
||||
<template #faction="{ row }">
|
||||
<TableActions :row="row" :actions="[
|
||||
{ label: '管理授权', type: 'primary', onClick: (r: any) => openAuth(r) },
|
||||
{ label: '编辑', type: 'primary', onClick: (r: any) => openFeatureDialog(r) }
|
||||
]" />
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- 授权白名单 -->
|
||||
<div v-if="authFeature" class="auth-block">
|
||||
<div class="auth-title">
|
||||
「{{ authFeature.name }}」授权企业({{ authList.length }} 家)
|
||||
<el-button size="small" type="primary" :icon="Plus" @click="openAdd">添加企业</el-button>
|
||||
</div>
|
||||
<el-table :data="authList" v-loading="authLoading" size="small" border>
|
||||
<el-table-column prop="enterprise_name" label="企业名称" min-width="240" />
|
||||
<el-table-column prop="create_time" label="授权时间" width="170">
|
||||
<template #default="{ row }"><span class="rr-mono">{{ String(row.create_time || '').replace('T', ' ').slice(0, 19) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" plain @click="removeAuth(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 模型编辑弹窗 -->
|
||||
<el-dialog v-model="modelDialog" :title="modelForm.id ? '编辑模型配置' : '新增模型配置'" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="配置名称 *"><el-input v-model="modelForm.name" placeholder="如: 生产-GLM-4" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="接口地址 *"><el-input v-model="modelForm.baseUrl" placeholder="OpenAI兼容, 如 https://api.xx.com/v1" /></el-form-item>
|
||||
<el-form-item :label="modelForm.id ? 'API Key(留空不改)' : 'API Key *'"><el-input v-model="modelForm.apiKey" placeholder="sk-..." show-password /></el-form-item>
|
||||
<el-form-item label="模型名 *"><el-input v-model="modelForm.model" placeholder="如 glm-4-long / deepseek-chat" /></el-form-item>
|
||||
<div style="display:flex;gap:12px">
|
||||
<el-form-item label="最大字符"><el-input-number v-model="modelForm.maxChars" :min="1000" :max="500000" :step="10000" style="width:160px" /></el-form-item>
|
||||
<el-form-item label="超时(秒)"><el-input-number v-model="modelForm.timeoutSeconds" :min="30" :max="600" :step="30" style="width:130px" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="备注"><el-input v-model="modelForm.remark" maxlength="255" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="modelDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="modelSaving" @click="saveModel">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 功能编辑弹窗 -->
|
||||
<el-dialog v-model="featureDialog" :title="featureForm.id ? '编辑AI功能' : '注册AI功能'" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="功能编码 *"><el-input v-model="featureForm.code" :disabled="!!featureForm.id" placeholder="如 inquiry_parse" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="功能名称 *"><el-input v-model="featureForm.name" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="指定模型">
|
||||
<el-select v-model="featureForm.modelConfigId" placeholder="不指定=跟随全局启用" clearable style="width:260px">
|
||||
<el-option v-for="m in models" :key="m.id" :label="`${m.name}(${m.model})`" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="总开关">
|
||||
<el-switch v-model="featureForm.enabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="featureForm.description" type="textarea" :rows="2" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="featureDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveFeature">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加授权企业弹窗 -->
|
||||
<el-dialog v-model="addDialog" :title="`添加授权企业 — ${authFeature?.name || ''}`" width="520px" destroy-on-close>
|
||||
<div class="add-search">
|
||||
<el-input v-model="addKeyword" placeholder="输入企业名称搜索(仅已认证企业)" clearable @keyup.enter="searchEnt">
|
||||
<template #append><el-button :icon="Search" :loading="addSearching" @click="searchEnt">搜索</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="add-list">
|
||||
<div v-for="c in addCandidates" :key="c.id" :class="['add-item', { active: addSelected?.id === c.id }]" @click="addSelected = c">
|
||||
<span class="add-name">{{ c.name }}</span>
|
||||
<span class="add-check">✓</span>
|
||||
</div>
|
||||
<div v-if="!addCandidates.length" class="rr-muted" style="text-align:center;padding:24px 0">输入关键词搜索并选择企业</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="addDialog = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!addSelected" @click="confirmAdd">确认授权</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-muted { color: var(--ink-3); }
|
||||
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
.empty-tip { text-align: center; color: var(--ink-3); padding: 28px 0; font-size: 13px; }
|
||||
.auth-block { border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; padding: 14px 16px; margin-top: 14px; background: var(--bg-1, #fafafa); }
|
||||
.auth-title { display: flex; align-items: center; justify-content: space-between; font-weight: 600; margin-bottom: 10px; font-size: 13px; }
|
||||
.add-search { margin-bottom: 12px; }
|
||||
.add-list { max-height: 320px; overflow-y: auto; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; }
|
||||
.add-item { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px; cursor: pointer; border-bottom: 1px solid var(--border-1, #e5e7eb); transition: background .12s; }
|
||||
.add-item:last-child { border-bottom: none; }
|
||||
.add-item:hover { background: var(--bg-2, #f8fafc); }
|
||||
.add-item.active { background: var(--primary-soft, #eff6ff); box-shadow: inset 0 0 0 1px var(--primary); }
|
||||
.add-name { font-size: 13.5px; font-weight: 600; }
|
||||
.add-check { width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; display: none; }
|
||||
.add-item.active .add-check { display: block; }
|
||||
</style>
|
||||
@ -17,7 +17,7 @@
|
||||
import { onMounted, ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { User, Clock, Upload, Download, MoreFilled } from '@element-plus/icons-vue'
|
||||
import { User, Clock, Upload, Download, MoreFilled, Trophy, Connection, Top, Bottom } from '@element-plus/icons-vue'
|
||||
import { httpGet, httpPost, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
@ -33,6 +33,8 @@ interface Enterprise {
|
||||
// 行业/等级/标签
|
||||
industry: string
|
||||
supplierLevel: number | null
|
||||
currentScore: number | null
|
||||
backfillEnabled: number | null
|
||||
supplierLabel: string
|
||||
// 业务员
|
||||
salesperson: string
|
||||
@ -54,6 +56,7 @@ interface Enterprise {
|
||||
registerTime: string
|
||||
orgNo: string
|
||||
failReason: string
|
||||
changeLog: string // 最近一次提交审核的变更记录 JSON [{field,old,new}]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@ -65,10 +68,11 @@ const limit = ref(10)
|
||||
const selectedIds = ref<string[]>([])
|
||||
const selectedRows = ref<Enterprise[]>([])
|
||||
|
||||
// ========== 单行审核 ==========
|
||||
const auditVisible = ref(false)
|
||||
// ========== 审核视图 (页面内嵌, 非弹窗: 变更对比 + 审核操作) ==========
|
||||
const auditRow = ref<Enterprise | null>(null)
|
||||
const auditChanges = ref<{ field: string; old: string; new: string }[]>([])
|
||||
const auditBusy = ref(false)
|
||||
const auditForm = reactive({ id: '', result: 2, failReason: '' })
|
||||
const auditForm = reactive({ result: 2, failReason: '' })
|
||||
|
||||
// ========== 批量审核 ==========
|
||||
const batchAuditVisible = ref(false)
|
||||
@ -78,7 +82,6 @@ const batchAuditForm = reactive({ result: 2, failReason: '' })
|
||||
const filters = reactive({
|
||||
enterpriseName: '',
|
||||
industry: '',
|
||||
supplierLevel: '',
|
||||
salesperson: '',
|
||||
supplierLabel: '',
|
||||
status: '',
|
||||
@ -93,14 +96,6 @@ const STATUS_META: Record<string, { label: string; color: string; bg: string }>
|
||||
'3': { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||
}
|
||||
|
||||
// supplierLevel 映射 (老 vue: 1=优质,2=钻石,3=金牌,4=银牌,5=铜牌)
|
||||
const LEVEL_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||
1: { label: '优质供应商', color: 'var(--st-lv1-fg)', bg: 'var(--st-lv1-bg)' },
|
||||
2: { label: '钻石供应商', color: 'var(--st-lv2-fg)', bg: 'var(--st-lv2-bg)' },
|
||||
3: { label: '金牌供应商', color: 'var(--st-lv3-fg)', bg: 'var(--st-lv3-bg)' },
|
||||
4: { label: '银牌供应商', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
|
||||
5: { label: '铜牌供应商', color: 'var(--st-lv5-fg)', bg: 'var(--st-lv5-bg)' }
|
||||
}
|
||||
|
||||
// 头像品牌渐变色组 (企业无 logo 时交替使用)
|
||||
const AVATAR_GRADIENTS = [
|
||||
@ -145,7 +140,6 @@ async function load() {
|
||||
const params: Record<string, any> = { page: page.value, limit: limit.value }
|
||||
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
|
||||
if (filters.industry) params.industry = filters.industry
|
||||
if (filters.supplierLevel) params.supplierLevel = Number(filters.supplierLevel)
|
||||
if (filters.salesperson) params.salesperson = filters.salesperson
|
||||
if (filters.supplierLabel) params.supplierLabel = filters.supplierLabel
|
||||
if (filters.status) params.status = filters.status
|
||||
@ -161,6 +155,8 @@ async function load() {
|
||||
administratorMobile: it.administratorMobile || '',
|
||||
industry: it.industry || '',
|
||||
supplierLevel: it.supplierLevel ?? null,
|
||||
currentScore: it.currentScore ?? 60,
|
||||
backfillEnabled: it.backfillEnabled ?? 0,
|
||||
supplierLabel: it.supplierLabel || '',
|
||||
salesperson: it.salesperson || '',
|
||||
salespersonMobile: it.salespersonMobile || '',
|
||||
@ -178,7 +174,8 @@ async function load() {
|
||||
mainProducts: it.mainProducts || '',
|
||||
registerTime: it.registerTime || '',
|
||||
orgNo: it.orgNo || '',
|
||||
failReason: it.failReason || ''
|
||||
failReason: it.failReason || '',
|
||||
changeLog: it.changeLog || ''
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
@ -190,7 +187,7 @@ async function load() {
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.enterpriseName = ''; filters.industry = ''; filters.supplierLevel = ''
|
||||
filters.enterpriseName = ''; filters.industry = ''
|
||||
filters.salesperson = ''; filters.supplierLabel = ''; filters.status = ''; filters.homeShow = ''
|
||||
page.value = 1; load()
|
||||
}
|
||||
@ -199,23 +196,39 @@ function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedRows.value = rows }
|
||||
|
||||
// ========== 审核交互 (后端 GET /user/enterprise/checkStatus?id=&status=&failReason=, 支持逗号批量) ==========
|
||||
/** 进入内嵌审核视图: 解析变更记录 JSON 供对比展示 */
|
||||
function onAudit(row: Enterprise) {
|
||||
auditForm.id = row.id
|
||||
auditRow.value = row
|
||||
auditForm.result = 2
|
||||
auditForm.failReason = ''
|
||||
auditVisible.value = true
|
||||
try {
|
||||
const parsed = row.changeLog ? JSON.parse(row.changeLog) : []
|
||||
auditChanges.value = Array.isArray(parsed) ? parsed.filter((c: any) => c && c.field) : []
|
||||
} catch { auditChanges.value = [] }
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
function backToList() { auditRow.value = null; auditChanges.value = [] }
|
||||
|
||||
/** 值是否为图片 (企业资料中的 URL 字段均为图片; 支持多图 " / " 连接) */
|
||||
function isImageVal(val: string) {
|
||||
if (!val) return false
|
||||
const segs = val.split(' / ').map(s => s.trim()).filter(Boolean)
|
||||
return segs.length > 0 && segs.every(s => /^https?:\/\//i.test(s))
|
||||
}
|
||||
function splitImgs(val: string) { return (val || '').split(' / ').map(s => s.trim()).filter(Boolean) }
|
||||
|
||||
async function submitAudit() {
|
||||
if (!auditRow.value) return
|
||||
if (auditForm.result === 3 && !auditForm.failReason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
auditBusy.value = true
|
||||
try {
|
||||
await httpGet('/user/enterprise/checkStatus', {
|
||||
id: auditForm.id, status: auditForm.result,
|
||||
id: auditRow.value.id, status: auditForm.result,
|
||||
failReason: auditForm.result === 3 ? auditForm.failReason.trim() : ''
|
||||
})
|
||||
ElMessage.success(auditForm.result === 2 ? '审核通过' : '已驳回')
|
||||
auditVisible.value = false
|
||||
backToList()
|
||||
load()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '审核操作失败') }
|
||||
finally { auditBusy.value = false }
|
||||
@ -294,12 +307,97 @@ async function submitChangeAdmin() {
|
||||
} catch (e: any) { ElMessage.error(e?.msg || '更换失败') }
|
||||
}
|
||||
|
||||
// ========== 履约评分 ==========
|
||||
const SCORE_LEVEL_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||
0: { label: '待整改供应商', color: '#b91c1c', bg: 'linear-gradient(135deg,#fef2f2,#fee2e2)' },
|
||||
3: { label: '金牌供应商', color: '#b45309', bg: 'linear-gradient(135deg,#fef3c7,#fde68a)' },
|
||||
2: { label: '银牌供应商', color: '#475569', bg: 'linear-gradient(135deg,#f1f5f9,#e2e8f0)' },
|
||||
1: { label: '铜牌供应商', color: '#9a3412', bg: 'linear-gradient(135deg,#ffedd5,#fed7aa)' },
|
||||
}
|
||||
const DIMENSIONS = [
|
||||
{ id: 1, name: '配合度', weight: 10, desc: '沟通响应、履约配合、售后态度' },
|
||||
{ id: 2, name: '中标数量', weight: 90, desc: '参与报价并中标的业务表现' },
|
||||
]
|
||||
const scoreDialog = ref(false)
|
||||
const scoreRow = ref<Enterprise | null>(null)
|
||||
const scoreLoading = ref(false)
|
||||
const scoreInfo = ref<any>(null) // { score, level }
|
||||
const scoreLogs = ref<any[]>([])
|
||||
const scoreForm = reactive({ dimension: 2, action: 1, rawScore: 5, reason: '' })
|
||||
const scoreSubmitting = ref(false)
|
||||
|
||||
function levelMeta(lv: number) { return SCORE_LEVEL_META[lv] || SCORE_LEVEL_META[1] }
|
||||
function levelOfScore(score: number): number { return score >= 80 ? 3 : score >= 70 ? 2 : score >= 60 ? 1 : 0 }
|
||||
function lvOf(v: any): number { const n = Number(v ?? 60); return n >= 80 ? 3 : n >= 70 ? 2 : n >= 60 ? 1 : 0 }
|
||||
|
||||
async function openScoreDialog(row: Enterprise) {
|
||||
scoreRow.value = row
|
||||
scoreInfo.value = null
|
||||
scoreLogs.value = []
|
||||
scoreForm.dimension = 2
|
||||
scoreForm.action = 1
|
||||
scoreForm.rawScore = 5
|
||||
scoreForm.reason = ''
|
||||
scoreDialog.value = true
|
||||
scoreLoading.value = true
|
||||
try {
|
||||
const [info, logs]: any = await Promise.all([
|
||||
httpGet('/user/enterprisescore/info', { enterpriseId: row.id }),
|
||||
httpGet('/user/enterprisescore/logs', { enterpriseId: row.id, page: 1, limit: 5 })
|
||||
])
|
||||
scoreInfo.value = info || { score: 60, level: 1 }
|
||||
scoreLogs.value = logs?.list || []
|
||||
} catch { scoreInfo.value = { score: 60, level: 1 } }
|
||||
scoreLoading.value = false
|
||||
}
|
||||
|
||||
const scoreChange = computed(() => {
|
||||
const dim = DIMENSIONS.find(d => d.id === scoreForm.dimension)!
|
||||
// 与后端一致: 录入分×权重 四舍五入取整入账(current_score 为整数列)
|
||||
const change = Math.round((Number(scoreForm.rawScore) || 0) * dim.weight / 100)
|
||||
return scoreForm.action === 1 ? Number(change.toFixed(2)) : -Number(change.toFixed(2))
|
||||
})
|
||||
const scoreAfter = computed(() => {
|
||||
const base = Number(scoreInfo.value?.score ?? 60)
|
||||
return Math.max(0, Math.min(100, Number((base + scoreChange.value).toFixed(2))))
|
||||
})
|
||||
const scoreLevelChange = computed(() => {
|
||||
const before = levelOfScore(Number(scoreInfo.value?.score ?? 60))
|
||||
const after = levelOfScore(scoreAfter.value)
|
||||
return { before, after, changed: before !== after, up: after > before }
|
||||
})
|
||||
const scoreLevelLocked = computed(() => scoreRow.value?.status !== 2)
|
||||
|
||||
async function submitScore() {
|
||||
if (!scoreRow.value) return
|
||||
if (!(Number(scoreForm.rawScore) > 0)) { ElMessage.warning('请填写分值'); return }
|
||||
scoreSubmitting.value = true
|
||||
try {
|
||||
const r: any = await httpPost('/user/enterprisescore/score', {
|
||||
enterpriseId: scoreRow.value.id,
|
||||
dimension: scoreForm.dimension,
|
||||
action: scoreForm.action,
|
||||
rawScore: scoreForm.rawScore,
|
||||
reason: scoreForm.reason,
|
||||
operator: 'admin'
|
||||
})
|
||||
ElMessage.success(`评分成功: ${r?.scoreBefore} → ${r?.scoreAfter} 分(${levelMeta(levelOfScore(Number(r?.scoreAfter))).label})`)
|
||||
scoreDialog.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '评分失败')
|
||||
} finally { scoreSubmitting.value = false }
|
||||
}
|
||||
|
||||
function fmtLogTime(v: any) { return String(v || '').replace('T', ' ').slice(0, 16) }
|
||||
function logDim(v: number) { return v === 1 ? '配合度' : '中标数量' }
|
||||
|
||||
async function handleMore(cmd: string, row: Enterprise) {
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'audit': onAudit(row); break
|
||||
case 'label': ElMessage.info('标签待实现'); break
|
||||
case 'rating': ElMessage.info('评级待实现'); break
|
||||
case 'rating': openScoreDialog(row); break
|
||||
case 'enabled':
|
||||
// 后端 @RequestParam 从 query 读, body 不绑定 (2026-08-18 实测修正)
|
||||
await httpPost(`/user/enterprise/enabled?id=${row.id}`)
|
||||
@ -328,6 +426,94 @@ onMounted(() => load())
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<!-- ===== 内嵌审核视图: 变更对比 + 审核操作 (页面切换, 不用独立弹窗) ===== -->
|
||||
<template v-if="auditRow">
|
||||
<div class="audit-view">
|
||||
<div class="audit-head">
|
||||
<el-button size="small" @click="backToList">← 返回列表</el-button>
|
||||
<div class="audit-head-main">
|
||||
<span class="audit-ent">{{ auditRow.enterpriseName }}</span>
|
||||
<span class="status-tag pulsing" style="color:var(--st-warn-fg);background:var(--st-warn-bg)">
|
||||
<span class="status-dot"></span>待审核
|
||||
</span>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="goDetail(auditRow)">查看企业完整资料</el-button>
|
||||
</div>
|
||||
|
||||
<div class="audit-card">
|
||||
<div class="audit-card-title">
|
||||
本次提交变更内容
|
||||
<span v-if="auditChanges.length" class="audit-change-count">共 {{ auditChanges.length }} 项变更</span>
|
||||
</div>
|
||||
|
||||
<div v-if="auditChanges.length" class="chg-list">
|
||||
<div v-for="(c, i) in auditChanges" :key="i" class="chg-item">
|
||||
<div class="chg-field">{{ c.field }}</div>
|
||||
<div class="chg-body">
|
||||
<div class="chg-cell old">
|
||||
<span class="chg-tag old">变更前</span>
|
||||
<div class="chg-val">
|
||||
<template v-if="isImageVal(c.old)">
|
||||
<div class="chg-imgs">
|
||||
<el-image v-for="(img, k) in splitImgs(c.old)" :key="k" :src="img"
|
||||
:preview-src-list="splitImgs(c.old)" :initial-index="k" fit="cover"
|
||||
class="chg-img" preview-teleported />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="!c.old" class="chg-empty">(未填写)</span>
|
||||
<span v-else>{{ c.old }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chg-arrow">→</div>
|
||||
<div class="chg-cell new">
|
||||
<span class="chg-tag new">变更后</span>
|
||||
<div class="chg-val">
|
||||
<template v-if="isImageVal(c.new)">
|
||||
<div class="chg-imgs">
|
||||
<el-image v-for="(img, k) in splitImgs(c.new)" :key="k" :src="img"
|
||||
:preview-src-list="splitImgs(c.new)" :initial-index="k" fit="cover"
|
||||
class="chg-img" preview-teleported />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="!c.new" class="chg-empty">(未填写)</span>
|
||||
<span v-else>{{ c.new }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chg-none">
|
||||
本次提交未检测到字段变更(可能是首次认证或重复提交),请结合企业完整资料进行审核。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-card">
|
||||
<div class="audit-card-title">审核操作</div>
|
||||
<el-form label-width="80px" label-position="left">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio :value="2">审核通过</el-radio>
|
||||
<el-radio :value="3">审核驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auditForm.result === 3" label="驳回原因" required>
|
||||
<el-input v-model="auditForm.failReason" type="textarea" :rows="4"
|
||||
placeholder="请填写驳回原因,将随站内消息与短信通知企业" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="audit-actions">
|
||||
<el-button @click="backToList">取消</el-button>
|
||||
<el-button type="primary" :loading="auditBusy" @click="submitAudit">确定提交</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<PageHeader title="企业列表" :sub="`共 ${total} 家企业`" />
|
||||
|
||||
<SearchBar
|
||||
@ -335,15 +521,6 @@ onMounted(() => load())
|
||||
:fields="[
|
||||
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' },
|
||||
{ key: 'industry', label: '行业类型', type: 'text', placeholder: '行业类型' },
|
||||
{ key: 'supplierLevel', label: '供应商等级', type: 'select', placeholder: '全部等级',
|
||||
options: [
|
||||
{ value: 1, label: '优质供应商' },
|
||||
{ value: 2, label: '钻石供应商' },
|
||||
{ value: 3, label: '金牌供应商' },
|
||||
{ value: 4, label: '银牌供应商' },
|
||||
{ value: 5, label: '铜牌供应商' }
|
||||
]
|
||||
},
|
||||
{ key: 'status', label: '审核状态', type: 'select', placeholder: '全部状态',
|
||||
options: [
|
||||
{ value: 1, label: '待审核' },
|
||||
@ -424,11 +601,9 @@ onMounted(() => load())
|
||||
<template #levelInfo="{ row }">
|
||||
<div class="two-line">
|
||||
<span class="line-main">{{ row.industry || '-' }}</span>
|
||||
<span v-if="LEVEL_META[row.supplierLevel as number]" class="level-chip"
|
||||
:style="{ color: LEVEL_META[row.supplierLevel as number].color, background: LEVEL_META[row.supplierLevel as number].bg }">
|
||||
{{ LEVEL_META[row.supplierLevel as number].label }}
|
||||
<span class="level-chip sc-newlvl" :style="{ background: levelMeta(lvOf(row.currentScore)).bg, color: levelMeta(lvOf(row.currentScore)).color }">
|
||||
{{ levelMeta(lvOf(row.currentScore)).label }} {{ Number(row.currentScore ?? 60) }}分
|
||||
</span>
|
||||
<span v-else class="line-sub">未评级</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -490,29 +665,7 @@ onMounted(() => load())
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 单行审核弹窗 -->
|
||||
<el-dialog v-model="auditVisible" title="企业认证审核" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||
<div style="background:var(--bg,#f6f8fb);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:13px;color:var(--ink-2);margin-bottom:18px">
|
||||
正在审核:<strong style="color:var(--ink)">{{ auditForm.id ? (list.find(r => r.id === auditForm.id)?.enterpriseName || '--') : '--' }}</strong>
|
||||
</div>
|
||||
<el-form label-width="80px" label-position="left">
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="auditForm.result">
|
||||
<el-radio :value="2">审核通过</el-radio>
|
||||
<el-radio :value="3">审核驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auditForm.result === 3" label="驳回原因" required>
|
||||
<el-input v-model="auditForm.failReason" type="textarea" :rows="4"
|
||||
placeholder="请填写驳回原因,将随站内消息与短信通知企业" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible=false">取消</el-button>
|
||||
<el-button type="primary" :loading="auditBusy" @click="submitAudit">确定提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<!-- 批量审核弹窗 -->
|
||||
<el-dialog v-model="batchAuditVisible" title="批量审核企业" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||
@ -554,6 +707,112 @@ onMounted(() => load())
|
||||
<el-button type="primary" @click="submitChangeAdmin">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 履约评分弹窗 -->
|
||||
<el-dialog v-model="scoreDialog" title="企业履约评分" width="680px" destroy-on-close class="score-dialog">
|
||||
<div v-if="scoreLoading" style="padding:40px 0;text-align:center;color:var(--ink-3)">加载中...</div>
|
||||
<template v-else-if="scoreRow">
|
||||
<!-- 企业信息 + 当前等级卡 -->
|
||||
<div class="sc-hero">
|
||||
<div class="sc-hero-left">
|
||||
<div class="sc-name">{{ scoreRow.enterpriseName }}</div>
|
||||
<div class="sc-sub">入驻时间 {{ String(scoreRow.createDate || '').slice(0, 10) }} · 单次录入分上限 10 分</div>
|
||||
</div>
|
||||
<div class="sc-hero-right">
|
||||
<div class="sc-score-num">{{ Number(scoreInfo?.score ?? 60) }}</div>
|
||||
<div class="sc-score-cap">当前积分</div>
|
||||
</div>
|
||||
<div class="sc-badge" :style="{ background: levelMeta(scoreInfo?.level ?? 1).bg, color: levelMeta(scoreInfo?.level ?? 1).color }">
|
||||
<el-icon style="font-size:18px"><Trophy /></el-icon>
|
||||
{{ levelMeta(scoreInfo?.level ?? 1).label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等级刻度尺 -->
|
||||
<div class="sc-scale">
|
||||
<div class="sc-seg fail" :class="{ on: (scoreInfo?.level ?? 1) === 0 }" style="flex:0 0 60%"><span>待整改</span><i><60 分</i></div>
|
||||
<div class="sc-seg bronze" :class="{ on: (scoreInfo?.level ?? 1) === 1 }" style="flex:0 0 10%; min-width:64px"><span>铜牌</span><i>60~70</i></div>
|
||||
<div class="sc-seg silver" :class="{ on: (scoreInfo?.level ?? 1) === 2 }" style="flex:0 0 10%; min-width:64px"><span>银牌</span><i>70~80</i></div>
|
||||
<div class="sc-seg gold" :class="{ on: (scoreInfo?.level ?? 1) === 3 }" style="flex:1"><span>金牌</span><i>≥80 分</i></div>
|
||||
<div class="sc-marker" :style="{ left: Math.min(98, Math.max(2, Number(scoreInfo?.score ?? 60))) + '%' }">
|
||||
<span class="sc-marker-tip">当前 {{ scoreInfo?.score ?? 60 }} 分</span>
|
||||
<span class="sc-marker-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-scale-cap">刻度按分数区间占比呈现,圆点即企业当前积分位置</div>
|
||||
|
||||
<!-- 维度选择卡 -->
|
||||
<div class="fee-section-title" style="margin-top:18px">评分维度</div>
|
||||
<div class="fee-type-cards">
|
||||
<div v-for="d in DIMENSIONS" :key="d.id" :class="['fee-type-card', { active: scoreForm.dimension === d.id }]" @click="scoreForm.dimension = d.id">
|
||||
<div class="ftc-head">
|
||||
<el-icon class="sc-dim-icon"><Trophy v-if="d.id === 2" /><Connection v-else /></el-icon>
|
||||
<span class="ftc-check">✓</span>
|
||||
</div>
|
||||
<div class="ftc-title">{{ d.name }} <span class="sc-weight-chip">权重 {{ d.weight }}%</span></div>
|
||||
<div class="ftc-desc">{{ d.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作与分值 -->
|
||||
<div class="fee-section-title">评分操作</div>
|
||||
<div class="sc-op-row">
|
||||
<div class="sc-seg2">
|
||||
<button :class="['sc-seg2-btn', { plus: scoreForm.action === 1 }]" @click="scoreForm.action = 1">
|
||||
<el-icon style="font-size:15px"><Top /></el-icon>加分
|
||||
</button>
|
||||
<button :class="['sc-seg2-btn', { minus: scoreForm.action === 2 }]" @click="scoreForm.action = 2">
|
||||
<el-icon style="font-size:15px"><Bottom /></el-icon>减分
|
||||
</button>
|
||||
</div>
|
||||
<el-input-number v-model="scoreForm.rawScore" :min="0.01" :max="10" :precision="2" :step="1" controls-position="right" style="width:150px" />
|
||||
<span class="fee-unit">分</span>
|
||||
<span class="fee-hint">录入分 × 权重 = 实际变动分</span>
|
||||
</div>
|
||||
<el-input v-model="scoreForm.reason" placeholder="评分原因(展示给平台记录,如:季度履约优秀 / 中标 3 次)" maxlength="100" show-word-limit style="margin-top:10px" />
|
||||
|
||||
<!-- 结果预览卡 -->
|
||||
<div class="fee-result" style="margin-top:14px">
|
||||
<div class="fr-left">
|
||||
<div class="fr-cap">实际变动</div>
|
||||
<div class="fr-val" :style="{ color: scoreForm.action === 1 ? 'var(--el-color-success)' : 'var(--el-color-danger)' }">{{ scoreChange > 0 ? '+' : '' }}{{ scoreChange }} 分</div>
|
||||
<div class="fr-calc">{{ Number(scoreForm.rawScore || 0) }} 分 × {{ DIMENSIONS.find(d => d.id === scoreForm.dimension)?.weight }}% 权重</div>
|
||||
</div>
|
||||
<div class="fr-right">
|
||||
<div class="fr-step">
|
||||
<span class="sc-arrow-text">{{ scoreInfo?.score ?? 60 }} 分 → <b>{{ scoreAfter }} 分</b></span>
|
||||
</div>
|
||||
<div class="fr-step" v-if="scoreLevelChange.changed">
|
||||
<span class="sc-lv-change" :style="{ color: scoreLevelChange.up ? '#b45309' : '#b91c1c' }">
|
||||
{{ levelMeta(scoreLevelChange.before).label }} → {{ levelMeta(scoreLevelChange.after).label }}
|
||||
<span class="sc-lv-tip">({{ scoreLevelChange.up ? '升级' : '降级' }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fr-step" v-else>
|
||||
<span class="sc-arrow-text">等级不变:{{ levelMeta(scoreLevelChange.after).label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近评分流水 -->
|
||||
<template v-if="scoreLogs.length">
|
||||
<div class="fee-section-title" style="margin-top:16px">最近评分记录</div>
|
||||
<div class="sc-log-list">
|
||||
<div v-for="lg in scoreLogs" :key="lg.id" class="sc-log-item">
|
||||
<span class="sc-log-time">{{ fmtLogTime(lg.create_time) }}</span>
|
||||
<span class="sc-log-dim">{{ logDim(lg.dimension) }}</span>
|
||||
<span :class="['sc-log-chg', Number(lg.change_score) > 0 ? 'up' : 'down']">{{ Number(lg.change_score) > 0 ? '+' : '' }}{{ Number(lg.change_score).toFixed(2) }}</span>
|
||||
<span class="sc-log-range">{{ Number(lg.score_before) }} → {{ Number(lg.score_after) }}</span>
|
||||
<span class="sc-log-reason" :title="lg.reason">{{ lg.reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="scoreDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="scoreSubmitting" :disabled="scoreLevelLocked" @click="submitScore">确认评分</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -640,4 +899,117 @@ onMounted(() => load())
|
||||
@media (max-width: 1200px) {
|
||||
.batch-actions { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ===== 内嵌审核视图 ===== */
|
||||
.audit-view { display: flex; flex-direction: column; gap: 14px; }
|
||||
.audit-head {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: var(--surface); border-radius: 12px; padding: 12px 16px;
|
||||
}
|
||||
.audit-head-main { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
|
||||
.audit-ent { font-size: 15px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.audit-card { background: var(--surface); border-radius: 12px; padding: 16px; }
|
||||
.audit-card-title {
|
||||
font-size: 14px; font-weight: 600; color: var(--ink); margin-bottom: 14px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.audit-change-count {
|
||||
font-size: 11px; font-weight: 600; padding: 2px 10px; border-radius: 20px;
|
||||
color: #b45309; background: var(--st-warn-bg, #fef3c7);
|
||||
}
|
||||
.chg-list { display: flex; flex-direction: column; }
|
||||
.chg-item { display: flex; gap: 14px; padding: 12px 0; border-bottom: 1px dashed var(--border); }
|
||||
.chg-item:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.chg-field { flex: 0 0 120px; font-size: 13px; font-weight: 600; color: var(--ink-2); padding-top: 2px; }
|
||||
.chg-body { flex: 1; display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
|
||||
.chg-cell {
|
||||
flex: 1; min-width: 0; border-radius: 8px; padding: 8px 10px;
|
||||
font-size: 13px; line-height: 1.6; word-break: break-all;
|
||||
}
|
||||
.chg-cell.old { background: var(--bg, #f6f8fb); border: 1px solid var(--border); }
|
||||
.chg-cell.new { background: var(--st-ok-bg, #ecfdf5); border: 1px solid rgba(5, 150, 105, .25); }
|
||||
.chg-tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 0 8px; border-radius: 4px; margin-bottom: 6px; }
|
||||
.chg-tag.old { color: var(--ink-3); background: var(--surface); border: 1px solid var(--border); }
|
||||
.chg-tag.new { color: var(--st-ok-fg, #059669); background: var(--surface); border: 1px solid rgba(5, 150, 105, .35); }
|
||||
.chg-arrow { flex: 0 0 auto; color: var(--ink-3); font-size: 16px; padding-top: 18px; }
|
||||
.chg-imgs { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chg-img { width: 84px; height: 84px; border-radius: 8px; border: 1px solid var(--border); background: #fff; cursor: zoom-in; }
|
||||
.chg-empty { color: var(--ink-3); font-style: italic; }
|
||||
.chg-none {
|
||||
font-size: 13px; color: var(--ink-2); line-height: 1.7;
|
||||
background: var(--bg, #f6f8fb); border-radius: 8px; padding: 14px;
|
||||
}
|
||||
.audit-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.chg-item { flex-direction: column; gap: 8px; }
|
||||
.chg-body { flex-direction: column; }
|
||||
.chg-arrow { display: none; }
|
||||
}
|
||||
|
||||
/* ===== 通用弹窗块样式(自 OrderListView 移植) ===== */
|
||||
.fee-section-title { font-size: 13px; font-weight: 700; color: var(--ink); margin: 16px 0 8px; }
|
||||
.fee-type-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.fee-type-card { position: relative; border: 1.5px solid var(--border-1, #e5e7eb); border-radius: 10px; padding: 12px 14px; cursor: pointer; transition: all .15s; background: var(--bg-1, #fff); }
|
||||
.fee-type-card:hover { border-color: var(--primary); }
|
||||
.fee-type-card.active { border-color: var(--primary); background: var(--primary-soft, #eff6ff); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||
.ftc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.ftc-check { display: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; }
|
||||
.fee-type-card.active .ftc-check { display: block; }
|
||||
.ftc-title { font-size: 13.5px; font-weight: 700; color: var(--ink); }
|
||||
.ftc-desc { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||
.fee-unit { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||
.fee-hint { font-size: 12px; color: var(--ink-3); }
|
||||
.fee-result { margin-top: 14px; border-radius: 10px; padding: 14px 16px; background: linear-gradient(135deg, var(--primary-soft, #eff6ff), rgba(255,255,255,0)); border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); display: flex; gap: 20px; align-items: center; }
|
||||
.fr-left { flex-shrink: 0; min-width: 150px; }
|
||||
.fr-cap { font-size: 12px; color: var(--ink-3); }
|
||||
.fr-val { font-size: 26px; font-weight: 800; color: var(--primary); line-height: 1.2; }
|
||||
.fr-calc { font-size: 12px; color: var(--ink-2); margin-top: 2px; }
|
||||
.fr-right { flex: 1; display: flex; flex-direction: column; gap: 7px; border-left: 1px dashed var(--border-1, #e5e7eb); padding-left: 18px; }
|
||||
.fr-step { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||
/* ===== 履约评分弹窗 ===== */
|
||||
.sc-hero { display: flex; align-items: center; gap: 14px; padding: 16px 18px; border-radius: 12px; background: var(--bg-2, #f8fafc); border: 1px solid var(--border-1, #e5e7eb); }
|
||||
.sc-hero-left { flex: 1; min-width: 0; }
|
||||
.sc-name { font-size: 16px; font-weight: 800; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sc-sub { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||||
.sc-hero-right { text-align: right; }
|
||||
.sc-score-num { font-size: 34px; font-weight: 900; color: var(--primary); line-height: 1; font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||
.sc-score-cap { font-size: 11px; color: var(--ink-3); margin-top: 2px; }
|
||||
.sc-badge { display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 999px; font-size: 13px; font-weight: 800; box-shadow: 0 1px 4px rgba(0,0,0,.08); white-space: nowrap; }
|
||||
.sc-scale { position: relative; display: flex; gap: 4px; margin-top: 14px; }
|
||||
.sc-seg { flex: 1; text-align: center; padding: 7px 0 6px; border-radius: 8px; opacity: .5; border: 1px solid transparent; }
|
||||
.sc-seg span { font-size: 12.5px; font-weight: 800; display: block; }
|
||||
.sc-seg i { font-style: normal; font-size: 10.5px; opacity: .8; }
|
||||
.sc-seg.fail { background: linear-gradient(135deg,#fef2f2,#fee2e2); color: #b91c1c; }
|
||||
.sc-seg.bronze { background: linear-gradient(135deg,#ffedd5,#fed7aa); color: #9a3412; }
|
||||
.sc-seg.silver { background: linear-gradient(135deg,#f1f5f9,#e2e8f0); color: #475569; }
|
||||
.sc-seg.gold { background: linear-gradient(135deg,#fef3c7,#fde68a); color: #b45309; }
|
||||
.sc-seg.on { opacity: 1; border-color: currentColor; box-shadow: 0 0 0 1px currentColor inset; }
|
||||
.sc-marker { position: absolute; top: -8px; transform: translateX(-50%); transition: left .3s; }
|
||||
.sc-marker-dot { display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--primary); border: 3px solid #fff; box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||
.sc-marker-tip { position: absolute; top: -24px; left: 50%; transform: translateX(-50%); background: var(--primary); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
|
||||
.sc-marker-tip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 4px solid transparent; border-top-color: var(--primary); }
|
||||
.sc-scale-cap { margin-top: 6px; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
.sc-dim-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background: var(--primary-soft, #dbeafe); color: var(--primary); font-size: 18px; }
|
||||
.sc-weight-chip { display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px; font-weight: 800; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; vertical-align: 1px; }
|
||||
.sc-op-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.sc-seg2 { display: inline-flex; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; overflow: hidden; }
|
||||
.sc-seg2-btn { display: inline-flex; align-items: center; gap: 4px; padding: 8px 18px; font-size: 13px; font-weight: 700; background: var(--bg-1, #fff); color: var(--ink-3); border: none; cursor: pointer; transition: all .15s; }
|
||||
.sc-seg2-btn + .sc-seg2-btn { border-left: 1px solid var(--border-1, #e5e7eb); }
|
||||
.sc-seg2-btn.plus { background: var(--el-color-success); color: #fff; }
|
||||
.sc-seg2-btn.minus { background: var(--el-color-danger); color: #fff; }
|
||||
.sc-arrow-text { font-size: 14px; color: var(--ink-2); }
|
||||
.sc-arrow-text b { font-size: 17px; color: var(--ink); }
|
||||
.sc-lv-change { font-size: 15px; font-weight: 800; }
|
||||
.sc-lv-tip { font-size: 11px; font-weight: 400; opacity: .7; }
|
||||
.sc-log-list { border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; overflow: hidden; }
|
||||
.sc-log-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-size: 12px; border-bottom: 1px solid var(--border-1, #e5e7eb); }
|
||||
.sc-log-item:last-child { border-bottom: none; }
|
||||
.sc-log-time { color: var(--ink-3); font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; flex-shrink: 0; }
|
||||
.sc-log-dim { color: var(--ink-2); font-weight: 700; flex-shrink: 0; }
|
||||
.sc-log-chg { font-weight: 800; flex-shrink: 0; width: 52px; text-align: right; }
|
||||
.sc-log-chg.up { color: var(--el-color-success); }
|
||||
.sc-log-chg.down { color: var(--el-color-danger); }
|
||||
.sc-log-range { color: var(--ink-3); flex-shrink: 0; font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; }
|
||||
.sc-log-reason { color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; text-align: right; }
|
||||
</style>
|
||||
453
src/modules/user/EnterpriseScoreListView.vue
Normal file
453
src/modules/user/EnterpriseScoreListView.vue
Normal file
@ -0,0 +1,453 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 企业评分与等级管理 — admin
|
||||
*
|
||||
* 数据源: /user/enterprisescore/{page,stats,logs,score}(user-server)
|
||||
* 规则: 初始60分(铜牌) / ≥80金 / [70,80)银 / <70铜;评分维度 配合度10% + 中标数量90%,单次录入≤10,实际变动=录入×权重
|
||||
*/
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { Trophy, Connection, Top, Bottom, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||
|
||||
interface Row {
|
||||
id: string | number
|
||||
enterprise_name: string
|
||||
current_score: number
|
||||
level: number
|
||||
last_score_time: string | null
|
||||
}
|
||||
|
||||
const LEVEL_META: Record<number, { label: string; icon?: string; color: string; bg: string; border: string }> = {
|
||||
0: { label: '待整改供应商', icon: 'warning', color: '#b91c1c', bg: 'linear-gradient(135deg,#fef2f2,#fee2e2)', border: '#ef4444' },
|
||||
3: { label: '金牌供应商', color: '#b45309', bg: 'linear-gradient(135deg,#fef3c7,#fde68a)', border: '#f59e0b' },
|
||||
2: { label: '银牌供应商', color: '#475569', bg: 'linear-gradient(135deg,#f1f5f9,#e2e8f0)', border: '#94a3b8' },
|
||||
1: { label: '铜牌供应商', color: '#9a3412', bg: 'linear-gradient(135deg,#ffedd5,#fed7aa)', border: '#fb923c' },
|
||||
}
|
||||
function levelMeta(lv: number) { return LEVEL_META[lv] || LEVEL_META[1] }
|
||||
|
||||
const LEVEL_TABS = [
|
||||
{ value: '', label: '全部企业' },
|
||||
{ value: '3', label: '金牌 (≥80分)' },
|
||||
{ value: '2', label: '银牌 (70~80分)' },
|
||||
{ value: '1', label: '铜牌 (60~70分)' },
|
||||
{ value: '0', label: '待整改 (<60分)' }
|
||||
]
|
||||
|
||||
const stats = ref<any>({ gold: 0, silver: 0, bronze: 0, fail: 0, total: 0 })
|
||||
const list = ref<Row[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ enterpriseName: '', level: '' })
|
||||
|
||||
const columns: any = [
|
||||
{ prop: 'enterprise_name', label: '企业名称', minWidth: 240, slot: 'name' },
|
||||
{ prop: 'level', label: '等级', width: 130, align: 'center', slot: 'level' },
|
||||
{ prop: 'current_score', label: '当前积分', width: 120, align: 'center', slot: 'score' },
|
||||
{ prop: 'score_bar', label: '分数区间', width: 220, align: 'center', slot: 'bar' },
|
||||
{ prop: 'last_score_time', label: '最近评分', width: 160, slot: 'time' },
|
||||
{ label: '操作', width: 160, align: 'center', slot: 'action' }
|
||||
]
|
||||
const tabItems = LEVEL_TABS.map(t => ({ key: t.value, label: t.label }))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params: any = { page: page.value, limit: limit.value }
|
||||
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
|
||||
const lv = Number(filters.level)
|
||||
if (lv) params.level = lv
|
||||
const r: any = await httpGet('/user/enterprisescore/page', params)
|
||||
list.value = r?.list || []
|
||||
const t = r?.total
|
||||
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r: any = await httpGet('/user/enterprisescore/stats')
|
||||
if (r) stats.value = r
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.enterpriseName = ''; filters.level = ''; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; load() }
|
||||
function onLevelTab(v: string) { filters.level = v; onSearch() }
|
||||
|
||||
// ===== 评分(复用企业页同款弹窗实现) =====
|
||||
const DIMENSIONS = [
|
||||
{ id: 1, name: '配合度', weight: 10, desc: '沟通响应、履约配合、售后态度' },
|
||||
{ id: 2, name: '中标数量', weight: 90, desc: '参与报价并中标的业务表现' },
|
||||
]
|
||||
const scoreDialog = ref(false)
|
||||
const scoreRow = ref<Row | null>(null)
|
||||
const scoreLoading = ref(false)
|
||||
const scoreInfo = ref<any>(null)
|
||||
const scoreLogs = ref<any[]>([])
|
||||
const scoreForm = reactive({ dimension: 2, action: 1, rawScore: 5, reason: '' })
|
||||
const scoreSubmitting = ref(false)
|
||||
const logDrawer = ref(false)
|
||||
const logList = ref<any[]>([])
|
||||
const logTotal = ref(0)
|
||||
const logLoading = ref(false)
|
||||
const logPage = ref(1)
|
||||
|
||||
function levelOfScore(score: number): number { return score >= 80 ? 3 : score >= 70 ? 2 : score >= 60 ? 1 : 0 }
|
||||
function lvOf(v: any): number { const n = Number(v ?? 60); return n >= 80 ? 3 : n >= 70 ? 2 : n >= 60 ? 1 : 0 }
|
||||
|
||||
async function openScore(row: Row) {
|
||||
scoreRow.value = row
|
||||
scoreInfo.value = { score: Number(row.current_score), level: Number(row.level) }
|
||||
scoreLogs.value = []
|
||||
scoreForm.dimension = 2; scoreForm.action = 1; scoreForm.rawScore = 5; scoreForm.reason = ''
|
||||
scoreDialog.value = true
|
||||
scoreLoading.value = true
|
||||
try {
|
||||
const [info, logs]: any = await Promise.all([
|
||||
httpGet('/user/enterprisescore/info', { enterpriseId: row.id }),
|
||||
httpGet('/user/enterprisescore/logs', { enterpriseId: row.id, page: 1, limit: 5 })
|
||||
])
|
||||
if (info) scoreInfo.value = info
|
||||
scoreLogs.value = logs?.list || []
|
||||
} catch { /* 保底当前行数据 */ }
|
||||
scoreLoading.value = false
|
||||
}
|
||||
|
||||
const scoreChange = computed(() => {
|
||||
const dim = DIMENSIONS.find(d => d.id === scoreForm.dimension)!
|
||||
const change = Math.round((Number(scoreForm.rawScore) || 0) * dim.weight / 100)
|
||||
return scoreForm.action === 1 ? Number(change.toFixed(2)) : -Number(change.toFixed(2))
|
||||
})
|
||||
const scoreAfter = computed(() => {
|
||||
const base = Number(scoreInfo.value?.score ?? 60)
|
||||
return Math.max(0, Math.min(100, Number((base + scoreChange.value).toFixed(2))))
|
||||
})
|
||||
const scoreLevelChange = computed(() => {
|
||||
const before = levelOfScore(Number(scoreInfo.value?.score ?? 60))
|
||||
const after = levelOfScore(scoreAfter.value)
|
||||
return { before, after, changed: before !== after, up: after > before }
|
||||
})
|
||||
|
||||
async function submitScore() {
|
||||
if (!scoreRow.value) return
|
||||
if (!(Number(scoreForm.rawScore) > 0)) { ElMessage.warning('请填写分值'); return }
|
||||
scoreSubmitting.value = true
|
||||
try {
|
||||
const r: any = await httpPost('/user/enterprisescore/score', {
|
||||
enterpriseId: scoreRow.value.id, dimension: scoreForm.dimension, action: scoreForm.action,
|
||||
rawScore: scoreForm.rawScore, reason: scoreForm.reason, operator: 'admin'
|
||||
})
|
||||
ElMessage.success(`评分成功: ${r?.scoreBefore} → ${r?.scoreAfter} 分`)
|
||||
scoreDialog.value = false
|
||||
await Promise.all([load(), loadStats()])
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '评分失败') }
|
||||
finally { scoreSubmitting.value = false }
|
||||
}
|
||||
|
||||
async function openLogs(row: Row) {
|
||||
scoreRow.value = row
|
||||
logDrawer.value = true
|
||||
logPage.value = 1
|
||||
await loadLogs()
|
||||
}
|
||||
async function loadLogs() {
|
||||
if (!scoreRow.value) return
|
||||
logLoading.value = true
|
||||
try {
|
||||
const r: any = await httpGet('/user/enterprisescore/logs', { enterpriseId: scoreRow.value.id, page: logPage.value, limit: 10 })
|
||||
logList.value = r?.list || []
|
||||
logTotal.value = Number(r?.total || 0)
|
||||
} catch { logList.value = [] }
|
||||
finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
function fmtTime(v: any) { return String(v || '').replace('T', ' ').slice(0, 16) }
|
||||
function logDim(v: number) { return v === 1 ? '配合度' : '中标数量' }
|
||||
|
||||
onMounted(() => { load(); loadStats() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<PageHeader title="企业评分与等级" sub="履约评分 · 权重:配合度 10% + 中标数量 90% · 单次录入 ≤10 分 · 初始 60 分(铜牌)" />
|
||||
|
||||
<!-- 统计卡 -->
|
||||
<div class="lvl-stats">
|
||||
<div class="lvl-card">
|
||||
<div class="lvl-num">{{ stats.total }}</div>
|
||||
<div class="lvl-cap">已认证企业</div>
|
||||
</div>
|
||||
<div class="lvl-card gold">
|
||||
<div class="lvl-badge"><el-icon><Trophy /></el-icon>金牌</div>
|
||||
<div class="lvl-num">{{ stats.gold }}</div>
|
||||
<div class="lvl-cap">≥80 分</div>
|
||||
</div>
|
||||
<div class="lvl-card silver">
|
||||
<div class="lvl-badge"><el-icon><Trophy /></el-icon>银牌</div>
|
||||
<div class="lvl-num">{{ stats.silver }}</div>
|
||||
<div class="lvl-cap">70 ~ 80 分</div>
|
||||
</div>
|
||||
<div class="lvl-card bronze">
|
||||
<div class="lvl-badge"><el-icon><Trophy /></el-icon>铜牌</div>
|
||||
<div class="lvl-num">{{ stats.bronze }}</div>
|
||||
<div class="lvl-cap">60 ~ 70 分(初始 60)</div>
|
||||
</div>
|
||||
<div class="lvl-card fail">
|
||||
<div class="lvl-badge"><el-icon><Warning /></el-icon>待整改</div>
|
||||
<div class="lvl-num">{{ stats.fail }}</div>
|
||||
<div class="lvl-cap"><60 分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListTabs v-model="filters.level" :items="tabItems" @change="onLevelTab" />
|
||||
|
||||
<SearchBar
|
||||
v-model:filters="filters"
|
||||
:fields="[{ key: 'enterpriseName', label: '企业名称', placeholder: '请输入企业名称' }]"
|
||||
@search="onSearch"
|
||||
@reset="onReset"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
:data="list"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #name="{ row }">
|
||||
<span class="rr-name">{{ row.enterprise_name }}</span>
|
||||
</template>
|
||||
<template #level="{ row }">
|
||||
<span class="lvl-pill" :style="{ background: levelMeta(row.level).bg, color: levelMeta(row.level).color, boxShadow: 'inset 0 0 0 1px ' + levelMeta(row.level).border }">
|
||||
<el-icon style="font-size:13px"><Trophy /></el-icon>{{ levelMeta(row.level).label }}
|
||||
</span>
|
||||
</template>
|
||||
<template #score="{ row }">
|
||||
<span class="rr-score">{{ Number(row.current_score) }}</span>
|
||||
<span class="rr-score-cap">分</span>
|
||||
</template>
|
||||
<template #bar="{ row }">
|
||||
<div class="score-track">
|
||||
<div class="score-track-in" :style="{ width: Math.min(100, Number(row.current_score)) + '%', background: levelMeta(row.level).border }"></div>
|
||||
<span class="score-track-mark" :style="{ left: '70%' }"></span>
|
||||
<span class="score-track-mark" :style="{ left: '80%' }"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #time="{ row }">
|
||||
<span class="rr-time">{{ row.last_score_time ? fmtTime(row.last_score_time) : '—' }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableActions
|
||||
:row="row"
|
||||
:actions="[
|
||||
{ label: '评分', type: 'primary', onClick: (r: any) => openScore(r) },
|
||||
{ label: '评分记录', type: 'primary', onClick: (r: any) => openLogs(r) }
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- 评分弹窗 -->
|
||||
<el-dialog v-model="scoreDialog" title="企业履约评分" width="680px" destroy-on-close>
|
||||
<div v-if="scoreLoading" style="padding:40px 0;text-align:center;color:var(--ink-3)">加载中...</div>
|
||||
<template v-else-if="scoreRow">
|
||||
<div class="sc-hero">
|
||||
<div class="sc-hero-left">
|
||||
<div class="sc-name">{{ scoreRow.enterprise_name }}</div>
|
||||
<div class="sc-sub">当前 <b>{{ scoreInfo?.score ?? 60 }}</b> 分 · {{ levelMeta(scoreInfo?.level ?? 1).label }}</div>
|
||||
</div>
|
||||
<div class="sc-badge" :style="{ background: levelMeta(scoreInfo?.level ?? 1).bg, color: levelMeta(scoreInfo?.level ?? 1).color }">
|
||||
<el-icon style="font-size:18px"><Trophy /></el-icon>
|
||||
{{ levelMeta(scoreInfo?.level ?? 1).label }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-scale">
|
||||
<div class="sc-seg fail" :class="{ on: (scoreInfo?.level ?? 1) === 0 }" style="flex:0 0 60%"><span>待整改</span><i><60 分</i></div>
|
||||
<div class="sc-seg bronze" :class="{ on: (scoreInfo?.level ?? 1) === 1 }" style="flex:0 0 10%; min-width:64px"><span>铜牌</span><i>60~70</i></div>
|
||||
<div class="sc-seg silver" :class="{ on: (scoreInfo?.level ?? 1) === 2 }" style="flex:0 0 10%; min-width:64px"><span>银牌</span><i>70~80</i></div>
|
||||
<div class="sc-seg gold" :class="{ on: (scoreInfo?.level ?? 1) === 3 }" style="flex:1"><span>金牌</span><i>≥80 分</i></div>
|
||||
<div class="sc-marker" :style="{ left: Math.min(98, Math.max(2, Number(scoreInfo?.score ?? 60))) + '%' }">
|
||||
<span class="sc-marker-tip">当前 {{ scoreInfo?.score ?? 60 }} 分</span>
|
||||
<span class="sc-marker-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc-scale-cap">刻度按分数区间占比呈现,圆点即企业当前积分位置</div>
|
||||
<div class="fee-section-title" style="margin-top:18px">评分维度</div>
|
||||
<div class="fee-type-cards">
|
||||
<div v-for="d in DIMENSIONS" :key="d.id" :class="['fee-type-card', { active: scoreForm.dimension === d.id }]" @click="scoreForm.dimension = d.id">
|
||||
<div class="ftc-head">
|
||||
<el-icon class="sc-dim-icon"><Trophy v-if="d.id === 2" /><Connection v-else /></el-icon>
|
||||
<span class="ftc-check">✓</span>
|
||||
</div>
|
||||
<div class="ftc-title">{{ d.name }} <span class="sc-weight-chip">权重 {{ d.weight }}%</span></div>
|
||||
<div class="ftc-desc">{{ d.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-section-title">评分操作</div>
|
||||
<div class="sc-op-row">
|
||||
<div class="sc-seg2">
|
||||
<button :class="['sc-seg2-btn', { plus: scoreForm.action === 1 }]" @click="scoreForm.action = 1"><el-icon style="font-size:15px"><Top /></el-icon>加分</button>
|
||||
<button :class="['sc-seg2-btn', { minus: scoreForm.action === 2 }]" @click="scoreForm.action = 2"><el-icon style="font-size:15px"><Bottom /></el-icon>减分</button>
|
||||
</div>
|
||||
<el-input-number v-model="scoreForm.rawScore" :min="0.01" :max="10" :precision="2" :step="1" controls-position="right" style="width:150px" />
|
||||
<span class="fee-unit">分</span>
|
||||
<span class="fee-hint">录入分 × 权重 = 实际变动</span>
|
||||
</div>
|
||||
<el-input v-model="scoreForm.reason" placeholder="评分原因(如:季度履约优秀 / 中标 3 次)" maxlength="100" show-word-limit style="margin-top:10px" />
|
||||
<div class="fee-result" style="margin-top:14px">
|
||||
<div class="fr-left">
|
||||
<div class="fr-cap">实际变动</div>
|
||||
<div class="fr-val" :style="{ color: scoreForm.action === 1 ? 'var(--el-color-success)' : 'var(--el-color-danger)' }">{{ scoreChange > 0 ? '+' : '' }}{{ scoreChange }} 分</div>
|
||||
<div class="fr-calc">{{ Number(scoreForm.rawScore || 0) }} 分 × {{ DIMENSIONS.find(d => d.id === scoreForm.dimension)?.weight }}% 权重</div>
|
||||
</div>
|
||||
<div class="fr-right">
|
||||
<div class="fr-step"><span class="sc-arrow-text">{{ scoreInfo?.score ?? 60 }} 分 → <b>{{ scoreAfter }} 分</b></span></div>
|
||||
<div class="fr-step" v-if="scoreLevelChange.changed">
|
||||
<span class="sc-lv-change" :style="{ color: scoreLevelChange.up ? '#b45309' : '#b91c1c' }">
|
||||
{{ levelMeta(scoreLevelChange.before).label }} → {{ levelMeta(scoreLevelChange.after).label }}({{ scoreLevelChange.up ? '升级' : '降级' }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="fr-step" v-else><span class="sc-arrow-text">等级不变:{{ levelMeta(scoreLevelChange.after).label }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="scoreDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="scoreSubmitting" @click="submitScore">确认评分</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 评分流水抽屉 -->
|
||||
<el-drawer v-model="logDrawer" :title="`评分记录 — ${scoreRow?.enterprise_name || ''}`" size="560px">
|
||||
<el-table :data="logList" v-loading="logLoading" size="small" border>
|
||||
<el-table-column label="时间" width="140">
|
||||
<template #default="{ row }"><span class="rr-time">{{ fmtTime(row.create_time) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="维度" width="80">
|
||||
<template #default="{ row }">{{ logDim(row.dimension) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变动" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: Number(row.change_score) > 0 ? 'var(--el-color-success)' : 'var(--el-color-danger)', fontWeight: 700 }">
|
||||
{{ Number(row.change_score) > 0 ? '+' : '' }}{{ Number(row.change_score).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分值变化" width="110" align="center">
|
||||
<template #default="{ row }">{{ Number(row.score_before) }} → {{ Number(row.score_after) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" min-width="130">
|
||||
<template #default="{ row }"><span class="rr-muted">{{ row.reason }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" width="70">
|
||||
<template #default="{ row }">{{ row.operator }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="logTotal > 10" class="log-pager">
|
||||
<el-button size="small" :disabled="logPage <= 1" @click="logPage--; loadLogs()">上一页</el-button>
|
||||
<span class="rr-muted">{{ logPage }} / {{ Math.ceil(logTotal / 10) }}</span>
|
||||
<el-button size="small" :disabled="logPage >= Math.ceil(logTotal / 10)" @click="logPage++; loadLogs()">下一页</el-button>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-muted { color: var(--ink-3); }
|
||||
.rr-name { font-weight: 600; color: var(--ink); }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
.rr-score { font-size: 17px; font-weight: 900; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||
.rr-score-cap { font-size: 11px; color: var(--ink-3); margin-left: 2px; }
|
||||
|
||||
.lvl-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||
.lvl-card { border-radius: 12px; padding: 16px 18px; border: 1px solid var(--border-1, #e5e7eb); background: var(--bg-1, #fff); position: relative; overflow: hidden; }
|
||||
.lvl-card.gold { background: linear-gradient(135deg, #fffbeb, #fef3c7); border-color: #f59e0b; }
|
||||
.lvl-card.silver { background: linear-gradient(135deg, #f8fafc, #e2e8f0); border-color: #94a3b8; }
|
||||
.lvl-card.bronze { background: linear-gradient(135deg, #fff7ed, #ffedd5); border-color: #fb923c; }
|
||||
.lvl-card.fail { background: linear-gradient(135deg, #fef2f2, #fee2e2); border-color: #ef4444; }
|
||||
.lvl-card.fail .lvl-badge { color: #b91c1c; }
|
||||
.lvl-num { font-size: 30px; font-weight: 900; line-height: 1.1; color: var(--ink); }
|
||||
.lvl-cap { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||
.lvl-badge { position: absolute; top: 12px; right: 14px; display: inline-flex; align-items: center; gap: 3px; font-size: 12px; font-weight: 800; }
|
||||
.lvl-card.gold .lvl-badge { color: #b45309; }
|
||||
.lvl-card.silver .lvl-badge { color: #475569; }
|
||||
.lvl-card.bronze .lvl-badge { color: #9a3412; }
|
||||
|
||||
.lvl-pill { display: inline-flex; align-items: center; gap: 3px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 800; white-space: nowrap; }
|
||||
.score-track { position: relative; height: 10px; border-radius: 999px; background: rgba(0,0,0,.06); overflow: visible; }
|
||||
.score-track-in { height: 100%; border-radius: 999px; transition: width .3s; }
|
||||
.score-track-mark { position: absolute; top: -2px; width: 1.5px; height: 14px; background: rgba(0,0,0,.25); }
|
||||
.log-pager { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; }
|
||||
|
||||
.fee-section-title { font-size: 13px; font-weight: 700; color: var(--ink); margin: 16px 0 8px; }
|
||||
.fee-type-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.fee-type-card { position: relative; border: 1.5px solid var(--border-1, #e5e7eb); border-radius: 10px; padding: 12px 14px; cursor: pointer; transition: all .15s; background: var(--bg-1, #fff); }
|
||||
.fee-type-card:hover { border-color: var(--primary); }
|
||||
.fee-type-card.active { border-color: var(--primary); background: var(--primary-soft, #eff6ff); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||
.ftc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.ftc-check { display: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; }
|
||||
.fee-type-card.active .ftc-check { display: block; }
|
||||
.ftc-title { font-size: 13.5px; font-weight: 700; color: var(--ink); }
|
||||
.ftc-desc { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||
.fee-unit { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||
.fee-hint { font-size: 12px; color: var(--ink-3); }
|
||||
.fee-result { margin-top: 14px; border-radius: 10px; padding: 14px 16px; background: linear-gradient(135deg, var(--primary-soft, #eff6ff), rgba(255,255,255,0)); border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); display: flex; gap: 20px; align-items: center; }
|
||||
.fr-left { flex-shrink: 0; min-width: 150px; }
|
||||
.fr-cap { font-size: 12px; color: var(--ink-3); }
|
||||
.fr-val { font-size: 26px; font-weight: 800; line-height: 1.2; }
|
||||
.fr-calc { font-size: 12px; color: var(--ink-2); margin-top: 2px; }
|
||||
.fr-right { flex: 1; display: flex; flex-direction: column; gap: 7px; border-left: 1px dashed var(--border-1, #e5e7eb); padding-left: 18px; }
|
||||
.fr-step { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||
.sc-hero { display: flex; align-items: center; gap: 14px; padding: 16px 18px; border-radius: 12px; background: var(--bg-2, #f8fafc); border: 1px solid var(--border-1, #e5e7eb); }
|
||||
.sc-hero-left { flex: 1; min-width: 0; }
|
||||
.sc-name { font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||
.sc-sub { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||||
.sc-badge { display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 999px; font-size: 13px; font-weight: 800; box-shadow: 0 1px 4px rgba(0,0,0,.08); white-space: nowrap; }
|
||||
.sc-scale { position: relative; display: flex; gap: 4px; margin-top: 14px; }
|
||||
.sc-seg { flex: 1; text-align: center; padding: 7px 0 6px; border-radius: 8px; opacity: .5; border: 1px solid transparent; }
|
||||
.sc-seg span { font-size: 12.5px; font-weight: 800; display: block; }
|
||||
.sc-seg i { font-style: normal; font-size: 10.5px; opacity: .8; }
|
||||
.sc-seg.fail { background: linear-gradient(135deg,#fef2f2,#fee2e2); color: #b91c1c; }
|
||||
.sc-seg.bronze { background: linear-gradient(135deg,#ffedd5,#fed7aa); color: #9a3412; }
|
||||
.sc-seg.silver { background: linear-gradient(135deg,#f1f5f9,#e2e8f0); color: #475569; }
|
||||
.sc-seg.gold { background: linear-gradient(135deg,#fef3c7,#fde68a); color: #b45309; }
|
||||
.sc-seg.on { opacity: 1; border-color: currentColor; box-shadow: 0 0 0 1px currentColor inset; }
|
||||
.sc-marker { position: absolute; top: -8px; transform: translateX(-50%); transition: left .3s; }
|
||||
.sc-marker-dot { display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--primary); border: 3px solid #fff; box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||
.sc-marker-tip { position: absolute; top: -24px; left: 50%; transform: translateX(-50%); background: var(--primary); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
|
||||
.sc-marker-tip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 4px solid transparent; border-top-color: var(--primary); }
|
||||
.sc-scale-cap { margin-top: 6px; font-size: 11px; color: var(--ink-3); }
|
||||
|
||||
.sc-dim-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background: var(--primary-soft, #dbeafe); color: var(--primary); font-size: 18px; }
|
||||
.sc-weight-chip { display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px; font-weight: 800; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; vertical-align: 1px; }
|
||||
.sc-op-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.sc-seg2 { display: inline-flex; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; overflow: hidden; }
|
||||
.sc-seg2-btn { display: inline-flex; align-items: center; gap: 4px; padding: 8px 18px; font-size: 13px; font-weight: 700; background: var(--bg-1, #fff); color: var(--ink-3); border: none; cursor: pointer; transition: all .15s; }
|
||||
.sc-seg2-btn + .sc-seg2-btn { border-left: 1px solid var(--border-1, #e5e7eb); }
|
||||
.sc-seg2-btn.plus { background: var(--el-color-success); color: #fff; }
|
||||
.sc-seg2-btn.minus { background: var(--el-color-danger); color: #fff; }
|
||||
.sc-arrow-text { font-size: 14px; color: var(--ink-2); }
|
||||
.sc-arrow-text b { font-size: 17px; color: var(--ink); }
|
||||
.sc-lv-change { font-size: 15px; font-weight: 800; }
|
||||
</style>
|
||||
@ -45,8 +45,13 @@ const STATUS_META: Record<number, { label: string; type: 'warning' | 'success' |
|
||||
3: { label: '已驳回', type: 'danger', icon: CircleCloseFilled }
|
||||
}
|
||||
const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' }
|
||||
const LEVEL_MAP: Record<number, string> = {
|
||||
1: '优质供应商', 2: '钻石供应商', 3: '金牌供应商', 4: '银牌供应商', 5: '铜牌供应商'
|
||||
// 企业类型 (主体类型): 1企业 2个体工商户 —— 与认证表单主体类型选择对应
|
||||
const TYPE_MAP: Record<number, string> = { 1: '企业', 2: '个体工商户' }
|
||||
// 平台履约等级(新体系): >=80金 / [70,80)银 / <70铜, 初始60
|
||||
const LEVEL_MAP: Record<number, string> = { 3: '金牌供应商', 2: '银牌供应商', 1: '铜牌供应商' }
|
||||
function levelOf(score: any): number {
|
||||
const n = Number(score ?? 60)
|
||||
return n >= 80 ? 3 : n >= 70 ? 2 : 1
|
||||
}
|
||||
|
||||
const status = computed<number>(() => Number(detail.value.status ?? 0))
|
||||
@ -57,6 +62,26 @@ const statusMeta = computed(() => STATUS_META[status.value])
|
||||
// 状态 → banner 修饰类 (CSS 里 banner-warning/success/danger 对应 warning/success/danger)
|
||||
const bannerClass = computed(() => `banner-${statusMeta.value?.type || 'unknown'}`)
|
||||
|
||||
// ===== 变更信息对比 (change_log: [{field, old, new}]) =====
|
||||
const auditChanges = computed(() => {
|
||||
const raw = detail.value?.changeLog
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const list = parsed.filter((c: any) => c && c.field)
|
||||
// 注册首审单的 changeLog 特征 = 全部条目 old 为空(空->填充值), 无对比意义, 隐藏 (2026-10-19)
|
||||
if (list.length && list.every((c: any) => !String(c.old || '').trim())) return []
|
||||
return list
|
||||
} catch { return [] }
|
||||
})
|
||||
const isImageVal = (val: any) => {
|
||||
if (!val) return false
|
||||
const segs = String(val).split(' / ').map((x: string) => x.trim()).filter(Boolean)
|
||||
return segs.length > 0 && segs.every((x: string) => /^https?:\/\//.test(x))
|
||||
}
|
||||
const splitImgs = (val: any) => String(val || '').split(' / ').map((x: string) => x.trim()).filter(Boolean)
|
||||
|
||||
const logoText = computed(() => (detail.value.enterpriseName || '企').trim().charAt(0))
|
||||
const registerYear = computed(() => {
|
||||
const t = detail.value.registerTime
|
||||
@ -219,7 +244,7 @@ async function toggleHome() {
|
||||
function goStore() {
|
||||
const eid = detail.value.enterpriseId || detail.value.id
|
||||
if (!eid) return
|
||||
window.open(`/v2/supplier/${eid}`, '_blank')
|
||||
window.open(`/supplier/${eid}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@ -234,6 +259,55 @@ onMounted(load)
|
||||
</PageHeader>
|
||||
|
||||
<!-- ===== ① 状态横幅 (审核操作区) ===== -->
|
||||
<!-- 变更信息对比卡: 有变更记录时展示(待审核/已审核均可见, 方便核对) -->
|
||||
<div v-if="auditChanges.length" class="chg-card">
|
||||
<div class="chg-card-title">
|
||||
<el-icon style="color:var(--primary)"><Document /></el-icon>
|
||||
<span>本次提交变更内容</span>
|
||||
<span class="chg-count">共 {{ auditChanges.length }} 项变更</span>
|
||||
</div>
|
||||
<div class="chg-list">
|
||||
<div v-for="(c, i) in auditChanges" :key="i" class="chg-item">
|
||||
<div class="chg-field">{{ c.field }}</div>
|
||||
<div class="chg-body">
|
||||
<div class="chg-cell old">
|
||||
<span class="chg-tag-old">变更前</span>
|
||||
<div class="chg-val">
|
||||
<template v-if="isImageVal(c.old)">
|
||||
<div class="chg-imgs">
|
||||
<el-image v-for="(img, k) in splitImgs(c.old)" :key="k" :src="img"
|
||||
:preview-src-list="splitImgs(c.old)" :initial-index="k" fit="cover"
|
||||
class="chg-img" preview-teleported />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="!c.old" class="chg-empty">(未填写)</span>
|
||||
<span v-else>{{ c.old }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chg-arrow">→</div>
|
||||
<div class="chg-cell new">
|
||||
<span class="chg-tag-new">变更后</span>
|
||||
<div class="chg-val">
|
||||
<template v-if="isImageVal(c.new)">
|
||||
<div class="chg-imgs">
|
||||
<el-image v-for="(img, k) in splitImgs(c.new)" :key="k" :src="img"
|
||||
:preview-src-list="splitImgs(c.new)" :initial-index="k" fit="cover"
|
||||
class="chg-img" preview-teleported />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="!c.new" class="chg-empty">(未填写)</span>
|
||||
<span v-else>{{ c.new }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-banner" :class="bannerClass" v-if="status">
|
||||
<div class="banner-icon">
|
||||
<el-icon v-if="statusMeta" :size="26"><component :is="statusMeta.icon" /></el-icon>
|
||||
@ -299,12 +373,18 @@ onMounted(load)
|
||||
<div class="header-right">
|
||||
<div class="header-stats">
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{{ LEVEL_MAP[detail.supplierLevel as number] || '--' }}</div>
|
||||
<div class="stat-label">供应商等级</div>
|
||||
<div class="stat-value">{{ LEVEL_MAP[levelOf(detail.currentScore)] }}</div>
|
||||
<div class="stat-label">平台等级</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{{ detail.currentScore ?? '--' }}</div>
|
||||
<div class="stat-label">当前评分</div>
|
||||
<div class="stat-value" :style="{ color: Number(detail.backfillEnabled) === 1 ? 'var(--el-color-success)' : 'var(--ink-3)' }">
|
||||
{{ Number(detail.backfillEnabled) === 1 ? '已开通' : '未开通' }}
|
||||
</div>
|
||||
<div class="stat-label">订单补录权限</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{{ detail.currentScore ?? 60 }}<span style="font-size:13px;color:var(--ink-3)"> 分</span></div>
|
||||
<div class="stat-label">履约评分</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{{ detail.enterpriseRate ?? '--' }}</div>
|
||||
@ -376,7 +456,7 @@ onMounted(load)
|
||||
<div class="info-item"><span class="label">注册时间</span><span class="value">{{ fmtDate(detail.registerTime) }}</span></div>
|
||||
<div class="info-item"><span class="label">所属行业</span><span class="value">{{ detail.industry || '--' }}</span></div>
|
||||
<div class="info-item"><span class="label">企业身份</span><span class="value">{{ IDENTITY_MAP[detail.enterpriseIdentity as number] || detail.enterpriseIdentity || '--' }}</span></div>
|
||||
<div class="info-item"><span class="label">企业类型</span><span class="value">{{ detail.enterpriseType ?? '--' }}</span></div>
|
||||
<div class="info-item"><span class="label">企业类型</span><span class="value">{{ TYPE_MAP[detail.enterpriseType as number] || detail.enterpriseType || '--' }}</span></div>
|
||||
<div class="info-item"><span class="label">统一信用代码</span><span class="value mono">{{ detail.creditCode || detail.orgNo || '--' }}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
@ -792,4 +872,32 @@ onMounted(load)
|
||||
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.banner-actions { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ===== 变更信息对比卡 ===== */
|
||||
.chg-card {
|
||||
background: #fff; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px;
|
||||
padding: 14px 18px; margin-bottom: 14px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||
}
|
||||
.chg-card-title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 14px; font-weight: 700; color: var(--ink); margin-bottom: 12px;
|
||||
}
|
||||
.chg-count { font-size: 12px; font-weight: 500; color: var(--primary); }
|
||||
.chg-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.chg-item {
|
||||
display: flex; gap: 12px; align-items: stretch;
|
||||
background: var(--bg-1, #fafafa); border: 1px solid var(--border-1, #ececec); border-radius: 8px; padding: 10px 14px;
|
||||
}
|
||||
.chg-field { flex: 0 0 110px; display: flex; align-items: center; font-size: 13px; font-weight: 600; color: var(--ink-2); }
|
||||
.chg-body { flex: 1; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.chg-cell { flex: 1; min-width: 200px; display: flex; gap: 8px; align-items: flex-start; }
|
||||
.chg-cell.old { background: #f7f8fa; border-radius: 6px; padding: 8px 10px; }
|
||||
.chg-cell.new { background: #f0f9eb; border-radius: 6px; padding: 8px 10px; }
|
||||
.chg-tag-old { flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--ink-3); background: #ececec; border-radius: 4px; padding: 1px 6px; margin-top: 2px; }
|
||||
.chg-tag-new { flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--el-color-success,#67c23a); background: #e1f3d8; border-radius: 4px; padding: 1px 6px; margin-top: 2px; }
|
||||
.chg-val { font-size: 13px; color: var(--ink); word-break: break-all; line-height: 1.6; }
|
||||
.chg-empty { color: var(--ink-3); }
|
||||
.chg-arrow { flex-shrink: 0; align-self: center; color: var(--ink-3); font-size: 16px; }
|
||||
.chg-imgs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chg-img { width: 56px; height: 56px; border-radius: 6px; border: 1px solid var(--border-1, #ececec); cursor: zoom-in; }
|
||||
</style>
|
||||
@ -8,7 +8,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||
|
||||
@ -55,28 +55,11 @@ const filtered = computed(() => {
|
||||
return rs
|
||||
})
|
||||
|
||||
// 顶部统计卡
|
||||
const stats = computed(() => {
|
||||
const rs = filtered.value
|
||||
const codeCount = new Set(rs.map(r => r.levelCode)).size
|
||||
const maxFrom = rs.length ? Math.max(...rs.map(r => r.scoreFrom)) : 0
|
||||
const minFrom = rs.length ? Math.min(...rs.map(r => r.scoreFrom)) : 0
|
||||
return [
|
||||
{ label: '等级总数', value: codeCount, icon: 'Medal', color: 'var(--brand)' },
|
||||
{ label: '最高分数段', value: maxFrom + ' 分起', icon: 'Top', color: 'var(--green)' },
|
||||
{ label: '最低分数段', value: minFrom + ' 分起', icon: 'Bottom', color: 'var(--ink-3)' },
|
||||
{ label: '分数总区间', value: minFrom + ' ~ ' + (rs.length ? Math.max(...rs.map(r => r.scoreTo)) : 0), icon: 'Histogram', color: 'var(--amber)' }
|
||||
]
|
||||
})
|
||||
|
||||
const searchFields: SearchField[] = [
|
||||
{ key:'enterpriseLevel', label:'等级名称', type:'text', placeholder:'如 A 战略级' },
|
||||
{ key:'id', label:'ID', type:'text', placeholder:'如 L001' }
|
||||
]
|
||||
const filters = reactive<Record<string, any>>({ id: '', enterpriseLevel: '' })
|
||||
|
||||
function onSearch() {
|
||||
filterKw.value = `${filters.id || ''}|${filters.enterpriseLevel || ''}`
|
||||
// 等级名称优先, 否则按 ID 检索 (原实现拼 'a|b' 整串 includes 永不匹配 2026-10-16 修)
|
||||
filterKw.value = (filters.enterpriseLevel || '').trim() || (filters.id || '').trim()
|
||||
page.value = 1
|
||||
}
|
||||
function onReset() {
|
||||
@ -119,28 +102,23 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError">
|
||||
<el-button type="primary" @click="onAdd">
|
||||
<el-icon style="margin-right:4px"><Plus /></el-icon>新增等级
|
||||
</el-button>
|
||||
<el-button @click="onExport">导出明细</el-button>
|
||||
</PageHeader>
|
||||
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError" />
|
||||
|
||||
<!-- 顶部统计卡 -->
|
||||
<div class="stats-grid">
|
||||
<div v-for="(s, i) in stats" :key="i" class="stat-card">
|
||||
<div class="stat-icon" :style="{ color: s.color }">
|
||||
<el-icon><component :is="s.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
<div class="stat-value">{{ s.value }}</div>
|
||||
</div>
|
||||
<!-- 工具行: 搜索 + 查询/重置 + 导出/新增 同排统一尺寸 (2026-10-16) -->
|
||||
<div class="toolbar-row">
|
||||
<el-input v-model="filters.enterpriseLevel" placeholder="如 A 战略级" clearable class="toolbar-input" @keyup.enter="onSearch" />
|
||||
<el-input v-model="filters.id" placeholder="如 L001" clearable class="toolbar-input" @keyup.enter="onSearch" />
|
||||
<div class="toolbar-actions">
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<span class="toolbar-divider"></span>
|
||||
<el-button @click="onExport">导出明细</el-button>
|
||||
<el-button type="primary" @click="onAdd">
|
||||
<el-icon style="margin-right:4px"><Plus /></el-icon>新增等级
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" @reset="onReset" />
|
||||
|
||||
<div class="rr-list-card">
|
||||
<!-- 批量操作栏 -->
|
||||
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||
@ -197,26 +175,24 @@ onMounted(load)
|
||||
<style scoped lang="scss">
|
||||
.rr-page { padding: 16px; }
|
||||
|
||||
/* 顶部统计卡 */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
/* 工具行: 搜索与操作按钮同排, 统一默认尺寸 */
|
||||
.toolbar-row {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 16px 18px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
transition: all .15s;
|
||||
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
|
||||
.toolbar-input { width: 200px; }
|
||||
.toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
:deep(.el-button + .el-button) { margin-left: 0; }
|
||||
}
|
||||
.stat-icon {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
.toolbar-divider {
|
||||
width: 1px; height: 18px; background: var(--border-strong, #cbd5e1);
|
||||
margin: 0 2px;
|
||||
}
|
||||
/* 表头与数据统一居中 (覆盖 DataTable 组件 inline 的表头居中/数据左对齐 2026-10-16) */
|
||||
.rr-list-card :deep(.el-table th.el-table__cell),
|
||||
.rr-list-card :deep(.el-table td.el-table__cell) {
|
||||
text-align: center !important;
|
||||
}
|
||||
.stat-info { flex: 1; min-width: 0; }
|
||||
.stat-label { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; }
|
||||
.stat-value { font-size: 22px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }
|
||||
</style>
|
||||
|
||||
@ -239,6 +239,14 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } },
|
||||
// W15 新增 - 订单售后 + 退款 + 付款回调 (前端 max-effort mock, 老 vue 同字段)
|
||||
{ path: '/order/orderpay', component: () => import('@/modules/order/payment/PaymentCallbackListView.vue'), meta: { title: '订单支付回调' } },
|
||||
// 平台服务费(居间费)管理 2026-09-29
|
||||
{ path: '/order/servicefee', component: () => import('@/modules/order/ServiceFeeListView.vue'), meta: { title: '平台服务费管理' } },
|
||||
// 补录管理 2026-09-29
|
||||
{ path: '/order/backfill-manage', component: () => import('@/modules/order/BackfillManageListView.vue'), meta: { title: '补录管理' } },
|
||||
// 企业评分与等级管理 2026-09-29
|
||||
{ path: '/user/enterprise-score', component: () => import('@/modules/user/EnterpriseScoreListView.vue'), meta: { title: '企业评分与等级' } },
|
||||
// AI 能力管理 2026-10-08
|
||||
{ path: '/sys/ai-manage', component: () => import('@/modules/sys/AiManageListView.vue'), meta: { title: 'AI 能力管理' } },
|
||||
{ path: '/order/refund', component: () => import('@/modules/order/refund/RefundProcessListView.vue'), meta: { title: '订单退款管理' } },
|
||||
{ path: '/order/aftersale', component: () => import('@/modules/order/aftersale/AftersaleListView.vue'), meta: { title: '售后订单' } },
|
||||
// other 7 个
|
||||
@ -276,7 +284,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: '/sys/menu', component: () => import('@/modules/sys/MenuListView.vue'), meta: { title: '菜单管理' } },
|
||||
// user 9 个
|
||||
{ path: '/user/enterprise/level', component: () => import('@/modules/user/enterpriselevel/EnterpriselevelListView.vue'), meta: { title: '企业等级管理' } },
|
||||
{ path: '/user/enterprise/rating', component: () => import('@/modules/user/enterpriserating/EnterpriseratingListView.vue'), meta: { title: '供应商评级' } },
|
||||
{ path: '/user/enterprise/rating', redirect: '/user/enterprise-score' }, // 旧mock评级页下线 2026-09-29, 统一到评分等级体系
|
||||
{ path: '/user/enterprise/score', component: () => import('@/modules/user/enterprisescore/EnterprisescoreListView.vue'), meta: { title: '供应商评分' } },
|
||||
{ path: '/user/finance/recharge', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 1 }), meta: { title: '充值管理' } },
|
||||
{ path: '/user/finance/recharge-tx', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 2 }), meta: { title: '提现管理' } },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user