用户封禁、后台筛选排序
This commit is contained in:
1 parent
6b263af74f
commit
c45025fc86
14 files changed
+293
-30
No files matched your search
@@ -11,6 +11,10 @@ Page({
|
||||
hasCourse: false,
|
||||
ownedCourses: [],
|
||||
tipsHtml: '',
|
||||
/** 账号被封禁:不显示已购权益,改为显示提示。文字全部来自后台配置 */
|
||||
blocked: false,
|
||||
blockedTitle: '',
|
||||
blockedDesc: '',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -31,9 +35,23 @@ Page({
|
||||
.getEntitlements()
|
||||
.then((list) => {
|
||||
store.setEntitlements(list);
|
||||
this.setData({ ownedCourses: list || [], hasCourse: (list || []).length > 0 });
|
||||
this.setData({ ownedCourses: list || [], hasCourse: (list || []).length > 0, blocked: false });
|
||||
})
|
||||
.catch((err) => console.warn('[kecheng] 已购课程加载失败', err));
|
||||
.catch((err) => {
|
||||
if (err && err.code === api.ACCOUNT_DISABLED) {
|
||||
// 服务端不返回权益了,本地缓存也一并清掉,免得露出上一次的课程
|
||||
store.setEntitlements([]);
|
||||
this.setData({
|
||||
ownedCourses: [],
|
||||
hasCourse: false,
|
||||
blocked: true,
|
||||
blockedTitle: store.cfg('account.blocked_title'),
|
||||
blockedDesc: store.cfg('account.blocked_desc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.warn('[kecheng] 已购课程加载失败', err);
|
||||
});
|
||||
},
|
||||
|
||||
loadTips() {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<scroll-view class="container" scroll-y enable-flex type="custom">
|
||||
<block wx:if="{{!hasCourse}}">
|
||||
<block wx:if="{{blocked}}">
|
||||
<view class="blocked-tip">
|
||||
<text wx:if="{{blockedTitle}}" class="blocked-title">{{blockedTitle}}</text>
|
||||
<text wx:if="{{blockedDesc}}" class="blocked-desc">{{blockedDesc}}</text>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:elif="{{!hasCourse}}">
|
||||
<view style="width: 100%;height: 400rpx;">
|
||||
<image style="margin-top: 30rpx; margin-left: 20rpx; height: 350rpx; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); width: 710rpx; border-radius: 20rpx; background-color: rgb(0, 255, 255);"
|
||||
src="{{staticBase}}/Images/Banner/BGbanner.png" mode="aspectFill" />
|
||||
@@ -7,7 +14,7 @@
|
||||
<Parser html="{{tipsHtml}}" />
|
||||
</block>
|
||||
|
||||
<block wx:if="{{hasCourse}}">
|
||||
<block wx:elif="{{hasCourse}}">
|
||||
<view wx:for="{{ownedCourses}}" wx:for-item="item" wx:for-index="index" wx:key="courseId"
|
||||
class="kecheng" bind:tap="toMyCourse" data-code="{{item.code}}">
|
||||
<view class="kecheng_video_up">
|
||||
|
||||
@@ -46,4 +46,29 @@
|
||||
font-size: 25rpx;
|
||||
margin-top: 15rpx;
|
||||
margin-left: 15rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 账号异常提示的容器:整页替换成这一块,文字由后台配置下发 */
|
||||
.blocked-tip {
|
||||
box-sizing: border-box;
|
||||
width: 710rpx;
|
||||
margin-top: 120rpx;
|
||||
padding: 60rpx 40rpx;
|
||||
border: 1rpx solid #ffd9d9;
|
||||
border-radius: 20rpx;
|
||||
background-color: #fff7f7;
|
||||
}
|
||||
.blocked-title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #e60012;
|
||||
}
|
||||
.blocked-desc {
|
||||
display: block;
|
||||
margin-top: 20rpx;
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
+12
-2
@@ -14,6 +14,13 @@ const store = require('./store');
|
||||
/** 进行中的会话恢复,避免并发 401 触发多次恢复流程 */
|
||||
let recovering = null;
|
||||
|
||||
/**
|
||||
* 服务端错误码:账号被封禁。
|
||||
* 要和「登录过期」分开:页面据此走自己的提示分支,而不是把人赶去登录。
|
||||
* 具体提示什么文字由后台配置决定,这里只管把错误码原样带出去。
|
||||
*/
|
||||
const ACCOUNT_DISABLED = 2003;
|
||||
|
||||
function staticUrl(path) {
|
||||
if (!path) return '';
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
@@ -74,7 +81,10 @@ function request(options) {
|
||||
if (needLogin && options.auth !== false && options.retried !== true) {
|
||||
return recoverSession()
|
||||
.then(() => request(Object.assign({}, options, { retried: true })))
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
// 账号异常:把原因原样抛给页面,并且不清本地用户——
|
||||
// 清掉的话知识页会把它当成未登录,只剩一片空白,看不出发生了什么
|
||||
if (err && err.code === ACCOUNT_DISABLED) throw err;
|
||||
store.reset();
|
||||
throw { code: 2000, message: '登录已过期,请重新进入小程序' };
|
||||
});
|
||||
@@ -236,4 +246,4 @@ function requestVirtualPayment(payData) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform });
|
||||
module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform, ACCOUNT_DISABLED });
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button, Card, Empty, Input, Modal, Notice, Select } from '@/components/ui';
|
||||
import { formatDate } from '@/lib/datetime';
|
||||
import { formatDateTime } from '@/lib/datetime';
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
@@ -44,6 +44,11 @@ export default function UsersPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
/** 筛选与排序:'-1' / '' 表示全部 */
|
||||
const [statusFilter, setStatusFilter] = useState('-1');
|
||||
const [entitlementFilter, setEntitlementFilter] = useState('');
|
||||
const [sortKey, setSortKey] = useState('id');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [error, setError] = useState('');
|
||||
const [editing, setEditing] = useState<UserRow | null>(null);
|
||||
const [form, setForm] = useState({ nickname: '', headicon_id: '0', status: '1' });
|
||||
@@ -54,22 +59,36 @@ export default function UsersPage() {
|
||||
const load = useCallback((nextPage: number, nextKeyword: string) => {
|
||||
setError('');
|
||||
api
|
||||
.listUsers(nextPage, PAGE_SIZE, nextKeyword)
|
||||
.listUsers({
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: nextKeyword,
|
||||
status: statusFilter === '-1' ? undefined : Number(statusFilter),
|
||||
entitlement: entitlementFilter === 'has' || entitlementFilter === 'none' ? entitlementFilter : undefined,
|
||||
sort: sortKey,
|
||||
order: sortOrder,
|
||||
})
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as UserRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
}, [statusFilter, entitlementFilter, sortKey, sortOrder]);
|
||||
|
||||
// 筛选 / 排序一变,load 的引用就变,这里会带着当前关键词回到第 1 页重拉
|
||||
useEffect(() => {
|
||||
load(1, keyword);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
// 发放权益时用的课程下拉,只在首次进页面拉一次
|
||||
useEffect(() => {
|
||||
load(1, '');
|
||||
api
|
||||
.listResource('courses', { page: 1, pageSize: 100 })
|
||||
.then((res) => setCourses(res.items as unknown as CourseRow[]))
|
||||
.catch(() => setCourses([]));
|
||||
}, [load]);
|
||||
}, []);
|
||||
|
||||
const loadEntitlements = (userId: number) => {
|
||||
api
|
||||
@@ -141,8 +160,48 @@ export default function UsersPage() {
|
||||
<Card
|
||||
title={`共 ${total} 位用户`}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Input value={keyword} onChange={setKeyword} placeholder="昵称或 openid…" />
|
||||
/* 筛选、排序、搜索排在一行;改任意一项都会从第 1 页重新拉 */
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: -1, label: '全部状态' },
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '封禁' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<Select
|
||||
value={entitlementFilter}
|
||||
onChange={setEntitlementFilter}
|
||||
options={[
|
||||
{ value: '', label: '全部用户' },
|
||||
{ value: 'has', label: '有已购课程' },
|
||||
{ value: 'none', label: '无已购课程' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={sortKey}
|
||||
onChange={setSortKey}
|
||||
options={[
|
||||
{ value: 'id', label: '按 ID' },
|
||||
{ value: 'lastLogin', label: '按最后登录时间' },
|
||||
{ value: 'registered', label: '按注册时间' },
|
||||
{ value: 'entitlements', label: '按已购课程数' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}>
|
||||
{sortOrder === 'desc' ? '降序 ↓' : '升序 ↑'}
|
||||
</Button>
|
||||
<div className="w-52">
|
||||
<Input value={keyword} onChange={setKeyword} placeholder="昵称或 openid…" />
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => load(1, keyword)}>
|
||||
搜索
|
||||
</Button>
|
||||
@@ -176,7 +235,7 @@ export default function UsersPage() {
|
||||
{u.status === 1 ? <span className="text-green-600">正常</span> : <span className="text-red-600">封禁</span>}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-gray-500">
|
||||
{formatDate(u.last_login_at)}
|
||||
{formatDateTime(u.last_login_at)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button variant="ghost" onClick={() => openEdit(u)}>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function Select({
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: Array<{ value: number; label: string }>;
|
||||
options: Array<{ value: number | string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
|
||||
@@ -121,8 +121,19 @@ export const api = {
|
||||
body: JSON.stringify({ env }),
|
||||
}),
|
||||
|
||||
listUsers: (page: number, pageSize: number, keyword: string) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/users${qs({ page, pageSize, keyword })}`),
|
||||
/** 用户列表。筛选和排序参数都会拼进 query,空值会被 qs 丢掉。 */
|
||||
listUsers: (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword: string;
|
||||
/** 1 正常 / 0 封禁 */
|
||||
status?: number;
|
||||
/** has 有已购课程 / none 没有 */
|
||||
entitlement?: 'has' | 'none';
|
||||
/** id / registered / lastLogin / entitlements */
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}) => request<Paged<Record<string, unknown>>>(`/admin/v1/users${qs({ ...params })}`),
|
||||
|
||||
updateUser: (id: number, payload: Record<string, unknown>) =>
|
||||
request<unknown>(`/admin/v1/users/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
@@ -19,6 +19,8 @@ export const ErrorCode = {
|
||||
UNAUTHORIZED: 2000,
|
||||
TOKEN_EXPIRED: 2001,
|
||||
FORBIDDEN: 2002,
|
||||
/** 账号被封禁。和「未登录」分开:小程序要提示「用户状态异常」,而不是把人踢去登录 */
|
||||
ACCOUNT_DISABLED: 2003,
|
||||
|
||||
NO_ENTITLEMENT: 3000,
|
||||
COURSE_OFFLINE: 3001,
|
||||
@@ -59,6 +61,10 @@ export class AppError extends HttpException {
|
||||
return new AppError(ErrorCode.FORBIDDEN, message, 403);
|
||||
}
|
||||
|
||||
static accountDisabled(message = '用户状态异常'): AppError {
|
||||
return new AppError(ErrorCode.ACCOUNT_DISABLED, message, 403);
|
||||
}
|
||||
|
||||
static noEntitlement(message = '尚未拥有该课程'): AppError {
|
||||
return new AppError(ErrorCode.NO_ENTITLEMENT, message, 403);
|
||||
}
|
||||
|
||||
@@ -93,8 +93,18 @@ export class AdminController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('entitlement') entitlement?: string,
|
||||
@Query('sort') sort?: string,
|
||||
@Query('order') order?: string,
|
||||
) {
|
||||
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
||||
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, {
|
||||
keyword,
|
||||
status,
|
||||
entitlement,
|
||||
sort,
|
||||
order,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import type { DataSourceEnv } from '../../common/admin-env';
|
||||
@@ -37,6 +37,68 @@ function notifyEventLabel(event: string | null): string {
|
||||
/** 后台主动发放 / 回收产生的订单。不是支付单,列表里订单号显示为空。 */
|
||||
const MANUAL_ORDER_STATUSES: number[] = [OrderStatus.ADMIN_GRANT, OrderStatus.ADMIN_REVOKE];
|
||||
|
||||
/** 用户列表允许的排序字段。只认这几个 key,避免把任意字符串透给 Prisma。 */
|
||||
export const USER_SORT_KEYS = ['id', 'registered', 'lastLogin', 'entitlements'] as const;
|
||||
export type UserSortKey = (typeof USER_SORT_KEYS)[number];
|
||||
|
||||
/** 用户列表的筛选与排序参数,直接来自 HTTP query,都是字符串。 */
|
||||
export interface UserListQuery {
|
||||
keyword?: string | null;
|
||||
/** 账号状态:1 正常 / 0 封禁,空表示全部 */
|
||||
status?: string | number | null;
|
||||
/** 按有无已购课程筛:has 有 / none 没有,空表示全部 */
|
||||
entitlement?: string | null;
|
||||
sort?: string | null;
|
||||
order?: string | null;
|
||||
}
|
||||
|
||||
function isSortKey(value: string | null | undefined): value is UserSortKey {
|
||||
return value !== null && value !== undefined && (USER_SORT_KEYS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表的筛选与排序条件。
|
||||
*
|
||||
* 参数直接来自 HTTP query,所以这里负责把字符串收敛成合法值:认不出来的一律当没传。
|
||||
* 抽成纯函数是为了能单测——字段名写错(比如把 last_login_at 拼错)只有跑起来才会暴露。
|
||||
*/
|
||||
export function buildUserListQuery(input: UserListQuery = {}): {
|
||||
where: Prisma.UserWhereInput;
|
||||
orderBy: Prisma.UserOrderByWithRelationInput;
|
||||
} {
|
||||
const keyword = (input.keyword ?? '').trim();
|
||||
// 注意:不能用 Number(''),那是 0,会被误当成「封禁」
|
||||
const statusText = input.status === null || input.status === undefined ? '' : String(input.status).trim();
|
||||
const status = statusText === '0' || statusText === '1' ? Number(statusText) : undefined;
|
||||
const entitlement = input.entitlement === 'has' || input.entitlement === 'none' ? input.entitlement : undefined;
|
||||
const sort: UserSortKey = isSortKey(input.sort) ? input.sort : 'id';
|
||||
const order: 'asc' | 'desc' = input.order === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (keyword) {
|
||||
where.OR = [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }];
|
||||
}
|
||||
if (status !== undefined) where.status = status;
|
||||
if (entitlement === 'has') where.entitlements = { some: {} };
|
||||
if (entitlement === 'none') where.entitlements = { none: {} };
|
||||
|
||||
let orderBy: Prisma.UserOrderByWithRelationInput;
|
||||
switch (sort) {
|
||||
case 'registered':
|
||||
orderBy = { registered_at: order };
|
||||
break;
|
||||
case 'lastLogin':
|
||||
orderBy = { last_login_at: order };
|
||||
break;
|
||||
case 'entitlements':
|
||||
orderBy = { entitlements: { _count: order } };
|
||||
break;
|
||||
default:
|
||||
orderBy = { id: order };
|
||||
}
|
||||
return { where, orderBy };
|
||||
}
|
||||
|
||||
/** 通用委托接口。用最小结构约束代替 any,保持 strict 校验通过。 */
|
||||
interface Delegate {
|
||||
findMany: (args?: unknown) => Promise<unknown[]>;
|
||||
@@ -149,18 +211,16 @@ export class AdminService {
|
||||
|
||||
// ── 用户 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listUsers(env: DataSourceEnv, page = 1, pageSize = 20, keyword = '') {
|
||||
async listUsers(env: DataSourceEnv, page = 1, pageSize = 20, query: UserListQuery = {}) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const where = keyword
|
||||
? { OR: [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }] }
|
||||
: {};
|
||||
const { where, orderBy } = buildUserListQuery(query);
|
||||
|
||||
const [items, total] = await db.$transaction([
|
||||
db.user.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
orderBy,
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
include: { _count: { select: { entitlements: true } } },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildUserListQuery } from './admin.service';
|
||||
|
||||
describe('用户列表的筛选与排序', () => {
|
||||
it('什么都不传时按 ID 倒序,且没有任何筛选条件', () => {
|
||||
expect(buildUserListQuery()).toEqual({ where: {}, orderBy: { id: 'desc' } });
|
||||
});
|
||||
|
||||
it('关键词同时匹配昵称与 openid,并去掉首尾空格', () => {
|
||||
expect(buildUserListQuery({ keyword: ' Yeomai ' }).where).toEqual({
|
||||
OR: [{ nickname: { contains: 'Yeomai' } }, { openid: { contains: 'Yeomai' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('状态筛选:空串不能当成「封禁」', () => {
|
||||
// Number('') 是 0,一不小心就会把「全部」当成「封禁」
|
||||
expect(buildUserListQuery({ status: '' }).where).toEqual({});
|
||||
expect(buildUserListQuery({ status: '0' }).where).toEqual({ status: 0 });
|
||||
expect(buildUserListQuery({ status: 1 }).where).toEqual({ status: 1 });
|
||||
expect(buildUserListQuery({ status: '9' }).where).toEqual({});
|
||||
});
|
||||
|
||||
it('按有无已购课程筛,认不出来的值当没传', () => {
|
||||
expect(buildUserListQuery({ entitlement: 'has' }).where).toEqual({ entitlements: { some: {} } });
|
||||
expect(buildUserListQuery({ entitlement: 'none' }).where).toEqual({ entitlements: { none: {} } });
|
||||
expect(buildUserListQuery({ entitlement: '乱填的' }).where).toEqual({});
|
||||
});
|
||||
|
||||
it('排序只认白名单,认不出来退回 ID', () => {
|
||||
expect(buildUserListQuery({ sort: 'lastLogin' }).orderBy).toEqual({ last_login_at: 'desc' });
|
||||
expect(buildUserListQuery({ sort: 'registered', order: 'asc' }).orderBy).toEqual({ registered_at: 'asc' });
|
||||
expect(buildUserListQuery({ sort: 'entitlements' }).orderBy).toEqual({
|
||||
entitlements: { _count: 'desc' },
|
||||
});
|
||||
expect(buildUserListQuery({ sort: 'rm -rf' }).orderBy).toEqual({ id: 'desc' });
|
||||
expect(buildUserListQuery({ order: '乱填的' }).orderBy).toEqual({ id: 'desc' });
|
||||
});
|
||||
|
||||
it('筛选与排序可以叠加', () => {
|
||||
expect(buildUserListQuery({ keyword: 'a', status: '1', entitlement: 'has', sort: 'lastLogin', order: 'asc' }))
|
||||
.toEqual({
|
||||
where: {
|
||||
OR: [{ nickname: { contains: 'a' } }, { openid: { contains: 'a' } }],
|
||||
status: 1,
|
||||
entitlements: { some: {} },
|
||||
},
|
||||
orderBy: { last_login_at: 'asc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,8 +66,6 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
|
||||
if (user.status !== 1) throw AppError.forbidden('账号已被封禁');
|
||||
|
||||
return {
|
||||
accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET),
|
||||
refreshToken: await this.sign(user.id, 'user', REFRESH_TTL, this.cfg.JWT_REFRESH_SECRET),
|
||||
@@ -97,7 +95,7 @@ export class AuthService {
|
||||
throw AppError.unauthorized('登录已过期,请重新进入小程序');
|
||||
}
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: sub } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
if (!user) throw AppError.unauthorized();
|
||||
return { accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET) };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export class UsersService {
|
||||
|
||||
async me(userId: number) {
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
if (!user) throw AppError.unauthorized();
|
||||
|
||||
const base = this.cfg.STATIC_BASE_URL;
|
||||
return {
|
||||
@@ -60,6 +60,15 @@ export class UsersService {
|
||||
|
||||
/** 我的课程。 */
|
||||
async entitlements(userId: number) {
|
||||
// 被封禁的账号不返回任何权益:知识页据此显示「用户状态异常」,
|
||||
// 而不是继续把已购课程列出来(token 在有效期内时,光靠鉴权拦不住)
|
||||
const user = await this.prisma.active.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (!user) throw AppError.unauthorized();
|
||||
if (user.status !== 1) throw AppError.accountDisabled();
|
||||
|
||||
// 小程序读开发库且开关关闭时,已购课程列表直接返回空
|
||||
if (!(await showOwnedCourses(this.prisma))) return [];
|
||||
|
||||
|
||||
Reference in new issue
Block a user