This commit is contained in:
van
2026-09-06 21:37:40 +08:00
parent d5a774e103
commit a42ac668c9
4 changed files with 554 additions and 2 deletions
@@ -0,0 +1,495 @@
<template>
<div class="app-container profit-analysis" v-loading="loading">
<el-card class="filter-card">
<div slot="header" class="filter-card-header">
<span class="filter-card-title">慢单利润 / 订单 AI 分析</span>
</div>
<el-alert
title="先按日/周/月汇总 jd_order(参与统计、已退款不计入利润),再套「DS提示词」里的「慢单利润分析」调用当前激活大模型。定时:每天 21:00 推当天日报,周一 08:20 推上周,每月 1 日 08:30 推上月。"
type="info"
:closable="false"
show-icon
class="hint-alert"
/>
<el-form :inline="true" size="small">
<el-form-item label="周期">
<el-radio-group v-model="periodType" @change="onPeriodChange">
<el-radio-button label="day">日报</el-radio-button>
<el-radio-button label="week">周报</el-radio-button>
<el-radio-button label="month">月报</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="dateLabel">
<el-date-picker
v-model="anchorDate"
:type="datePickerType"
:value-format="dateValueFormat"
:placeholder="datePlaceholder"
clearable
style="width: 220px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="loadStats">预览统计</el-button>
<el-button type="success" icon="el-icon-magic-stick" :loading="analyzing" @click="runAnalyze(false)">AI 分析</el-button>
<el-button type="warning" icon="el-icon-s-promotion" :loading="analyzing" @click="runAnalyze(true)">分析并推送</el-button>
<el-button type="danger" plain icon="el-icon-position" :loading="testing" @click="runTest">测试推送</el-button>
<el-button icon="el-icon-time" @click="loadLatest">最近一次</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row v-if="current" :gutter="16" class="summary-row">
<el-col v-for="item in summaryItems" :key="item.key" :xs="24" :sm="12" :md="8" :lg="4">
<el-card class="summary-card" shadow="hover">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value">{{ item.display }}</div>
<div class="summary-hint" :class="item.deltaClass">{{ item.hint }}</div>
</el-card>
</el-col>
</el-row>
<el-row v-if="current" :gutter="16" class="table-row">
<el-col :xs="24" :lg="12">
<el-card shadow="hover">
<div slot="header">分销标记</div>
<el-table :data="current.byMark || []" stripe size="small" max-height="360">
<el-table-column prop="name" label="标记" min-width="110" />
<el-table-column prop="orderCount" label="单量" width="70" />
<el-table-column prop="payment" label="付款" width="100">
<template slot-scope="scope">{{ money(scope.row.payment) }}</template>
</el-table-column>
<el-table-column prop="profit" label="利润" width="100">
<template slot-scope="scope">{{ money(scope.row.profit) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow="hover">
<div slot="header">型号(利润前 {{ (current.byModel || []).length }})</div>
<el-table :data="current.byModel || []" stripe size="small" max-height="360">
<el-table-column prop="name" label="型号" min-width="160" show-overflow-tooltip />
<el-table-column prop="orderCount" label="单量" width="70" />
<el-table-column prop="profit" label="利润" width="100">
<template slot-scope="scope">{{ money(scope.row.profit) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-card v-if="report" class="report-card" shadow="hover">
<div slot="header" class="report-header">
<span>AI 报告{{ periodLabel ? ' · ' + periodLabel : '' }}</span>
<el-button type="text" icon="el-icon-document-copy" @click="copyReport">复制</el-button>
</div>
<el-alert
v-if="aiError"
:title="'大模型失败,已用数字摘要兜底:' + aiError"
type="warning"
:closable="false"
show-icon
class="hint-alert"
/>
<el-alert
v-if="pushError"
:title="'推送未成功:' + pushError"
type="error"
:closable="false"
show-icon
class="hint-alert"
/>
<pre class="report-text">{{ report }}</pre>
</el-card>
<el-card class="log-card" shadow="hover">
<div slot="header" class="report-header">
<span>发送记录</span>
<el-button type="text" icon="el-icon-refresh" :loading="logsLoading" @click="loadSendLogs">刷新</el-button>
</div>
<el-table :data="sendLogs" stripe size="small" max-height="420" empty-text="暂无发送记录">
<el-table-column prop="sentAt" label="时间" width="160" />
<el-table-column prop="trigger" label="来源" width="88">
<template slot-scope="scope">
<el-tag :type="triggerTag(scope.row.trigger)" size="mini">{{ triggerLabel(scope.row.trigger) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="periodLabel" label="周期" min-width="160" show-overflow-tooltip />
<el-table-column label="结果" width="88">
<template slot-scope="scope">
<el-tag :type="scope.row.pushed ? 'success' : 'danger'" size="mini">{{ scope.row.pushed ? '已推送' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="orderCount" label="单量" width="70" />
<el-table-column label="利润" width="100">
<template slot-scope="scope">{{ money(scope.row.totalProfit) }}</template>
</el-table-column>
<el-table-column prop="toUser" label="接收人" min-width="110" show-overflow-tooltip />
<el-table-column label="说明" min-width="160" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.pushError || scope.row.aiError || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="showLogReport(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog title="发送内容" :visible.sync="logDialogVisible" width="640px" append-to-body>
<p class="log-dialog-meta">{{ logDialogMeta }}</p>
<pre class="report-text">{{ logDialogReport }}</pre>
</el-dialog>
<el-card v-if="stats" class="json-card" shadow="hover">
<div slot="header">统计 JSON(发给模型的数据)</div>
<el-input type="textarea" :value="statsJson" :autosize="{ minRows: 8, maxRows: 22 }" readonly />
</el-card>
</div>
</template>
<script>
import { previewOrderProfitStats, analyzeOrderProfit, latestOrderProfitAnalysis, testOrderProfitPush, listOrderProfitSendLogs } from '@/api/system/orderProfitAnalysis'
export default {
name: 'SlowOrderProfitAnalysis',
data() {
return {
loading: false,
analyzing: false,
testing: false,
periodType: 'day',
anchorDate: '',
stats: null,
report: '',
periodLabel: '',
aiError: '',
pushError: '',
pushed: false,
sendLogs: [],
logsLoading: false,
logDialogVisible: false,
logDialogMeta: '',
logDialogReport: ''
}
},
computed: {
datePickerType() {
if (this.periodType === 'month') return 'month'
return 'date'
},
dateValueFormat() {
return this.periodType === 'month' ? 'yyyy-MM-01' : 'yyyy-MM-dd'
},
dateLabel() {
if (this.periodType === 'week') return '周内日期'
if (this.periodType === 'month') return '月份'
return '日期'
},
datePlaceholder() {
if (this.periodType === 'week') return '空则上完整周'
if (this.periodType === 'month') return '空则上月'
return '空则今天'
},
current() {
return this.stats && this.stats.current ? this.stats.current : null
},
compare() {
return this.stats && this.stats.compare ? this.stats.compare : {}
},
statsJson() {
if (!this.stats) return ''
try {
return JSON.stringify(this.stats, null, 2)
} catch (e) {
return ''
}
},
summaryItems() {
if (!this.current) return []
return [
this.card('订单数', this.current.orderCount, 'orderCount', false),
this.card('有效单', this.current.validOrderCount, null, false, '退款 ' + this.fmtNum(this.current.refundCount)),
this.card('利润', this.current.totalProfit, 'totalProfit', true),
this.card('付款', this.current.totalPayment, 'totalPayment', true),
this.card('后返', this.current.totalRebate, 'totalRebate', true),
this.card('利润率', this.current.profitRatePct, 'profitRatePct', false, '均值 ' + this.money(this.current.avgProfit), '%')
]
}
},
created() {
this.loadStats()
this.loadSendLogs()
},
methods: {
queryDate() {
return this.anchorDate || undefined
},
onPeriodChange() {
this.anchorDate = ''
},
card(title, value, cmpKey, isMoney, extraHint, suffix) {
const hintParts = []
if (cmpKey && this.compare[cmpKey]) {
const c = this.compare[cmpKey]
hintParts.push('环比 ' + this.signed(c.delta, isMoney) + ' / ' + this.signed(c.changePct, false) + '%')
}
if (extraHint) hintParts.push(extraHint)
let deltaClass = ''
if (cmpKey && this.compare[cmpKey]) {
const d = Number(this.compare[cmpKey].delta)
if (d > 0) deltaClass = 'is-up'
else if (d < 0) deltaClass = 'is-down'
}
let display = isMoney ? this.money(value) : this.fmtNum(value)
if (suffix) display = display + suffix
return { key: title, title, display, hint: hintParts.join(' · ') || '—', deltaClass }
},
money(v) {
const n = Number(v)
if (Number.isNaN(n)) return '¥0.00'
return '¥' + n.toFixed(2)
},
fmtNum(v) {
if (v == null || v === '') return '0'
return String(v)
},
signed(v, isMoney) {
const n = Number(v)
if (Number.isNaN(n)) return '0'
const body = isMoney ? n.toFixed(2) : String(n)
return n > 0 ? '+' + body : body
},
applyPayload(data) {
if (!data) {
this.stats = null
this.report = ''
return
}
this.stats = data.stats || data
this.periodLabel = data.periodLabel || (this.stats && this.stats.periodLabel) || ''
this.report = data.report || ''
this.aiError = data.aiError || ''
this.pushError = data.pushError || ''
this.pushed = !!data.pushed
},
async loadStats() {
this.loading = true
try {
const res = await previewOrderProfitStats({
periodType: this.periodType,
date: this.queryDate()
})
if (res.code === 200) {
this.applyPayload(res.data)
this.report = ''
this.aiError = ''
this.pushError = ''
} else {
this.$message.error(res.msg || '预览失败')
}
} catch (e) {
this.$message.error('预览失败:' + (e.message || '未知错误'))
} finally {
this.loading = false
}
},
async runAnalyze(push) {
this.analyzing = true
this.loading = true
try {
const res = await analyzeOrderProfit({
periodType: this.periodType,
date: this.queryDate(),
push
})
if (res.code === 200) {
this.applyPayload(res.data)
if (push) {
this.loadSendLogs()
}
if (push && res.data && res.data.pushed) {
this.$message.success('分析完成并已推送')
} else if (res.data && res.data.aiError) {
this.$message.warning('已生成数字摘要(大模型失败)')
} else {
this.$message.success('分析完成')
}
} else {
this.$message.error(res.msg || '分析失败')
}
} catch (e) {
this.$message.error('分析失败:' + (e.message || '未知错误'))
} finally {
this.analyzing = false
this.loading = false
}
},
async runTest() {
this.testing = true
this.loading = true
try {
const res = await testOrderProfitPush({
periodType: this.periodType,
date: this.queryDate()
})
if (res.code === 200) {
this.applyPayload(res.data)
this.loadSendLogs()
if (res.data && res.data.pushed) {
this.$message.success('测试推送已发出')
} else {
this.$message.error((res.data && res.data.pushError) || '测试推送失败')
}
} else {
this.$message.error(res.msg || '测试失败')
}
} catch (e) {
this.$message.error('测试失败:' + (e.message || '未知错误'))
} finally {
this.testing = false
this.loading = false
}
},
async loadSendLogs() {
this.logsLoading = true
try {
const res = await listOrderProfitSendLogs(30)
if (res.code === 200) {
this.sendLogs = Array.isArray(res.data) ? res.data : []
}
} catch (e) {
this.$message.error('读取发送记录失败:' + (e.message || '未知错误'))
} finally {
this.logsLoading = false
}
},
triggerLabel(t) {
if (t === 'scheduled') return '定时'
if (t === 'test') return '测试'
return '手动'
},
triggerTag(t) {
if (t === 'scheduled') return ''
if (t === 'test') return 'warning'
return 'info'
},
showLogReport(row) {
if (!row) return
this.logDialogMeta = [row.sentAt, this.triggerLabel(row.trigger), row.periodLabel, row.toUser].filter(Boolean).join(' · ')
this.logDialogReport = row.report || '(无正文)'
this.logDialogVisible = true
},
async loadLatest() {
this.loading = true
try {
const res = await latestOrderProfitAnalysis(this.periodType)
if (res.code === 200 && res.data && Object.keys(res.data).length) {
this.applyPayload(res.data)
} else {
this.$message.info('暂无最近分析结果')
}
} catch (e) {
this.$message.error('读取失败:' + (e.message || '未知错误'))
} finally {
this.loading = false
}
},
copyReport() {
if (!this.report) return
const text = this.report
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$message.success('已复制')
}).catch(() => this.fallbackCopy(text))
return
}
this.fallbackCopy(text)
},
fallbackCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
this.$message.success('已复制')
} catch (e) {
this.$message.error('复制失败')
}
document.body.removeChild(ta)
}
}
}
</script>
<style scoped>
.filter-card {
margin-bottom: 16px;
}
.filter-card-header {
display: flex;
align-items: center;
}
.filter-card-title {
font-weight: 600;
}
.hint-alert {
margin-bottom: 14px;
}
.summary-row,
.table-row {
margin-bottom: 16px;
}
.summary-card {
margin-bottom: 12px;
}
.summary-title {
color: #909399;
font-size: 13px;
}
.summary-value {
margin: 8px 0 6px;
font-size: 22px;
font-weight: 600;
color: #303133;
}
.summary-hint {
font-size: 12px;
color: #909399;
min-height: 18px;
}
.summary-hint.is-up {
color: #67c23a;
}
.summary-hint.is-down {
color: #f56c6c;
}
.report-card,
.json-card,
.log-card {
margin-bottom: 16px;
}
.log-dialog-meta {
margin: 0 0 10px;
color: #909399;
font-size: 13px;
}
.report-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.report-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
font-family: inherit;
font-size: 14px;
color: #303133;
}
</style>
@@ -41,6 +41,7 @@
<li><strong>闲鱼·教你下单尾部</strong>:接在「更新日期」后的附加说明</li>
<li><strong>闲鱼·标题清洗正则</strong>:从标题、型号备注中删除匹配片段(须为合法 Java 正则)</li>
<li><strong>闲鱼·种草文案提示词</strong>:用于可选生成高转化种草文案,支持 goods_title / goods_model / goods_type 等占位符</li>
<li><strong>慢单利润分析</strong>:日/周/月订单利润 AI 报告,占位符 {{stats_json}}</li>
</ul>
</div>
</el-alert>
@@ -430,7 +431,8 @@ export default {
'xianyu:wenan_base': '闲鱼·正文基础',
'xianyu:jiaonixiadan_extra': '闲鱼·教你下单尾部',
'xianyu:title_clean_regex': '闲鱼·标题清洗正则',
'xianyu:seed_note_prompt': '闲鱼·种草文案提示词'
'xianyu:seed_note_prompt': '闲鱼·种草文案提示词',
'order:profit_analysis': '慢单利润分析'
}
return labels[key] || key
},