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.
460 lines
19 KiB
460 lines
19 KiB
<template>
|
|
<div>
|
|
<!-- ── 账号列表 ── -->
|
|
<div v-show="view === 'list'">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-lg font-bold text-slate-700">后台账号</h2>
|
|
<p class="text-xs text-slate-400 mt-0.5">超管 / 管理员 / 代理 / 运营。代理·运营可绑定应用·区域·产品,登录后仪表盘按绑定限制。</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="openEdit()">+ 新建账号</button>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr class="text-slate-500">
|
|
<th>ID</th><th>用户名</th><th>角色</th><th>状态</th><th>绑定作用域</th><th>备注</th><th class="text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="7" class="text-center text-slate-400 py-8">加载中...</td>
|
|
</tr>
|
|
<tr v-else-if="!rows.length">
|
|
<td colspan="7" class="text-center text-slate-400 py-8">暂无账号(引导超管不在此列)</td>
|
|
</tr>
|
|
<tr v-for="a in rows" :key="a.id">
|
|
<td class="font-mono">{{ a.id }}</td>
|
|
<td class="font-medium">{{ a.username || '' }}</td>
|
|
<td><span class="badge badge-sm" :class="ROLE_CLS[a.identity] || ''">{{ ROLE[a.identity] || a.identity }}</span></td>
|
|
<td>
|
|
<span v-if="a.enabled" class="text-green-600 text-xs">启用</span>
|
|
<span v-else class="text-slate-400 text-xs">停用</span>
|
|
</td>
|
|
<td>
|
|
<template v-if="a.identity === 3 || a.identity === 4">
|
|
<span v-if="scopeParts(a).length" class="text-xs text-slate-500">{{ scopeParts(a).join(' · ') }}</span>
|
|
<span v-else class="text-xs text-slate-300">未绑定(不限)</span>
|
|
</template>
|
|
<template v-else-if="a.identity === 2">
|
|
<span v-if="a.access" class="text-xs text-slate-500">菜单:{{ fromCsv(a.access).length }}</span>
|
|
<span v-else class="text-xs text-slate-300">默认(除SaaS/系统管理)</span>
|
|
</template>
|
|
<span v-else class="text-xs text-slate-300">全部权限</span>
|
|
<div v-if="a.identity === 3" class="text-[11px] text-emerald-600 mt-0.5">
|
|
余额 VIP{{ a.balance_vipday || 0 }}天 · 翻译{{ a.balance_trademin || 0 }}分 · 会议{{ a.balance_meetmin || 0 }}分
|
|
</div>
|
|
</td>
|
|
<td class="text-xs text-slate-500">{{ a.remark || '' }}</td>
|
|
<td>
|
|
<div class="flex gap-1 justify-end">
|
|
<button v-if="a.identity === 3" class="btn btn-ghost btn-xs text-emerald-600" @click="openTopup(a)">充值</button>
|
|
<button class="btn btn-ghost btn-xs" @click="openEdit(a)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-amber-600" @click="resetPwd(a.id, a.username)">改密</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delAccount(a.id, a.username)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 新建/编辑账号 ── -->
|
|
<div v-show="view === 'form'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="closeEdit()">← 返回列表</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ editId ? ('编辑账号 #' + editId) : '新建账号' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="text-xs text-slate-500">用户名</label>
|
|
<input v-model="form.username" :disabled="!!editId" class="input input-bordered input-sm w-full" placeholder="登录用户名" />
|
|
</div>
|
|
<div v-show="!editId">
|
|
<label class="text-xs text-slate-500">密码</label>
|
|
<input v-model="form.password" type="password" class="input input-bordered input-sm w-full" placeholder="登录密码" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-500">角色</label>
|
|
<select v-model.number="form.identity" class="select select-bordered select-sm w-full">
|
|
<option :value="2">管理员(默认除 SaaS/系统管理外全部)</option>
|
|
<option :value="3">代理(外部,按绑定限制)</option>
|
|
<option :value="4">运营(内部,按绑定限制)</option>
|
|
<option :value="1">超管(全部权限)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-slate-500">状态</label>
|
|
<select v-model="form.enabled" class="select select-bordered select-sm w-full">
|
|
<option value="1">启用</option>
|
|
<option value="0">停用</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="text-xs text-slate-500">备注</label>
|
|
<input v-model="form.remark" class="input input-bordered input-sm w-full" placeholder="可选" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 菜单权限(非超管显示;超管恒为全量) -->
|
|
<div v-show="form.identity !== 1" class="mt-4 border-t border-slate-100 pt-4">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<div class="text-sm font-semibold text-slate-600">菜单权限</div>
|
|
<div class="flex gap-1">
|
|
<button type="button" class="btn btn-ghost btn-xs" @click="sel.access = DEFAULT_ADMIN_ACCESS.slice()">管理员默认</button>
|
|
<button type="button" class="btn btn-ghost btn-xs" @click="sel.access = ALL_MENU_IDS.slice()">全选</button>
|
|
<button type="button" class="btn btn-ghost btn-xs" @click="sel.access = []">清空</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mb-3">管理员默认开放除「SaaS 管理 / 系统管理」以外的全部菜单(权限留空时按默认生效);代理/运营需显式勾选。</p>
|
|
<div v-for="g in MENU_GROUPS" :key="g.label" class="mb-2.5">
|
|
<div class="text-xs text-slate-500 mb-1.5">{{ g.label }}<span v-if="g.restricted" class="text-amber-500 ml-1">(管理员默认不开放)</span></div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<label v-for="m in g.items" :key="m.id" class="chk">
|
|
<input v-model="sel.access" type="checkbox" :value="m.id" />{{ m.label }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 作用域绑定(仅 代理/运营 显示)-->
|
|
<div v-show="form.identity === 3 || form.identity === 4" class="mt-4 border-t border-slate-100 pt-4">
|
|
<div class="text-sm font-semibold text-slate-600 mb-2">作用域绑定 <span class="text-xs text-slate-400 font-normal">(代理/运营生效;限制仪表盘数据范围)</span></div>
|
|
|
|
<div class="mb-3">
|
|
<div class="text-xs text-slate-500 mb-1.5">绑定应用 <span class="text-slate-300">(仪表盘只显示这些应用数据;不勾=不限)</span></div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<template v-if="apps.length">
|
|
<label v-for="a in apps" :key="a.id" class="chk">
|
|
<input v-model="sel.apps" type="checkbox" :value="a.name" />{{ a.name || ('应用#' + a.id) }}
|
|
</label>
|
|
</template>
|
|
<span v-else class="text-xs text-slate-300">无注册应用</span>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="text-xs text-slate-500 mb-1.5">绑定区域 <span class="text-slate-300">(不勾=不限)</span></div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<label v-for="r in REGIONS" :key="r[0]" class="chk">
|
|
<input v-model="sel.regions" type="checkbox" :value="r[0]" />{{ r[1] }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-slate-500 mb-1.5">绑定产品 <span class="text-slate-300">(按厂家分组;仪表盘只显示这些产品数据;不勾=不限)</span></div>
|
|
<div class="max-h-56 overflow-y-auto pr-1 border border-slate-100 rounded-lg p-2">
|
|
<span v-if="prodLoading" class="text-xs text-slate-300">加载产品中...</span>
|
|
<span v-else-if="!productGroups.length" class="text-xs text-slate-300">无产品</span>
|
|
<div v-for="g in productGroups" v-else :key="g.fid" class="mb-2 last:mb-0">
|
|
<div class="text-[11px] font-semibold text-slate-500 mb-1 sticky top-0 bg-white py-0.5">🏭 {{ g.label }}</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<label v-for="p in g.products" :key="p.id" class="chk">
|
|
<input v-model="sel.products" type="checkbox" :value="String(p.id)" />{{ (p.devicename || '产品') + ' #' + p.id }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end mt-6">
|
|
<button class="btn btn-ghost btn-sm" @click="closeEdit()">取消</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="saveAccount()">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 代理充值/调整资源点弹窗 ── -->
|
|
<dialog ref="topupModal" class="modal">
|
|
<div class="modal-box max-w-md">
|
|
<h3 class="font-bold text-lg mb-1">充值 / 调整资源点</h3>
|
|
<p class="text-xs text-slate-400 mb-3">代理:<span class="font-medium text-slate-600">{{ topup.name }}</span>。填增量(可填负数下调),当前余额:<span class="text-emerald-600">{{ topup.cur }}</span></p>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
<div><label class="text-xs text-slate-500">VIP天数</label><input v-model.number="topup.vipday" type="number" class="input input-bordered input-sm w-full" /></div>
|
|
<div><label class="text-xs text-slate-500">翻译分钟</label><input v-model.number="topup.trademin" type="number" class="input input-bordered input-sm w-full" /></div>
|
|
<div><label class="text-xs text-slate-500">会议分钟</label><input v-model.number="topup.meetmin" type="number" class="input input-bordered input-sm w-full" /></div>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button class="btn btn-ghost btn-sm" @click="closeTopup()">取消</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="submitTopup()">确认</button>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, watch, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import { MENU_GROUPS, ALL_MENU_IDS, DEFAULT_ADMIN_ACCESS } from '~/utils/menus'
|
|
|
|
definePageMeta({ title: '后台账号' })
|
|
|
|
interface Account {
|
|
id: number
|
|
username: string
|
|
identity: number
|
|
enabled: boolean
|
|
remark: string
|
|
access: string
|
|
apps: string
|
|
regions: string
|
|
products: string
|
|
balance_vipday: number
|
|
balance_trademin: number
|
|
balance_meetmin: number
|
|
[key: string]: unknown
|
|
}
|
|
interface AppItem { id: number; name: string }
|
|
interface Brand { id: number; brand: string }
|
|
interface ProductItem { id: number; devicename: string; brandid: number }
|
|
interface ProductGroup { fid: string; label: string; products: ProductItem[] }
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const { success, error, warn } = useToast()
|
|
|
|
const ROLE: Record<number, string> = { 1: '超管', 2: '管理员', 3: '代理', 4: '运营' }
|
|
const ROLE_CLS: Record<number, string> = { 1: 'badge-error', 2: 'badge-primary', 3: 'badge-warning', 4: 'badge-info' }
|
|
// 页面访问项统一取自 utils/menus.ts 的菜单目录(MENU_GROUPS),与侧边栏严格一致。
|
|
const REGIONS: [string, string][] = [['cn', '中国'], ['us', '美国'], ['ea', '东亚'], ['sea', '东南亚'], ['sa', '南亚'], ['me', '中东'], ['eu', '欧洲'], ['ru', '俄罗斯'], ['af', '非洲'], ['sam', '南美'], ['nam', '北美'], ['oc', '大洋洲'], ['br', '巴西'], ['in', '印度']]
|
|
|
|
const view = ref<'list' | 'form'>('list')
|
|
const rows = ref<Account[]>([])
|
|
const apps = ref<AppItem[]>([])
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const editId = ref(0)
|
|
|
|
const form = reactive({
|
|
username: '',
|
|
password: '',
|
|
identity: 2,
|
|
enabled: '1',
|
|
remark: '',
|
|
})
|
|
// 勾选集合(v-model 数组)
|
|
const sel = reactive({
|
|
access: [] as string[],
|
|
apps: [] as string[],
|
|
regions: [] as string[],
|
|
products: [] as string[],
|
|
})
|
|
|
|
const productGroups = ref<ProductGroup[]>([])
|
|
const prodLoading = ref(false)
|
|
|
|
function fromCsv(s?: string): string[] {
|
|
return (s || '').split(',').map((x) => x.trim()).filter((x) => x)
|
|
}
|
|
function csv(arr: string[]): string {
|
|
return arr.join(',')
|
|
}
|
|
|
|
function scopeParts(a: Account): string[] {
|
|
const parts: string[] = []
|
|
if (a.apps) parts.push('应用:' + fromCsv(a.apps).length)
|
|
if (a.regions) parts.push('区域:' + fromCsv(a.regions).length)
|
|
if (a.products) parts.push('产品:' + fromCsv(a.products).length)
|
|
if (a.access) parts.push('页面:' + fromCsv(a.access).length)
|
|
return parts
|
|
}
|
|
|
|
// ── 列表 ──
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
// 预载注册应用(绑定应用勾选 + 产品提示用)
|
|
try {
|
|
apps.value = (await consoleApi<AppItem[]>('apps/list', {})) ?? []
|
|
} catch {
|
|
apps.value = []
|
|
}
|
|
const data = await consoleApi<Account[]>('accounts/list', {})
|
|
rows.value = data ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取账号列表失败')
|
|
rows.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// ── 产品分组加载(按品牌商分组勾选)──
|
|
async function renderScopeOptions() {
|
|
prodLoading.value = true
|
|
productGroups.value = []
|
|
try {
|
|
const [fd, pd] = await Promise.all([
|
|
webApi<{ brands: Brand[] }>('getbrands', {}),
|
|
webApi<{ products: ProductItem[] }>('getproducts', {}),
|
|
])
|
|
const facs = fd?.brands ?? []
|
|
const list = pd?.products ?? []
|
|
const facName: Record<string, string> = {}
|
|
facs.forEach((b) => { facName[b.id] = b.brand || ('品牌商#' + b.id) })
|
|
const groups: Record<string, ProductItem[]> = {}
|
|
list.forEach((p) => {
|
|
const fid = String(p.brandid || 0)
|
|
;(groups[fid] = groups[fid] || []).push(p)
|
|
})
|
|
const fids = Object.keys(groups).sort((a, b) => (parseInt(a) || 0) - (parseInt(b) || 0))
|
|
productGroups.value = fids.map((fid) => ({
|
|
fid,
|
|
label: facName[fid] || (parseInt(fid) ? ('品牌商#' + fid) : '未分组'),
|
|
products: groups[fid] ?? [],
|
|
}))
|
|
} catch {
|
|
productGroups.value = []
|
|
} finally {
|
|
prodLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openEdit(a?: Account) {
|
|
editId.value = a ? a.id : 0
|
|
form.username = a ? (a.username || '') : ''
|
|
form.password = ''
|
|
form.identity = a ? a.identity : 2
|
|
form.enabled = (a && !a.enabled) ? '0' : '1'
|
|
form.remark = a ? (a.remark || '') : ''
|
|
// 回填勾选;管理员未显式配置过权限时,预填默认权限集(与登录侧 hasAccess 的默认回退一致)
|
|
sel.access = fromCsv(a?.access)
|
|
if (form.identity === 2 && !sel.access.length) sel.access = DEFAULT_ADMIN_ACCESS.slice()
|
|
sel.apps = fromCsv(a?.apps)
|
|
sel.regions = fromCsv(a?.regions)
|
|
sel.products = fromCsv(a?.products)
|
|
renderScopeOptions()
|
|
view.value = 'form'
|
|
}
|
|
|
|
// 切换角色时联动权限勾选:切到管理员且未勾选任何页面 → 预填默认集
|
|
watch(() => form.identity, (idt) => {
|
|
if (idt === 2 && sel.access.length === 0) sel.access = DEFAULT_ADMIN_ACCESS.slice()
|
|
})
|
|
|
|
function closeEdit() {
|
|
view.value = 'list'
|
|
loadData()
|
|
}
|
|
|
|
async function saveAccount() {
|
|
const idt = form.identity
|
|
const body: Record<string, unknown> = {
|
|
identity: idt,
|
|
enabled: form.enabled === '1',
|
|
remark: form.remark.trim(),
|
|
access: '', apps: '', regions: '', products: '',
|
|
}
|
|
if (idt !== 1) {
|
|
body.access = csv(sel.access) // 管理员/代理/运营均存显式菜单权限(超管恒全量不存)
|
|
}
|
|
if (idt === 3 || idt === 4) {
|
|
body.apps = csv(sel.apps)
|
|
body.regions = csv(sel.regions)
|
|
body.products = csv(sel.products)
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
if (editId.value) {
|
|
body.id = editId.value
|
|
await consoleApi('accounts/update', body)
|
|
} else {
|
|
body.username = form.username.trim()
|
|
body.password = form.password
|
|
if (!body.username || !body.password) { error('用户名和密码必填'); submitting.value = false; return }
|
|
await consoleApi('accounts/add', body)
|
|
}
|
|
success('已保存')
|
|
closeEdit()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function resetPwd(id: number, name: string) {
|
|
const pwd = window.prompt('为账号【' + name + '】设置新密码:')
|
|
if (!pwd) return
|
|
try {
|
|
await consoleApi('accounts/resetpwd', { id, password: pwd })
|
|
success('密码已重置')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '失败')
|
|
}
|
|
}
|
|
|
|
function delAccount(id: number, name: string) {
|
|
if (!window.confirm('确认删除账号【' + name + '】?该操作不可恢复。')) return
|
|
consoleApi('accounts/del', { id })
|
|
.then(() => { success('已删除'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
// ── 代理充值 / 调整资源点 ──
|
|
const topupModal = ref<HTMLDialogElement | null>(null)
|
|
const topupId = ref(0)
|
|
const topup = reactive({
|
|
name: '',
|
|
cur: '',
|
|
vipday: 0,
|
|
trademin: 0,
|
|
meetmin: 0,
|
|
})
|
|
|
|
function openTopup(a: Account) {
|
|
topupId.value = a.id
|
|
topup.name = a.username || ('#' + a.id)
|
|
topup.cur = 'VIP' + (a.balance_vipday || 0) + '天 · 翻译' + (a.balance_trademin || 0) + '分 · 会议' + (a.balance_meetmin || 0) + '分'
|
|
topup.vipday = 0
|
|
topup.trademin = 0
|
|
topup.meetmin = 0
|
|
topupModal.value?.showModal()
|
|
}
|
|
|
|
function closeTopup() {
|
|
topupModal.value?.close()
|
|
}
|
|
|
|
async function submitTopup() {
|
|
const vipday = Number(topup.vipday) || 0
|
|
const trademin = Number(topup.trademin) || 0
|
|
const meetmin = Number(topup.meetmin) || 0
|
|
if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
|
|
try {
|
|
await consoleApi('accounts/topup', { account_id: topupId.value, vipday, trademin, meetmin })
|
|
success('余额已更新')
|
|
closeTopup()
|
|
loadData()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '操作失败')
|
|
}
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chk {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 9px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.chk:hover {
|
|
background: #f8fafc;
|
|
}
|
|
.chk input {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
</style>
|
|
|