1
This commit is contained in:
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user