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.
 
 
 
 
 
 

301 lines
15 KiB

<template>
<div>
<div class="flex items-center gap-2 mb-1">
<h2 class="text-lg font-semibold text-slate-800">会员与算力</h2>
</div>
<p class="text-xs text-slate-400 mb-4">
VIP 管功能准入(无 VIP 时翻译与会议纪要不可用);算力管用量计量(翻译 / 会议 / AI 对话统一折算,先扣设备赠送、再扣用户余额)。
设备赠送的 VIP 天数与算力在「MAC 生成管理」按批次配置;用户余额在「用户查询」里调整。
</p>
<!-- 应用选择 -->
<div class="flex items-center gap-2 mb-5">
<span class="text-xs text-slate-500">应用</span>
<select v-model="curAppId" class="select select-bordered select-sm min-w-56" @change="onAppChange">
<option v-for="a in apps" :key="a.id" :value="a.id">{{ a.app_name || a.name }} · {{ regionLabel(a.region) }}</option>
</select>
<span v-if="!apps.length" class="text-xs text-slate-400">没有可选应用</span>
</div>
<div v-if="curAppId" class="grid grid-cols-1 xl:grid-cols-3 gap-5">
<!-- ===== 换算系数与闸门 ===== -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-700 mb-1">换算系数</h3>
<!-- 读取失败必须显式说明:默认值 1/1/1 与真实配置在界面上长得一模一样,
不标出来运营会以为读到了配置,一保存就把默认值写进库里 -->
<div v-if="cfgError" class="alert alert-error text-xs py-2 mb-3">
<span>读取失败,下面显示的是默认值、<b>不是当前配置</b>:{{ cfgError }}</span>
</div>
<p class="text-xs text-slate-400 mb-4">
改动只影响之后的消耗,历史流水不重算。存本应用业务库 config 表,随应用配置下发客户端(仅用于本地预估显示,上报的永远是原始量)。
</p>
<div class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">翻译 · 算力 / 分钟</span></label>
<input v-model.number="cfg.translate" type="number" min="1" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">会议纪要 · 算力 / 分钟</span></label>
<input v-model.number="cfg.meeting" type="number" min="1" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">AI 对话 · 算力 / 100 字</span><span class="label-text-alt text-slate-400">输入 + 输出字符数</span></label>
<input v-model.number="cfg.aichat" type="number" min="1" class="input input-bordered input-sm w-full" />
</div>
<div class="divider my-2"></div>
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="cfg.gate" type="checkbox" class="toggle toggle-error toggle-sm mt-0.5" />
<div>
<div class="text-sm font-medium text-slate-700">算力闸门</div>
<div class="text-xs text-slate-400 mt-0.5">
关(默认):余额不够也放行,差额记为「超额」。开:余额不足即拒绝。
</div>
<div v-if="cfg.gate" class="text-xs text-red-600 mt-1 font-medium">
⚠️ 开启前需确认预扣逻辑已上线:现在是用完才上报,余额只剩 1 的用户仍可先用满再报,没有预扣的闸门形同虚设。
</div>
</div>
</label>
</div>
<div class="divider my-3"></div>
<h3 class="text-sm font-semibold text-slate-700 mb-1">运营参数</h3>
<p class="text-xs text-slate-400 mb-3">四项都可以填 0:开户礼填 0 = 注册不送,提示阈值填 0 = 不提示。</p>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">开户礼 · VIP 天数</span></label>
<input v-model.number="cfg.newuser_gift_vipdays" type="number" min="0" max="3650" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">开户礼 · 算力</span></label>
<input v-model.number="cfg.newuser_gift_compute" type="number" min="0" class="input input-bordered input-sm w-full" />
</div>
</div>
<p class="text-[11px] text-slate-400 -mt-1">注册即到账,只影响改动之后注册的新用户。</p>
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">提示 · VIP 到期前(天)</span></label>
<input v-model.number="cfg.vip_warn_days" type="number" min="0" max="365" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">提示 · 算力低于</span></label>
<input v-model.number="cfg.compute_warn" type="number" min="0" class="input input-bordered input-sm w-full" />
</div>
</div>
<p class="text-[11px] text-slate-400 -mt-1">随会员状态接口下发给客户端,由客户端决定怎么展示。</p>
</div>
<div class="flex items-center gap-2 mt-5">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !cfgDirty || !!cfgError" @click="saveCfg">
<span v-if="saving" class="loading loading-spinner loading-xs"></span>保存
</button>
<button class="btn btn-ghost btn-sm" :disabled="!cfgDirty" @click="Object.assign(cfg, cfgBase)">撤销</button>
</div>
</div>
<!-- ===== 看板 ===== -->
<div class="xl:col-span-2 space-y-5">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-slate-700">算力消耗</h3>
<div class="flex items-center gap-2">
<select v-model.number="rangeDays" class="select select-bordered select-xs" @change="loadStats">
<option :value="7">近 7 天</option>
<option :value="30">近 30 天</option>
<option :value="90">近 90 天</option>
</select>
<button class="btn btn-ghost btn-xs" :disabled="statsLoading" @click="loadStats">刷新</button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<div class="stat-tile"><div class="k">区间消耗</div><div class="v">{{ fmt(totalCompute) }}</div><div class="s">算力</div></div>
<div class="stat-tile"><div class="k">翻译</div><div class="v">{{ fmt(stats?.kinds?.translate?.compute) }}</div><div class="s">{{ minutes(stats?.kinds?.translate?.raw) }} · {{ stats?.kinds?.translate?.times || 0 }} 次</div></div>
<div class="stat-tile"><div class="k">会议纪要</div><div class="v">{{ fmt(stats?.kinds?.meeting?.compute) }}</div><div class="s">{{ minutes(stats?.kinds?.meeting?.raw) }} · {{ stats?.kinds?.meeting?.times || 0 }} 次</div></div>
<div class="stat-tile"><div class="k">AI 对话</div><div class="v">{{ fmt(stats?.kinds?.aichat?.compute) }}</div><div class="s">{{ stats?.kinds?.aichat?.times || 0 }} 次</div></div>
</div>
<!-- 按日条形 -->
<div v-if="statsError" class="alert alert-error text-xs py-2">
<span>看板读取失败,上面的 0 不代表没有消耗:{{ statsError }}</span>
</div>
<div v-else-if="stats?.days?.length" class="overflow-x-auto">
<div class="flex items-end gap-1 h-32 min-w-[520px]">
<div v-for="d in stats.days" :key="d.day" class="flex-1 flex flex-col items-center justify-end gap-1" :title="`${d.day}:${d.compute} 算力 · ${d.users} 人`">
<div class="w-full bg-indigo-400/80 rounded-t" :style="{ height: barH(d.compute) + '%' }"></div>
<div class="text-[9px] text-slate-400 tabular-nums">{{ d.day.slice(5) }}</div>
</div>
</div>
</div>
<div v-else class="text-xs text-slate-400">区间内没有消耗记录</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-700 mb-1">用户消耗 Top {{ stats?.top?.length || 0 }}</h3>
<p class="text-xs text-slate-400 mb-3">额度闸门取消后单用户成本不封顶,这里是异常用量的发现入口。</p>
<table class="table table-xs">
<thead><tr><th>#</th><th>UID</th><th class="text-right">算力</th></tr></thead>
<tbody>
<tr v-for="(t, i) in stats?.top || []" :key="t.uid">
<td class="text-slate-400">{{ i + 1 }}</td>
<td class="font-mono text-xs">{{ t.uid }}</td>
<td class="text-right tabular-nums">{{ fmt(t.compute) }}</td>
</tr>
<tr v-if="!stats?.top?.length"><td colspan="3" class="text-slate-400 text-center">无</td></tr>
</tbody>
</table>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-700 mb-1">设备赠送算力</h3>
<p class="text-xs text-slate-400 mb-3">已激活且带算力的设备(跨应用汇总)。</p>
<div class="grid grid-cols-3 gap-3">
<div class="stat-tile"><div class="k">已发放</div><div class="v">{{ fmt(stats?.device?.grant) }}</div><div class="s">{{ stats?.device?.devices || 0 }} 台</div></div>
<div class="stat-tile"><div class="k">已消耗</div><div class="v">{{ fmt(stats?.device?.used) }}</div></div>
<div class="stat-tile"><div class="k">剩余</div><div class="v">{{ fmt(stats?.device?.left) }}</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
import { regionLabel } from '~/utils/regions'
definePageMeta({ title: '会员与算力' })
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const auth = useAuthStore()
interface AppItem { id: number; name?: string; app_name?: string; region?: number }
const apps = ref<AppItem[]>([])
const curAppId = ref<number | ''>('')
interface Cfg {
translate: number; meeting: number; aichat: number; gate: boolean
newuser_gift_vipdays: number; newuser_gift_compute: number
vip_warn_days: number; compute_warn: number
}
const CFG_DEFAULT: Cfg = {
translate: 1, meeting: 1, aichat: 1, gate: false,
newuser_gift_vipdays: 30, newuser_gift_compute: 100,
vip_warn_days: 7, compute_warn: 100,
}
const cfg = reactive<Cfg>({ ...CFG_DEFAULT })
const cfgBase = reactive<Cfg>({ ...CFG_DEFAULT })
const cfgDirty = computed(() => (Object.keys(cfgBase) as (keyof Cfg)[]).some((k) => cfg[k] !== cfgBase[k]))
const saving = ref(false)
const cfgError = ref('')
interface StatsKind { compute: number; raw: number; times: number }
interface Stats {
days: { day: string; compute: number; users: number }[]
kinds: { translate: StatsKind; meeting: StatsKind; aichat: StatsKind }
top: { uid: string; compute: number }[]
device: { grant: number; used: number; left: number; devices: number }
}
const stats = ref<Stats | null>(null)
const statsLoading = ref(false)
const statsError = ref('')
const rangeDays = ref(30)
const totalCompute = computed(() => (stats.value?.days || []).reduce((s, d) => s + Number(d.compute || 0), 0))
const maxDay = computed(() => Math.max(1, ...(stats.value?.days || []).map((d) => Number(d.compute || 0))))
function barH(v: number) { return Math.max(2, Math.round((Number(v || 0) / maxDay.value) * 100)) }
function fmt(v: any) { return Number(v || 0).toLocaleString() }
function minutes(sec: any) { return `${Math.round(Number(sec || 0) / 60)} 分钟` }
async function loadApps() {
// 与用户查询页同源:超管/管理员走 apps/list,其余角色走 getmyapps(403 时回退)。
try {
apps.value = (await consoleApi<AppItem[]>('apps/list', {})) || []
} catch {
try {
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {})
apps.value = d?.apps ?? []
} catch (e: any) {
error(e?.message || '获取应用列表失败')
}
}
if (!curAppId.value) {
const preferred = apps.value.find((a) => a.id === auth.currentAppId) || apps.value[0]
if (preferred) curAppId.value = preferred.id
}
if (curAppId.value) await onAppChange()
}
async function onAppChange() {
if (!curAppId.value) return
auth.setCurrentApp(Number(curAppId.value))
await Promise.all([loadCfg(), loadStats()])
}
async function loadCfg() {
try {
const d = await webApi<Cfg>('getcomputeconfig', {})
// ⚠️ 运营参数用 ?? 不用 ||:0 是合法配置(不送 / 不提示),用 || 会把它当成「没配」而显示成默认值
const snap: Cfg = {
translate: Number(d?.translate || 1),
meeting: Number(d?.meeting || 1),
aichat: Number(d?.aichat || 1),
gate: !!d?.gate,
newuser_gift_vipdays: Number(d?.newuser_gift_vipdays ?? CFG_DEFAULT.newuser_gift_vipdays),
newuser_gift_compute: Number(d?.newuser_gift_compute ?? CFG_DEFAULT.newuser_gift_compute),
vip_warn_days: Number(d?.vip_warn_days ?? CFG_DEFAULT.vip_warn_days),
compute_warn: Number(d?.compute_warn ?? CFG_DEFAULT.compute_warn),
}
Object.assign(cfg, snap)
Object.assign(cfgBase, snap)
cfgError.value = ''
} catch (e: any) {
cfgError.value = e?.message || '读取算力配置失败'
error(cfgError.value)
}
}
async function saveCfg() {
if (cfg.gate && !cfgBase.gate) {
if (!confirm('确定开启算力闸门?开启后余额不足的用户会被直接拒绝。请先确认预扣逻辑已上线。')) return
}
saving.value = true
try {
await webApi('savecomputeconfig', { ...cfg })
Object.assign(cfgBase, cfg)
success('已保存,业务服务 30 秒内生效')
} catch (e: any) {
error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function loadStats() {
statsLoading.value = true
try {
const to = Math.floor(Date.now() / 1000)
const from = to - rangeDays.value * 86400
stats.value = await webApi<Stats>('getcomputestats', { from, to, top: 20 })
statsError.value = ''
} catch (e: any) {
stats.value = null
statsError.value = e?.message || '读取看板失败'
error(statsError.value)
} finally {
statsLoading.value = false
}
}
onMounted(loadApps)
</script>
<style scoped>
.stat-tile { @apply rounded-xl bg-slate-50 border border-slate-100 px-4 py-3; }
.stat-tile .k { @apply text-[11px] text-slate-400; }
.stat-tile .v { @apply text-xl font-semibold text-slate-800 tabular-nums mt-0.5; }
.stat-tile .s { @apply text-[11px] text-slate-400 mt-0.5; }
</style>