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.
 
 
 
 
 
 

534 lines
23 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()">&larr; 返回列表</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="5">渠道商(绑定单个渠道商,只见自己数据)</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 v-if="!roleCeiling" 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 = allowedMenuIds.slice()">全选</button>
<button type="button" class="btn btn-ghost btn-xs" @click="sel.access = []">清空</button>
</div>
</div>
<p v-if="roleCeiling" class="text-xs text-slate-400 mb-3">该角色为外部账号,可见页面固定在下列范围内(留空即全部开放这些页面)。</p>
<p v-else class="text-xs text-slate-400 mb-3">管理员默认开放除「SaaS 管理 / 系统管理」以外的全部菜单(权限留空时按默认生效);代理/运营需显式勾选。</p>
<div v-for="g in menuGroupsForRole" :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 === 5" 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="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-slate-500">所属品牌商 *</label>
<select v-model.number="form.brandid" class="select select-bordered select-sm w-full" @change="form.channelid = ''">
<option :value="0">请选择品牌商</option>
<option v-for="b in brands" :key="b.id" :value="b.id">{{ b.brand }}</option>
</select>
</div>
<div v-show="form.identity === 5">
<label class="text-xs text-slate-500">所属渠道商 *</label>
<select v-model="form.channelid" class="select select-bordered select-sm w-full" :disabled="!accountChannelOptions.length">
<option value="">{{ form.brandid ? (accountChannelOptions.length ? '请选择渠道商' : '该品牌商暂无渠道商') : '请先选择品牌商' }}</option>
<option v-for="ch in accountChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option>
</select>
</div>
</div>
<p class="text-xs text-slate-400 mt-2">
数据隔离由后端强制:品牌商账号只能看到本品牌商及其名下渠道商的数据,渠道商账号只能看到自己渠道的数据。
</p>
</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="appNames.length">
<label v-for="n in appNames" :key="n" class="chk">
<input v-model="sel.apps" type="checkbox" :value="n" />{{ n }}
</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 REGION_OPTIONS" :key="r.value" class="chk">
<input v-model="sel.regions" type="checkbox" :value="r.value" />{{ r.label }}
</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, computed, watch, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { MENU_GROUPS, ALL_MENU_IDS, DEFAULT_ADMIN_ACCESS, ROLE_ACCESS_CEILING } from '~/utils/menus'
import { REGION_OPTIONS } from '~/utils/regions'
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
brandid: number
channelid: string
[key: string]: unknown
}
interface ChannelItem { id: string; brandid: number; name: string; status: number }
interface AppItem { id: number; name: string; app_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: '运营', 5: '渠道商' }
const ROLE_CLS: Record<number, string> = { 1: 'badge-error', 2: 'badge-primary', 3: 'badge-warning', 4: 'badge-info', 5: 'badge-accent' }
// 页面访问项统一取自 utils/menus.ts 的菜单目录(MENU_GROUPS),与侧边栏严格一致。
const view = ref<'list' | 'form'>('list')
const rows = ref<Account[]>([])
// apps/list 是「部署」(同一应用国内/海外各一行);绑定按应用维度勾,故按应用名去重后展示。
const apps = ref<AppItem[]>([])
const appNames = computed(() =>
[...new Set(apps.value.map((a) => a.app_name || a.name).filter(Boolean))].sort(),
)
const loading = ref(false)
const submitting = ref(false)
const editId = ref(0)
const form = reactive({
username: '',
password: '',
identity: 2,
enabled: '1',
remark: '',
brandid: 0,
channelid: '',
})
// 角色菜单上限(品牌商/渠道商):编辑器只呈现该角色允许的页面,勾了上限外的也不会生效(见 stores/auth hasAccess)。
const roleCeiling = computed<string[] | null>(() => ROLE_ACCESS_CEILING[form.identity] ?? null)
const allowedMenuIds = computed<string[]>(() => roleCeiling.value ?? ALL_MENU_IDS)
const menuGroupsForRole = computed(() => {
const ceiling = roleCeiling.value
if (!ceiling) return MENU_GROUPS
return MENU_GROUPS
.map((g) => ({ ...g, items: g.items.filter((m) => ceiling.includes(m.id)) }))
.filter((g) => g.items.length > 0)
})
// 全量渠道商(含未启用),按所选品牌商过滤出下拉项
const channels = ref<ChannelItem[]>([])
const accountChannelOptions = computed(() =>
form.brandid ? channels.value.filter((ch) => ch.brandid === form.brandid) : [],
)
// 勾选集合(v-model 数组)
const sel = reactive({
access: [] as string[],
apps: [] as string[],
regions: [] as string[],
products: [] as string[],
})
const brands = ref<Brand[]>([])
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, cd] = await Promise.all([
webApi<{ brands: Brand[] }>('getbrands', {}),
webApi<{ products: ProductItem[] }>('getproducts', {}),
// 全量渠道商(含未启用):给渠道商账号选绑定用
webApi<{ channels: ChannelItem[] }>('getchannels', { status: -1 }),
])
const facs = fd?.brands ?? []
brands.value = facs
channels.value = cd?.channels ?? []
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 || '') : ''
form.brandid = a ? (Number(a.brandid) || 0) : 0
form.channelid = a ? (a.channelid || '') : ''
// 回填勾选;管理员未显式配置过权限时,预填默认权限集(与登录侧 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()
const ceiling = ROLE_ACCESS_CEILING[idt]
if (ceiling) sel.access = sel.access.filter((id) => ceiling.includes(id))
})
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)
}
// 归属绑定:品牌商账号只传 brandid;渠道商账号两者都传;其余角色一律留空(后端会校验自洽)。
body.brandid = 0
body.channelid = ''
if (idt === 3 || idt === 5) {
if (!form.brandid) { error('请选择所属品牌商'); return }
body.brandid = Number(form.brandid)
if (idt === 5) {
if (!form.channelid) { error('请选择所属渠道商'); return }
body.channelid = form.channelid
}
}
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>