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() {
return [
{ title: 'ID', key: 'id', width: 70 },
{ title: '用户', key: 'user_id', width: 90 },
{ title: '套餐', key: 'plan_id', width: 90 },
{ title: '用户', key: 'user_name', minWidth: 160, ellipsis: true, tooltip: true },
{ title: '套餐', key: 'plan_name', minWidth: 160, ellipsis: true, tooltip: true },
{ title: '状态', key: 'status', width: 100 },
{ title: '开始', key: 'start_time', minWidth: 150 },
{ title: '结束', key: 'end_time', minWidth: 150 },

View File

@@ -70,79 +70,36 @@
</div>
</Modal>
<Modal
v-model="create_token_modal"
title="生成 API Token"
width="560"
:loading="create_token_saving"
@on-ok="submit_create_token"
>
<Modal v-model="create_token_modal" 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">
用户{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
· ID {{ create_token_target_user.id }}
</p>
<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.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>
</Modal>
<Modal v-model="detailVisible" title="用户详情" width="920" footer-hide>
<div v-if="detail && detail.user" class="detail-user-bar mb12">
<span class="detail-user-item">ID {{ detail.user.id }}</span>
<span class="detail-user-item">{{ detail.user.name || '—' }}</span>
<span class="detail-user-item">{{ detail.user.mobile || '—' }}</span>
<Tag>{{ detail.user.status }}</Tag>
<Button
type="primary"
size="small"
class="ml12"
:disabled="detail.user.status !== 'active'"
@click="open_create_token_for_user(detail.user)"
>生成 Token</Button>
</div>
<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="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>
<p v-else class="text-muted mb0">无存储明文旧数据或已吊销可对 active 记录重新生成后查看</p>
</div>
</div>
<Modal v-model="detailVisible" title="用户详情" width="960" footer-hide>
<template v-if="detail && detail.user">
<p class="detail-one-line mb12">
<strong>{{ detail.user.name || '—' }}</strong>
<span class="text-muted"> · ID {{ detail.user.id }} · {{ detail.user.mobile || '—' }} · {{ detail.user.status
}}</span>
<Button type="primary" size="small" class="ml12" :disabled="detail.user.status !== 'active'"
@click="open_create_token_for_user(detail.user)">生成 Token</Button>
</p>
<p class="sub-title">Token{{ (detail.tokens && detail.tokens.length) || 0 }}</p>
<Table :columns="tokenCols" :data="detail.tokens || []" size="small" border
class="mb16 token-table-in-detail" />
<p class="sub-title">订阅</p>
<Table v-if="detail.subscriptions && detail.subscriptions.length" :columns="subCols"
:data="detail.subscriptions" size="small" border />
<p v-else class="text-muted">暂无订阅</p>
</template>
</Modal>
</div>
</template>
@@ -171,11 +128,6 @@ export default {
},
detailVisible: false,
detail: null,
tokenListVisible: false,
tokenListRows: [],
tokenListUserName: '',
tokenDetailVisible: false,
selectedToken: null,
token_plain_modal: false,
token_plain_title: '',
token_plain_text: '',
@@ -183,7 +135,6 @@ export default {
create_token_saving: false,
create_token_target_user: null,
create_token_form: { token_name: 'default', expire_at: '' },
tokenList_target_user: null,
subCols: [
{ title: 'ID', key: 'id', width: 80 },
{ title: '套餐ID', key: 'plan_id', width: 90 },
@@ -215,11 +166,11 @@ export default {
on: {
click: (e) => {
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() {
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 },
{ title: 'ID', key: 'id', width: 56 },
{ title: '名称', key: 'token_name', width: 88 },
{ title: '套餐', key: 'plan_id', width: 64 },
{ title: '状态', key: 'status', width: 72 },
{ title: '过期', key: 'expire_at', minWidth: 128 },
{ title: '最后使用', key: 'last_used_at', minWidth: 128 },
{
title: '明文',
key: 'plain_token',
minWidth: 200,
minWidth: 280,
render: (h, p) => {
const v = p.row.plain_token
if (!v) {
return h('span', { class: { 'text-muted': true } }, '—')
}
const snip = v.length > 28 ? `${v.slice(0, 28)}` : v
return h('div', { class: 'plain-token-row' }, [
h('span', { class: 'plain-token-snip', attrs: { title: v } }, snip),
return h('div', { class: 'plain-cell' }, [
h('Input', {
props: {
type: 'textarea',
value: v,
rows: 2,
readonly: true,
},
class: 'plain-cell-input',
}),
h(
'Button',
{
@@ -331,7 +289,7 @@ export default {
{
title: '操作',
key: 'tok_op',
width: 120,
width: 96,
render: (h, p) => {
if (p.row.status !== 'active') {
return h('span', { class: { 'text-muted': true } }, '—')
@@ -340,7 +298,12 @@ export default {
'Button',
{
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() {
this.load(1)
@@ -503,21 +462,6 @@ export default {
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) {
this.token_plain_title = title
this.token_plain_text = text || ''
@@ -527,7 +471,6 @@ export default {
const res = await userServer.detail(user_id)
if (res && res.code === 0) {
if (this.detailVisible) this.detail = res.data
if (this.tokenListVisible) this.tokenListRows = res.data.tokens || []
this.load(1)
}
},
@@ -650,59 +593,25 @@ export default {
text-decoration: underline;
}
.token-detail .label {
display: inline-block;
width: 88px;
color: #808695;
.token-table-in-detail :deep(.plain-cell) {
display: flex;
flex-direction: column;
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;
padding-top: 12px;
border-top: 1px solid #e8eaec;
}
.token-plain-block .label {
margin-bottom: 6px;
}
.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;
.detail-one-line {
line-height: 32px;
}
</style>

View File

@@ -3,6 +3,20 @@ const { build_search_where } = require("../utils/query_helpers");
const logic = require("../service/biz_subscription_logic");
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 = {
"POST /biz_subscription/page": async (ctx) => {
const body = ctx.getBody();
@@ -19,8 +33,13 @@ module.exports = {
offset,
limit: page_size,
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) => {
const q = ctx.query || {};
@@ -95,7 +114,11 @@ module.exports = {
where,
limit: 10000,
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) });
},
};