四端上传
This commit is contained in:
1 parent
25bbb5f9c8
commit
c6e2cb4d5b
276 files changed
+17812
-3
No files matched your search
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ResourceTable } from '@/components/ResourceTable';
|
||||
import { findResource } from '@/lib/resources';
|
||||
|
||||
export default function ResourcePage() {
|
||||
const params = useParams<{ resource: string }>();
|
||||
const key = params?.resource ?? '';
|
||||
const config = findResource(key);
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-500">
|
||||
未知的资源类型:{key}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">{config.title}</h1>
|
||||
<ResourceTable config={config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { clearToken, isLoggedIn } from '@/lib/auth';
|
||||
import { RESOURCE_CONFIGS } from '@/lib/resources';
|
||||
|
||||
const FIXED_NAV = [
|
||||
{ href: '/', label: '数据看板' },
|
||||
{ href: '/users', label: '用户' },
|
||||
{ href: '/orders', label: '订单' },
|
||||
{ href: '/settings', label: '系统配置' },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
}, [router]);
|
||||
|
||||
if (!ready) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-gray-400">加载中…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="w-56 shrink-0 border-r border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-4 py-4">
|
||||
<div className="text-sm font-semibold text-gray-900">叶雷讲数学</div>
|
||||
<div className="text-xs text-gray-400">管理后台</div>
|
||||
</div>
|
||||
<nav className="p-2">
|
||||
{FIXED_NAV.map((item) => (
|
||||
<NavItem key={item.href} href={item.href} label={item.label} active={pathname === item.href} />
|
||||
))}
|
||||
|
||||
<div className="px-3 pt-4 pb-1 text-xs text-gray-400">内容管理</div>
|
||||
{RESOURCE_CONFIGS.map((r) => (
|
||||
<NavItem key={r.key} href={`/${r.key}`} label={r.title} active={pathname === `/${r.key}`} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
}}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ href, label, active }: { href: string; label: string; active: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`block rounded-md px-3 py-2 text-sm transition ${
|
||||
active ? 'bg-blue-50 font-medium text-blue-700' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ORDER_STATUS_TEXT } from '@/lib/resources';
|
||||
import { Button, Card, Empty, Input, Notice, Select } from '@/components/ui';
|
||||
|
||||
interface OrderRow {
|
||||
id: number;
|
||||
out_trade_no: string;
|
||||
wx_order_id: string | null;
|
||||
status: number;
|
||||
amount_fen: number;
|
||||
nickname: string | null;
|
||||
openid: string;
|
||||
course_title: string;
|
||||
created_at: string;
|
||||
paid_at: string | null;
|
||||
delivered_at: string | null;
|
||||
refunded_at: string | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [items, setItems] = useState<OrderRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [refundTarget, setRefundTarget] = useState<OrderRow | null>(null);
|
||||
const [reason, setReason] = useState('用户申请退款');
|
||||
|
||||
const load = useCallback((nextPage: number, nextStatus: string) => {
|
||||
setError('');
|
||||
api
|
||||
.listOrders(nextPage, PAGE_SIZE, nextStatus === '' ? undefined : Number(nextStatus))
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as OrderRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(1, '');
|
||||
}, [load]);
|
||||
|
||||
const submitRefund = () => {
|
||||
if (!refundTarget) return;
|
||||
setError('');
|
||||
setNotice('');
|
||||
api
|
||||
.refundOrder(refundTarget.id, reason)
|
||||
.then(() => {
|
||||
setNotice('退款请求已提交。iOS 端需用户自行向 App Store 申请,开发者无法发起。');
|
||||
setRefundTarget(null);
|
||||
load(page, status);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">订单</h1>
|
||||
<Notice message={error} />
|
||||
{notice && <Notice message={notice} kind="success" />}
|
||||
|
||||
<Card
|
||||
title={`共 ${total} 笔订单`}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={[
|
||||
{ value: -1, label: '全部状态' },
|
||||
{ value: 0, label: '待支付' },
|
||||
{ value: 1, label: '已支付' },
|
||||
{ value: 2, label: '已开通' },
|
||||
{ value: 3, label: '已退款' },
|
||||
{ value: 4, label: '已关闭' },
|
||||
]}
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => load(1, status === '-1' ? '' : status)}>
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<Empty text="暂无订单" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th className="px-2 py-2">订单号</th>
|
||||
<th className="px-2 py-2">用户</th>
|
||||
<th className="px-2 py-2">课程</th>
|
||||
<th className="px-2 py-2">金额</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">下单时间</th>
|
||||
<th className="px-2 py-2 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((o) => (
|
||||
<tr key={o.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-2 py-2 font-mono text-xs">{o.out_trade_no}</td>
|
||||
<td className="px-2 py-2">{o.nickname || o.openid.slice(0, 8)}</td>
|
||||
<td className="px-2 py-2">{o.course_title}</td>
|
||||
<td className="px-2 py-2">¥{(o.amount_fen / 100).toFixed(2)}</td>
|
||||
<td className="px-2 py-2">{ORDER_STATUS_TEXT[o.status] ?? o.status}</td>
|
||||
<td className="px-2 py-2 text-gray-500">{String(o.created_at).slice(0, 16).replace('T', ' ')}</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{o.status === 2 && (
|
||||
<Button variant="danger" onClick={() => setRefundTarget(o)}>
|
||||
退款
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1, status)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1, status)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{refundTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 className="mb-3 text-base font-semibold">发起退款</h3>
|
||||
<p className="mb-3 text-sm text-gray-600">
|
||||
订单 {refundTarget.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)}
|
||||
</p>
|
||||
<Input value={reason} onChange={setReason} placeholder="退款原因" />
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setRefundTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="danger" onClick={submitRefund}>
|
||||
确认退款
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, Notice } from '@/components/ui';
|
||||
|
||||
interface Stats {
|
||||
users: number;
|
||||
courses: number;
|
||||
orders: number;
|
||||
delivered: number;
|
||||
refunded: number;
|
||||
revenueFen: number;
|
||||
payEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.stats()
|
||||
.then(setStats)
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
const yuan = (fen: number) => (fen / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">数据看板</h1>
|
||||
<Notice message={error} />
|
||||
|
||||
{!stats ? (
|
||||
<div className="text-sm text-gray-400">加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Metric label="用户总数" value={String(stats.users)} />
|
||||
<Metric label="在售课程" value={String(stats.courses)} />
|
||||
<Metric label="累计订单" value={String(stats.orders)} />
|
||||
<Metric label="已开通" value={String(stats.delivered)} />
|
||||
<Metric label="已退款" value={String(stats.refunded)} />
|
||||
<Metric label="成交金额(元)" value={yuan(stats.revenueFen)} />
|
||||
<Metric label="支付能力" value={stats.payEnabled ? '已配置' : '未配置'} />
|
||||
</div>
|
||||
|
||||
{!stats.payEnabled && (
|
||||
<Card title="支付尚未启用">
|
||||
<p className="text-sm text-gray-600">
|
||||
服务端未配置虚拟支付参数(WX_OFFER_ID / WX_PAY_APPKEY),用户暂时无法购买课程。
|
||||
请在微信小程序后台开通虚拟支付后,将 OfferID 与现网 AppKey 写入服务端环境变量。
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button, Card, Input, Notice } from '@/components/ui';
|
||||
|
||||
interface SettingRow {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [rows, setRows] = useState<SettingRow[]>([]);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [savingKey, setSavingKey] = useState('');
|
||||
|
||||
const load = () => {
|
||||
setError('');
|
||||
api
|
||||
.listSettings()
|
||||
.then((list) => {
|
||||
setRows(list);
|
||||
const d: Record<string, string> = {};
|
||||
list.forEach((r) => {
|
||||
d[r.key] = r.value ?? '';
|
||||
});
|
||||
setDraft(d);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const save = (row: SettingRow) => {
|
||||
setSavingKey(row.key);
|
||||
setError('');
|
||||
setNotice('');
|
||||
api
|
||||
.upsertSetting(row.key, draft[row.key] ?? '', row.remark ?? undefined)
|
||||
.then(() => {
|
||||
setNotice(`已保存:${row.key}`);
|
||||
load();
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message))
|
||||
.finally(() => setSavingKey(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">系统配置</h1>
|
||||
<Notice message={error} />
|
||||
{notice && <Notice message={notice} kind="success" />}
|
||||
|
||||
<Card title="站点文案与开关">
|
||||
<p className="mb-4 text-xs text-gray-500">
|
||||
小程序首屏显示的标题、我的页面菜单文案、版本号等都在这里。修改后小程序下拉刷新即可生效。
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="flex items-center gap-3">
|
||||
<div className="w-56 shrink-0">
|
||||
<div className="text-sm text-gray-700">{row.remark || row.key}</div>
|
||||
<div className="font-mono text-xs text-gray-400">{row.key}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={draft[row.key] ?? ''}
|
||||
onChange={(v) => setDraft({ ...draft, [row.key]: v })}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => save(row)} disabled={savingKey === row.key}>
|
||||
{savingKey === row.key ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && <div className="py-6 text-center text-sm text-gray-400">暂无配置</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button, Card, Empty, Input, Modal, Notice, Select } from '@/components/ui';
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
openid: string;
|
||||
nickname: string | null;
|
||||
headicon_id: number;
|
||||
status: number;
|
||||
registered_at: string;
|
||||
last_login_at: string | null;
|
||||
entitlement_count: number;
|
||||
}
|
||||
|
||||
interface EntitlementRow {
|
||||
id: number;
|
||||
course_id: number;
|
||||
course_title: string;
|
||||
course_code: string;
|
||||
source: number;
|
||||
granted_at: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface CourseRow {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const SOURCE_TEXT: Record<number, string> = {
|
||||
1: '订单购买',
|
||||
2: '历史数据',
|
||||
3: '后台发放',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function UsersPage() {
|
||||
const [items, setItems] = useState<UserRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [editing, setEditing] = useState<UserRow | null>(null);
|
||||
const [form, setForm] = useState({ nickname: '', headicon_id: '0', status: '1' });
|
||||
const [courses, setCourses] = useState<CourseRow[]>([]);
|
||||
const [entitlements, setEntitlements] = useState<EntitlementRow[]>([]);
|
||||
const [grantCourseId, setGrantCourseId] = useState('');
|
||||
|
||||
const load = useCallback((nextPage: number, nextKeyword: string) => {
|
||||
setError('');
|
||||
api
|
||||
.listUsers(nextPage, PAGE_SIZE, nextKeyword)
|
||||
.then((res) => {
|
||||
setItems(res.items as unknown as UserRow[]);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
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
|
||||
.listEntitlements(userId)
|
||||
.then((list) => setEntitlements(list as unknown as EntitlementRow[]))
|
||||
.catch(() => setEntitlements([]));
|
||||
};
|
||||
|
||||
const openEdit = (user: UserRow) => {
|
||||
setEditing(user);
|
||||
setForm({
|
||||
nickname: user.nickname ?? '',
|
||||
headicon_id: String(user.headicon_id),
|
||||
status: String(user.status),
|
||||
});
|
||||
setGrantCourseId('');
|
||||
loadEntitlements(user.id);
|
||||
};
|
||||
|
||||
const saveUser = () => {
|
||||
if (!editing) return;
|
||||
setError('');
|
||||
api
|
||||
.updateUser(editing.id, {
|
||||
nickname: form.nickname,
|
||||
headicon_id: Number(form.headicon_id),
|
||||
status: Number(form.status),
|
||||
})
|
||||
.then(() => {
|
||||
setEditing(null);
|
||||
load(page, keyword);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
};
|
||||
|
||||
const grant = () => {
|
||||
if (!editing || !grantCourseId || grantCourseId === '0') return;
|
||||
setError('');
|
||||
api
|
||||
.grantEntitlement(editing.id, Number(grantCourseId), '后台发放')
|
||||
.then(() => {
|
||||
setGrantCourseId('');
|
||||
loadEntitlements(editing.id);
|
||||
load(page, keyword);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
};
|
||||
|
||||
const revoke = (courseId: number) => {
|
||||
if (!editing) return;
|
||||
if (!window.confirm('确定回收该课程权益吗?用户将失去访问权限。')) return;
|
||||
setError('');
|
||||
api
|
||||
.revokeEntitlement(editing.id, courseId)
|
||||
.then(() => {
|
||||
loadEntitlements(editing.id);
|
||||
load(page, keyword);
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message));
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold text-gray-900">用户</h1>
|
||||
<Notice message={error} />
|
||||
|
||||
<Card
|
||||
title={`共 ${total} 位用户`}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Input value={keyword} onChange={setKeyword} placeholder="昵称或 openid…" />
|
||||
<Button variant="ghost" onClick={() => load(1, keyword)}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||
<th className="px-2 py-2">ID</th>
|
||||
<th className="px-2 py-2">昵称</th>
|
||||
<th className="px-2 py-2">已购课程</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">最后登录</th>
|
||||
<th className="px-2 py-2 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((u) => (
|
||||
<tr key={u.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-gray-400">{u.id}</td>
|
||||
<td className="px-2 py-2">{u.nickname || <span className="text-gray-400">游客</span>}</td>
|
||||
<td className="px-2 py-2">{u.entitlement_count}</td>
|
||||
<td className="px-2 py-2">
|
||||
{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">
|
||||
{u.last_login_at ? String(u.last_login_at).slice(0, 10) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<Button variant="ghost" onClick={() => openEdit(u)}>
|
||||
编辑 / 权益
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => load(page - 1, keyword)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={page >= totalPages} onClick={() => load(page + 1, keyword)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
title={`用户 #${editing?.id ?? ''}`}
|
||||
onClose={() => setEditing(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setEditing(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={saveUser}>保存</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">昵称</label>
|
||||
<Input value={form.nickname} onChange={(v) => setForm({ ...form, nickname: v })} />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm text-gray-600">头像序号</label>
|
||||
<Input value={form.headicon_id} onChange={(v) => setForm({ ...form, headicon_id: v })} type="number" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm text-gray-600">账号状态</label>
|
||||
<Select
|
||||
value={form.status}
|
||||
onChange={(v) => setForm({ ...form, status: v })}
|
||||
options={[
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '封禁' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-800">课程权益</h4>
|
||||
<p className="mb-2 text-xs text-gray-500">
|
||||
这是原「口令码」的替代:有人拿旧码来找你时,在这里给他发对应的课程即可。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={grantCourseId}
|
||||
onChange={setGrantCourseId}
|
||||
options={[{ value: 0, label: '选择课程…' }].concat(
|
||||
courses.map((c) => ({ value: c.id, label: `${c.id} · ${c.title}` })),
|
||||
)}
|
||||
/>
|
||||
<Button onClick={grant} disabled={!grantCourseId || grantCourseId === '0'}>
|
||||
发放
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<table className="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
{entitlements.map((e) => (
|
||||
<tr key={e.id} className="border-b border-gray-100">
|
||||
<td className="py-2">{e.course_title}</td>
|
||||
<td className="py-2 text-xs text-gray-500">{SOURCE_TEXT[e.source] ?? e.source}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button variant="danger" onClick={() => revoke(e.course_id)}>
|
||||
回收
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entitlements.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-4 text-center text-xs text-gray-400">
|
||||
暂无权益
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #f6f7f9;
|
||||
color: #111827;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '叶雷讲数学 · 管理后台',
|
||||
description: '课程、订单与用户管理',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="min-h-screen">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { setToken } from '@/lib/auth';
|
||||
import { Button, Card, Input, Notice } from '@/components/ui';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [mode, setMode] = useState<'login' | 'bootstrap'>('login');
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
setError('');
|
||||
setInfo('');
|
||||
if (!username || !password) {
|
||||
setError('请输入账号与密码');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
||||
const action =
|
||||
mode === 'login' ? api.login(username, password) : api.bootstrap(username, password);
|
||||
|
||||
action
|
||||
.then((result) => {
|
||||
if (mode === 'bootstrap') {
|
||||
const created = (result as { created: boolean }).created;
|
||||
setInfo(created ? '管理员已创建,请切换到登录。' : '系统已存在管理员,无法重复创建。');
|
||||
setMode('login');
|
||||
return;
|
||||
}
|
||||
setToken((result as { accessToken: string }).accessToken);
|
||||
router.replace('/');
|
||||
})
|
||||
.catch((err: { message: string }) => setError(err.message))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">叶雷讲数学</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">管理后台</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Notice message={error} />
|
||||
{info && <Notice message={info} kind="success" />}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">账号</label>
|
||||
<Input value={username} onChange={setUsername} placeholder="管理员账号" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-gray-600">密码</label>
|
||||
<Input value={password} onChange={setPassword} placeholder="密码" type="password" />
|
||||
</div>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
{busy ? '处理中…' : mode === 'login' ? '登录' : '创建管理员'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'bootstrap' : 'login');
|
||||
setError('');
|
||||
setInfo('');
|
||||
}}
|
||||
className="mt-4 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{mode === 'login' ? '首次部署?创建管理员账号' : '返回登录'}
|
||||
</button>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user