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.
 
 
 
 
 
 

306 lines
12 KiB

<template>
<div class="space-y-6">
<!-- ========== 查询条件:应用 → 区域 → UID ========== -->
<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">应用</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">区域</span></label>
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:140px;">
<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">
<label class="label py-1"><span class="label-text text-xs">用户UID</span></label>
<input v-model="qUid" type="text" class="input input-bordered input-sm" style="min-width:240px;" placeholder="输入用户 UID" @keydown.enter.prevent="searchUser" />
</div>
<button class="btn btn-primary btn-sm text-white" @click="searchUser">搜索</button>
</div>
<p class="text-xs text-slate-400 mt-2">选择应用与区域后输入 UID,直接查询该部署业务库中的用户信息。</p>
</div>
<!-- ========== 结果 ========== -->
<div v-if="!user" class="bg-white rounded-xl shadow-sm border border-slate-100 p-12 text-center text-slate-400">
{{ emptyHint }}
</div>
<div v-else class="space-y-5">
<!-- 头部 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
<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">
{{ avatarInitial }}
</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-3">
<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-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>
<!-- 赠送资源点弹窗 -->
<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>。代理账号将从自己的资源点余额中扣减,超管/管理员无消耗。</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
}
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()
// 启用的注册项 [{id,name,region,...}]
const apps = ref<AppItem[]>([])
const qApp = ref('')
const qRegion = ref<number | ''>('')
const qUid = ref('')
const user = ref<Record<string, any> | null>(null)
const emptyHint = ref('请输入查询条件后点击「搜索」')
// 最近一次成功查询的应用/用户,赠送时复用
const lastAppId = ref<number | ''>('')
const lastUid = ref('')
// 超管/管理员/代理可赠送(运营不可)
const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity))
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 : ''
}
// ---- 展示工具 ----
function fmtTs(ts: any) {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString()
}
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
}
const sections: Section[] = [
{ title: '基本信息', rows: [
['uid', 'UID'], ['name', '用户名'], ['account', '登录账号'], ['mail', '邮箱'], ['phone', '手机号'],
['gender', '性别', 'gender'], ['language', '语言'], ['version', '版本'], ['channel', '渠道'], ['State', '状态'],
['country_code', '国家码'], ['continent_code', '大洲码'], ['servicetag', '服务区'], ['phonemodel', '手机型号'], ['phonemac', '设备ID'],
] },
{ title: '第三方登录', rows: [
['wxopenid', '微信 openid'], ['googleopenid', 'Google openid'], ['facebookopenid', 'Facebook openid'], ['appleopenid', 'Apple openid'],
] },
{ title: '会员与积分', rows: [
['viplv', 'VIP等级'], ['vipexptime', 'VIP到期', 'ts'],
['aichatintegral', 'AI积分'], ['aichattotalintegral', 'AI累计积分'],
['tradeintegral', '翻译积分'], ['tradetotalintegral', '翻译累计积分'],
['meetintegral', '会议积分'], ['meettotalintegral', '会议累计积分'],
['lastbindproductid', '最后绑定产品ID'], ['isactivatecode', '已领激活码', 'bool'], ['isgiveaway', '首次绑定赠送', 'bool'],
] },
{ 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: Record<string, any>, 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)
}
const avatarInitial = computed(() => {
const u = user.value
if (!u) return '?'
return String(u.name || u.uid || '?').charAt(0).toUpperCase()
})
// 选定本页应用 → 同步到全局 currentAppId,使 webApi 自动带上对应 X-App-Id
async function searchUser() {
if (!qApp.value) { warn('请选择应用'); return }
if (!qRegion.value) { warn('请选择区域'); return }
const uid = qUid.value.trim()
if (!uid) { warn('请输入用户UID'); return }
const appId = Number(qRegion.value)
lastAppId.value = appId
lastUid.value = uid
// 带本页选中的 X-App-Id(webApi 取 auth.currentAppId)
auth.setCurrentApp(appId)
try {
const data = await webApi<Record<string, any>>('getuserinfo', { uid })
user.value = data || {}
} catch (e: any) {
error(e?.message || '查询失败')
user.value = null
emptyHint.value = e?.message || '未找到该用户'
}
}
// ---- 赠送资源点 ----
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()
searchUser() // 刷新用户资源显示
} catch (e: any) {
error(e?.message || '赠送失败')
}
}
// 根据去重后的应用名自动选第一个应用及其第一个区域
function buildAppSelect() {
const names = appNames.value
if (!names.length) {
qApp.value = ''
qRegion.value = ''
return
}
qApp.value = names[0]
onAppChange()
}
// ---- 初始化:加载启用的注册应用 ----
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()
return
}
try {
const list = await consoleApi<AppItem[]>('apps/list', {})
apps.value = (list || []).filter((a) => a.enabled)
} catch {
apps.value = []
}
buildAppSelect()
}
onMounted(loadApps)
</script>