数据分析ui补充完善,接入数据库

This commit is contained in:
comlibmb
2026-01-31 21:47:42 +08:00
parent 8f181b2b6a
commit 6716398175
71 changed files with 6501 additions and 10593 deletions

View File

@@ -50,7 +50,7 @@
<view class="kpi-card">
<text class="kpi-label">热销商品</text>
<text class="kpi-value">{{ formatInt(productData.hot_products) }}</text>
<text class="kpi-meta">销量 > 100</text>
<text class="kpi-meta">销量 &gt; 100</text>
</view>
<view class="kpi-card">
<text class="kpi-label">库存周转率</text>
@@ -70,7 +70,7 @@
<text class="card-title">商品销售分析</text>
<view class="card-head-right">
<select class="select" v-model="selectedProductId" @change="handleProductChange">
<option v-for="p in realTopProducts" :key="p.id" :value="p.id">{{ p.name }}</option>
<option v-for="p in topProducts" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</view>
</view>
@@ -167,274 +167,307 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { computed, onLoad, reactive, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchProductOverview, fetchTopProducts, fetchProductTrend, fetchCategorySales, fetchStockInsights, fetchPriceTrend, fetchReviewInsights } from '@/services/analytics/productInsightsService.uts'
import { computeDateRange, toDateOnly } from '@/services/analytics/dateRange.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
type ProductData = {
total_products: number
product_growth: number
hot_products: number
turnover_rate: number
turnover_growth: number
avg_stock: number
stock_growth: number
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { ProductData, ProductRank } from '@/types/analytics/product.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/product-insights')
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const productData = reactive<ProductData>({
total_products: 0,
product_growth: 0,
hot_products: 0,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
})
const topProducts = reactive<Array<ProductRank>>([])
const salesChartOption = ref<any>({})
const categoryChartOption = ref<any>({})
const stockChartOption = ref<any>({})
const priceChartOption = ref<any>({})
const reviewChartOption = ref<any>({})
const selectedProductId = ref('')
const loading = ref(false)
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
onLoad(() => {
updateTime()
loadProductData()
})
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
type ProductRank = { id: string; rank: number; name: string; sales: number; growth: number }
type ProductTrendRow = { date: string; gmv: number; qty: number; orders: number }
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/product-insights',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
productData: {
total_products: 0,
product_growth: 0,
hot_products: 0,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
} as ProductData,
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
topProducts: [] as Array<ProductRank>,
salesChartOption: {} as any,
categoryChartOption: {} as any,
stockChartOption: {} as any,
priceChartOption: {} as any,
reviewChartOption: {} as any,
selectedProductId: '' as string,
loading: false
async function loadSelectedProductTrend() {
try {
if (selectedProductId.value == null || selectedProductId.value === '') {
salesChartOption.value = {}
priceChartOption.value = {}
return
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
},
realTopProducts(): Array<ProductRank> {
return this.topProducts.filter((p) => !String(p.id).startsWith('fake-product-'))
const trend = await fetchProductTrend(selectedPeriod.value, selectedProductId.value)
const rows: Array<any> = trend as any
const x: Array<string> = []
const gmv: Array<number> = []
const qty: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
gmv.push(Number(rows[i].gmv) || 0)
qty.push(Number(rows[i].qty) || 0)
orders.push(Number(rows[i].orders) || 0)
}
},
onLoad() {
this.updateTime()
this.loadProductData()
},
methods: {
async loadSelectedProductTrend() {
try {
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
return
}
const trend = await fetchProductTrend(this.selectedPeriod, this.selectedProductId)
const rows: Array<any> = trend as any
const x: Array<string> = []
const gmv: Array<number> = []
const qty: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
gmv.push(Number(rows[i].gmv) || 0)
qty.push(Number(rows[i].qty) || 0)
orders.push(Number(rows[i].orders) || 0)
}
// 组合图GMV左轴 + 件数/订单(线,右轴)
this.salesChartOption = {
grid: { left: 50, right: 50, top: 20, bottom: 46 },
tooltip: { trigger: 'axis' },
legend: { data: ['GMV', '件数', '订单数'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: [
{ type: 'value', name: 'GMV', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
{ type: 'value', name: '件/单', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { show: false } }
],
series: [
{ name: 'GMV', type: 'bar', data: gmv, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '件数', type: 'line', yAxisIndex: 1, data: qty, smooth: true, symbolSize: 6 },
{ name: '订单数', type: 'line', yAxisIndex: 1, data: orders, smooth: true, symbolSize: 6 }
]
}
// 价格趋势:计算均价
const avgPrice: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const g = Number(rows[i].gmv) || 0
const q = Number(rows[i].qty) || 0
avgPrice.push(q > 0 ? g / q : 0)
}
this.priceChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', name: '均价', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ name: '均价', type: 'line', data: avgPrice, smooth: true, symbolSize: 6, color: '#f97316' }]
}
} catch (e) {
console.error('loadSelectedProductTrend failed', e)
this.salesChartOption = {}
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '加载商品趋势失败' }), icon: 'none' })
}
},
handleProductChange() {
this.loadSelectedProductTrend()
},
async loadProductData() {
this.loading = true
try {
this.updateTime()
const [overview, topList, catRows, stockRows, priceRows, reviewRows] = await Promise.all([
fetchProductOverview(this.selectedPeriod),
fetchTopProducts(this.selectedPeriod, 10),
fetchCategorySales(this.selectedPeriod),
fetchStockInsights(this.selectedPeriod),
fetchPriceTrend(this.selectedPeriod),
fetchReviewInsights()
])
this.productData = overview
// 不足 10 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
const top = topList.slice()
if (top.length < 10) {
const need = 10 - top.length
for (let i = 0; i < need; i++) {
const n = top.length + 1
top.push({
id: `fake-product-${n}`,
rank: n,
name: `示例商品${n}`,
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
} else {
top.splice(10)
}
for (let i = 0; i < top.length; i++) top[i].rank = i + 1
this.topProducts = top
if ((this.selectedProductId == null || this.selectedProductId === '') && top.length > 0) {
const real = top.find((it) => !String(it.id).startsWith('fake-product-'))
this.selectedProductId = real ? real.id : ''
}
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
} else {
await this.loadSelectedProductTrend()
}
this.buildCategoryChart(catRows)
this.buildStockChart(stockRows)
// priceChartOption 在 loadSelectedProductTrend 里会生成均价趋势;这里仍保留整体价格趋势图(如果你有对应图表函数可以接入)
this.buildReviewChart(reviewRows)
this.updateTime()
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '商品洞察数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadProductData()
},
refreshData() {
this.loadProductData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
buildChartOptions() {
// TODO: 构建图表配置
this.salesChartOption = {}
this.categoryChartOption = {}
this.stockChartOption = {}
this.priceChartOption = {}
this.reviewChartOption = {}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
salesChartOption.value = {
grid: { left: 50, right: 50, top: 20, bottom: 46 },
tooltip: { trigger: 'axis' },
legend: { data: ['GMV', '件数', '订单数'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: [
{ type: 'value', name: 'GMV', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
{ type: 'value', name: '件/单', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { show: false } }
],
series: [
{ name: 'GMV', type: 'bar', data: gmv, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '件数', type: 'line', yAxisIndex: 1, data: qty, smooth: true, symbolSize: 6 },
{ name: '订单数', type: 'line', yAxisIndex: 1, data: orders, smooth: true, symbolSize: 6 }
]
}
const avgPrice: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const g = Number(rows[i].gmv) || 0
const q = Number(rows[i].qty) || 0
avgPrice.push(q > 0 ? g / q : 0)
}
priceChartOption.value = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', name: '均价', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ name: '均价', type: 'line', data: avgPrice, smooth: true, symbolSize: 6, color: '#f97316' }]
}
} catch (e) {
console.error('loadSelectedProductTrend failed', e)
salesChartOption.value = {}
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '加载商品趋势失败' }), icon: 'none' })
}
}
function handleProductChange() {
loadSelectedProductTrend()
}
function buildCategoryChart(catRows: any) {
const rows: Array<any> = Array.isArray(catRows) ? (catRows as Array<any>) : []
const names: Array<string> = []
const values: Array<number> = []
for (let i = 0; i < rows.length; i++) {
names.push(`${rows[i].category_name ?? '未分类'}`)
values.push(Number(rows[i].total_sales) || 0)
}
categoryChartOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 18, top: 20, bottom: 40 },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'category', data: names, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: values, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
function buildStockChart(stockRows: any) {
const rows: Array<any> = Array.isArray(stockRows) ? (stockRows as Array<any>) : []
const names: Array<string> = []
const values: Array<number> = []
for (let i = 0; i < rows.length; i++) {
names.push(`${rows[i].bucket ?? ''}`)
values.push(Number(rows[i].value) || 0)
}
stockChartOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 18, top: 20, bottom: 40 },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'category', data: names, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: values, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
function buildReviewChart(reviewRows: any) {
const rows: Array<any> = Array.isArray(reviewRows) ? (reviewRows as Array<any>) : []
const names: Array<string> = []
const values: Array<number> = []
for (let i = 0; i < rows.length; i++) {
names.push(`${rows[i].rating ?? ''}`)
values.push(Number(rows[i].count) || 0)
}
reviewChartOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 18, top: 20, bottom: 40 },
xAxis: { type: 'category', data: names, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: values, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
async function loadProductData() {
loading.value = true
try {
updateTime()
const [overview, topList, catRows, stockRows, _priceRows, reviewRows] = await Promise.all([
fetchProductOverview(selectedPeriod.value),
fetchTopProducts(selectedPeriod.value, 10),
fetchCategorySales(selectedPeriod.value),
fetchStockInsights(selectedPeriod.value),
fetchPriceTrend(selectedPeriod.value),
fetchReviewInsights()
])
productData.total_products = overview.total_products
productData.product_growth = overview.product_growth
productData.hot_products = overview.hot_products
productData.turnover_rate = overview.turnover_rate
productData.turnover_growth = overview.turnover_growth
productData.avg_stock = overview.avg_stock
productData.stock_growth = overview.stock_growth
const top = topList.slice()
for (let i = 0; i < top.length; i++) top[i].rank = i + 1
topProducts.splice(0, topProducts.length, ...top)
if ((selectedProductId.value == null || selectedProductId.value === '') && top.length > 0) {
const real = top.find((it) => !String(it.id).startsWith('fake-product-'))
selectedProductId.value = real ? real.id : ''
}
if (selectedProductId.value == null || selectedProductId.value === '') {
salesChartOption.value = {}
} else {
await loadSelectedProductTrend()
}
buildCategoryChart(catRows)
buildStockChart(stockRows)
buildReviewChart(reviewRows)
updateTime()
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '商品洞察数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
loading.value = false
updateTime()
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
loadProductData()
}
function refreshData() {
loadProductData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
</script>
<style>
@@ -561,6 +594,7 @@ export default {
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
@@ -700,12 +734,62 @@ export default {
color: rgba(0,0,0,0.55);
}
.card-head-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
}
.select {
padding: 6px 10px;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 10px;
font-size: 13px;
}
.chart-box {
width: 100%;
height: 360px;
}
/* 排行列表 */
.chart-box-sm {
height: 300px;
}
.chart-loading {
width: 100%;
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.chart-loading-sm {
height: 220px;
}
.grid-row {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
.grid-col-item {
flex: 1 1 calc(50% - 6px);
min-width: 360px;
}
.rank-list-scroll {
max-height: 320px;
overflow-y: auto;
}
.rank-list {
display: flex;
flex-direction: column;
@@ -742,11 +826,6 @@ export default {
color: #111;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.rank-right {
display: flex;
flex-direction: row !important;
@@ -754,6 +833,11 @@ export default {
gap: 8px;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.chip {
padding: 3px 8px;
border-radius: 999px;
@@ -770,24 +854,25 @@ export default {
color: #dc2626;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
}
@media screen and (max-width: 960px) {
.grid-row {
flex-direction: column;
}
.grid-col-item {
flex: 1 1 100%;
min-width: 100%;
}
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
@@ -798,7 +883,7 @@ export default {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}