This commit is contained in:
张成
2026-04-01 13:54:13 +08:00
parent 2d900ef2ac
commit a934d5b239
3 changed files with 89 additions and 157 deletions

View File

@@ -132,8 +132,8 @@ export default {
columns() { columns() {
return [ return [
{ title: 'ID', key: 'id', width: 70 }, { title: 'ID', key: 'id', width: 70 },
{ title: '用户', key: 'user_id', width: 90 }, { title: '用户', key: 'user_name', minWidth: 160, ellipsis: true, tooltip: true },
{ title: '套餐', key: 'plan_id', width: 90 }, { title: '套餐', key: 'plan_name', minWidth: 160, ellipsis: true, tooltip: true },
{ title: '状态', key: 'status', width: 100 }, { title: '状态', key: 'status', width: 100 },
{ title: '开始', key: 'start_time', minWidth: 150 }, { title: '开始', key: 'start_time', minWidth: 150 },
{ title: '结束', key: 'end_time', minWidth: 150 }, { title: '结束', key: 'end_time', minWidth: 150 },

View File

@@ -70,79 +70,36 @@
</div> </div>
</Modal> </Modal>
<Modal <Modal v-model="create_token_modal" title="生成 API Token" width="560" :loading="create_token_saving"
v-model="create_token_modal" @on-ok="submit_create_token">
title="生成 API Token"
width="560"
:loading="create_token_saving"
@on-ok="submit_create_token"
>
<p v-if="create_token_target_user" class="text-muted mb12"> <p v-if="create_token_target_user" class="text-muted mb12">
用户{{ create_token_target_user.name || ('#' + create_token_target_user.id) }} 用户{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
· ID {{ create_token_target_user.id }} · ID {{ create_token_target_user.id }}
</p> </p>
<Form :label-width="100"> <Form :label-width="100">
<FormItem label="名称"><Input v-model="create_token_form.token_name" placeholder="default" /></FormItem> <FormItem label="名称"><Input v-model="create_token_form.token_name" placeholder="default" /></FormItem>
<FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" /></FormItem> <FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" />
</FormItem>
</Form> </Form>
</Modal> </Modal>
<Modal v-model="detailVisible" title="用户详情" width="920" footer-hide> <Modal v-model="detailVisible" title="用户详情" width="960" footer-hide>
<div v-if="detail && detail.user" class="detail-user-bar mb12"> <template v-if="detail && detail.user">
<span class="detail-user-item">ID {{ detail.user.id }}</span> <p class="detail-one-line mb12">
<span class="detail-user-item">{{ detail.user.name || '—' }}</span> <strong>{{ detail.user.name || '—' }}</strong>
<span class="detail-user-item">{{ detail.user.mobile || '—' }}</span> <span class="text-muted"> · ID {{ detail.user.id }} · {{ detail.user.mobile || '—' }} · {{ detail.user.status
<Tag>{{ detail.user.status }}</Tag> }}</span>
<Button <Button type="primary" size="small" class="ml12" :disabled="detail.user.status !== 'active'"
type="primary" @click="open_create_token_for_user(detail.user)">生成 Token</Button>
size="small" </p>
class="ml12" <p class="sub-title">Token{{ (detail.tokens && detail.tokens.length) || 0 }}</p>
:disabled="detail.user.status !== 'active'" <Table :columns="tokenCols" :data="detail.tokens || []" size="small" border
@click="open_create_token_for_user(detail.user)" class="mb16 token-table-in-detail" />
>生成 Token</Button> <p class="sub-title">订阅</p>
</div> <Table v-if="detail.subscriptions && detail.subscriptions.length" :columns="subCols"
<p v-if="detail" class="mb8">Token 数量{{ detail.tokenCount }}</p> :data="detail.subscriptions" size="small" border />
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p> <p v-else class="text-muted">暂无订阅</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="980" footer-hide>
<div v-if="tokenList_target_user" class="mb12">
<Button
type="primary"
size="small"
:disabled="tokenList_target_user.status !== 'active'"
@click="open_create_token_for_user(tokenList_target_user)"
>生成 Token</Button>
<span class="text-muted ml10">点击行查看详情列表明文来自服务端加密存储吊销后不可查看</span>
</div>
<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="600" 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 class="token-plain-block">
<p><span class="label">明文</span></p>
<template v-if="selectedToken.plain_token">
<Input type="textarea" :rows="3" :value="selectedToken.plain_token" readonly class="token-plain-ta" />
<Button type="primary" size="small" class="mt8" @click="copy_text(selectedToken.plain_token)">复制全文</Button>
</template> </template>
<p v-else class="text-muted mb0">无存储明文旧数据或已吊销可对 active 记录重新生成后查看</p>
</div>
</div>
</Modal> </Modal>
</div> </div>
</template> </template>
@@ -171,11 +128,6 @@ export default {
}, },
detailVisible: false, detailVisible: false,
detail: null, detail: null,
tokenListVisible: false,
tokenListRows: [],
tokenListUserName: '',
tokenDetailVisible: false,
selectedToken: null,
token_plain_modal: false, token_plain_modal: false,
token_plain_title: '', token_plain_title: '',
token_plain_text: '', token_plain_text: '',
@@ -183,7 +135,6 @@ export default {
create_token_saving: false, create_token_saving: false,
create_token_target_user: null, create_token_target_user: null,
create_token_form: { token_name: 'default', expire_at: '' }, create_token_form: { token_name: 'default', expire_at: '' },
tokenList_target_user: null,
subCols: [ subCols: [
{ title: 'ID', key: 'id', width: 80 }, { title: 'ID', key: 'id', width: 80 },
{ title: '套餐ID', key: 'plan_id', width: 90 }, { title: '套餐ID', key: 'plan_id', width: 90 },
@@ -215,11 +166,11 @@ export default {
on: { on: {
click: (e) => { click: (e) => {
e.preventDefault() e.preventDefault()
this.openTokenList(p.row) this.showDetail(p.row)
}, },
}, },
}, },
n > 0 ? `${n} · 列表` : String(n) n > 0 ? `${n} ` : '0'
) )
}, },
}, },
@@ -294,24 +245,31 @@ export default {
}, },
tokenCols() { tokenCols() {
return [ return [
{ title: 'ID', key: 'id', width: 72 }, { title: 'ID', key: 'id', width: 56 },
{ title: '名称', key: 'token_name', minWidth: 100 }, { title: '名称', key: 'token_name', width: 88 },
{ title: '套餐', key: 'plan_id', width: 80 }, { title: '套餐', key: 'plan_id', width: 64 },
{ title: '状态', key: 'status', width: 90 }, { title: '状态', key: 'status', width: 72 },
{ title: '过期', key: 'expire_at', minWidth: 150 }, { title: '过期', key: 'expire_at', minWidth: 128 },
{ title: '最后使用', key: 'last_used_at', minWidth: 150 }, { title: '最后使用', key: 'last_used_at', minWidth: 128 },
{ {
title: '明文', title: '明文',
key: 'plain_token', key: 'plain_token',
minWidth: 200, minWidth: 280,
render: (h, p) => { render: (h, p) => {
const v = p.row.plain_token const v = p.row.plain_token
if (!v) { if (!v) {
return h('span', { class: { 'text-muted': true } }, '—') return h('span', { class: { 'text-muted': true } }, '—')
} }
const snip = v.length > 28 ? `${v.slice(0, 28)}` : v return h('div', { class: 'plain-cell' }, [
return h('div', { class: 'plain-token-row' }, [ h('Input', {
h('span', { class: 'plain-token-snip', attrs: { title: v } }, snip), props: {
type: 'textarea',
value: v,
rows: 2,
readonly: true,
},
class: 'plain-cell-input',
}),
h( h(
'Button', 'Button',
{ {
@@ -331,7 +289,7 @@ export default {
{ {
title: '操作', title: '操作',
key: 'tok_op', key: 'tok_op',
width: 120, width: 96,
render: (h, p) => { render: (h, p) => {
if (p.row.status !== 'active') { if (p.row.status !== 'active') {
return h('span', { class: { 'text-muted': true } }, '—') return h('span', { class: { 'text-muted': true } }, '—')
@@ -340,7 +298,12 @@ export default {
'Button', 'Button',
{ {
props: { type: 'warning', size: 'small' }, props: { type: 'warning', size: 'small' },
on: { click: () => this.do_regenerate_token(p.row) }, on: {
click: (e) => {
e.stopPropagation()
this.do_regenerate_token(p.row)
},
},
}, },
'重新生成' '重新生成'
) )
@@ -348,10 +311,6 @@ export default {
}, },
] ]
}, },
tokenListTitle() {
const name = this.tokenListUserName || ''
return name ? `Token 列表 — ${name}` : 'Token 列表'
},
}, },
mounted() { mounted() {
this.load(1) this.load(1)
@@ -503,21 +462,6 @@ export default {
this.create_token_saving = false this.create_token_saving = false
} }
}, },
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.tokenList_target_user = res.data.user || row
this.tokenListVisible = true
} else {
this.$Message.error((res && res.message) || '加载 Token 失败')
}
},
onTokenRowClick(row) {
this.selectedToken = row
this.tokenDetailVisible = true
},
show_token_plain(title, text) { show_token_plain(title, text) {
this.token_plain_title = title this.token_plain_title = title
this.token_plain_text = text || '' this.token_plain_text = text || ''
@@ -527,7 +471,6 @@ export default {
const res = await userServer.detail(user_id) const res = await userServer.detail(user_id)
if (res && res.code === 0) { if (res && res.code === 0) {
if (this.detailVisible) this.detail = res.data if (this.detailVisible) this.detail = res.data
if (this.tokenListVisible) this.tokenListRows = res.data.tokens || []
this.load(1) this.load(1)
} }
}, },
@@ -650,59 +593,25 @@ export default {
text-decoration: underline; text-decoration: underline;
} }
.token-detail .label { .token-table-in-detail :deep(.plain-cell) {
display: inline-block; display: flex;
width: 88px; flex-direction: column;
color: #808695; gap: 4px;
align-items: flex-start;
} }
.token-plain-block { .token-table-in-detail :deep(.plain-cell-input textarea) {
font-family: monospace;
font-size: 11px;
line-height: 1.4;
min-width: 260px;
}
.mt12 {
margin-top: 12px; margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e8eaec;
} }
.token-plain-block .label { .detail-one-line {
margin-bottom: 6px; line-height: 32px;
}
.token-plain-ta :deep(textarea) {
font-family: monospace;
font-size: 12px;
}
.mb0 {
margin-bottom: 0;
}
.mt8 {
margin-top: 8px;
}
.plain-token-row {
display: flex;
align-items: center;
gap: 6px;
}
.plain-token-snip {
flex: 1;
min-width: 0;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.detail-user-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e8eaec;
}
.detail-user-item {
color: #515a6e;
} }
</style> </style>

View File

@@ -3,6 +3,20 @@ const { build_search_where } = require("../utils/query_helpers");
const logic = require("../service/biz_subscription_logic"); const logic = require("../service/biz_subscription_logic");
const audit = require("../utils/biz_audit"); const audit = require("../utils/biz_audit");
function subscription_rows_with_names(instances) {
return instances.map((r) => {
const j = r.toJSON();
const u = j.biz_user;
const p = j.biz_plan;
const { biz_user, biz_plan, ...rest } = j;
return {
...rest,
user_name: u ? [u.name, u.mobile].filter(Boolean).join(" ").trim() : "",
plan_name: p ? String(p.plan_name || p.plan_code || "").trim() : "",
};
});
}
module.exports = { module.exports = {
"POST /biz_subscription/page": async (ctx) => { "POST /biz_subscription/page": async (ctx) => {
const body = ctx.getBody(); const body = ctx.getBody();
@@ -19,8 +33,13 @@ module.exports = {
offset, offset,
limit: page_size, limit: page_size,
order: [["id", "DESC"]], order: [["id", "DESC"]],
include: [
{ model: baseModel.biz_user, as: "biz_user", attributes: ["name", "mobile"] },
{ model: baseModel.biz_plan, as: "biz_plan", attributes: ["plan_name", "plan_code"] },
],
distinct: true,
}); });
ctx.success({ rows, count }); ctx.success({ rows: subscription_rows_with_names(rows), count });
}, },
"GET /biz_subscription/detail": async (ctx) => { "GET /biz_subscription/detail": async (ctx) => {
const q = ctx.query || {}; const q = ctx.query || {};
@@ -95,7 +114,11 @@ module.exports = {
where, where,
limit: 10000, limit: 10000,
order: [["id", "DESC"]], order: [["id", "DESC"]],
include: [
{ model: baseModel.biz_user, as: "biz_user", attributes: ["name", "mobile"] },
{ model: baseModel.biz_plan, as: "biz_plan", attributes: ["plan_name", "plan_code"] },
],
}); });
ctx.success({ rows }); ctx.success({ rows: subscription_rows_with_names(rows) });
}, },
}; };