Files
wechatWeb/admin/src/views/subscription/users.vue
张成 7199c6b5cf 1
2026-04-01 10:37:51 +08:00

461 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="content-view">
<div class="table-head-tool">
<div class="table-title-row">
<Button type="primary" @click="openEdit(null)">新增</Button>
</div>
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
<FormItem label="条件">
<Select v-model="param.seachOption.key" style="width: 140px">
<Option value="mobile">手机</Option>
<Option value="company_name">公司</Option>
<Option value="status">状态</Option>
</Select>
<Input v-model="param.seachOption.value" placeholder="关键字" style="width: 220px" class="ml10" search
@on-search="load(1)" />
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
</FormItem>
</Form>
</div>
<div class="table-body">
<Table :columns="columns" :data="rows" border stripe />
<div class="table-page-bar">
<Page :total="total" :current="param.pageOption.page" :page-size="param.pageOption.pageSize" show-total
@on-change="onPage" @on-page-size-change="onSize" />
</div>
</div>
<Modal v-model="modal" :title="form.id ? '编辑用户' : '新增用户'" width="640" :loading="saving" @on-ok="save">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="名称" prop="name">
<Input v-model="form.name" />
</FormItem>
<FormItem label="手机" prop="mobile">
<Input v-model="form.mobile" />
</FormItem>
<FormItem label="邮箱">
<Input v-model="form.email" />
</FormItem>
<FormItem label="公司">
<Input v-model="form.company_name" />
</FormItem>
<FormItem label="状态" prop="status">
<Select v-model="form.status" style="width: 100%">
<Option value="active">正常</Option>
<Option value="disabled">禁用</Option>
</Select>
</FormItem>
</Form>
</Modal>
<Modal v-model="detailVisible" title="用户详情" width="800" footer-hide>
<p v-if="detail" class="mb8">Token 数量{{ detail.tokenCount }}</p>
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p>
<Table
v-if="detail && detail.tokens && detail.tokens.length"
:columns="tokenCols"
:data="detail.tokens"
size="small"
border
highlight-row
class="mb16"
@on-row-click="onTokenRowClick"
/>
<p v-if="detail && detail.subscriptions && detail.subscriptions.length" class="sub-title">订阅记录</p>
<Table v-if="detail && detail.subscriptions" :columns="subCols" :data="detail.subscriptions" size="small"
border />
</Modal>
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="820" footer-hide>
<p v-if="tokenListRows.length" class="text-muted mb8">点击表格某一行查看该 Token 详情明文不可查需重新创建</p>
<Table
v-if="tokenListRows.length"
:columns="tokenCols"
:data="tokenListRows"
size="small"
border
highlight-row
@on-row-click="onTokenRowClick"
/>
<p v-else class="text-muted">暂无 Token</p>
</Modal>
<Modal v-model="tokenDetailVisible" title="Token 详情" width="520" footer-hide>
<div v-if="selectedToken" class="token-detail">
<p><span class="label">ID</span>{{ selectedToken.id }}</p>
<p><span class="label">名称</span>{{ selectedToken.token_name }}</p>
<p><span class="label">用户</span>{{ selectedToken.user_id }}</p>
<p><span class="label">套餐</span>{{ selectedToken.plan_id != null ? selectedToken.plan_id : '—' }}</p>
<p><span class="label">状态</span>{{ selectedToken.status }}</p>
<p><span class="label">过期时间</span>{{ selectedToken.expire_at || '—' }}</p>
<p><span class="label">最后使用</span>{{ selectedToken.last_used_at || '—' }}</p>
</div>
</Modal>
</div>
</template>
<script>
import userServer from '@/api/subscription/user_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default {
name: 'SubscriptionUsers',
data() {
return {
rows: [],
total: 0,
param: {
seachOption: { key: 'mobile', value: '' },
pageOption: { page: 1, pageSize: 20, total: 0 },
},
modal: false,
saving: false,
form: {},
rules: {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
},
detailVisible: false,
detail: null,
tokenListVisible: false,
tokenListRows: [],
tokenListUserName: '',
tokenDetailVisible: false,
selectedToken: null,
subCols: [
{ title: 'ID', key: 'id', width: 80 },
{ title: '套餐ID', key: 'plan_id', width: 90 },
{ title: '状态', key: 'status', width: 100 },
{ title: '开始', key: 'start_time', minWidth: 160 },
{ title: '结束', key: 'end_time', minWidth: 160 },
],
}
},
computed: {
columns() {
return [
{ title: 'ID', key: 'id', width: 80 },
{ title: '名称', key: 'name', minWidth: 120 },
{ title: '手机', key: 'mobile', width: 130 },
{ title: '公司', key: 'company_name', minWidth: 140 },
{ title: '状态', key: 'status', width: 90 },
{
title: 'API Token',
key: 'token_count',
width: 130,
render: (h, p) => {
const n = p.row.token_count != null ? Number(p.row.token_count) : 0
return h('div', [
h('span', { class: { mr8: true } }, String(n)),
h(
'Button',
{
props: { type: 'primary', size: 'small', ghost: true },
on: { click: () => this.openTokenList(p.row) },
},
'查看'
),
])
},
},
{
title: '操作',
key: 'a',
width: 380,
render: (h, p) => {
return h('div', [
h(
'Button',
{
props: { type: 'info', size: 'small' },
on: { click: () => this.openEdit(p.row) },
},
'编辑'
),
h(
'Button',
{
props: { type: 'default', size: 'small' },
class: { ml8: true },
on: { click: () => this.showDetail(p.row) },
},
'详情'
),
p.row.status === 'disabled'
? h(
'Button',
{
props: { type: 'success', size: 'small' },
class: { ml8: true },
on: { click: () => this.doEnable(p.row) },
},
'启用'
)
: h(
'Button',
{
props: { type: 'warning', size: 'small' },
class: { ml8: true },
on: { click: () => this.doDisable(p.row) },
},
'禁用'
),
h(
'Button',
{
props: { type: 'default', size: 'small' },
class: { ml8: true },
on: { click: () => this.revokeAllTokens(p.row) },
},
'吊销全部Token'
),
h(
'Button',
{
props: { type: 'error', size: 'small' },
class: { ml8: true },
on: { click: () => this.doDel(p.row) },
},
'删除'
),
])
},
},
]
},
tokenCols() {
return [
{ title: 'ID', key: 'id', width: 72 },
{ title: '名称', key: 'token_name', minWidth: 100 },
{ title: '套餐', key: 'plan_id', width: 80 },
{ title: '状态', key: 'status', width: 90 },
{ title: '过期', key: 'expire_at', minWidth: 150 },
{ title: '最后使用', key: 'last_used_at', minWidth: 150 },
]
},
tokenListTitle() {
const name = this.tokenListUserName || ''
return name ? `Token 列表 — ${name}` : 'Token 列表'
},
},
mounted() {
this.load(1)
},
methods: {
async load(page) {
if (page) this.param.pageOption.page = page
const res = await userServer.page({ param: this.param })
if (res && res.code === 0) {
this.rows = res.data.rows || []
this.total = res.data.count || 0
} else {
this.$Message.error((res && res.message) || '加载失败')
}
},
onPage(p) {
this.param.pageOption.page = p
this.load()
},
onSize(s) {
this.param.pageOption.pageSize = s
this.load(1)
},
openEdit(row) {
if (row) {
this.form = { ...row }
} else {
this.form = { name: '', mobile: '', email: '', company_name: '', status: 'active' }
}
this.modal = true
},
save() {
this.saving = true
this.$refs.formRef.validate(async (ok) => {
if (!ok) {
this.saving = false
return
}
try {
const res = this.form.id
? await userServer.edit(this.form)
: await userServer.add(this.form)
if (res && res.code === 0) {
this.$Message.success('保存成功')
this.modal = false
this.load(1)
} else {
this.$Message.error((res && res.message) || '保存失败')
}
} finally {
this.saving = false
}
})
},
async showDetail(row) {
const res = await userServer.detail(row.id)
if (res && res.code === 0) {
this.detail = res.data
this.detailVisible = true
} else {
this.$Message.error((res && res.message) || '加载详情失败')
}
},
async openTokenList(row) {
const res = await userServer.detail(row.id)
if (res && res.code === 0) {
this.tokenListRows = res.data.tokens || []
this.tokenListUserName = row.name || String(row.id)
this.tokenListVisible = true
} else {
this.$Message.error((res && res.message) || '加载 Token 失败')
}
},
onTokenRowClick(row) {
this.selectedToken = row
this.tokenDetailVisible = true
},
doDisable(row) {
this.$Modal.confirm({
title: '禁用用户',
content: '确认禁用该用户?',
onOk: async () => {
const res = await userServer.disable({ id: row.id })
if (res && res.code === 0) {
this.$Message.success('已禁用')
this.load(1)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
},
})
},
doEnable(row) {
this.$Modal.confirm({
title: '启用用户',
content: '确认重新启用该用户?',
onOk: async () => {
const res = await userServer.enable({ id: row.id })
if (res && res.code === 0) {
this.$Message.success('已启用')
this.load(1)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
},
})
},
doDel(row) {
this.$Modal.confirm({
title: '删除用户',
content: '确认删除?若存在订阅/Token 可能受外键限制。',
onOk: async () => {
const res = await userServer.del({ id: row.id })
if (res && res.code === 0) {
this.$Message.success('已删除')
this.load(1)
} else {
this.$Message.error((res && res.message) || '删除失败')
}
},
})
},
async doExport() {
const res = await userServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'users.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
revokeAllTokens(row) {
this.$Modal.confirm({
title: '吊销全部 Token',
content: '将吊销该用户下所有有效 Token是否继续',
onOk: async () => {
const res = await userServer.revokeAllTokens({ user_id: row.id })
if (res && res.code === 0) {
const n = (res.data && res.data.revoked) != null ? res.data.revoked : 0
this.$Message.success('已吊销 ' + n + ' 条')
this.load(1)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
},
})
},
resetQuery() {
this.param.seachOption = { key: 'mobile', value: '' }
this.load(1)
},
},
}
</script>
<style lang="less" scoped>
.content-view {
width: 100%;
max-width: 1720px;
margin: 0 auto;
padding: 26px 36px 36px;
box-sizing: border-box;
}
.table-head-tool {
margin-bottom: 16px;
}
.table-title-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.ml10 {
margin-left: 10px;
}
.ml8 {
margin-left: 8px;
}
.table-page-bar {
margin-top: 12px;
text-align: right;
}
.mb8 {
margin-bottom: 8px;
}
.mb16 {
margin-bottom: 16px;
}
.sub-title {
font-weight: 600;
margin: 10px 0 8px;
}
.text-muted {
color: #808695;
}
.mr8 {
margin-right: 8px;
}
.token-detail .label {
display: inline-block;
width: 88px;
color: #808695;
}
</style>