四端上传

This commit is contained in:
tianmo committed 2026-09-29 15:25:40 +08:00
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>
);
}
+71
View File
@@ -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>
);
}
+12
View File
@@ -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;
}
+16
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}