chore: 补提交工作区未提交改动(2026-10-30 交接快照)——补录管理/服务费/AI能力管理/企业等级页面等已上生产功能源码(含新文件)

This commit is contained in:
zou 2026-10-30 18:26:47 +08:00
parent dd000053a2
commit 4377d39b79
18 changed files with 3827 additions and 876 deletions

View File

@ -48,18 +48,22 @@ async function uploadFile(file: File): Promise<string> {
throw new Error(data.msg || "图片上传失败") throw new Error(data.msg || "图片上传失败")
} }
// === 外链图片转存: 公众号图片有防盗链, 下载后上传平台存储 === // === 外链图片转存: 微信等 CDN 有防盗链且无 CORS 头, 前端 fetch 必败 —— 走后端服务端拉取转存 ===
async function remoteToLocal(src: string): Promise<string> { async function remoteToLocal(src: string): Promise<string> {
try { try {
const resp = await fetch(src, { referrerPolicy: "no-referrer", mode: "cors" }) const token = localStorage.getItem("access_token") || ""
if (!resp.ok) return src const resp = await fetch("/api/sys/file/uploadByUrl", {
const blob = await resp.blob() method: "POST",
if (!blob.type.startsWith("image/")) return src headers: { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "" },
const file = new File([blob], "paste-image." + (blob.type.split("/")[1] || "png"), { type: blob.type }) body: JSON.stringify({ url: src }),
return await uploadFile(file) })
} catch { const data = await resp.json()
return src 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: 转存外链图 -> 清洗危险/不兼容标签 -> 返回 [原始版, 编辑器版] === // === 处理粘贴 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 editorSafe = editorSafe
.replace(/<script[\s\S]*?<\/script>/gi, "") .replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<iframe[\s\S]*?<\/iframe>/gi, "") .replace(/<iframe[\s\S]*?<\/iframe>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "") .replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<svg[\s\S]*?<\/svg>/gi, "")
.replace(/<section\b/gi, "<div") .replace(/<section\b/gi, "<div")
.replace(/<\/section>/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(/\sclass=["\x27][^"\x27]*["\x27]/gi, "")
.replace(/<br\s*\/?\s*>/gi, "<br>") .replace(/<br\s*\/?\s*>/gi, "<br>")
@ -138,7 +156,14 @@ const editorConfig = {
// 保存原始 HTML: 提交时用 (公众号排版完整保留) // 保存原始 HTML: 提交时用 (公众号排版完整保留)
rawPasteHtml = raw rawPasteHtml = raw
programmaticSet = true programmaticSet = true
try {
editor.dangerouslyInsertHtml(editorSafe) 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) setTimeout(() => { programmaticSet = false }, 100)
} catch (e: any) { } catch (e: any) {
console.error("paste html error:", e) console.error("paste html error:", e)
@ -222,14 +247,18 @@ onBeforeUnmount(() => {
.rich-editor { .rich-editor {
border: 1px solid var(--el-border-color, #dcdfe6); border: 1px solid var(--el-border-color, #dcdfe6);
border-radius: 6px; border-radius: 6px;
overflow: hidden;
} }
.rich-editor__toolbar { .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); border-bottom: 1px solid var(--el-border-color, #dcdfe6);
} }
.rich-editor__body { .rich-editor__body {
min-height: 320px; min-height: 320px;
z-index: 100;
} }
.rich-editor__body :deep(.w-e-text-container) { .rich-editor__body :deep(.w-e-text-container) {
min-height: 320px; min-height: 320px;

View File

@ -74,6 +74,10 @@ export const MENU_LABELS: MenuLabel[] = [
{ url: 'order/serviceorder-commission', label: '服务订单分佣统计', icon: 'Coin', path: '/order/serviceorder/commission', sort: 221 }, { 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/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/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/refund', label: '订单退款管理', icon: 'Refresh', path: '/order/refund', sort: 250 },
{ url: 'order/aftersale', label: '售后订单', icon: 'Service', path: '/order/aftersale', sort: 260 }, { url: 'order/aftersale', label: '售后订单', icon: 'Service', path: '/order/aftersale', sort: 260 },

View File

@ -17,6 +17,7 @@ import qs from 'qs'
import axios from 'axios' import axios from 'axios'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { View, Hide, Lock } from '@element-plus/icons-vue'
import { computed } from 'vue' import { computed } from 'vue'
import logoUrl from '@/assets/logo-jubian.png' import logoUrl from '@/assets/logo-jubian.png'
import logoLightUrl from '@/assets/logo-jubian-light.png' import logoLightUrl from '@/assets/logo-jubian-light.png'
@ -27,6 +28,7 @@ const userStore = useUserStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const logoSrc = computed(() => themeStore.current === 'dark' ? logoLightUrl : logoUrl) const logoSrc = computed(() => themeStore.current === 'dark' ? logoLightUrl : logoUrl)
const showPwd = ref(false)
const form = reactive({ const form = reactive({
username: 'admin', username: 'admin',
password: 'admin', password: 'admin',
@ -150,7 +152,8 @@ async function onLogin() {
<label class="rr-field-label">密码</label> <label class="rr-field-label">密码</label>
<div class="rr-input-wrap"> <div class="rr-input-wrap">
<el-icon class="rr-input-icon"><Lock /></el-icon> <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>
</div> </div>
@ -312,6 +315,20 @@ async function onLogin() {
color: var(--ink-3); color: var(--ink-3);
pointer-events: none; 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 { .rr-input {
width: 100%; height: 38px; width: 100%; height: 38px;
padding: 0 12px 0 34px; padding: 0 12px 0 34px;

View File

@ -13,6 +13,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request' import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
import { ArrowRight } from '@element-plus/icons-vue'
interface Inquiry { interface Inquiry {
id: string id: string
@ -105,7 +106,17 @@ const inquiryId = computed(() => String(route.params.id || ''))
const PROC_TYPE: Record<number, string> = { 1: '定量采购', 2: '框架采购' } const PROC_TYPE: Record<number, string> = { 1: '定量采购', 2: '框架采购' }
const DEMAND: 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 QUOTE_TYPE: Record<number, string> = { 1: '公开报价', 2: '邀请报价' }
const mapLabel = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v)) 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)) 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), quotationTypeName: mapLabel(QUOTE_TYPE, resp.quotationType),
quotationDemandName: mapLabel(DEMAND, resp.quotationDemand), quotationDemandName: mapLabel(DEMAND, resp.quotationDemand),
paymentMethodName: resp.paymentMethodName || '--', paymentMethodName: resp.paymentMethodName || '--',
invoiceTypeName: mapLabel(INVOICE, resp.invoiceType), invoiceTypeName: invoiceTypeLabel(resp.invoiceType),
inquiryValidDate: resp.inquiryValidDate || '--', inquiryValidDate: resp.inquiryValidDate || '--',
note: resp.note || '', note: resp.note || '',
detailNote: '', // 清除 FALLBACK 占位文案,备注一律取真实数据 detailNote: '', // 清除 FALLBACK 占位文案,备注一律取真实数据
@ -152,15 +163,28 @@ async function load() {
} }
} }
// 附件:发布时 images 为 URL 数组 + imageNames 名称数组 // 附件:兼容两种返回形态——① images=URL 字符串数组 + imageNames ② images=[{url, imagesName}] 对象数组
function resetFiles(resp: any): any[] { function resetFiles(resp: any): any[] {
const urls: string[] = resp.images || [] const raw: any[] = resp.images || []
const names: string[] = resp.imageNames || [] const names: string[] = Array.isArray(resp.imageNames) ? resp.imageNames : String(resp.imageNames || '').split(',')
return urls.map((u: string, i: number) => ({ url: u, imagesName: names[i] || ('附件' + (i + 1)) })) 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 quoteGroups = ref<any[]>([])
const quoteLoading = ref(false) const quoteLoading = ref(false)
// 报价列表卡片展开索引(-1=全部收起)
const openSupIndex = ref(-1)
function toggleSup(i: number) { openSupIndex.value = openSupIndex.value === i ? -1 : i }
// 供应商维度报价列表:受邀供应商(含报价状态)+ 主动报价供应商补充 // 供应商维度报价列表:受邀供应商(含报价状态)+ 主动报价供应商补充
const supplierRows = computed(() => { const supplierRows = computed(() => {
@ -189,8 +213,8 @@ const supplierRows = computed(() => {
const existing = rows.find(r => r.name === name) const existing = rows.find(r => r.name === name)
const quoted = Number(inv.quotationStatus) === 1 const quoted = Number(inv.quotationStatus) === 1
if (existing) { if (existing) {
// 已有真实报价行 = 已报价,状态不被邀请表状态覆盖(邀请表可能仍标记未响应)
existing.invited = true existing.invited = true
if (!quoted) existing.status = 0
continue continue
} }
rows.push({ rows.push({
@ -225,10 +249,18 @@ async function loadQuotes(orderId: any) {
let g = groups.find(x => x.name === name) let g = groups.find(x => x.name === name)
if (!g) { g = { name, rows: [], count: 0, total: 0, date: '', list: [] }; groups.push(g) } if (!g) { g = { name, rows: [], count: 0, total: 0, date: '', list: [] }; groups.push(g) }
g.rows.push(q) 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 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 quoteGroups.value = groups
} catch { quoteGroups.value = [] } finally { quoteLoading.value = false } } 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 inquiryInfo(s: number) { return INQUIRY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
function onBack() { router.push('/inquiry') } 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) { function onDownloadFile(url: string, name: string) {
if (!url) return if (!url) return
const a = document.createElement('a') 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.download = name || ''
a.target = '_blank' a.target = '_blank'
a.click() a.click()
} }
function onPreviewFile(url: string) {
// 走预览代理(inline 输出,浏览器原生预览图片/PDF)
window.open('/other/file/preview?url=' + encodeURIComponent(url), '_blank')
}
function onExportPriceComparison() { ElMessage.info('导出功能开发中') } function onExportPriceComparison() { ElMessage.info('导出功能开发中') }
function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (请前往询价管理)`) } function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (请前往询价管理)`) }
function onQuote(row: Quotation) { ElMessage.info('请前往前台发起报价') } function onQuote(row: Quotation) { ElMessage.info('请前往前台发起报价') }
@ -356,73 +405,94 @@ watch(inquiryId, load)
</el-table> </el-table>
</div> </div>
<!-- 询价附件 --> <!-- 询价附件:图片缩略图网格 + 文件行,预览=页内弹窗 -->
<div class="rr-card" v-if="detail.fileList.length > 0"> <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 class="rr-files">
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-file-item"> <div class="rr-attach-grid">
<el-icon><Document /></el-icon> <div v-for="(f, i) in detail.fileList" :key="i" class="rr-attach-item" @click="onPreviewFile(f.url, f.imagesName)">
<span class="rr-file-name">{{ f.imagesName }}</span> <img v-if="fileKind(f.url) === 'image'" :src="previewSrc(f.url)" class="rr-attach-thumb" loading="lazy" />
<el-button text type="primary" size="small" @click="onPreviewFile(f.url)">预览</el-button> <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> <el-button text size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
</div> </div>
</div> </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">
<div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ supplierRows.length }} 家供应商)</h3></div> <div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ supplierRows.length }} 家供应商)</h3></div>
<div v-if="quoteLoading" class="rr-empty">加载报价中...</div> <div v-if="quoteLoading" class="rr-empty">加载报价中...</div>
<div v-else-if="!supplierRows.length" 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"> <div v-else class="rr-sup-list">
<el-table-column type="expand"> <div v-for="(row, ri) in supplierRows" :key="row.name" class="rr-sup-card" :class="{ 'is-open': openSupIndex === ri }">
<template #default="{ row }"> <div class="rr-sup-head" @click="toggleSup(ri)">
<div v-if="row.rows.length" class="rr-expand"> <div class="rr-sup-title">
<el-table :data="row.rows" border size="small" class="rr-expand-table"> <el-icon class="rr-sup-arrow"><ArrowRight /></el-icon>
<el-table-column type="index" label="#" width="46" align="center" /> <span class="rr-sup-name">{{ row.name }}</span>
<el-table-column prop="productName" label="商品" min-width="150" show-overflow-tooltip /> <span v-if="Number(row.status) === 1" class="rr-tag rr-tag-green">已报价</span>
<el-table-column prop="specification" label="规格" min-width="120"> <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> <template #default="{ row: r }">{{ r.specification || '--' }}</template>
</el-table-column> </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> <template #default="{ row: r }">¥ {{ Number(r.unitPriceTaxIncluded || r.unitPrice || 0).toLocaleString() }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="quantity" label="数量" width="80" align="center" /> <el-table-column label="税率" width="70" align="center">
<el-table-column prop="unit" 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> <template #default="{ row: r }">{{ r.unit || '件' }}</template>
</el-table-column> </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> <template #default="{ row: r }">{{ r.productionCycle || '--' }}</template>
</el-table-column> </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> <template #default="{ row: r }">{{ r.quotationNote || '--' }}</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</template>
<div v-else class="rr-tip rr-sup-empty">该供应商尚未提交报价</div>
</div> </div>
<div v-else class="rr-tip">该供应商尚未提交报价</div> </div>
</template> </div>
</el-table-column> <div class="rr-tip mt10">点击供应商卡片展开/收起该企业的逐商品报价明细(仅显示当前最新一轮报价)</div>
<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>
</div> </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-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-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-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-files { padding: 16px 20px; }
.rr-file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--bg-soft); border-radius: var(--radius); } .rr-attach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.rr-file-name { flex: 1; color: var(--ink); font-size: 13px; } .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-groups { display: flex; flex-direction: column; gap: 12px; }
.rr-quote-group { border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden; } .rr-quote-group { border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden; }

View File

@ -9,19 +9,20 @@
*/ */
import { onMounted, computed, reactive, ref } from 'vue' import { onMounted, computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue' import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue' import ListTabs from '@/components/ListTabs/ListTabs.vue'
import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry' import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry'
import { httpGet } from '@/utils/request' import { httpGet, httpPost } from '@/utils/request'
interface InquiryRow { interface InquiryRow {
id: string id: string
customer: string customer: string
product: string product: string
qty: string qty: string
hidden?: number
status: 'pending' | 'quoting' | 'done' | 'cancelled' | string status: 'pending' | 'quoting' | 'done' | 'cancelled' | string
time: string | number time: string | number
raw: any raw: any
@ -78,6 +79,7 @@ async function load() {
const resp = await httpGet<any>('/supplier/inquiry/page', { const resp = await httpGet<any>('/supplier/inquiry/page', {
page: page.value, page: page.value,
limit: limit.value, limit: limit.value,
includeHidden: 1,
...(filters.value.apprStatus ? { apprStatus: filters.value.apprStatus } : {}) ...(filters.value.apprStatus ? { apprStatus: filters.value.apprStatus } : {})
}) })
const arr = Array.isArray(resp) ? resp : (resp?.list || []) const arr = Array.isArray(resp) ? resp : (resp?.list || [])
@ -89,6 +91,7 @@ async function load() {
qty: it.purchaseQuantity || it.qty || '--', qty: it.purchaseQuantity || it.qty || '--',
status: it.status || (it.apprStatus != null ? (['pending','quoting','done','cancelled'][it.apprStatus] || 'pending') : 'pending'), status: it.status || (it.apprStatus != null ? (['pending','quoting','done','cancelled'][it.apprStatus] || 'pending') : 'pending'),
apprStatus: it.apprStatus, apprStatus: it.apprStatus,
hidden: it.hidden,
time: it.publishDate || it.createDate || it.time, time: it.publishDate || it.createDate || it.time,
raw: it raw: it
})) }))
@ -122,6 +125,68 @@ function onStatusTab(v: string) {
page.value = 1 page.value = 1
load() 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 onRowClick(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
function onView(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: 'customer', label: '客户', minWidth: 180 },
{ prop: 'product', label: '商品', minWidth: 180 }, { prop: 'product', label: '商品', minWidth: 180 },
{ prop: 'qty', label: '数量', width: 120 }, { 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' }, { prop: 'time', label: '时间', width: 170, slot: 'time' },
{ label: '操作', width: 100, slot: 'action' } { label: '操作', width: 265, slot: 'action' }
]) ])
onMounted(() => { load() }) 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"> <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 ? '已驳回' : '--')) }} {{ row.apprStatus === 1 ? '待审核' : (row.apprStatus === 2 ? '已通过' : (row.apprStatus === 3 ? '已驳回' : '--')) }}
</el-tag> </el-tag>
<el-tag v-if="row.hidden === 1" type="info" size="small" effect="dark" style="margin-left: 4px">已隐藏</el-tag>
</template> </template>
<template #time="{ row }"> <template #time="{ row }">
<span class="rr-time">{{ fmtDate(row.time) }}</span> <span class="rr-time">{{ fmtDate(row.time) }}</span>
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
<div class="rr-action-cell">
<el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button> <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> </template>
</DataTable> </DataTable>
</div> </div>
@ -191,6 +264,12 @@ onMounted(() => { load() })
padding: 16px; padding: 16px;
} }
.rr-action-cell {
display: flex;
justify-content: center;
align-items: center;
}
.rr-time { .rr-time {
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px; font-size: 12px;
@ -217,3 +296,15 @@ onMounted(() => { load() })
.rr-badge-done { background: var(--green-soft); color: var(--green); } .rr-badge-done { background: var(--green-soft); color: var(--green); }
.rr-badge-cancelled { background: var(--red-soft); color: var(--red); } .rr-badge-cancelled { background: var(--red-soft); color: var(--red); }
</style> </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>

View 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

View File

@ -1,25 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 订单管理列表 - W9 A3 标准化示范 * 订单管理列表(商品订单) — 真实业务数据版
* *
* 沿用老 vue order/order-good.vue 289 行 + order/serviceorder 154 行等: * 数据源: GET /order/ordergood/adminPageList(order-server 后台管理分页, 全量订单, 无企业隔离)
* - 搜索: 订单编号/合同编号/供应商/采购方/状态/创建时间范围 * - 过滤: status / orderCode(OP号) / enterpriseName(供应商) / currentEnterpriseName(采购方) / startDate / endDate
* - 状态 tab: 全部/待支付/待发货/待收货/待评价/已完成/已取消 * - orderGoodType 必传: 1=普通/秒杀 2=拼团
* - 表格列: 订单号/合同号/供应商/采购方/订单类型/金额/已发/状态/时间 * 详情: 跳转 /order/{supplierOrderCode} → OrderDetailView(只读, 信息维度与工作台订单详情一致)
* - 操作: 详情/付款/发货/收货/取消/退款/导出
* - 批量导出
* - 通过 query.type 区分 good/service/group
* *
* 字段: id/orderNo/contractCode/enterpriseName/orderUserName/orderType/totalAmount/totalNum/shippedNum/status/createDate/payDate/payType/pickupType * 历史: W12 版本错挂平台助手 MCP 端点 /sys/openapi/order/list, 供应商/采购方/合同号全空,
* 端点: /order/ordergood/page 200 (按 type 区分 good/service/group) * 详情页更用 FALLBACK 模拟数据兜底(公司=深圳科技、物流=顺丰均为假数据)。
*
* W9 A3: 用 PageHeader / SearchBar 组件替换硬编码, 用 DataTable type:'selection' 替换裸表
*/ */
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock } from '@element-plus/icons-vue' 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 PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue' import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.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' import TableActions from '@/components/TableActions/TableActions.vue'
interface Order { interface Order {
id: string supplierOrderCode: string
orderNo: string
contractCode: string contractCode: string
enterpriseName: string supplierName: string
orderUserName: string buyerName: string
orderType: number goodType: number
totalAmount: number totalAmount: number
totalNum: number totalNum: number
shippedNum: number shippedNum: number
status: number status: number
createDate: string createDate: string
payDate: string | null payDate: string | null
payType: number
pickupType: number
raw: any raw: any
} }
@ -67,17 +59,6 @@ const STATUS_TABS = [
{ value: '7', label: '已取消' } { 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 list = ref<Order[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
@ -85,9 +66,9 @@ const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(10)
const filters = reactive({ const filters = reactive({
orderNo: '', orderCode: '',
contractCode: '',
enterpriseName: '', enterpriseName: '',
currentEnterpriseName: '',
status: '', status: '',
startDate: '', startDate: '',
endDate: '' endDate: ''
@ -95,23 +76,18 @@ const filters = reactive({
const selection = ref<Order[]>([]) const selection = ref<Order[]>([])
const columns: any = computed(() => [ const columns: any = computed(() => [
{ type: 'selection', label: '', width: 50 }, { type: 'selection', label: '', width: 50 },
{ prop: 'orderNo', label: '订单号', width: 180 }, { prop: 'supplierOrderCode', label: '订单号', width: 200, slot: 'orderNo' },
{ prop: 'contractCode', label: '合同号', width: 160, slot: 'contractCode' }, { prop: 'contractCode', label: '合同号', width: 150, slot: 'contractCode' },
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, slot: 'enterpriseName' }, { prop: 'supplierName', label: '供应商', minWidth: 170, slot: 'supplier' },
{ prop: 'orderUserName', label: '采购方', minWidth: 160, slot: 'orderUserName' }, { prop: 'buyerName', label: '采购方', minWidth: 160, slot: 'buyer' },
{ prop: 'totalAmount', label: '金额', width: 130, align: 'right', slot: 'amount' }, { prop: 'totalAmount', label: '金额', width: 120, align: 'right', slot: 'amount' },
{ prop: 'totalNum', label: '数量', width: 80, align: 'center' }, { prop: 'totalNum', label: '数量', width: 70, align: 'center' },
{ prop: 'shippedNum', label: '已发', width: 80, align: 'center' }, { prop: 'shippedNum', label: '已发', width: 70, align: 'center' },
{ prop: 'status', label: '状态', width: 110, align: 'center', slot: 'status' }, { prop: 'status', label: '状态', width: 100, align: 'center', slot: 'status' },
{ prop: 'createDate', label: '下单时间', width: 170, slot: 'time' }, { prop: 'createDate', label: '下单时间', width: 180, slot: 'time' },
{ label: '操作', width: 140, align: 'center', slot: 'action' } { label: '操作', width: 140, align: 'center', slot: 'action' }
]) ])
const tabItems = computed(() => STATUS_TABS.map(t => ({ const tabItems = computed(() => STATUS_TABS.map(t => ({ key: t.value, label: t.label })))
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 router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -121,50 +97,51 @@ const listType = computed(() => {
if (path.includes('/group-supplier') || path.includes('/groupsupplierorder')) return 'groupsupplier' if (path.includes('/group-supplier') || path.includes('/groupsupplierorder')) return 'groupsupplier'
if (path.includes('/group-service') || path.includes('/groupserviceorder')) return 'groupservice' if (path.includes('/group-service') || path.includes('/groupserviceorder')) return 'groupservice'
if (path.includes('/after')) return 'aftersale' if (path.includes('/after')) return 'aftersale'
if (path.includes('/commission')) return 'commission'
return 'good' return 'good'
}) })
// W12.2 修正: 后端 OpenApiOrderController 只暴露 /openapi/order/list 一个端点, // 后端后台分页: orderGoodType 必传 (1=商品订单 2=拼团订单)
// 旧路径 (/order/ordergood/page 等) 404。所有 5 种订单类型都走统一端点。 const endpoint = computed(() => '/order/ordergood/adminPageList')
const endpoint = computed(() => '/sys/openapi/order/list')
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// W12.3 修复: httpGet() 已解开 res.data.data, 所以 r 直接是 {total, list, page, limit} const params: any = {
const r: any = await httpGet(endpoint.value, { page: page.value, limit: limit.value }) 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 respList: any[] = Array.isArray(r?.list) ? r.list : []
const rows: Order[] = respList.map((it: any) => ({ const rows: Order[] = respList.map((it: any) => ({
id: it.orderId || '', supplierOrderCode: it.supplierOrderCode || it.orderCode || '',
orderNo: it.orderId || '', contractCode: it.contractCode || '',
contractCode: '', supplierName: it.enterpriseName || '',
enterpriseName: '', buyerName: it.currentEnterpriseName || it.orderUserName || (it.nickName ? `${it.nickName}${it.mobile ? ' (' + it.mobile + ')' : ''}` : ''),
orderUserName: '', goodType: Number(it.goodType ?? 1),
orderType: 1, totalAmount: Number(it.orderMoney ?? it.sum ?? 0),
totalAmount: Number(it.totalAmount ?? 0), totalNum: Number(it.totalNum ?? 0),
totalNum: Number(it.goodsQuantity ?? 0), shippedNum: Number(it.shippedNum ?? 0),
shippedNum: 0,
status: Number(it.status ?? 0), status: Number(it.status ?? 0),
createDate: it.createDate ? String(it.createDate) : '', createDate: it.createDate ? String(it.createDate) : '',
payDate: null, payDate: it.payDate ? String(it.payDate) : null,
payType: 0,
pickupType: 1,
raw: it raw: it
})) }))
list.value = rows list.value = rows
// 后端 total 是字符串 "1356", parseInt 兜底 // 后端 total 是字符串 "1356", parseInt 兜底
const t = r?.total const t = r?.total
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0) 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) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
list.value = FALLBACK list.value = []
total.value = FALLBACK.length total.value = 0
} finally { } finally {
loading.value = false loading.value = false
} }
@ -187,9 +164,9 @@ function fmtDate(ts?: string | number | null) {
} }
function onReset() { function onReset() {
filters.orderNo = '' filters.orderCode = ''
filters.contractCode = ''
filters.enterpriseName = '' filters.enterpriseName = ''
filters.currentEnterpriseName = ''
filters.status = '' filters.status = ''
filters.startDate = '' filters.startDate = ''
filters.endDate = '' filters.endDate = ''
@ -200,18 +177,77 @@ function onLimitChange(l: number) { limit.value = l; load() }
function onSelectionChange(rows: Order[]) { selection.value = rows } function onSelectionChange(rows: Order[]) { selection.value = rows }
function onStatusTabChange(status: string) { filters.status = status; onSearch() } function onStatusTabChange(status: string) { filters.status = status; onSearch() }
function onViewDetail(row: Order) { router.push(`/order/${row.id}`) } function onViewDetail(row: Order) { router.push(`/order/${row.supplierOrderCode}`) }
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 onExportSelected() { function onExportSelected() {
if (!selection.value.length) { ElMessage.warning('请先选择订单'); return } if (!selection.value.length) { ElMessage.warning('请先选择订单'); return }
ElMessage.info('导出功能开发中') 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) onMounted(load)
</script> </script>
@ -228,9 +264,9 @@ onMounted(load)
<SearchBar <SearchBar
v-model:filters="filters" v-model:filters="filters"
:fields="[ :fields="[
{ key: 'orderNo', label: '订单号', placeholder: '请输入订单号' }, { key: 'orderCode', label: '订单号', placeholder: '请输入订单号' },
{ key: 'contractCode', label: '合同号', placeholder: '请输入合同号' },
{ key: 'enterpriseName', label: '供应商', placeholder: '请输入供应商名称' }, { key: 'enterpriseName', label: '供应商', placeholder: '请输入供应商名称' },
{ key: 'currentEnterpriseName', label: '采购方', placeholder: '请输入采购方企业名称' },
{ key: 'startDate', label: '开始日期', type: 'date', placeholder: '选择开始日期' }, { key: 'startDate', label: '开始日期', type: 'date', placeholder: '选择开始日期' },
{ key: 'endDate', label: '结束日期', type: 'date', placeholder: '选择结束日期' } { key: 'endDate', label: '结束日期', type: 'date', placeholder: '选择结束日期' }
]" ]"
@ -250,21 +286,24 @@ onMounted(load)
:error="error" :error="error"
empty-text="暂无订单" empty-text="暂无订单"
:borderless="true" :borderless="true"
row-key="id" row-key="supplierOrderCode"
@page-change="onPageChange" @page-change="onPageChange"
@limit-change="onLimitChange" @limit-change="onLimitChange"
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<template #orderNo="{ row }">
<span class="rr-order-no">{{ row.supplierOrderCode }}</span>
</template>
<template #contractCode="{ row }"> <template #contractCode="{ row }">
<span v-if="row.contractCode">{{ row.contractCode }}</span> <span v-if="row.contractCode">{{ row.contractCode }}</span>
<span v-else class="rr-muted">--</span> <span v-else class="rr-muted">--</span>
</template> </template>
<template #enterpriseName="{ row }"> <template #supplier="{ row }">
<span v-if="row.enterpriseName">{{ row.enterpriseName }}</span> <span v-if="row.supplierName">{{ row.supplierName }}</span>
<span v-else class="rr-muted">--</span> <span v-else class="rr-muted">--</span>
</template> </template>
<template #orderUserName="{ row }"> <template #buyer="{ row }">
<span v-if="row.orderUserName">{{ row.orderUserName }}</span> <span v-if="row.buyerName">{{ row.buyerName }}</span>
<span v-else class="rr-muted">--</span> <span v-else class="rr-muted">--</span>
</template> </template>
<template #time="{ row }"> <template #time="{ row }">
@ -283,22 +322,203 @@ onMounted(load)
:row="row" :row="row"
:actions="[ :actions="[
{ label: '详情', type: 'primary', onClick: onViewDetail }, { label: '详情', type: 'primary', onClick: onViewDetail },
{ label: '付款', type: 'primary', when: (r: any) => r.status === 1, onClick: onPay }, { label: '服务费', type: 'primary', onClick: onServiceFee }
{ 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 }
]" ]"
/> />
</template> </template>
</DataTable> </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> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1500px; } .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-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-amount { font-weight: 600; color: var(--primary); }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); } .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> </style>

View 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>

View File

@ -10,7 +10,7 @@
* 图片: POST /sys/file/upload (FormData, 返 data.url) — 参考旧系统 information-add-or-update.vue * 图片: POST /sys/file/upload (FormData, 返 data.url) — 参考旧系统 information-add-or-update.vue
*/ */
import { onMounted, ref, reactive, computed } from '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 { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
@ -62,10 +62,12 @@ const columns = [
] ]
const imgStyle = computed(() => ({ const imgStyle = computed(() => ({
maxWidth: '140px', // 固定宽高 + cover 裁切: 封面列所有图统一尺寸 (原 max 约束按原图比例缩放导致大小不一)
maxHeight: '48px', width: '140px',
height: '48px',
objectFit: 'cover' as const, objectFit: 'cover' as const,
borderRadius: '4px', borderRadius: '4px',
display: 'block' as const,
})) }))
async function load() { async function load() {
@ -147,7 +149,8 @@ async function submit(status: number) {
async function onDelete(row: Info) { async function onDelete(row: Info) {
try { try {
await ElMessageBox.confirm(`确定删除资讯「${row.title}」吗?`, '提示', { type: 'warning' }) 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('删除成功') ElMessage.success('删除成功')
load() load()
} catch (e: any) { } catch (e: any) {

View File

@ -121,12 +121,14 @@ const filters = reactive({
checkStatus: '' checkStatus: ''
}) })
// 弹窗 // 商品详情/审核抽屉 (2026-09-18 重设计: 大抽屉分区展示 完整信息 + 审核操作条)
const detailVisible = ref(false) const drawerVisible = ref(false)
const detailData = ref<Good | null>(null) const drawerLoading = ref(false)
const checkDialogVisible = ref(false) const drawerRow = ref<Good | null>(null)
const checkData = ref<Good | null>(null) const drawerInfo = ref<any>(null)
const checkForm = reactive({ checkStatus: 2, checkOpinion: '' }) const drawerFocusAudit = ref(false)
const drawerAuditForm = reactive({ checkStatus: 2, checkOpinion: '' })
const galleryActive = ref(0)
const categoryDialogVisible = ref(false) const categoryDialogVisible = ref(false)
const categoryData = ref<Good | null>(null) const categoryData = ref<Good | null>(null)
const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' }) const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' })
@ -194,13 +196,17 @@ async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// W13.1: /supplier/good/pageList 返完整字段 (16133 条) — 比 search 端点更全 // W13.1: /supplier/good/pageList 返完整字段 — 比 search 端点更全
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', { // 2026-09-20: 筛选条件下推到后端 (原仅前端过滤当前页, 导致 tab 计数 42 与列表 16106 条不一致)
const queryParams: Record<string, any> = {
page: page.value, page: page.value,
limit: limit.value, limit: limit.value,
type: 1, // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示) type: 1 // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
...(filters.title ? { title: filters.title } : {}) }
}) 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) => ({ let rows = (data.list || []).map((it: any) => ({
id: String(it.id), id: String(it.id),
supplierGoodId: it.supplierGoodId || String(it.id), supplierGoodId: it.supplierGoodId || String(it.id),
@ -218,8 +224,7 @@ async function load() {
createDate: it.createDate || '', createDate: it.createDate || '',
raw: it raw: it
})) }))
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName)) // 筛选已由后端条件完成 (checkStatus/enterpriseName/title 下推), rows 即当前页数据
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
list.value = rows list.value = rows
total.value = Number(data.total) || 0 total.value = Number(data.total) || 0
} catch (e: any) { } catch (e: any) {
@ -247,25 +252,50 @@ const tabItems = computed(() => [
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() } function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
function onShowDetail(row: Good) { detailData.value = row; detailVisible.value = true } function splitImages(v: any): string[] {
function onShowCheckDialog(row: Good) { checkData.value = row; checkForm.checkStatus = 2; checkForm.checkOpinion = ''; checkDialogVisible.value = true } return String(v || '').split(',').map((s: string) => s.trim()).filter(Boolean)
async function onSubmitCheck() { }
if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return } /** 打开商品详情/审核抽屉 (focusAudit=true 时审核入口进入并定位操作条) */
// W14.3: 真实调 /supplier/good/checkGood?checkStatus=&checkOpinion= 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 { try {
const id = checkData.value?.id || checkData.value?.raw?.id
const r: any = await httpGet('/supplier/good/checkGood', { const r: any = await httpGet('/supplier/good/checkGood', {
id, id, checkStatus: f.checkStatus, checkOpinion: f.checkOpinion.trim()
checkStatus: checkForm.checkStatus,
checkOpinion: checkForm.checkOpinion || ''
}) })
if (isOk(r)) { if (isOk(r)) {
ElMessage.success(checkForm.checkStatus === 2 ? '审核通过' : '已驳回') ElMessage.success(f.checkStatus === 2 ? '审核通过' : '已驳回')
checkDialogVisible.value = false drawerVisible.value = false
load() load()
} else { } else { ElMessage.error(r?.msg || '操作失败') }
ElMessage.error(r?.msg || '操作失败')
}
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') } } catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
} }
async function onOnShelf(row: Good) { async function onOnShelf(row: Good) {
@ -477,74 +507,141 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
</el-table> </el-table>
</div> </div>
<!-- 详情弹窗 (含 SKU 列表, 沿用老 vue 弹窗布局) --> <!-- ===== 商品详情 / 审核抽屉 (2026-09-18 重设计: 分区式完整信息 + 底部审核操作条) ===== -->
<el-dialog v-model="detailVisible" title="商品详情" width="960px" :close-on-click-modal="false"> <el-drawer v-model="drawerVisible" size="min(940px, 94vw)" :with-header="true"
<div v-if="detailData"> :title="drawerRow?.title ? `商品详情 · ${drawerRow.title}` : '商品详情'"
<el-descriptions :column="2" border> destroy-on-close :close-on-click-modal="false" class="rr-gd-drawer">
<el-descriptions-item label="商品ID">{{ detailData.supplierGoodId }}</el-descriptions-item> <div v-loading="drawerLoading" class="rr-gd-wrap">
<el-descriptions-item label="商品名称" :span="2">{{ detailData.title }}</el-descriptions-item> <template v-if="drawerInfo">
<el-descriptions-item label="供应商">{{ detailData.enterpriseName }}</el-descriptions-item> <!-- 头部: 主图 + 标题/标签 + 元信息 + 价格 -->
<el-descriptions-item label="品牌">{{ detailData.brandName }}</el-descriptions-item> <div class="rr-gd-head">
<el-descriptions-item label="分类" :span="2"> <el-image :src="drawerInfo.icon || drawerImages[0]" fit="cover"
{{ [detailData.firstCategoryName, detailData.secondCategoryName, detailData.thirdCategoryName].filter(Boolean).join(' / ') }} :preview-src-list="[drawerInfo.icon || drawerImages[0]].filter(Boolean)" preview-teleported
</el-descriptions-item> class="rr-gd-mainimg">
<el-descriptions-item label="状态"> <template #error><div class="rr-gd-imgerr">无图</div></template>
<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-image>
</el-descriptions-item> <div class="rr-gd-headmain">
<el-descriptions-item label="铺货"> <div class="rr-gd-titleline">
<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> <span class="rr-gd-title">{{ drawerInfo.title || drawerRow?.title }}
</el-descriptions-item> <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>
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item> </span>
</el-descriptions> <span class="rr-gd-code">{{ drawerInfo.goodCode || drawerInfo.id }}</span>
</div>
<h4 class="rr-sku-title">SKU 列表 ({{ detailData.skuList?.length || 0 }})</h4> <div class="rr-gd-tags">
<el-table v-if="detailData.skuList && detailData.skuList.length > 0" :data="detailData.skuList" border> <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>
<el-table-column label="SKU编码" prop="skuCode" min-width="140" show-overflow-tooltip /> <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>
<el-table-column label="SKU组合" prop="propertyNames" min-width="200" show-overflow-tooltip /> </div>
<el-table-column label="原价" width="100" align="right"> <div class="rr-gd-price">
<template #default="{ row }">¥ {{ row.oldPrice?.toLocaleString() }}</template> <span class="rr-gd-price-now">¥ {{ drawerInfo.newPrice ?? drawerRow?.raw?.newPrice ?? '--' }}</span>
</el-table-column> <span v-if="drawerInfo.oldPrice" class="rr-gd-price-old">¥ {{ drawerInfo.oldPrice }}</span>
<el-table-column label="现价" width="100" align="right"> <span class="rr-gd-price-unit">/ {{ drawerInfo.unitName || drawerRow?.unitName || '件' }}</span>
<template #default="{ row }">¥ {{ row.newPrice?.toLocaleString() }}</template> </div>
</el-table-column> <div class="rr-gd-meta">
<el-table-column label="秒杀价" width="100" align="right"> <span><label>供应商</label>{{ drawerInfo.enterpriseName || drawerRow?.enterpriseName || '--' }}</span>
<template #default="{ row }"> <span><label>品牌</label>{{ drawerInfo.brandName || drawerRow?.brandName || '--' }}</span>
<span v-if="row.seckillPrice">¥ {{ row.seckillPrice.toLocaleString() }}</span> <span><label>分类</label>{{ drawerCategoryPath || '--' }}</span>
<span v-else>--</span> <span><label>申请时间</label>{{ drawerInfo.createDate || drawerRow?.createDate || '--' }}</span>
</template> </div>
</el-table-column> </div>
<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> </div>
</el-dialog>
<!-- 审核弹窗 --> <!-- 商品图片画廊 -->
<el-dialog v-model="checkDialogVisible" title="商品审核" width="500px"> <div class="rr-gd-section" v-if="drawerImages.length">
<el-form v-if="checkData" label-width="100px"> <h4 class="rr-gd-sectitle">商品图片 <span class="rr-gd-seccount">{{ drawerImages.length }} 张</span></h4>
<el-form-item label="商品名称">{{ checkData.title }}</el-form-item> <div class="rr-gd-gallery">
<el-form-item label="审核结果"> <el-image v-for="(img, gi) in drawerImages" :key="gi" :src="img" fit="cover"
<el-radio-group v-model="checkForm.checkStatus"> :preview-src-list="drawerImages" :initial-index="gi" preview-teleported hide-on-click-modal
<el-radio :value="2">通过</el-radio> class="rr-gd-thumb">
<el-radio :value="3">驳回</el-radio> <template #error><div class="rr-gd-imgerr">加载失败</div></template>
</el-radio-group> </el-image>
</el-form-item> </div>
<el-form-item label="审核意见" required> </div>
<el-input v-model="checkForm.checkOpinion" type="textarea" :rows="4" placeholder="请填写审核意见" />
</el-form-item> <!-- 介绍视频 -->
</el-form> <div class="rr-gd-section" v-if="drawerInfo.videoUrl">
<template #footer> <h4 class="rr-gd-sectitle">介绍视频</h4>
<el-button @click="checkDialogVisible = false">取消</el-button> <video :src="drawerInfo.videoUrl" controls preload="metadata" class="rr-gd-video"></video>
<el-button type="primary" @click="onSubmitCheck">确定</el-button> </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> </template>
</el-dialog> </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>
<!-- 底部固定审核操作条 -->
<template #footer>
<!-- 待审核判断以列表行状态为准 (审核入口数据源; 个别脏数据 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-drawer>
<!-- 修改分类弹窗 (单个) --> <!-- 修改分类弹窗 (单个) -->
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px"> <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-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-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-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> </style>

View File

@ -1,9 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 价格公式变量管理 * 价格公式变量管理 (运营后台)
* 走 /supplier/supplierGoodPriceFormulaVariable/page (2026-08-08 探测: 200, total=5) *
* CRUD: save/update/delete + updateValue * 数据源: /supplier/supplierGoodPriceFormulaVariable/*
* 字段: id/var_key/var_name/var_unit/current_value/source_type/source_code/formula/sort/status/create_date * 来源类型: 1手动输入 / 2自动采集(HTTP) / 3公式计算 / 4脚本获取(JS, Nashorn)
* 能力:
* - 新增/编辑/删除 (编辑走 save 全字段更新)
* - 行内快捷改值: 点击"当前值"直接输入新值 → updateValue → 引用商品公式自动重算
* - 立即刷新: 采集/脚本来源点击立即执行 → refresh → 值更新 → 引用商品重算 (不需改商品公式)
* - 自动刷新: refresh_interval_minutes 分钟间隔后台定时执行
*/ */
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@ -21,6 +26,10 @@ interface FormulaVar {
source_type: number source_type: number
source_code: string source_code: string
formula: string formula: string
refresh_script: string
refresh_interval_minutes: number | null
last_refresh_time: any
last_refresh_msg: string | null
sort: number sort: number
status: number status: number
create_date: any create_date: any
@ -30,12 +39,22 @@ interface FormulaVar {
const SOURCE_MAP: Record<number, { label: string; type: string }> = { const SOURCE_MAP: Record<number, { label: string; type: string }> = {
1: { label: '手动输入', type: 'info' }, 1: { label: '手动输入', type: 'info' },
2: { label: '自动采集', type: 'success' }, 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 }> = { const STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '禁用', type: 'danger' }, 0: { label: '禁用', type: 'danger' },
1: { label: '启用', type: 'success' } 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 list = ref<FormulaVar[]>([])
const total = ref(0) const total = ref(0)
@ -45,19 +64,19 @@ const filters = reactive({ var_name: '' })
const editVisible = ref(false) const editVisible = ref(false)
const editMode = ref<'add' | 'edit'>('add') 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 = [ const columns = [
{ prop: 'id', label: 'ID', width: 60 }, { prop: 'id', label: 'ID', width: 60 },
{ prop: 'var_name', label: '变量名称', minWidth: 120 }, { prop: 'var_name', label: '变量名称', minWidth: 110 },
{ prop: 'var_key', label: '变量标识', width: 120 }, { prop: 'var_unit', label: '单位', width: 70 },
{ prop: 'var_unit', label: '单位', width: 80 }, { prop: 'current_value', label: '当前值', width: 110, slot: 'value' },
{ prop: 'current_value', label: '当前值', width: 120, formatter: (r: FormulaVar) => r.current_value?.toLocaleString() || '0' },
{ prop: 'source_type', label: '数据来源', width: 100, slot: 'source' }, { prop: 'source_type', label: '数据来源', width: 100, slot: 'source' },
{ prop: 'formula', label: '计算公式', minWidth: 150, formatter: (r: FormulaVar) => r.formula || '--' }, { prop: 'refresh_interval_minutes', label: '自动刷新', width: 100, slot: 'interval' },
{ prop: 'sort', label: '排序', width: 60 }, { prop: 'last_refresh_time', label: '最近刷新', width: 170, slot: 'lastRefresh' },
{ prop: 'status', label: '状态', width: 80, slot: 'status' }, { 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 { function fmtDate(d: any): string {
@ -65,6 +84,10 @@ function fmtDate(d: any): string {
if (typeof d === 'string') return d.slice(0, 19) 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 '--' } 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() { async function load() {
loading.value = true loading.value = true
@ -81,22 +104,46 @@ function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
function onAdd() { function onAdd() {
editMode.value = 'add' 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 editVisible.value = true
} }
function onEdit(row: FormulaVar) { function onEdit(row: FormulaVar) {
editMode.value = 'edit' 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 editVisible.value = true
} }
function onSubmitEdit() { function onSubmitEdit() {
if (!editForm.var_name.trim() || !editForm.var_key.trim()) { ElMessage.warning('请填写变量名称和标识'); return } if (!editForm.var_name.trim()) { ElMessage.warning('请填写变量名称'); return }
const ep = editMode.value === 'add' ? '/supplier/supplierGoodPriceFormulaVariable/save' : '/supplier/supplierGoodPriceFormulaVariable/updateValue' // 变量标识未填时自动取变量名称 (系统公式按变量名称引用, 标识仅作唯一性用途)
httpPost(ep, { ...editForm }).then(() => { const payload = { ...editForm, var_key: editForm.var_key.trim() || editForm.var_name.trim() }
ElMessage.success(editMode.value === 'add' ? '新增成功' : '修改成功') // 新增和编辑都走 save (全字段保存; 编辑不再走 updateValue 以免丢失名称/单位/来源等修改)
httpPost('/supplier/supplierGoodPriceFormulaVariable/save', payload).then(() => {
ElMessage.success(editMode.value === 'add' ? '新增成功' : '保存成功')
editVisible.value = false; load() editVisible.value = false; load()
}).catch((e: any) => ElMessage.error(e?.msg || '操作失败')) }).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) { function onDelete(row: FormulaVar) {
ElMessageBox.confirm(`确认删除「${row.var_name}」?`, '提示', { type: 'warning' }).then(async () => { ElMessageBox.confirm(`确认删除「${row.var_name}」?`, '提示', { type: 'warning' }).then(async () => {
try { await httpPost('/supplier/supplierGoodPriceFormulaVariable/delete', { id: row.id }); ElMessage.success('已删除'); load() } 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" <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" :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"> 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 }"> <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> <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>
<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 }"> <template #status="{ row }">
<el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag> <el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
</template> </template>
@ -128,16 +191,21 @@ onMounted(load)
<span class="rr-actions"> <span class="rr-actions">
<el-link type="primary" @click="onEdit(row)">编辑</el-link> <el-link type="primary" @click="onEdit(row)">编辑</el-link>
<span class="rr-actions__sep">|</span> <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> <el-link type="danger" @click="onDelete(row)">删除</el-link>
</span> </span>
</template> </template>
</DataTable> </DataTable>
</div> </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 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_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 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="当前值"><el-input-number v-model="editForm.current_value" :precision="2" :step="100" /></el-form-item>
<el-form-item label="数据来源"> <el-form-item label="数据来源">
@ -145,10 +213,25 @@ onMounted(load)
<el-radio :value="1">手动输入</el-radio> <el-radio :value="1">手动输入</el-radio>
<el-radio :value="2">自动采集</el-radio> <el-radio :value="2">自动采集</el-radio>
<el-radio :value="3">公式计算</el-radio> <el-radio :value="3">公式计算</el-radio>
<el-radio :value="4">脚本获取</el-radio>
</el-radio-group> </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>
<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-input-number v-model="editForm.sort" :min="0" /></el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-radio-group v-model="editForm.status"> <el-radio-group v-model="editForm.status">
@ -166,6 +249,14 @@ onMounted(load)
</template> </template>
<style scoped lang="scss"> <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-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> </style>

View 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>

View File

@ -17,7 +17,7 @@
import { onMounted, ref, reactive, computed } from 'vue' import { onMounted, ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { httpGet, httpPost, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
@ -33,6 +33,8 @@ interface Enterprise {
// 行业/等级/标签 // 行业/等级/标签
industry: string industry: string
supplierLevel: number | null supplierLevel: number | null
currentScore: number | null
backfillEnabled: number | null
supplierLabel: string supplierLabel: string
// 业务员 // 业务员
salesperson: string salesperson: string
@ -54,6 +56,7 @@ interface Enterprise {
registerTime: string registerTime: string
orgNo: string orgNo: string
failReason: string failReason: string
changeLog: string // 最近一次提交审核的变更记录 JSON [{field,old,new}]
} }
const router = useRouter() const router = useRouter()
@ -65,10 +68,11 @@ const limit = ref(10)
const selectedIds = ref<string[]>([]) const selectedIds = ref<string[]>([])
const selectedRows = ref<Enterprise[]>([]) 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 auditBusy = ref(false)
const auditForm = reactive({ id: '', result: 2, failReason: '' }) const auditForm = reactive({ result: 2, failReason: '' })
// ========== 批量审核 ========== // ========== 批量审核 ==========
const batchAuditVisible = ref(false) const batchAuditVisible = ref(false)
@ -78,7 +82,6 @@ const batchAuditForm = reactive({ result: 2, failReason: '' })
const filters = reactive({ const filters = reactive({
enterpriseName: '', enterpriseName: '',
industry: '', industry: '',
supplierLevel: '',
salesperson: '', salesperson: '',
supplierLabel: '', supplierLabel: '',
status: '', 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)' } '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 时交替使用) // 头像品牌渐变色组 (企业无 logo 时交替使用)
const AVATAR_GRADIENTS = [ const AVATAR_GRADIENTS = [
@ -145,7 +140,6 @@ async function load() {
const params: Record<string, any> = { page: page.value, limit: limit.value } const params: Record<string, any> = { page: page.value, limit: limit.value }
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
if (filters.industry) params.industry = filters.industry if (filters.industry) params.industry = filters.industry
if (filters.supplierLevel) params.supplierLevel = Number(filters.supplierLevel)
if (filters.salesperson) params.salesperson = filters.salesperson if (filters.salesperson) params.salesperson = filters.salesperson
if (filters.supplierLabel) params.supplierLabel = filters.supplierLabel if (filters.supplierLabel) params.supplierLabel = filters.supplierLabel
if (filters.status) params.status = filters.status if (filters.status) params.status = filters.status
@ -161,6 +155,8 @@ async function load() {
administratorMobile: it.administratorMobile || '', administratorMobile: it.administratorMobile || '',
industry: it.industry || '', industry: it.industry || '',
supplierLevel: it.supplierLevel ?? null, supplierLevel: it.supplierLevel ?? null,
currentScore: it.currentScore ?? 60,
backfillEnabled: it.backfillEnabled ?? 0,
supplierLabel: it.supplierLabel || '', supplierLabel: it.supplierLabel || '',
salesperson: it.salesperson || '', salesperson: it.salesperson || '',
salespersonMobile: it.salespersonMobile || '', salespersonMobile: it.salespersonMobile || '',
@ -178,7 +174,8 @@ async function load() {
mainProducts: it.mainProducts || '', mainProducts: it.mainProducts || '',
registerTime: it.registerTime || '', registerTime: it.registerTime || '',
orgNo: it.orgNo || '', orgNo: it.orgNo || '',
failReason: it.failReason || '' failReason: it.failReason || '',
changeLog: it.changeLog || ''
})) }))
total.value = data.total || 0 total.value = data.total || 0
} catch (e: any) { } catch (e: any) {
@ -190,7 +187,7 @@ async function load() {
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
function onReset() { function onReset() {
filters.enterpriseName = ''; filters.industry = ''; filters.supplierLevel = '' filters.enterpriseName = ''; filters.industry = ''
filters.salesperson = ''; filters.supplierLabel = ''; filters.status = ''; filters.homeShow = '' filters.salesperson = ''; filters.supplierLabel = ''; filters.status = ''; filters.homeShow = ''
page.value = 1; load() 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 } function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedRows.value = rows }
// ========== 审核交互 (后端 GET /user/enterprise/checkStatus?id=&status=&failReason=, 支持逗号批量) ========== // ========== 审核交互 (后端 GET /user/enterprise/checkStatus?id=&status=&failReason=, 支持逗号批量) ==========
/** 进入内嵌审核视图: 解析变更记录 JSON 供对比展示 */
function onAudit(row: Enterprise) { function onAudit(row: Enterprise) {
auditForm.id = row.id auditRow.value = row
auditForm.result = 2 auditForm.result = 2
auditForm.failReason = '' 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() { async function submitAudit() {
if (!auditRow.value) return
if (auditForm.result === 3 && !auditForm.failReason.trim()) { ElMessage.warning('请填写驳回原因'); return } if (auditForm.result === 3 && !auditForm.failReason.trim()) { ElMessage.warning('请填写驳回原因'); return }
auditBusy.value = true auditBusy.value = true
try { try {
await httpGet('/user/enterprise/checkStatus', { 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() : '' failReason: auditForm.result === 3 ? auditForm.failReason.trim() : ''
}) })
ElMessage.success(auditForm.result === 2 ? '审核通过' : '已驳回') ElMessage.success(auditForm.result === 2 ? '审核通过' : '已驳回')
auditVisible.value = false backToList()
load() load()
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '审核操作失败') } } catch (e: any) { ElMessage.error(e?.msg || e?.message || '审核操作失败') }
finally { auditBusy.value = false } finally { auditBusy.value = false }
@ -294,12 +307,97 @@ async function submitChangeAdmin() {
} catch (e: any) { ElMessage.error(e?.msg || '更换失败') } } 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) { async function handleMore(cmd: string, row: Enterprise) {
try { try {
switch (cmd) { switch (cmd) {
case 'audit': onAudit(row); break case 'audit': onAudit(row); break
case 'label': ElMessage.info('标签待实现'); break case 'label': ElMessage.info('标签待实现'); break
case 'rating': ElMessage.info('评级待实现'); break case 'rating': openScoreDialog(row); break
case 'enabled': case 'enabled':
// 后端 @RequestParam 从 query 读, body 不绑定 (2026-08-18 实测修正) // 后端 @RequestParam 从 query 读, body 不绑定 (2026-08-18 实测修正)
await httpPost(`/user/enterprise/enabled?id=${row.id}`) await httpPost(`/user/enterprise/enabled?id=${row.id}`)
@ -328,6 +426,94 @@ onMounted(() => load())
<template> <template>
<div class="rr-page"> <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} 家企业`" /> <PageHeader title="企业列表" :sub="`共 ${total} 家企业`" />
<SearchBar <SearchBar
@ -335,15 +521,6 @@ onMounted(() => load())
:fields="[ :fields="[
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' }, { key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' },
{ key: 'industry', 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: '全部状态', { key: 'status', label: '审核状态', type: 'select', placeholder: '全部状态',
options: [ options: [
{ value: 1, label: '待审核' }, { value: 1, label: '待审核' },
@ -424,11 +601,9 @@ onMounted(() => load())
<template #levelInfo="{ row }"> <template #levelInfo="{ row }">
<div class="two-line"> <div class="two-line">
<span class="line-main">{{ row.industry || '-' }}</span> <span class="line-main">{{ row.industry || '-' }}</span>
<span v-if="LEVEL_META[row.supplierLevel as number]" class="level-chip" <span class="level-chip sc-newlvl" :style="{ background: levelMeta(lvOf(row.currentScore)).bg, color: levelMeta(lvOf(row.currentScore)).color }">
:style="{ color: LEVEL_META[row.supplierLevel as number].color, background: LEVEL_META[row.supplierLevel as number].bg }"> {{ levelMeta(lvOf(row.currentScore)).label }} {{ Number(row.currentScore ?? 60) }}分
{{ LEVEL_META[row.supplierLevel as number].label }}
</span> </span>
<span v-else class="line-sub">未评级</span>
</div> </div>
</template> </template>
@ -490,29 +665,7 @@ onMounted(() => load())
</template> </template>
</DataTable> </DataTable>
</div> </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> </template>
</el-dialog>
<!-- 批量审核弹窗 --> <!-- 批量审核弹窗 -->
<el-dialog v-model="batchAuditVisible" title="批量审核企业" width="500px" :close-on-click-modal="false" destroy-on-close> <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> <el-button type="primary" @click="submitChangeAdmin">确定</el-button>
</template> </template>
</el-dialog> </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>&lt;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> </div>
</template> </template>
@ -640,4 +899,117 @@ onMounted(() => load())
@media (max-width: 1200px) { @media (max-width: 1200px) {
.batch-actions { margin-left: 0; } .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> </style>

View 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">&lt;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>&lt;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>

View File

@ -45,8 +45,13 @@ const STATUS_META: Record<number, { label: string; type: 'warning' | 'success' |
3: { label: '已驳回', type: 'danger', icon: CircleCloseFilled } 3: { label: '已驳回', type: 'danger', icon: CircleCloseFilled }
} }
const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' } const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' }
const LEVEL_MAP: Record<number, string> = { // 企业类型 (主体类型): 1企业 2个体工商户 —— 与认证表单主体类型选择对应
1: '优质供应商', 2: '钻石供应商', 3: '金牌供应商', 4: '银牌供应商', 5: '铜牌供应商' 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)) 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) // 状态 → banner 修饰类 (CSS 里 banner-warning/success/danger 对应 warning/success/danger)
const bannerClass = computed(() => `banner-${statusMeta.value?.type || 'unknown'}`) 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 logoText = computed(() => (detail.value.enterpriseName || '企').trim().charAt(0))
const registerYear = computed(() => { const registerYear = computed(() => {
const t = detail.value.registerTime const t = detail.value.registerTime
@ -219,7 +244,7 @@ async function toggleHome() {
function goStore() { function goStore() {
const eid = detail.value.enterpriseId || detail.value.id const eid = detail.value.enterpriseId || detail.value.id
if (!eid) return if (!eid) return
window.open(`/v2/supplier/${eid}`, '_blank') window.open(`/supplier/${eid}`, '_blank')
} }
onMounted(load) onMounted(load)
@ -234,6 +259,55 @@ onMounted(load)
</PageHeader> </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="audit-banner" :class="bannerClass" v-if="status">
<div class="banner-icon"> <div class="banner-icon">
<el-icon v-if="statusMeta" :size="26"><component :is="statusMeta.icon" /></el-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-right">
<div class="header-stats"> <div class="header-stats">
<div class="stat-cell"> <div class="stat-cell">
<div class="stat-value">{{ LEVEL_MAP[detail.supplierLevel as number] || '--' }}</div> <div class="stat-value">{{ LEVEL_MAP[levelOf(detail.currentScore)] }}</div>
<div class="stat-label">供应商等级</div> <div class="stat-label">平台等级</div>
</div> </div>
<div class="stat-cell"> <div class="stat-cell">
<div class="stat-value">{{ detail.currentScore ?? '--' }}</div> <div class="stat-value" :style="{ color: Number(detail.backfillEnabled) === 1 ? 'var(--el-color-success)' : 'var(--ink-3)' }">
<div class="stat-label">当前评分</div> {{ 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>
<div class="stat-cell"> <div class="stat-cell">
<div class="stat-value">{{ detail.enterpriseRate ?? '--' }}</div> <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">{{ 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">{{ 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">{{ 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 class="info-item"><span class="label">统一信用代码</span><span class="value mono">{{ detail.creditCode || detail.orgNo || '--' }}</span></div>
</div> </div>
</section> </section>
@ -792,4 +872,32 @@ onMounted(load)
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.banner-actions { flex-direction: column; } .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> </style>

View File

@ -8,7 +8,7 @@ import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue' 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 DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue' import TableActions from '@/components/TableActions/TableActions.vue'
@ -55,28 +55,11 @@ const filtered = computed(() => {
return rs 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: '' }) const filters = reactive<Record<string, any>>({ id: '', enterpriseLevel: '' })
function onSearch() { 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 page.value = 1
} }
function onReset() { function onReset() {
@ -119,27 +102,22 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError"> <PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError" />
<!-- 工具行: 搜索 + 查询/重置 + 导出/新增 同排统一尺寸 (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-button type="primary" @click="onAdd">
<el-icon style="margin-right:4px"><Plus /></el-icon>新增等级 <el-icon style="margin-right:4px"><Plus /></el-icon>新增等级
</el-button> </el-button>
<el-button @click="onExport">导出明细</el-button>
</PageHeader>
<!-- 顶部统计卡 -->
<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> </div>
</div> </div>
</div>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" @reset="onReset" />
<div class="rr-list-card"> <div class="rr-list-card">
<!-- 批量操作栏 --> <!-- 批量操作栏 -->
@ -197,26 +175,24 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { padding: 16px; } .rr-page { padding: 16px; }
/* 顶部统计卡 */ /* 工具行: 搜索与操作按钮同排, 统一默认尺寸 */
.stats-grid { .toolbar-row {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 16px; margin-bottom: 14px;
} }
.stat-card { .toolbar-input { width: 200px; }
background: var(--surface); border: 1px solid var(--border); .toolbar-actions {
border-radius: var(--radius-lg); padding: 16px 18px; margin-left: auto;
display: flex; align-items: center; gap: 12px; display: inline-flex; align-items: center; gap: 10px;
transition: all .15s; :deep(.el-button + .el-button) { margin-left: 0; }
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
} }
.stat-icon { .toolbar-divider {
width: 40px; height: 40px; border-radius: 10px; width: 1px; height: 18px; background: var(--border-strong, #cbd5e1);
display: flex; align-items: center; justify-content: center; margin: 0 2px;
font-size: 20px; }
background: var(--brand-soft); /* 表头与数据统一居中 (覆盖 DataTable 组件 inline 的表头居中/数据左对齐 2026-10-16) */
color: var(--brand); .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> </style>

View File

@ -239,6 +239,14 @@ const routes: RouteRecordRaw[] = [
{ path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } }, { path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } },
// W15 新增 - 订单售后 + 退款 + 付款回调 (前端 max-effort mock, 老 vue 同字段) // W15 新增 - 订单售后 + 退款 + 付款回调 (前端 max-effort mock, 老 vue 同字段)
{ path: '/order/orderpay', component: () => import('@/modules/order/payment/PaymentCallbackListView.vue'), meta: { title: '订单支付回调' } }, { 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/refund', component: () => import('@/modules/order/refund/RefundProcessListView.vue'), meta: { title: '订单退款管理' } },
{ path: '/order/aftersale', component: () => import('@/modules/order/aftersale/AftersaleListView.vue'), meta: { title: '售后订单' } }, { path: '/order/aftersale', component: () => import('@/modules/order/aftersale/AftersaleListView.vue'), meta: { title: '售后订单' } },
// other 7 个 // other 7 个
@ -276,7 +284,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/sys/menu', component: () => import('@/modules/sys/MenuListView.vue'), meta: { title: '菜单管理' } }, { path: '/sys/menu', component: () => import('@/modules/sys/MenuListView.vue'), meta: { title: '菜单管理' } },
// user 9 个 // user 9 个
{ path: '/user/enterprise/level', component: () => import('@/modules/user/enterpriselevel/EnterpriselevelListView.vue'), meta: { title: '企业等级管理' } }, { 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/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', 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: '提现管理' } }, { path: '/user/finance/recharge-tx', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 2 }), meta: { title: '提现管理' } },