You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

560 lines
28 KiB

<template>
<div>
<!-- 全局筛选条(应用/区域/产品,时间由各版面单独控制)-->
<div class="panel p-4 mb-5">
<div class="flex flex-wrap items-end gap-3">
<div v-if="!IS_SCOPED || appOptions.length" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用</span></label>
<select v-model="filters.app" class="select select-bordered select-sm w-40">
<option value="">{{ allLabel('应用') }}</option>
<option v-for="a in appOptions" :key="a.value" :value="a.value">{{ a.label }}</option>
</select>
</div>
<div v-if="!IS_SCOPED" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域</span></label>
<select v-model="filters.region" class="select select-bordered select-sm w-32">
<option value="">全部区域</option>
<option v-for="r in regionOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div v-if="!IS_SCOPED || productOptions.length" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">产品</span></label>
<select v-model="filters.product" class="select select-bordered select-sm w-48">
<option value="">{{ allLabel('产品') }}</option>
<option v-for="p in productOptions" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</div>
<div v-if="!auth.isDealer && (!IS_SCOPED || channelOptions.length)" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">渠道商</span></label>
<select v-model="filters.channel" class="select select-bordered select-sm w-48">
<option value="">{{ allLabel('渠道商') }}</option>
<option value="__none__">未分配渠道</option>
<option v-for="ch in channelOptions" :key="ch.value" :value="ch.value">{{ ch.label }}</option>
</select>
</div>
<button class="btn btn-primary btn-sm text-white" @click="loadAll">查询</button>
<span class="text-xs text-slate-400 ml-auto self-center">
{{ IS_SCOPED ? `顶部为${SCOPE_PREFIX}总览(筛选项按${SCOPE_PREFIX}关联产品推导);各版面右上角可单独选时间周期`
: '顶部为全量总览;各版面右上角可单独选时间周期' }}
</span>
</div>
</div>
<!-- ① 累计板块 -->
<div class="sec-title muted">
<span class="bar" style="background:#cbd5e1;"></span>
<h2>累计数据</h2>
<span class="sub">全量累计 · 随筛选条件变化</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<div v-for="c in globalCards" :key="c[0]" class="cum-card">
<span class="ico" :style="{ background: cardStyle(c[0])[1] }">{{ cardStyle(c[0])[0] }}</span>
<div class="body">
<div class="lbl">{{ c[1] }}</div>
<div class="val">{{ globalVal(c) }}</div>
</div>
</div>
</div>
<!-- ② 今日板块 -->
<div class="today-wrap mb-8">
<div class="sec-title hero" style="margin-bottom:16px;">
<span class="bar" style="background:linear-gradient(180deg,#0EA5E9,#22D3EE);"></span>
<h2>今日数据</h2>
<span class="tag" style="color:#0369a1; background:#dbeafe;">{{ todayTag }}</span>
<span class="sub">今日 0 点至现在 · 含较昨日同比(设备类无日期维度,仅见累计)</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div v-for="c in todayCards" :key="c[0]" class="today-card">
<span class="accent" :style="{ background: cardStyle(c[0])[2] }"></span>
<div class="today-card-top">
<span class="lbl">{{ c[1] }}</span>
<span class="ico" :style="{ background: cardStyle(c[0])[1] }">{{ cardStyle(c[0])[0] }}</span>
</div>
<div class="val">{{ todayVal(c) }}</div>
<div class="foot">
<template v-if="todayDelta(c).type === 'new'"><span class="delta up">新</span></template>
<template v-else-if="todayDelta(c).type !== 'none'">
<span class="delta" :class="todayDelta(c).type">{{ todayDelta(c).arrow }} {{ todayDelta(c).abs }}%</span>
</template>
<span>较昨日</span>
</div>
</div>
</div>
</div>
<!-- ③ 趋势分析 -->
<div class="sec-title">
<span class="bar" style="background:linear-gradient(180deg,#10B981,#22D3EE);"></span>
<h2>趋势分析</h2>
<span class="sub">各版面右上角可独立选择时间周期</span>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div v-for="p in panels" :key="p.key" class="panel">
<div class="panel-head">
<span class="panel-dot" :style="{ background: p.tint }">
<svg width="17" height="17" fill="none" viewBox="0 0 24 24" :stroke="p.color">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="p.icon" />
</svg>
</span>
<span class="font-semibold text-slate-700 text-sm">{{ p.title }}</span>
<span v-if="p.key === 'device'" class="ml-auto text-xs text-slate-400">当前累计 · 无日期维度</span>
<div v-else class="ml-auto flex items-center gap-1">
<button
v-for="d in rangeBtns"
:key="d[0]"
class="tbtn"
:class="{ 'tbtn-on': d[0] === panelDays[p.key] }"
@click="setRange(p.key, Number(d[0]))"
>{{ d[1] }}</button>
</div>
</div>
<div class="px-5 py-4">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
<div v-for="s in p.stats" :key="s[0]" class="stat-box">
<div class="stat-label">{{ s[1] }}</div>
<div class="stat-val">{{ panelStatVal(p, s) }}</div>
</div>
</div>
<div v-if="p.key !== 'device'" style="height:170px;margin-top:1rem;">
<canvas :ref="(el) => setCanvas(p.key, el)"></canvas>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useAuthStore } from '~/stores/auth'
import { useApi } from '~/composables/useApi'
import { REGION_OPTIONS, normalizeRegion } from '~/utils/regions'
const auth = useAuthStore()
const { webApi, consoleApi } = useApi()
definePageMeta({ title: '数据概览' })
// 区域:只有「国内 / 海外」两档(见 ~/utils/regions)。后端收到 hw 会展开成除 cn 外的全部区域码,
// 兼容 stats_global_day 里历史写入的细分码(us/ea/sea…)。
// 卡片视觉元数据:字段 -> [emoji, 图标底色, 强调色]
const CARD_STYLE: Record<string, [string, string, string]> = {
new_user_count: ['👥', '#EEF2FF', '#6366F1'],
active_user_count: ['🟢', '#ECFDF5', '#10B981'],
bind_device_count: ['📱', '#E0F2FE', '#0EA5E9'],
qr_active_count: ['🔳', '#FCE7F3', '#EC4899'],
order_paid_count: ['🧾', '#FEF3C7', '#F59E0B'],
order_paid_amount: ['💰', '#FEE2E2', '#EF4444'],
pay_user_count: ['⭐', '#FFF7ED', '#F97316'],
meet_second: ['🎙️', '#CFFAFE', '#22D3EE'],
trade_second: ['🌐', '#F3E8FF', '#8B5CF6'],
}
type Card = [string, string, boolean?, boolean?]
const GLOBAL_CARDS_ALL: Card[] = [
['new_user_count', '累计用户', false],
['bind_device_count', '累计绑定设备', false],
['qr_active_count', '累计二维码激活', false],
['order_paid_count', '累计支付订单', false],
['order_paid_amount', '累计支付金额', true],
['meet_second', '累计会议时长', false],
['trade_second', '累计翻译时长', false],
]
const TODAY_CARDS_ALL: Card[] = [
['new_user_count', '新增用户', false],
['active_user_count', '活跃用户', false],
['qr_active_count', '二维码激活', false],
['order_paid_count', '支付订单', false],
['order_paid_amount', '支付金额', true],
['pay_user_count', '付费用户', false],
['meet_second', '会议时长', false, true],
['trade_second', '翻译时长', false, true],
]
interface Panel {
key: string; title: string; color: string; tint: string; icon: string
stats: [string, string, boolean?, boolean?][]
lines: [string, string, string][]
}
const PANELS_ALL: Panel[] = [
{
key: 'user', title: '用户增长', color: '#6366F1', tint: '#EEF2FF',
icon: 'M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-1.13a4 4 0 10-4-4 4 4 0 004 4z',
stats: [['new_user_count', '新增用户'], ['active_user_count', '活跃用户'], ['login_user_count', '登录用户'], ['login_count', '登录次数']],
lines: [['new_user_count', '新增用户', '#6366F1'], ['active_user_count', '活跃用户', '#34D399']],
},
{
key: 'device', title: '设备激活绑定', color: '#0EA5E9', tint: '#E0F2FE',
icon: 'M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z',
stats: [['active_device_count', '已激活'], ['bind_device_count', '已绑定']],
lines: [['active_device_count', '已激活', '#0EA5E9'], ['bind_device_count', '已绑定', '#818CF8']],
},
{
key: 'qrcode', title: '二维码激活', color: '#EC4899', tint: '#FCE7F3',
icon: 'M12 4v1m6 11h2m-6 0h-1v4m-6-4H5m14 0h-2m-6-7H5m0 0V5m0 4v6m14-6V5h-4M5 5h4M5 19h4m6 0h4v-4M4 4h6v6H4V4zm10 0h6v6h-6V4zM4 14h6v6H4v-6z',
stats: [['qr_active_count', '二维码激活次数']],
lines: [['qr_active_count', '二维码激活', '#EC4899']],
},
{
key: 'order', title: '订单支付', color: '#F59E0B', tint: '#FEF3C7',
icon: 'M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z',
stats: [['order_create_count', '创建订单'], ['order_paid_count', '支付订单'], ['order_failed_count', '失败订单'], ['pay_user_count', '付费用户'], ['order_paid_amount', '支付金额', true], ['order_create_amount', '创建金额', true]],
lines: [['order_paid_count', '支付订单', '#F59E0B'], ['order_create_count', '创建订单', '#FCD34D']],
},
{
key: 'meeting', title: '会议', color: '#22D3EE', tint: '#CFFAFE',
icon: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z',
stats: [['meet_count', '会议次数'], ['meet_second', '会议时长', false, true], ['grant_meet_second', '发放会议时长', false, true]],
lines: [['meet_count', '会议次数', '#22D3EE'], ['meet_second', '会议时长(秒)', '#0EA5E9']],
},
{
key: 'translate', title: '翻译', color: '#8B5CF6', tint: '#F3E8FF',
icon: 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129',
stats: [['trade_count', '翻译次数'], ['trade_second', '翻译时长', false, true], ['trade_words', '翻译字数'], ['grant_trade_second', '发放翻译时长', false, true]],
lines: [['trade_count', '翻译次数', '#8B5CF6'], ['trade_second', '翻译时长(秒)', '#A78BFA']],
},
]
// 老代理账号(identity=3 且未绑定品牌商):隐藏用户数据 + 锁定 apps/products 绑定范围。
// 注意与「品牌商账号」区分:品牌商/渠道商账号(绑定了 brandid/channelid)按约定要能看到用户与
// 支付金额,且它们的收敛由后端按品牌商/渠道商做,不走 apps/products 那套绑定。
const IS_AGENT = computed(() => auth.identity === 3 && !auth.brandid)
// 渠道分成体系账号(品牌商 / 渠道商):数据范围由后端按归属强制收敛,前端只需隐藏无意义的筛选项。
const IS_SCOPED = computed(() => auth.isBrandAccount || auth.isDealer)
const BOUND_APPS = computed(() => auth.apps)
const BOUND_REGIONS = computed(() => auth.regions)
const BOUND_PRODUCTS = computed(() => auth.products.map((p) => parseInt(p)).filter((n) => !isNaN(n)))
const HIDE_FOR_AGENT = ['new_user_count', 'active_user_count', 'qr_active_count']
const globalCards = computed(() => IS_AGENT.value ? GLOBAL_CARDS_ALL.filter((c) => !HIDE_FOR_AGENT.includes(c[0])) : GLOBAL_CARDS_ALL)
const todayCards = computed(() => IS_AGENT.value ? TODAY_CARDS_ALL.filter((c) => !HIDE_FOR_AGENT.includes(c[0])) : TODAY_CARDS_ALL)
const panels = computed(() => IS_AGENT.value ? PANELS_ALL.filter((p) => p.key !== 'user' && p.key !== 'qrcode') : PANELS_ALL)
// 筛选项的「全部」文案。品牌商/渠道商账号的数据范围由后端按归属强制收敛(品牌商=名下渠道商∪产品,
// 渠道商=自己那一个渠道商),下拉里的选项也全部由「品牌商关联产品」推导:
// 应用 = 名下产品绑定的应用(api_getmyapps)、产品 = 名下产品、渠道商 = 名下渠道商。
// 此时再写「全部应用/全部产品」会让人误以为看的是全平台,故按归属改写成「本品牌/本渠道全部 XX」。
const SCOPE_PREFIX = computed(() => auth.isDealer ? '本渠道' : (auth.isBrandAccount ? '本品牌' : ''))
function allLabel(noun: string): string {
return SCOPE_PREFIX.value ? `${SCOPE_PREFIX.value}全部${noun}` : `全部${noun}`
}
const rangeBtns: [number, string][] = [[7, '7天'], [30, '30天'], [90, '90天'], [0, '全部']]
// ── 筛选状态 ──
const filters = reactive({ app: '', region: '', product: '', channel: '' })
const appOptions = ref<{ value: string; label: string }[]>([])
const channelOptions = ref<{ value: string; label: string }[]>([])
const productOptions = ref<{ value: string | number; label: string }[]>([])
const regionOptions = computed(() => {
// 代理绑定的历史区域码(us/ea/…)先归一到国内/海外,再取交集
if (IS_AGENT.value && BOUND_REGIONS.value.length) {
const bound = new Set(BOUND_REGIONS.value.map((r) => normalizeRegion(r)))
return REGION_OPTIONS.filter((r) => bound.has(r.value))
}
return REGION_OPTIONS
})
// ── 数据 ──
const globalData = ref<Record<string, number>>({})
const deviceData = ref<Record<string, number>>({})
const todayData = ref<Record<string, number>>({})
const yestData = ref<Record<string, number>>({})
const todayTag = ref('实时')
const panelStats = ref<Record<string, Record<string, number>>>({})
const panelDays = reactive<Record<string, number>>({})
PANELS_ALL.forEach((p) => { panelDays[p.key] = 30 })
// ── 工具 ──
function ymdNum(d: Date) {
const y = d.getFullYear(), m = ('0' + (d.getMonth() + 1)).slice(-2), dd = ('0' + d.getDate()).slice(-2)
return parseInt(y + m + dd)
}
function rangeOf(days: number) {
if (!days) return { start: 0, end: 0 }
const end = new Date(), start = new Date()
start.setDate(start.getDate() - (days - 1))
return { start: ymdNum(start), end: ymdNum(end) }
}
function fmt(n: number, isAmount?: boolean) {
n = Number(n) || 0
return isAmount ? '¥' + (n / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : n.toLocaleString()
}
function fmtSecs(sec: number) {
sec = Math.max(0, Math.floor(Number(sec) || 0))
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60
if (h > 0) return h + '时' + m + '分'
if (m > 0) return m + '分' + s + '秒'
return s + '秒'
}
function fmtStat(v: number, isAmount?: boolean, isSecs?: boolean) {
if (isSecs) return fmtSecs(v)
return fmt(v, isAmount)
}
function fmtDay(v: number) {
const s = String(v)
return s.length === 8 ? s.slice(4, 6) + '-' + s.slice(6, 8) : s
}
function pctDelta(cur: number, prev: number): number | null {
cur = Number(cur) || 0; prev = Number(prev) || 0
if (prev === 0) return cur > 0 ? null : 0
return (cur - prev) / prev * 100
}
function cardStyle(field: string): [string, string, string] {
return CARD_STYLE[field] || ['📊', '#f1f5f9', '#94a3b8']
}
// 卡片值
function globalVal(c: Card) {
const src = (c[0] === 'bind_device_count' || c[0] === 'active_device_count') ? deviceData.value : globalData.value
const isSecs = (c[0] === 'meet_second' || c[0] === 'trade_second')
return fmtStat(src[c[0]], c[2], isSecs)
}
function todayVal(c: Card) {
return fmtStat(todayData.value[c[0]], c[2], !!c[3])
}
function todayDelta(c: Card): { type: string; arrow: string; abs: string } {
const pct = pctDelta(todayData.value[c[0]], yestData.value[c[0]])
if (pct === null) return { type: 'new', arrow: '', abs: '' }
const type = pct > 0 ? 'up' : (pct < 0 ? 'down' : 'flat')
const arrow = pct > 0 ? '▲' : (pct < 0 ? '▼' : '—')
return { type, arrow, abs: Math.abs(pct).toFixed(1) }
}
function panelStatVal(p: Panel, s: [string, string, boolean?, boolean?]) {
return fmtStat((panelStats.value[p.key] || {})[s[0]], s[2], s[3])
}
// ── 请求构造 ──
// 只传"具体选中值";选"全部"(空值)时不传任何过滤,由后端按登录账号绑定自动收敛
// (代理/运营 = 我的应用/产品/区域;超管/管理员 = 真·全部)。前端不再手工拼 apps/product_ids 列表。
function gFilter(): Record<string, unknown> {
const req: Record<string, unknown> = {}
if (filters.app !== '') req.app_id = filters.app
if (filters.region !== '') req.region = filters.region
if (filters.product !== '') req.product_id = parseInt(String(filters.product))
applyChannel(req)
return req
}
function deviceFilter(): Record<string, unknown> {
const req: Record<string, unknown> = {}
if (filters.product !== '') req.product_id = parseInt(String(filters.product))
applyChannel(req)
return req
}
// 渠道商过滤:'' 不传(全部);'__none__' 传空串,专查未分配渠道(历史数据)的行。
// 后端 channel_id 是指针,nil 才代表不过滤,空串是合法取值。
function applyChannel(req: Record<string, unknown>) {
if (filters.channel === '') return
req.channel_id = filters.channel === '__none__' ? '' : filters.channel
}
// useApi.webApi 已解包:成功返回 data 本体,失败 throw。这里再包成 {code,data} 以贴合原版调用风格。
async function api(method: string, params?: unknown): Promise<{ code: number; data: any } | null> {
try { const data = await webApi(method, params); return { code: 0, data: data ?? {} } } catch { return null }
}
// ── ① 累计板块 ──
async function loadGlobalCards() {
const res = await api('getstatssummary', gFilter())
globalData.value = (res && res.code === 0 && res.data) ? res.data : {}
const dres = await api('getstatssummary', deviceFilter())
deviceData.value = (dres && dres.code === 0 && dres.data) ? dres.data : {}
}
// ── ② 今日板块 ──
async function loadTodayCards() {
const today = ymdNum(new Date())
const yd = new Date(); yd.setDate(yd.getDate() - 1); const yest = ymdNum(yd)
const reqT = gFilter(); reqT.start_day = today; reqT.end_day = today
const reqY = gFilter(); reqY.start_day = yest; reqY.end_day = yest
const tRes = await api('getstatssummary', reqT)
todayData.value = (tRes && tRes.code === 0 && tRes.data) ? tRes.data : {}
const yRes = await api('getstatssummary', reqY)
yestData.value = (yRes && yRes.code === 0 && yRes.data) ? yRes.data : {}
const d = new Date(), mm = ('0' + (d.getMonth() + 1)).slice(-2), dd = ('0' + d.getDate()).slice(-2)
todayTag.value = mm + '-' + dd + ' 实时'
}
// ── ③ 版面 ──
async function loadPanel(p: Panel) {
let req: Record<string, unknown>
if (p.key === 'device') {
req = deviceFilter()
} else {
const r = rangeOf(panelDays[p.key])
req = gFilter(); req.start_day = r.start; req.end_day = r.end
}
const sumRes = await api('getstatssummary', req)
const m = (sumRes && sumRes.code === 0 && sumRes.data) ? sumRes.data : {}
panelStats.value = { ...panelStats.value, [p.key]: m }
if (p.key === 'device') return
const trendRes = await api('getstatstrend', req)
const rows = (trendRes && trendRes.code === 0) ? ((trendRes.data as any) || []) : []
renderChart(p, rows)
}
function setRange(key: string, days: number) {
panelDays[key] = days
const p = PANELS_ALL.find((x) => x.key === key)
if (p) loadPanel(p)
}
function loadAll() {
loadGlobalCards()
loadTodayCards()
panels.value.forEach((p) => loadPanel(p))
}
// ── Chart.js ──
const canvasEls: Record<string, HTMLCanvasElement> = {}
const charts: Record<string, any> = {}
function setCanvas(key: string, el: any) { if (el) canvasEls[key] = el as HTMLCanvasElement }
function renderChart(p: Panel, rows: any[]) {
// @ts-ignore
const Chart = (window as any).Chart
const ctx = canvasEls[p.key]
if (!Chart || !ctx) return
const labels = rows.map((r) => fmtDay(r.stat_day))
const datasets = p.lines.map((l) => ({
label: l[1],
data: rows.map((r) => Number(r[l[0]]) || 0),
borderColor: l[2], backgroundColor: l[2] + '22',
tension: 0.35, fill: true, pointRadius: 0, borderWidth: 2,
}))
if (charts[p.key]) {
charts[p.key].data.labels = labels
charts[p.key].data.datasets = datasets
charts[p.key].update()
return
}
charts[p.key] = new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12, font: { size: 11 } } } },
scales: {
x: { ticks: { maxTicksLimit: 8, font: { size: 10 }, color: '#94a3b8' }, grid: { display: false } },
y: { beginAtZero: true, ticks: { precision: 0, maxTicksLimit: 5, font: { size: 10 }, color: '#94a3b8' }, grid: { color: '#f1f5f9' } },
},
interaction: { mode: 'index', intersect: false },
},
})
}
// ── 初始化筛选器 ──
async function initFilters() {
// 应用
if (IS_SCOPED.value) {
// 品牌商/渠道商账号无权读应用注册表,改走 api_getmyapps:只列出「本品牌产品绑定过的应用」。
// 产品一个都没绑应用时返回空 → 下拉整体隐藏,等同于「不按应用维度筛选」。
try {
const d = await webApi<{ apps: { name: string; app_name?: string }[] }>('getmyapps', {})
const list: { name: string; app_name?: string }[] = d?.apps ?? []
const names: string[] = [...new Set(list.map((a) => a.app_name || a.name).filter(Boolean))]
appOptions.value = names.map((n) => ({ value: n, label: n }))
} catch { appOptions.value = [] }
} else if (IS_AGENT.value) {
// 代理只列绑定应用;"全部应用"复用模板里固定的空值项(选它=不传过滤,后端按账号绑定自动收敛为"我的全部")
appOptions.value = BOUND_APPS.value.map((name) => ({ value: name, label: name }))
} else {
try {
// apps/list 返回的是「部署」(同一应用国内/海外各一行),按应用名去重,
// 否则同名应用在下拉里出现两次;区域另由「区域」筛选控制。
const da = await consoleApi<any[]>('apps/list', {})
const apps = Array.isArray(da) ? da : []
const names = [...new Set(apps.map((a: any) => a.app_name || a.name || '').filter(Boolean))]
appOptions.value = names.map((n) => ({ value: n, label: n }))
} catch { appOptions.value = [] }
}
// 产品:品牌商/渠道商账号由后端按绑定品牌商收敛(只回本品牌名下产品),
// 代理再按账号勾选的产品过滤一次;两者都拿不到别家品牌的产品。
try {
const dp = await webApi<{ products: any[] }>('getproducts', {})
let ps = (dp && dp.products) ? dp.products : []
if (IS_AGENT.value && BOUND_PRODUCTS.value.length) {
ps = ps.filter((p: any) => BOUND_PRODUCTS.value.includes(p.id))
}
productOptions.value = ps.map((p: any) => ({ value: p.id, label: (p.devicename || ('产品#' + p.id)) + ' (#' + p.id + ')' }))
} catch { productOptions.value = [] }
// 渠道商(含停用的:历史统计行仍挂在它名下,筛选项要能选到);
// 品牌商账号由后端强制收敛为「本品牌名下渠道商」。
try {
const dc = await webApi<{ channels: any[] }>('getchannels', { status: -1 })
const cs = (dc && dc.channels) ? dc.channels : []
channelOptions.value = cs.map((c: any) => ({ value: c.id, label: (c.name || '渠道商') + ' (' + c.id + ')' }))
} catch { channelOptions.value = [] }
}
// 动态加载 Chart.js(与原版同版本)
function loadChartJs(): Promise<void> {
return new Promise((resolve) => {
if (!import.meta.client) return resolve()
// @ts-ignore
if ((window as any).Chart) return resolve()
const s = document.createElement('script')
s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'
s.onload = () => resolve()
s.onerror = () => resolve()
document.head.appendChild(s)
})
}
onMounted(async () => {
await loadChartJs()
await initFilters()
loadAll()
})
</script>
<style scoped>
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); overflow:hidden; }
.panel-head { padding:12px 18px; display:flex; align-items:center; gap:10px; border-bottom:1px solid #f8fafc; flex-wrap:wrap; }
.panel-dot { width:30px; height:30px; border-radius:9px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.stat-box { padding:10px 12px; border-radius:10px; background:#fafbfc; }
.stat-label { font-size:11px; color:#94a3b8; margin-bottom:3px; }
.stat-val { font-size:20px; font-weight:700; color:#1e293b; line-height:1.1; letter-spacing:-0.02em; }
/* 版块标题 */
.sec-title { display:flex; align-items:center; gap:10px; margin:0 0 14px; }
.sec-title .bar { width:4px; height:18px; border-radius:3px; }
.sec-title h2 { font-size:15px; font-weight:700; color:#0f172a; letter-spacing:-0.01em; }
.sec-title .sub { font-size:12px; color:#94a3b8; }
.sec-title .tag { font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px; }
.sec-title.muted .bar { height:14px; opacity:.6; }
.sec-title.muted h2 { font-size:13px; font-weight:600; color:#94a3b8; }
.sec-title.hero .bar { width:5px; height:22px; }
.sec-title.hero h2 { font-size:18px; font-weight:800; color:#0f172a; }
/* 累计卡片 */
.cum-card { display:flex; align-items:center; gap:11px; background:#f8fafc; border:1px solid #eef2f7; border-radius:12px; padding:10px 13px; }
.cum-card .ico { width:30px; height:30px; border-radius:9px; display:flex; align-items:center; justify-content:center; flex-shrink:0; font-size:14px; filter:saturate(.65); opacity:.85; }
.cum-card .body { min-width:0; }
.cum-card .lbl { font-size:11.5px; color:#94a3b8; line-height:1.2; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cum-card .val { font-size:17px; font-weight:700; color:#64748b; line-height:1.3; letter-spacing:-0.01em; margin-top:1px; }
/* 今日卡片 */
.today-wrap { background:linear-gradient(180deg,#e8f1ff 0%,#f5f9ff 100%); border:1px solid #d6e4ff; border-radius:22px; padding:20px; box-shadow:0 1px 3px rgba(13,110,253,.05); }
.today-card { position:relative; background:#fff; border:1px solid #e6eefb; border-radius:18px; padding:17px 19px; box-shadow:0 6px 18px rgba(13,110,253,.08); transition:transform .18s ease,box-shadow .18s ease; overflow:hidden; }
.today-card:hover { transform:translateY(-3px); box-shadow:0 14px 32px rgba(13,110,253,.15); }
.today-card .accent { position:absolute; left:0; top:0; bottom:0; width:5px; }
.today-card-top { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
.today-card .lbl { font-size:13px; color:#475569; font-weight:600; }
.today-card .ico { width:42px; height:42px; border-radius:13px; display:flex; align-items:center; justify-content:center; flex-shrink:0; font-size:20px; }
.today-card .val { font-size:31px; font-weight:800; color:#0f172a; line-height:1.05; letter-spacing:-0.02em; }
.today-card .foot { margin-top:9px; font-size:12px; color:#94a3b8; display:flex; align-items:center; gap:6px; }
.delta { display:inline-flex; align-items:center; gap:2px; font-weight:700; padding:2px 7px; border-radius:7px; font-size:11.5px; }
.delta.up { color:#059669; background:#ecfdf5; }
.delta.down { color:#dc2626; background:#fef2f2; }
.delta.flat { color:#64748b; background:#f1f5f9; }
/* 时间按钮 */
.tbtn { font-size:11px; padding:3px 9px; border-radius:7px; color:#64748b; background:#f1f5f9; line-height:1.4; }
.tbtn:hover { background:#e2e8f0; }
.tbtn-on { background:#1e293b; color:#fff; }
</style>