This commit is contained in:
张成
2026-04-01 10:37:51 +08:00
parent 494555a6e1
commit 7199c6b5cf
10 changed files with 187 additions and 17 deletions

View File

@@ -85,9 +85,9 @@
</Form>
</Modal>
<Modal v-model="upgradeModal" title="升级套餐" :loading="saving" @on-ok="submitUpgrade">
<Modal v-model="upgradeModal" title="编辑订阅" :loading="saving" @on-ok="submitUpgrade">
<Form :label-width="100">
<FormItem label="套餐">
<FormItem label="套餐">
<Select v-model="upgradeForm.new_plan_id" filterable clearable placeholder="请选择" style="width: 100%">
<Option v-for="p in bizPlanOptions" :key="p.id" :value="p.id">{{ bizPlanLabel(p) }}</Option>
</Select>
@@ -141,7 +141,7 @@ export default {
render: (h, p) =>
h('div', [
h('Button', { props: { size: 'small' }, on: { click: () => this.openRenew(p.row) } }, '续费'),
h('Button', { props: { size: 'small' }, class: { ml8: true }, on: { click: () => this.openUpgrade(p.row) } }, '升级'),
h('Button', { props: { size: 'small' }, class: { ml8: true }, on: { click: () => this.openUpgrade(p.row) } }, '编辑'),
h('Button', { props: { type: 'error', size: 'small' }, class: { ml8: true }, on: { click: () => this.doCancel(p.row) } }, '取消'),
]),
},
@@ -268,7 +268,7 @@ export default {
if (!this.currentRow) return false
const np = this.upgradeForm.new_plan_id
if (np === undefined || np === null || np === '') {
this.$Message.warning('请选择套餐')
this.$Message.warning('请选择套餐')
return false
}
this.saving = true
@@ -289,7 +289,7 @@ export default {
end_time: this.upgradeForm.end_time || undefined,
})
if (res && res.code === 0) {
this.$Message.success('已升级')
this.$Message.success('已保存')
this.upgradeModal = false
this.load(1)
} else {

View File

@@ -54,11 +54,49 @@
</Form>
</Modal>
<Modal v-model="detailVisible" title="用户详情" width="720" footer-hide>
<p v-if="detail">Token 数量{{ detail.tokenCount }}</p>
<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>
@@ -85,6 +123,11 @@ export default {
},
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 },
@@ -102,6 +145,25 @@ export default {
{ 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',
@@ -167,6 +229,20 @@ 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 },
]
},
tokenListTitle() {
const name = this.tokenListUserName || ''
return name ? `Token 列表 — ${name}` : 'Token 列表'
},
},
mounted() {
this.load(1)
@@ -230,6 +306,20 @@ export default {
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: '禁用用户',
@@ -340,4 +430,31 @@ export default {
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>

View File

@@ -1,5 +1,4 @@
const crud = require("../service/biz_admin_crud");
const baseModel = require("../../middleware/baseModel");
const audit = require("../service/biz_audit_service");

View File

@@ -1,3 +1,4 @@
const Sequelize = require("sequelize");
const crud = require("../service/biz_admin_crud");
const baseModel = require("../../middleware/baseModel");
@@ -7,8 +8,31 @@ const audit = require("../service/biz_audit_service");
module.exports = {
"POST /biz_user/page": async (ctx) => {
const body = ctx.getBody();
const data = await crud.page("biz_user", body);
ctx.success({ rows: data.rows, count: data.count });
const param = body.param || body;
const pageOption = param.pageOption || {};
const seachOption = param.seachOption || {};
const pageNum = parseInt(pageOption.page, 10) || 1;
const pageSize = parseInt(pageOption.pageSize, 10) || 20;
const offset = (pageNum - 1) * pageSize;
const model = baseModel.biz_user;
const where = crud.buildSearchWhere(model, seachOption);
const { count, rows } = await model.findAndCountAll({
where,
offset,
limit: pageSize,
order: [["id", "DESC"]],
attributes: {
include: [
[
Sequelize.literal(
`(SELECT COUNT(*) FROM biz_api_tokens WHERE biz_api_tokens.user_id = biz_user.id)`
),
"token_count",
],
],
},
});
ctx.success({ rows, count });
},
"POST /biz_user/add": async (ctx) => {
const body = ctx.getBody();
@@ -62,10 +86,17 @@ module.exports = {
const tokenCount = await baseModel.biz_api_token.count({
where: { user_id: id },
});
const tokens = await baseModel.biz_api_token.findAll({
where: { user_id: id },
order: [["id", "DESC"]],
limit: 200,
attributes: ["id", "user_id", "plan_id", "token_name", "status", "expire_at", "last_used_at"],
});
ctx.success({
user,
subscriptions,
tokenCount,
tokens,
});
},
"GET /biz_user/all": async (ctx) => {

View File

@@ -4,7 +4,7 @@ module.exports = (db) => {
const biz_api_token = db.define(
"biz_api_token",
{
user_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: false,
@@ -39,6 +39,6 @@ module.exports = (db) => {
comment: "API Token",
}
);
// biz_api_token.sync({ alter: true });
// biz_api_token.sync({ force: true });
return biz_api_token;
};

View File

@@ -192,6 +192,5 @@ module.exports = {
detail,
all,
exportCsv,
getRequestBody,
buildSearchWhere,
};

View File

@@ -1,6 +1,7 @@
const crypto = require("crypto");
const Sequelize = require("sequelize");
const op = Sequelize.Op;
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
const MAX_TOKENS_PER_USER = 5;

View File

@@ -1,5 +1,6 @@
const Sequelize = require("sequelize");
const op = Sequelize.Op;
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
function currentStatMonth(d = new Date()) {
const y = d.getFullYear();

8
app.js
View File

@@ -27,8 +27,12 @@ async function start() {
const { buildProxyRoutes } = require('./api/controller_custom/proxy_api');
// 从 swagger.json 动态注册 193 个转发路由到 /api 前缀
const proxyRoutes = buildProxyRoutes();
framework.addRoutes('/api', proxyRoutes);
console.log(`📡 已注册 ${Object.keys(proxyRoutes).length} 个 API 转发路由`);
const n = Object.keys(proxyRoutes).length;
// 与 swagger 文档一致:/admin/...、/login/...
framework.addRoutes("", proxyRoutes);
// 兼容历史调用:/api/admin/...
framework.addRoutes("/api", proxyRoutes);
console.log(`📡 已注册 ${n} 个转发接口(文档路径 + /api 前缀各一套)`);
}
});

View File

@@ -30,6 +30,24 @@ const baseConfig = {
"api/swagger.json",
"/api/auth/verify",
// 转发层路由白名单(框架不鉴权,由控制器内部做 Token 鉴权)
// 与 swagger 一致的无 /api 前缀路径
"/admin/",
"/applet/",
"/equipment/",
"/favor/",
"/finder/",
"/friend/",
"/group/",
"/label/",
"/login/",
"/message/",
"/other/",
"/pay/",
"/qy/",
"/shop/",
"/sns/",
"/user/",
"/ws/",
"/api/admin/",
"/api/applet/",
"/api/equipment/",