461 lines
14 KiB
Vue
461 lines
14 KiB
Vue
<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>
|