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.
773 lines
37 KiB
773 lines
37 KiB
<template>
|
|
<div class="space-y-5">
|
|
<!-- ========== 列表视图 ========== -->
|
|
<div v-show="view === 'list'" class="space-y-5">
|
|
<!-- 查询条件:应用 → 区域 是硬前提(用户住在所选应用的业务库里),其余是过滤 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
|
|
<div class="flex items-end gap-3 flex-wrap">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用 *</span></label>
|
|
<select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange">
|
|
<option v-if="!appNames.length" value="">无可用应用</option>
|
|
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域 *</span></label>
|
|
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:130px;" @change="reload">
|
|
<option v-if="!regionItems.length" value="">无可用区域</option>
|
|
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control flex-1" style="min-width:220px;">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label>
|
|
<input
|
|
v-model="q.keyword" type="text" class="input input-bordered input-sm w-full"
|
|
placeholder="UID(精确) / 手机号 / 邮箱 / 用户名 / 登录账号 / 设备ID" @keydown.enter.prevent="reload" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">状态</span></label>
|
|
<select v-model.number="q.state" class="select select-bordered select-sm w-28" @change="reload">
|
|
<option :value="-1">全部</option>
|
|
<option :value="0">正常</option>
|
|
<option :value="1">已禁用</option>
|
|
<option :value="2">已注销</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">会员</span></label>
|
|
<select v-model.number="q.vip" class="select select-bordered select-sm w-28" @change="reload">
|
|
<option :value="-1">全部</option>
|
|
<option :value="1">会员中</option>
|
|
<option :value="0">非会员</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">实名</span></label>
|
|
<select v-model.number="q.idverified" class="select select-bordered select-sm w-28" @change="reload">
|
|
<option :value="-1">全部</option>
|
|
<option :value="1">已实名</option>
|
|
<option :value="0">未实名</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">注册起</span></label>
|
|
<input v-model="q.startDate" type="date" class="input input-bordered input-sm" @change="reload" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs text-slate-500">注册止</span></label>
|
|
<input v-model="q.endDate" type="date" class="input input-bordered input-sm" @change="reload" />
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="reload">查询</button>
|
|
<button class="btn btn-ghost btn-sm" @click="resetFilters">重置</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 结果 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100">
|
|
<h3 class="text-sm font-semibold text-slate-700">
|
|
用户列表<span class="text-xs text-slate-400 font-normal ml-2">按注册时间倒序,点行查看详情</span>
|
|
</h3>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs text-slate-500">共 {{ total }} 人</span>
|
|
<select v-model.number="q.pageSize" class="select select-bordered select-xs" @change="reload">
|
|
<option :value="20">20/页</option>
|
|
<option :value="50">50/页</option>
|
|
<option :value="100">100/页</option>
|
|
<option :value="200">200/页</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>用户</th><th>联系方式</th><th>状态</th><th>会员</th>
|
|
<th class="text-right">算力</th>
|
|
<th>实名</th><th>注册时间</th><th>最后登录</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading"><td colspan="9" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
|
|
<tr v-else-if="!qRegion"><td colspan="9" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
|
|
<tr v-else-if="listError"><td colspan="9" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
|
|
<tr v-else-if="!rows.length"><td colspan="9" class="text-center text-slate-400 py-10">没有符合条件的用户</td></tr>
|
|
<tr v-for="u in rows" v-else :key="u.uid" class="hover cursor-pointer" @click="openDetail(u)">
|
|
<td>
|
|
<div class="flex items-center gap-2">
|
|
<img v-if="u.avatar" :src="u.avatar" class="w-8 h-8 rounded-full object-cover border border-slate-100" />
|
|
<div v-else class="w-8 h-8 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center text-xs font-bold shrink-0">
|
|
{{ initialOf(u) }}
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="font-medium truncate max-w-[140px]">{{ u.name || '(未设置)' }}</div>
|
|
<div class="text-[11px] text-slate-400 font-mono truncate max-w-[140px]" :title="u.uid">{{ u.uid }}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="text-xs text-slate-500">
|
|
<div v-if="u.phone">{{ u.phone }}</div>
|
|
<div v-if="u.mail" class="truncate max-w-[160px]" :title="u.mail">{{ u.mail }}</div>
|
|
<div v-if="u.account" class="text-slate-400">{{ u.account }}</div>
|
|
<span v-if="!u.phone && !u.mail && !u.account" class="text-slate-300">-</span>
|
|
</td>
|
|
<td><span class="badge badge-sm" :class="stateClass(u.State)">{{ stateLabel(u.State) }}</span></td>
|
|
<td class="text-xs">
|
|
<span v-if="isVip(u)" class="badge badge-sm badge-primary badge-outline">VIP {{ u.viplv || 0 }}</span>
|
|
<span v-else class="text-slate-300">-</span>
|
|
<div v-if="u.vipexptime" class="text-[11px] text-slate-400 mt-0.5">{{ fmtDate(u.vipexptime) }}</div>
|
|
</td>
|
|
<td class="text-right text-xs tabular-nums">{{ Number(u.computebalance || 0).toLocaleString() }}</td>
|
|
<td><span class="badge badge-sm" :class="u.idverified ? 'badge-success badge-outline' : 'badge-ghost'">{{ u.idverified ? '已实名' : '未实名' }}</span></td>
|
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.createtime) }}</td>
|
|
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.lastsgintime) }}</td>
|
|
<td><button class="btn btn-ghost btn-xs text-indigo-600" @click.stop="openDetail(u)">详情</button></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
|
|
<span class="text-xs text-slate-500">{{ pageInfo }}</span>
|
|
<div class="flex items-center gap-2">
|
|
<button class="btn btn-ghost btn-xs" :disabled="q.page <= 0 || loading" @click="changePage(-1)">上一页</button>
|
|
<span class="text-xs text-slate-500">第 {{ q.page + 1 }} / {{ totalPages }} 页</span>
|
|
<button class="btn btn-ghost btn-xs" :disabled="q.page + 1 >= totalPages || loading" @click="changePage(1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ========== 详情视图 ========== -->
|
|
<div v-show="view === 'detail'" class="space-y-5">
|
|
<div class="flex items-center gap-3">
|
|
<button class="btn btn-ghost btn-sm" @click="view = 'list'">← 返回列表</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">用户详情</h2>
|
|
</div>
|
|
|
|
<div v-if="user" class="space-y-5">
|
|
<!-- 头部 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4 flex-wrap">
|
|
<img v-if="user.avatar" :src="user.avatar" class="w-14 h-14 rounded-full object-cover border border-slate-100" />
|
|
<div v-else class="w-14 h-14 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center text-xl font-bold">
|
|
{{ initialOf(user) }}
|
|
</div>
|
|
<div>
|
|
<div class="text-lg font-semibold text-slate-800">{{ dispVal(user.name) }}</div>
|
|
<div class="text-xs text-slate-400 font-mono mt-0.5">{{ dispVal(user.uid) }}</div>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2 flex-wrap">
|
|
<span class="badge badge-sm" :class="stateClass(user.State)">{{ stateLabel(user.State) }}</span>
|
|
<span class="badge badge-primary badge-outline" :title="vipBadgeTitle(user)">{{ vipBadgeText(user) }}</span>
|
|
<span class="badge badge-ghost">算力 {{ Number(user.computebalance || 0).toLocaleString() }}<template v-if="Number(user.compute_deviceleft || 0)"> + 设备 {{ Number(user.compute_deviceleft).toLocaleString() }}</template></span>
|
|
<button v-if="giftAllowed" class="btn btn-success btn-sm text-white" @click="openGift">赠送资源点</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 绑定设备赋予的权益:VIP 期与算力都挂在设备行上,按当前绑定关系取(解绑即失去、随设备流转) -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
|
|
<span class="text-sm font-semibold text-slate-700">绑定设备赋予的权益</span>
|
|
<span class="text-xs text-slate-400">设备 VIP 期 = 激活日 + 天数,按日历连续走;设备算力共用一份、已用不重置</span>
|
|
</div>
|
|
<table class="table table-sm">
|
|
<thead><tr><th>设备</th><th>MAC</th><th>激活日</th><th>VIP</th><th>VIP 到期</th><th class="text-right">算力 已用 / 总量</th><th class="text-right">剩余</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="d in (user.compute_devices || [])" :key="d.devicemac">
|
|
<td>{{ d.devicename || '-' }}</td>
|
|
<td class="font-mono text-xs">{{ d.devicemac }}</td>
|
|
<td class="text-xs">{{ d.usedtime ? fmtTs(d.usedtime) : '未激活' }}</td>
|
|
<td class="text-xs">{{ d.vipdays ? (Number(d.viplevel) >= 2 ? 'VIP-Pro' : 'VIP') + ` ${d.vipdays} 天` : '-' }}</td>
|
|
<td class="text-xs" :class="Number(d.vipexptime) * 1000 > Date.now() ? 'text-emerald-600' : 'text-slate-400'">{{ d.vipexptime ? fmtTs(d.vipexptime) : '-' }}</td>
|
|
<td class="text-right text-xs tabular-nums">{{ Number(d.computeused || 0).toLocaleString() }} / {{ Number(d.computegrant || 0).toLocaleString() }}</td>
|
|
<td class="text-right text-xs tabular-nums font-medium">{{ Number(d.computeleft || 0).toLocaleString() }}</td>
|
|
</tr>
|
|
<tr v-if="!(user.compute_devices || []).length"><td colspan="7" class="text-center text-slate-400 py-4">当前没有绑定设备</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 算力流水 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
|
|
<span class="text-sm font-semibold text-slate-700">算力流水 <span class="text-xs text-slate-400 font-normal">最近 {{ logs.length }} / 共 {{ logsTotal }} 条</span></span>
|
|
<button class="btn btn-ghost btn-xs" @click="loadLogs">刷新</button>
|
|
</div>
|
|
<table class="table table-sm">
|
|
<thead><tr><th>时间</th><th>类型</th><th>原始量</th><th class="text-right">算力</th><th>来源拆分</th><th>备注</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="l in logs" :key="l.id">
|
|
<td class="text-xs whitespace-nowrap">{{ fmtTs(l.ts) }}</td>
|
|
<td class="text-xs">{{ logKind(l) }}</td>
|
|
<td class="text-xs">{{ logRaw(l) }}</td>
|
|
<td class="text-right text-xs tabular-nums" :class="Number(l.compute) < 0 ? 'text-red-500' : 'text-emerald-600'">{{ Number(l.compute || 0) > 0 ? '+' : '' }}{{ Number(l.compute || 0) }}</td>
|
|
<td class="text-xs text-slate-500">{{ logSource(l) }}</td>
|
|
<td class="text-xs text-slate-400 truncate max-w-[220px]" :title="l.extra">{{ l.extra }}</td>
|
|
</tr>
|
|
<tr v-if="!logs.length"><td colspan="6" class="text-center text-slate-400 py-4">暂无流水</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- 设置参数:仅超管/管理员。代理要发资源走「赠送资源点」,那条路会扣自己的余额 -->
|
|
<div v-if="canEdit" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
|
|
<span class="text-sm font-semibold text-slate-700">设置参数</span>
|
|
<span class="text-xs text-slate-400">只提交你改过的项。VIP 到期填的是<b>自购部分</b>;设备赋予的 VIP 由设备行决定,不在这里改</span>
|
|
</div>
|
|
<div class="px-5 py-4">
|
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">用户名</span></label>
|
|
<input v-model="edit.name" type="text" class="input input-bordered input-sm" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">账号状态</span></label>
|
|
<select v-model.number="edit.state" class="select select-bordered select-sm">
|
|
<option :value="0">正常</option>
|
|
<option :value="1">禁用</option>
|
|
<option :value="2">已注销</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">VIP 等级</span></label>
|
|
<input v-model.number="edit.viplv" type="number" min="0" class="input input-bordered input-sm" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">VIP 到期</span></label>
|
|
<input v-model="edit.vipexpDate" type="datetime-local" class="input input-bordered input-sm" />
|
|
<span class="label-text-alt text-slate-400 mt-1">清空 = 不设到期(等于没有会员)</span>
|
|
</div>
|
|
</div>
|
|
<!-- 算力余额:只能按增量调整并留备注(走 AdminAdjust 流水),不能直接改绝对值 -->
|
|
<div class="mt-4 pt-4 border-t border-slate-100">
|
|
<div class="text-xs font-semibold text-slate-600 mb-2">
|
|
算力余额 <span class="font-mono text-slate-800">{{ Number(user.computebalance || 0).toLocaleString() }}</span>
|
|
<span class="text-slate-400 font-normal ml-2">按增量调整,负数为扣减,余额不能低于 0;每次调整写一条流水</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 items-end">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-xs">增量(可负)</span></label>
|
|
<input v-model.number="adj.delta" type="number" class="input input-bordered input-sm" placeholder="如 100 或 -50" />
|
|
</div>
|
|
<div class="form-control md:col-span-2">
|
|
<label class="label py-1"><span class="label-text text-xs">备注(必填)</span></label>
|
|
<input v-model="adj.remark" type="text" class="input input-bordered input-sm" placeholder="原因,如:客诉补偿 / 误发扣回" />
|
|
</div>
|
|
<button class="btn btn-warning btn-sm" :disabled="adjusting || !adj.delta || !adj.remark.trim()" @click="submitAdjust">
|
|
<span v-if="adjusting" class="loading loading-spinner loading-xs"></span>调整算力
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !dirtyCount" @click="saveEdit">
|
|
<span v-if="saving" class="loading loading-spinner loading-xs"></span>
|
|
保存{{ dirtyCount ? `(${dirtyCount} 项改动)` : '' }}
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" :disabled="!dirtyCount" @click="resetEdit">撤销改动</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 只读分组卡片 -->
|
|
<div v-for="s in sections" :key="s.title" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<div class="px-5 py-3 border-b border-slate-50 text-sm font-semibold text-slate-700">{{ s.title }}</div>
|
|
<div class="px-5 py-3 grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
|
<div v-for="r in s.rows" :key="r[0]" class="flex justify-between gap-3 py-1.5 border-b border-slate-50">
|
|
<span class="text-xs text-slate-400 shrink-0">{{ r[1] }}</span>
|
|
<span class="text-sm text-slate-700 break-all text-right">{{ cellValue(user, r) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 赠送资源点弹窗 -->
|
|
<dialog ref="giftModal" class="modal">
|
|
<div class="modal-box max-w-md">
|
|
<h3 class="font-bold text-lg mb-1 text-slate-800">赠送资源点</h3>
|
|
<p class="text-xs text-slate-400 mb-4">
|
|
目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。
|
|
这里是<b>增量赠送</b>(在现有余额上累加);代理账号从自己的资源点余额中扣减,超管/管理员无消耗。
|
|
</p>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-sm">VIP天数</span></label>
|
|
<input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-sm">翻译分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label>
|
|
<input v-model.number="gift.trademin" type="number" class="input input-bordered" min="0" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-sm">会议分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label>
|
|
<input v-model.number="gift.meetmin" type="number" class="input input-bordered" min="0" />
|
|
</div>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button class="btn btn-sm" @click="closeGift">取消</button>
|
|
<button class="btn btn-success btn-sm text-white" @click="submitGift">确认赠送</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
</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: '用户查询' })
|
|
|
|
interface AppItem {
|
|
id: number
|
|
name: string // 部署注册名,如 "Voitrans(国内)"
|
|
app_name?: string // 所属应用名(一级选项按它去重)
|
|
region: string
|
|
enabled?: boolean
|
|
}
|
|
// 列表行与详情是同一个 pb.DBUser,详情多几个 idverify_* 附加字段,故统一用宽松 map
|
|
type UserRow = Record<string, any>
|
|
|
|
type SectionRow = [string, string, ('ts' | 'bool' | 'gender')?]
|
|
interface Section {
|
|
title: string
|
|
rows: SectionRow[]
|
|
}
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const { success, error, warn } = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
const view = ref<'list' | 'detail'>('list')
|
|
|
|
// ---- 应用/区域(硬前提:用户住在所选应用的业务库里)----
|
|
const apps = ref<AppItem[]>([])
|
|
const qApp = ref('')
|
|
const qRegion = ref<number | ''>('')
|
|
|
|
// ---- 列表 ----
|
|
const rows = ref<UserRow[]>([])
|
|
const total = ref(0)
|
|
const loading = ref(false)
|
|
const listError = ref('')
|
|
// state / vip / idverified 的「不限」必须是 -1:0 是合法取值(正常 / 非会员 / 未实名),
|
|
// 拿零值当不限会让默认查询悄悄只返回一部分人。
|
|
const q = reactive({ keyword: '', state: -1, vip: -1, idverified: -1, startDate: '', endDate: '', page: 0, pageSize: 20 })
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / q.pageSize)))
|
|
const pageInfo = computed(() => {
|
|
if (!total.value) return '-'
|
|
const from = q.page * q.pageSize + 1
|
|
return `第 ${from} - ${Math.min(from + rows.value.length - 1, total.value)} 条 / 共 ${total.value} 条`
|
|
})
|
|
|
|
// ---- 详情 ----
|
|
const user = ref<UserRow | null>(null)
|
|
const lastAppId = ref<number | ''>('')
|
|
const lastUid = ref('')
|
|
|
|
// 修改用户参数仅超管/管理员(后端同样把关);代理只能走「赠送资源点」那条扣自己余额的路。
|
|
const canEdit = computed(() => auth.isAdmin)
|
|
// 超管/管理员/代理可赠送(运营不可)
|
|
const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity))
|
|
|
|
const saving = ref(false)
|
|
const edit = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '' })
|
|
const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '' })
|
|
// 算力调整(增量 + 备注),与「设置参数」分开提交
|
|
const adj = reactive({ delta: 0, remark: '' })
|
|
const adjusting = ref(false)
|
|
// 算力流水(详情打开时拉最近一页)
|
|
const logs = ref<Record<string, any>[]>([])
|
|
const logsTotal = ref(0)
|
|
|
|
const giftModal = ref<HTMLDialogElement | null>(null)
|
|
const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 })
|
|
|
|
// 一级:去重后的应用名(同一应用的国内/海外部署合成一项)
|
|
const appNames = computed(() => {
|
|
const names: string[] = []
|
|
apps.value.forEach((a) => {
|
|
const n = a.app_name || a.name
|
|
if (n && !names.includes(n)) names.push(n)
|
|
})
|
|
return names
|
|
})
|
|
// 二级:当前应用下的部署(按国内/海外展示)
|
|
const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value))
|
|
|
|
function onAppChange() {
|
|
const items = regionItems.value
|
|
qRegion.value = items.length ? items[0].id : ''
|
|
reload()
|
|
}
|
|
|
|
// ---- 展示工具 ----
|
|
function fmtTs(ts: any) {
|
|
if (!ts) return '-'
|
|
return new Date(ts * 1000).toLocaleString()
|
|
}
|
|
function fmtDate(ts: any) {
|
|
if (!ts) return '-'
|
|
const d = new Date(ts * 1000)
|
|
const p = (n: number) => ('0' + n).slice(-2)
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
}
|
|
function fmtBool(v: any) { return v ? '是' : '否' }
|
|
function fmtGender(g: any) { return g === 1 ? '男' : (g === 2 ? '女' : '未知') }
|
|
function dispVal(v: any) { return (v === undefined || v === null || v === '') ? '-' : v }
|
|
// 翻译/会议积分按【秒】存储,展示成分钟;AI 积分是次数,不参与换算
|
|
function fmtMin(sec: any) {
|
|
const n = Number(sec) || 0
|
|
return n ? Math.floor(n / 60) + ' 分' : '0'
|
|
}
|
|
function initialOf(u: UserRow) {
|
|
return String(u?.name || u?.uid || '?').charAt(0).toUpperCase()
|
|
}
|
|
const STATE_LABELS: Record<number, string> = { 0: '正常', 1: '已禁用', 2: '已注销' }
|
|
const STATE_CLASS: Record<number, string> = { 0: 'badge-success badge-outline', 1: 'badge-error', 2: 'badge-ghost' }
|
|
function stateLabel(s: any) { return STATE_LABELS[Number(s) || 0] ?? String(s) }
|
|
function stateClass(s: any) { return STATE_CLASS[Number(s) || 0] ?? 'badge-ghost' }
|
|
// 会员按到期时间判,不按 viplv:等级过期后并不会被清零,拿它判会把过期用户也算成会员
|
|
function isVip(u: UserRow) { return Number(u.vipexptime || 0) * 1000 > Date.now() }
|
|
|
|
// 日期输入 → unix 秒。结束日按「当天 24:00」算,否则选同一天会查不到当天注册的人
|
|
function dateToUnix(s: string, endOfDay: boolean): number {
|
|
if (!s) return 0
|
|
const d = new Date(s + 'T00:00:00')
|
|
if (isNaN(d.getTime())) return 0
|
|
return Math.floor(d.getTime() / 1000) + (endOfDay ? 86400 : 0)
|
|
}
|
|
// <input type="datetime-local"> ↔ unix 秒
|
|
function unixToLocalInput(ts: any): string {
|
|
const n = Number(ts) || 0
|
|
if (!n) return ''
|
|
const d = new Date(n * 1000)
|
|
const p = (x: number) => ('0' + x).slice(-2)
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
|
|
}
|
|
function localInputToUnix(s: string): number {
|
|
if (!s) return 0
|
|
const d = new Date(s)
|
|
return isNaN(d.getTime()) ? 0 : Math.floor(d.getTime() / 1000)
|
|
}
|
|
|
|
const sections: Section[] = [
|
|
{ title: '基本信息', rows: [
|
|
['uid', 'UID'], ['name', '用户名'], ['account', '登录账号'], ['mail', '邮箱'], ['phone', '手机号'],
|
|
['gender', '性别', 'gender'], ['language', '语言'], ['version', '版本'], ['channel', '渠道'],
|
|
['country_code', '国家码'], ['continent_code', '大洲码'], ['servicetag', '服务区'], ['phonemodel', '手机型号'], ['phonemac', '设备ID'],
|
|
] },
|
|
{ title: '第三方登录', rows: [
|
|
['wxopenid', '微信 openid'], ['googleopenid', 'Google openid'], ['facebookopenid', 'Facebook openid'], ['appleopenid', 'Apple openid'],
|
|
] },
|
|
{ title: '设备与归属', rows: [
|
|
['lastbindproductid', '最后绑定产品ID'], ['lastbindchannelid', '最后绑定渠道商'],
|
|
['isactivatecode', '已领公码', 'bool'], ['isgiveaway', '已领过赠送', 'bool'],
|
|
] },
|
|
{ title: '会员(有效 = 自购 或 设备赋予)', rows: [
|
|
['vip_effective_level', '有效VIP等级'], ['vip_effective_exptime', '有效VIP到期', 'ts'],
|
|
['vip_effective_source', '当前来源'],
|
|
['viplv', '自购VIP等级'], ['vipexptime', '自购VIP到期', 'ts'],
|
|
] },
|
|
{ title: '算力', rows: [
|
|
['computebalance', '账户余额'], ['compute_deviceleft', '设备剩余合计'],
|
|
] },
|
|
{ title: '已停用的旧额度(2026-09 起不再作为闸门,存量已折算进算力)', rows: [
|
|
['aichatintegral', 'AI积分(次)'], ['tradeintegral', '翻译积分(秒)'], ['meetintegral', '会议积分(秒)'],
|
|
] },
|
|
{ title: '实名认证', rows: [
|
|
['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'],
|
|
['idverify_realname', '姓名'], ['idverify_idcardmask', '身份证号'],
|
|
['idverify_provider', '核验服务商'], ['idverify_samecardusers', '同证件账号数'],
|
|
] },
|
|
{ title: '时间', rows: [
|
|
['createtime', '注册时间', 'ts'], ['lastsgintime', '最后登录', 'ts'], ['canceltime', '注销时间', 'ts'],
|
|
] },
|
|
]
|
|
|
|
function cellValue(u: UserRow, row: SectionRow) {
|
|
const t = row[2]
|
|
const v = u[row[0]]
|
|
if (t === 'ts') return fmtTs(v)
|
|
if (t === 'bool') return fmtBool(v)
|
|
if (t === 'gender') return fmtGender(v)
|
|
return dispVal(v)
|
|
}
|
|
|
|
// ---- 列表加载 ----
|
|
// 选定本页应用 → 同步到全局 currentAppId,使 webApi 自动带上对应 X-App-Id
|
|
async function loadUsers() {
|
|
if (!qApp.value || !qRegion.value) {
|
|
rows.value = []
|
|
total.value = 0
|
|
return
|
|
}
|
|
const appId = Number(qRegion.value)
|
|
lastAppId.value = appId
|
|
auth.setCurrentApp(appId)
|
|
loading.value = true
|
|
listError.value = ''
|
|
try {
|
|
const d = await webApi<{ users: UserRow[]; total: number }>('getusers', {
|
|
keyword: q.keyword.trim(),
|
|
state: Number(q.state),
|
|
vip: Number(q.vip),
|
|
idverified: Number(q.idverified),
|
|
start: dateToUnix(q.startDate, false),
|
|
end: dateToUnix(q.endDate, true),
|
|
page: q.page,
|
|
page_size: q.pageSize,
|
|
})
|
|
rows.value = d?.users ?? []
|
|
total.value = d?.total ?? 0
|
|
} catch (e: any) {
|
|
listError.value = e?.message || '查询失败'
|
|
rows.value = []
|
|
total.value = 0
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
// 改了任何筛选项都要回到第一页——留在第 5 页上换条件,多半会看到一片空白
|
|
function reload() {
|
|
q.page = 0
|
|
loadUsers()
|
|
}
|
|
function changePage(d: number) {
|
|
const next = q.page + d
|
|
if (next < 0 || next >= totalPages.value) return
|
|
q.page = next
|
|
loadUsers()
|
|
}
|
|
function resetFilters() {
|
|
q.keyword = ''
|
|
q.state = -1
|
|
q.vip = -1
|
|
q.idverified = -1
|
|
q.startDate = ''
|
|
q.endDate = ''
|
|
reload()
|
|
}
|
|
|
|
// ---- 详情 ----
|
|
// 列表行已经是整行 user,但详情要多出 idverify_* 那几项(getuserinfo 才附带),所以再查一次。
|
|
async function openDetail(row: UserRow) {
|
|
lastUid.value = String(row.uid || '')
|
|
user.value = row
|
|
fillEdit(row)
|
|
view.value = 'detail'
|
|
if (!lastUid.value) return
|
|
try {
|
|
auth.setCurrentApp(Number(lastAppId.value))
|
|
const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value })
|
|
if (d) {
|
|
// 后端把有效 VIP 放在嵌套的 vip_effective 里;详情表按扁平 key 取值,这里拍平
|
|
const ve = (d.vip_effective || {}) as Record<string, any>
|
|
d.vip_effective_level = Number(ve.level || 0)
|
|
d.vip_effective_exptime = Number(ve.exptime || 0)
|
|
d.vip_effective_source = ve.level ? (ve.fromdevice ? '设备赋予' : '自购') : '无'
|
|
user.value = d
|
|
fillEdit(d)
|
|
loadLogs()
|
|
}
|
|
} catch (e: any) {
|
|
// 详情拉不到不清空:列表那一行的数据仍然是对的,够看大部分字段
|
|
warn(e?.message || '获取实名等附加信息失败,已显示列表数据')
|
|
}
|
|
}
|
|
|
|
function fillEdit(u: UserRow) {
|
|
const snap = {
|
|
name: String(u.name || ''),
|
|
state: Number(u.State || 0),
|
|
viplv: Number(u.viplv || 0),
|
|
vipexpDate: unixToLocalInput(u.vipexptime),
|
|
}
|
|
Object.assign(edit, snap)
|
|
Object.assign(editBase, snap)
|
|
}
|
|
function resetEdit() {
|
|
Object.assign(edit, editBase)
|
|
}
|
|
// 只提交改过的项:整包提交会把并发中变化的余额一起覆盖成后台读到的旧值
|
|
const dirtyCount = computed(() =>
|
|
(Object.keys(editBase) as (keyof typeof editBase)[]).filter((k) => edit[k] !== editBase[k]).length,
|
|
)
|
|
|
|
function vipBadgeText(u: UserRow) {
|
|
const lv = Number(u.vip_effective_level ?? u.viplv ?? 0)
|
|
if (!lv) return '无 VIP'
|
|
return `${lv >= 2 ? 'VIP-Pro' : 'VIP'}${u.vip_effective_source === '设备赋予' ? '(设备)' : ''}`
|
|
}
|
|
function vipBadgeTitle(u: UserRow) {
|
|
const exp = Number(u.vip_effective_exptime || 0)
|
|
return exp ? `有效到 ${fmtTs(exp)}` : ''
|
|
}
|
|
|
|
async function submitAdjust() {
|
|
if (!lastUid.value || !adj.delta || !adj.remark.trim()) return
|
|
const delta = Number(adj.delta)
|
|
if (!confirm(`确定给该用户${delta > 0 ? '增加' : '扣减'} ${Math.abs(delta)} 算力?`)) return
|
|
adjusting.value = true
|
|
try {
|
|
auth.setCurrentApp(Number(lastAppId.value))
|
|
await webApi('adjustusercompute', { uid: lastUid.value, delta, remark: adj.remark.trim() })
|
|
success('已调整')
|
|
adj.delta = 0
|
|
adj.remark = ''
|
|
await refreshCurrent()
|
|
} catch (e: any) {
|
|
error(e?.message || '调整失败')
|
|
} finally {
|
|
adjusting.value = false
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
if (!lastUid.value) return
|
|
try {
|
|
auth.setCurrentApp(Number(lastAppId.value))
|
|
const d = await webApi<{ logs: Record<string, any>[]; total: number }>('getusercomputelogs', { uid: lastUid.value, page: 1, page_size: 30 })
|
|
logs.value = d?.logs || []
|
|
logsTotal.value = Number(d?.total || 0)
|
|
} catch {
|
|
logs.value = []
|
|
}
|
|
}
|
|
const LOGTYPE: Record<number, string> = { 1: '购买', 2: '活动/激活', 3: '后台赠送', 4: '消耗', 5: '后台调整' }
|
|
function logKind(l: Record<string, any>) {
|
|
if (Number(l.addtradesecond) < 0) return '翻译'
|
|
if (Number(l.addmeetsecond) < 0) return '会议纪要'
|
|
if (Number(l.addagentintegral) < 0) return 'AI 对话'
|
|
return LOGTYPE[Number(l.logtype)] || String(l.logtype)
|
|
}
|
|
function logRaw(l: Record<string, any>) {
|
|
if (Number(l.addtradesecond) < 0) return `${Math.round(-Number(l.addtradesecond) / 60)} 分钟`
|
|
if (Number(l.addmeetsecond) < 0) return `${Math.round(-Number(l.addmeetsecond) / 60)} 分钟`
|
|
if (Number(l.addvipday) > 0) return `VIP +${l.addvipday} 天`
|
|
return ''
|
|
}
|
|
function logSource(l: Record<string, any>) {
|
|
const parts: string[] = []
|
|
if (Number(l.computedevice) > 0) parts.push(`设备 ${l.computedevice}`)
|
|
if (Number(l.computeuser) > 0) parts.push(`账户 ${l.computeuser}`)
|
|
if (Number(l.computeover) > 0) parts.push(`超额 ${l.computeover}`)
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!lastUid.value || !dirtyCount.value) return
|
|
const payload: Record<string, unknown> = { uid: lastUid.value }
|
|
if (edit.name !== editBase.name) payload.name = edit.name.trim()
|
|
if (edit.state !== editBase.state) payload.state = Number(edit.state)
|
|
if (edit.viplv !== editBase.viplv) payload.viplv = Number(edit.viplv)
|
|
if (edit.vipexpDate !== editBase.vipexpDate) payload.vipexptime = localInputToUnix(edit.vipexpDate)
|
|
saving.value = true
|
|
try {
|
|
auth.setCurrentApp(Number(lastAppId.value))
|
|
await webApi('updateuser', payload)
|
|
success('已保存')
|
|
await refreshCurrent()
|
|
} catch (e: any) {
|
|
error(e?.message || '保存失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
// 保存/赠送之后重新拉详情与列表,避免界面上还是改之前的数
|
|
async function refreshCurrent() {
|
|
try {
|
|
auth.setCurrentApp(Number(lastAppId.value))
|
|
const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value })
|
|
if (d) {
|
|
// 后端把有效 VIP 放在嵌套的 vip_effective 里;详情表按扁平 key 取值,这里拍平
|
|
const ve = (d.vip_effective || {}) as Record<string, any>
|
|
d.vip_effective_level = Number(ve.level || 0)
|
|
d.vip_effective_exptime = Number(ve.exptime || 0)
|
|
d.vip_effective_source = ve.level ? (ve.fromdevice ? '设备赋予' : '自购') : '无'
|
|
user.value = d
|
|
fillEdit(d)
|
|
loadLogs()
|
|
}
|
|
} catch { /* 详情刷新失败不影响已保存的结果 */ }
|
|
loadUsers()
|
|
}
|
|
|
|
// ---- 赠送资源点 ----
|
|
function openGift() {
|
|
if (!lastUid.value) { warn('请先选择用户'); return }
|
|
gift.vipday = 0
|
|
gift.trademin = 0
|
|
gift.meetmin = 0
|
|
giftModal.value?.showModal()
|
|
}
|
|
function closeGift() { giftModal.value?.close() }
|
|
|
|
async function submitGift() {
|
|
const vipday = Number(gift.vipday) || 0
|
|
const trademin = Number(gift.trademin) || 0
|
|
const meetmin = Number(gift.meetmin) || 0
|
|
if (vipday < 0 || trademin < 0 || meetmin < 0) { warn('数量不能为负'); return }
|
|
if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
|
|
if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return }
|
|
auth.setCurrentApp(Number(lastAppId.value))
|
|
try {
|
|
await webApi('giftuserresource', { uid: lastUid.value, vipday, trademin, meetmin })
|
|
success('赠送成功')
|
|
giftModal.value?.close()
|
|
await refreshCurrent()
|
|
} catch (e: any) {
|
|
error(e?.message || '赠送失败')
|
|
}
|
|
}
|
|
|
|
// 根据去重后的应用名自动选第一个应用及其第一个区域
|
|
function buildAppSelect() {
|
|
const names = appNames.value
|
|
if (!names.length) {
|
|
qApp.value = ''
|
|
qRegion.value = ''
|
|
return
|
|
}
|
|
qApp.value = names[0]
|
|
const items = regionItems.value
|
|
qRegion.value = items.length ? items[0].id : ''
|
|
}
|
|
|
|
// ---- 初始化:加载启用的注册应用 ----
|
|
async function loadApps() {
|
|
// 超管/管理员之外的角色(代理/运营/品牌商/渠道商)无权调 apps/list(403),改走 api_getmyapps:
|
|
// 后端按账号绑定下发可选部署——品牌商/渠道商即「名下产品绑定过的应用」。
|
|
// 接口失败时退回登录时下发的 appbinds(同源,只是不含产品绑定的最新变化)。
|
|
if (!auth.isAdmin) {
|
|
try {
|
|
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {})
|
|
apps.value = d?.apps ?? []
|
|
} catch {
|
|
apps.value = (auth.appbinds || []).map((a) => ({
|
|
id: a.id, name: a.name, app_name: a.app_name, region: a.region, enabled: true,
|
|
}))
|
|
}
|
|
buildAppSelect()
|
|
loadUsers()
|
|
return
|
|
}
|
|
try {
|
|
const list = await consoleApi<AppItem[]>('apps/list', {})
|
|
apps.value = (list || []).filter((a) => a.enabled)
|
|
} catch {
|
|
apps.value = []
|
|
}
|
|
buildAppSelect()
|
|
loadUsers()
|
|
}
|
|
|
|
onMounted(loadApps)
|
|
</script>
|
|
|