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.
 
 
 
 
 
 

644 lines
30 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 class="text-right">会议</th><th class="text-right">AI</th>
<th>实名</th><th>注册时间</th><th>最后登录</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="11" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!qRegion"><td colspan="11" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
<tr v-else-if="listError"><td colspan="11" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
<tr v-else-if="!rows.length"><td colspan="11" 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">{{ fmtMin(u.tradeintegral) }}</td>
<td class="text-right text-xs">{{ fmtMin(u.meetintegral) }}</td>
<td class="text-right text-xs">{{ u.aichatintegral || 0 }}</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'">&larr; 返回列表</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">VIP {{ user.viplv || 0 }}</span>
<button v-if="giftAllowed" class="btn btn-success btn-sm text-white" @click="openGift">赠送资源点</button>
</div>
</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">只提交你改过的项;积分填的是<b>余额绝对值</b>,不是增量</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 class="form-control">
<label class="label py-1"><span class="label-text text-xs">翻译余额(分钟)</span></label>
<input v-model.number="edit.trademin" 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">会议余额(分钟)</span></label>
<input v-model.number="edit.meetmin" 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">AI 余额(次)</span></label>
<input v-model.number="edit.aichat" type="number" min="0" class="input input-bordered input-sm" />
</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></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></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: '', trademin: 0, meetmin: 0, aichat: 0 })
const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 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: [
['viplv', 'VIP等级'], ['vipexptime', 'VIP到期', 'ts'],
['aichatintegral', 'AI积分(次)'], ['aichattotalintegral', 'AI累计'],
['tradeintegral', '翻译积分(秒)'], ['tradetotalintegral', '翻译累计(秒)'],
['meetintegral', '会议积分(秒)'], ['meettotalintegral', '会议累计(秒)'],
] },
{ 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) {
user.value = d
fillEdit(d)
}
} 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),
trademin: Math.floor((Number(u.tradeintegral) || 0) / 60),
meetmin: Math.floor((Number(u.meetintegral) || 0) / 60),
aichat: Number(u.aichatintegral || 0),
}
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,
)
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)
// 分钟 → 秒:库里存的是秒,表单填的是分钟
if (edit.trademin !== editBase.trademin) payload.tradeintegral = Math.max(0, Number(edit.trademin) || 0) * 60
if (edit.meetmin !== editBase.meetmin) payload.meetintegral = Math.max(0, Number(edit.meetmin) || 0) * 60
if (edit.aichat !== editBase.aichat) payload.aichatintegral = Math.max(0, Number(edit.aichat) || 0)
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) {
user.value = d
fillEdit(d)
}
} 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>