四端上传

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>
);
}
@@ -0,0 +1,264 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import type { FieldDef, ResourceConfig } from '@/lib/resources';
import { Button, Card, Empty, Input, Modal, Notice, Select, Textarea } from './ui';
const PAGE_SIZE = 20;
type Row = Record<string, unknown>;
type FormState = Record<string, string>;
function toInputValue(field: FieldDef, raw: unknown): string {
if (raw === null || raw === undefined) return '';
if (field.type === 'date') {
const d = new Date(String(raw));
return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
}
return String(raw);
}
/** 表单值统一按字符串持有,提交时按字段类型转换。 */
function buildPayload(fields: FieldDef[], form: FormState): Row {
const payload: Row = {};
for (const field of fields) {
const value = form[field.name];
if (value === undefined) continue;
if (value === '') {
payload[field.name] = field.type === 'number' ? 0 : null;
} else if (field.type === 'number' || field.type === 'select') {
payload[field.name] = Number(value);
} else {
payload[field.name] = value;
}
}
return payload;
}
function displayCell(field: FieldDef, row: Row): string {
const raw = row[field.name];
if (raw === null || raw === undefined || raw === '') return '—';
if (field.type === 'select' && field.options) {
const hit = field.options.find((o) => o.value === Number(raw));
return hit ? hit.label : String(raw);
}
if (field.type === 'date') return String(raw).slice(0, 10);
return String(raw);
}
export function ResourceTable({ config }: { config: ResourceConfig }) {
const [items, setItems] = useState<Row[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<Row | null>(null);
const [form, setForm] = useState<FormState>({});
const [saving, setSaving] = useState(false);
const tableFields = config.fields.filter((f) => !f.hideInTable);
const load = useCallback(
(nextPage: number, nextKeyword: string) => {
setLoading(true);
setError('');
api
.listResource(config.key, { page: nextPage, pageSize: PAGE_SIZE, keyword: nextKeyword })
.then((res) => {
setItems(res.items as Row[]);
setTotal(res.total);
setPage(res.page);
})
.catch((err: { message: string }) => setError(err.message))
.finally(() => setLoading(false));
},
[config.key],
);
useEffect(() => {
setKeyword('');
load(1, '');
}, [config.key, load]);
const openCreate = () => {
const initial: FormState = {};
for (const f of config.fields) {
initial[f.name] = f.type === 'select' ? String(f.options?.[0]?.value ?? 1) : '';
}
setForm(initial);
setEditing(null);
setFormOpen(true);
};
const openEdit = (row: Row) => {
const initial: FormState = {};
for (const f of config.fields) initial[f.name] = toInputValue(f, row[f.name]);
setForm(initial);
setEditing(row);
setFormOpen(true);
};
const save = () => {
const editable = config.fields.filter((f) => !(editing && f.immutable));
const payload = buildPayload(editable, form);
setSaving(true);
setError('');
const action = editing
? api.updateResource(config.key, Number(editing.id), payload)
: api.createResource(config.key, payload);
action
.then(() => {
setFormOpen(false);
load(page, keyword);
})
.catch((err: { message: string }) => setError(err.message))
.finally(() => setSaving(false));
};
const remove = (row: Row) => {
if (!window.confirm('确定删除这条记录吗?此操作不可撤销。')) return;
setError('');
api
.deleteResource(config.key, Number(row.id))
.then(() => 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">
<Notice message={error} />
<Card
title={`${config.title}(共 ${total} 条)`}
action={
<div className="flex gap-2">
<Input value={keyword} onChange={setKeyword} placeholder="搜索…" />
<Button variant="ghost" onClick={() => load(1, keyword)}>
搜索
</Button>
<Button onClick={openCreate}>新建</Button>
</div>
}
>
<p className="mb-3 text-xs text-gray-500">{config.description}</p>
{loading ? (
<div className="py-10 text-center text-sm text-gray-400">加载中…</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>
{tableFields.map((f) => (
<th key={f.name} className="px-2 py-2">
{f.label}
</th>
))}
<th className="px-2 py-2 text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={String(row.id)} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-2 py-2 text-gray-400">{String(row.id)}</td>
{tableFields.map((f) => {
const text = displayCell(f, row);
return (
<td key={f.name} className="max-w-[280px] truncate px-2 py-2" title={text}>
{text}
</td>
);
})}
<td className="whitespace-nowrap px-2 py-2 text-right">
<Button variant="ghost" onClick={() => openEdit(row)}>
编辑
</Button>{' '}
<Button variant="danger" onClick={() => remove(row)}>
删除
</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={formOpen}
title={`${editing ? '编辑' : '新建'}${config.title}`}
onClose={() => setFormOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setFormOpen(false)}>
取消
</Button>
<Button onClick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</>
}
>
<div className="space-y-3">
{config.fields.map((field) => {
const disabled = Boolean(editing && field.immutable);
return (
<div key={field.name}>
<label className="mb-1 block text-sm text-gray-600">
{field.label}
{field.immutable && <span className="ml-2 text-xs text-orange-500">创建后不可修改</span>}
</label>
{field.type === 'textarea' ? (
<Textarea value={form[field.name] ?? ''} onChange={(v) => setForm({ ...form, [field.name]: v })} />
) : field.type === 'select' ? (
<Select
value={form[field.name] ?? ''}
onChange={(v) => setForm({ ...form, [field.name]: v })}
options={field.options ?? []}
/>
) : (
<div className={disabled ? 'pointer-events-none opacity-60' : ''}>
<Input
value={form[field.name] ?? ''}
onChange={(v) => setForm({ ...form, [field.name]: v })}
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
placeholder={field.placeholder}
/>
</div>
)}
{field.hint && <p className="mt-1 text-xs text-gray-400">{field.hint}</p>}
</div>
);
})}
</div>
</Modal>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import type { ReactNode } from 'react';
export function Button({
children,
onClick,
variant = 'primary',
disabled,
type = 'button',
}: {
children: ReactNode;
onClick?: () => void;
variant?: 'primary' | 'ghost' | 'danger';
disabled?: boolean;
type?: 'button' | 'submit';
}) {
const styles = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
danger: 'bg-red-600 text-white hover:bg-red-700',
}[variant];
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${styles}`}
>
{children}
</button>
);
}
export function Input({
value,
onChange,
placeholder,
type = 'text',
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<input
type={type}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm outline-none focus:border-blue-500"
/>
);
}
export function Textarea({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<textarea
value={value}
rows={6}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-1.5 font-mono text-xs outline-none focus:border-blue-500"
/>
);
}
export function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: Array<{ value: number; label: string }>;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm outline-none focus:border-blue-500"
>
{options.map((o) => (
<option key={o.value} value={String(o.value)}>
{o.label}
</option>
))}
</select>
);
}
export function Card({ title, children, action }: { title?: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
{(title || action) && (
<header className="mb-4 flex items-center justify-between">
{title && <h2 className="text-base font-semibold text-gray-900">{title}</h2>}
{action}
</header>
)}
{children}
</section>
);
}
export function Modal({
open,
title,
onClose,
children,
footer,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="max-h-[85vh] w-full max-w-2xl overflow-auto rounded-lg bg-white shadow-xl">
<header className="flex items-center justify-between border-b border-gray-200 px-5 py-3">
<h3 className="text-base font-semibold">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600" aria-label="关闭">
✕
</button>
</header>
<div className="px-5 py-4">{children}</div>
{footer && <footer className="flex justify-end gap-2 border-t border-gray-200 px-5 py-3">{footer}</footer>}
</div>
</div>
);
}
/** 统一的错误 / 提示条。 */
export function Notice({ message, kind = 'error' }: { message: string; kind?: 'error' | 'success' }) {
if (!message) return null;
const cls = kind === 'error' ? 'bg-red-50 text-red-700 border-red-200' : 'bg-green-50 text-green-700 border-green-200';
return <div className={`mb-3 rounded-md border px-3 py-2 text-sm ${cls}`}>{message}</div>;
}
export function Empty({ text = '暂无数据' }: { text?: string }) {
return <div className="py-10 text-center text-sm text-gray-400">{text}</div>;
}
+137
View File
@@ -0,0 +1,137 @@
'use client';
import { clearToken, getToken } from './auth';
const BASE = (process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
export interface ApiFailure {
code: number;
message: string;
}
interface Envelope<T> {
code: number;
message: string;
data: T;
requestId: string;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'content-type': 'application/json',
...((init.headers as Record<string, string>) ?? {}),
};
if (token) headers.Authorization = `Bearer ${token}`;
let res: Response;
try {
res = await fetch(`${BASE}${path}`, { ...init, headers });
} catch {
throw { code: -1, message: '无法连接服务端,请检查网络或接口地址' } as ApiFailure;
}
let body: Envelope<T> | null = null;
try {
body = (await res.json()) as Envelope<T>;
} catch {
throw { code: -1, message: `服务端返回异常(HTTP ${res.status})` } as ApiFailure;
}
// 管理员 token 失效:清理并回到登录页
if (res.status === 401) {
clearToken();
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
throw { code: body?.code ?? 2000, message: body?.message ?? '登录已失效,请重新登录' } as ApiFailure;
}
if (!body || body.code !== 0) {
throw { code: body?.code ?? -1, message: body?.message ?? '请求失败' } as ApiFailure;
}
return body.data;
}
export interface Paged<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
const qs = (params: Record<string, string | number | undefined>): string => {
const parts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
return parts.length ? `?${parts.join('&')}` : '';
};
export const api = {
base: BASE,
request,
login: (username: string, password: string) =>
request<{ accessToken: string; admin: { id: number; role: number; displayName: string | null } }>(
'/admin/v1/auth/login',
{ method: 'POST', body: JSON.stringify({ username, password }) },
),
bootstrap: (username: string, password: string) =>
request<{ created: boolean }>('/admin/v1/auth/bootstrap', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
stats: () =>
request<{
users: number;
courses: number;
orders: number;
delivered: number;
refunded: number;
revenueFen: number;
payEnabled: boolean;
}>('/admin/v1/stats'),
listUsers: (page: number, pageSize: number, keyword: string) =>
request<Paged<Record<string, unknown>>>(`/admin/v1/users${qs({ page, pageSize, keyword })}`),
updateUser: (id: number, payload: Record<string, unknown>) =>
request<unknown>(`/admin/v1/users/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
listResource: (resource: string, params: Record<string, string | number | undefined>) =>
request<Paged<Record<string, unknown>>>(`/admin/v1/data/${resource}${qs(params)}`),
createResource: (resource: string, payload: Record<string, unknown>) =>
request<unknown>(`/admin/v1/data/${resource}`, { method: 'POST', body: JSON.stringify(payload) }),
updateResource: (resource: string, id: number, payload: Record<string, unknown>) =>
request<unknown>(`/admin/v1/data/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteResource: (resource: string, id: number) =>
request<unknown>(`/admin/v1/data/${resource}/${id}`, { method: 'DELETE' }),
listSettings: () => request<Array<{ id: number; key: string; value: string | null; remark: string | null }>>('/admin/v1/settings'),
upsertSetting: (key: string, value: string, remark?: string) =>
request<unknown>('/admin/v1/settings', { method: 'PUT', body: JSON.stringify({ key, value, remark }) }),
listOrders: (page: number, pageSize: number, status?: number) =>
request<Paged<Record<string, unknown>>>(`/admin/v1/orders${qs({ page, pageSize, status })}`),
refundOrder: (id: number, reason: string) =>
request<unknown>(`/admin/v1/orders/${id}/refund`, { method: 'POST', body: JSON.stringify({ reason }) }),
listEntitlements: (userId: number) => request<Array<Record<string, unknown>>>(`/admin/v1/entitlements${qs({ userId })}`),
grantEntitlement: (userId: number, courseId: number, note?: string) =>
request<unknown>('/admin/v1/entitlements', {
method: 'POST',
body: JSON.stringify({ userId, courseId, note }),
}),
revokeEntitlement: (userId: number, courseId: number) =>
request<unknown>(`/admin/v1/entitlements${qs({ userId, courseId })}`, { method: 'DELETE' }),
};
+21
View File
@@ -0,0 +1,21 @@
'use client';
const TOKEN_KEY = 'yelay_admin_token';
export function getToken(): string {
if (typeof window === 'undefined') return '';
return window.localStorage.getItem(TOKEN_KEY) ?? '';
}
export function setToken(token: string): void {
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
window.localStorage.removeItem(TOKEN_KEY);
}
export function isLoggedIn(): boolean {
return getToken() !== '';
}
+150
View File
@@ -0,0 +1,150 @@
/**
* 内容资源的字段定义。
*
* 一个通用的表格组件 + 这张配置表,覆盖了后台的绝大部分内容管理,
* 不需要为每类内容单独写页面。字段名与服务端保持一致(snake_case)。
*/
export type FieldType = 'text' | 'number' | 'textarea' | 'date' | 'select';
export interface FieldDef {
name: string;
label: string;
type: FieldType;
/** 编辑时不可修改(如 courses.code,改动会让视频路径失效) */
immutable?: boolean;
options?: Array<{ value: number; label: string }>;
hint?: string;
/** 表格中隐藏,只在编辑表单出现 */
hideInTable?: boolean;
placeholder?: string;
}
export interface ResourceConfig {
key: string;
title: string;
description: string;
fields: FieldDef[];
/** 额外筛选条件(如按课程筛课时) */
filterByCourse?: boolean;
}
const STATUS_FIELD: FieldDef = {
name: 'status',
label: '状态',
type: 'select',
options: [
{ value: 1, label: '启用' },
{ value: 0, label: '停用' },
],
};
const SORT_FIELD: FieldDef = { name: 'sort_order', label: '排序', type: 'number', hideInTable: true };
export const RESOURCE_CONFIGS: ResourceConfig[] = [
{
key: 'courses',
title: '课程',
description: '付费课程。注意 code 是视频目录名与封面名,创建后不可修改。',
fields: [
{ name: 'code', label: '课程标识', type: 'text', immutable: true, hint: '64 位哈希,对应视频目录' },
{ name: 'title', label: '课程名称', type: 'text' },
{ name: 'intro', label: '课程介绍', type: 'text' },
{ name: 'price_fen', label: '价格(分)', type: 'number' },
{ name: 'cover_image', label: '封面文件', type: 'text', hideInTable: true },
STATUS_FIELD,
SORT_FIELD,
],
},
{
key: 'lessons',
title: '课时',
description: '课程下的视频。chapter_id 与 play_id 参与视频路径拼接,改动会使旧地址失效。',
filterByCourse: true,
fields: [
{ name: 'course_id', label: '课程 ID', type: 'number' },
{ name: 'chapter_id', label: '章节 ID', type: 'text' },
{ name: 'play_id', label: '播放 ID', type: 'text' },
{ name: 'chapter_title', label: '章节名称', type: 'text' },
{ name: 'lesson_title', label: '视频名称', type: 'text' },
{ name: 'duration', label: '时长', type: 'text' },
{ name: 'published_at', label: '发布日期', type: 'date' },
SORT_FIELD,
],
},
{
key: 'home-courses',
title: '首页新课',
description: '首页「新课上线」的长图页面。course_name 决定图片路径,不要改动。',
fields: [
{ name: 'course_name', label: '条目名称', type: 'text', hint: '对应 Images/NewCourse/{名称}.png' },
{ name: 'intro', label: '简介', type: 'text' },
{ name: 'course_id', label: '关联课程 ID', type: 'number', hint: '留空则长图页不显示购买按钮' },
{ name: 'content', label: '长图内容', type: 'textarea', hideInTable: true },
{ name: 'published_at', label: '发布日期', type: 'date', hideInTable: true },
STATUS_FIELD,
SORT_FIELD,
],
},
{
key: 'home-free-courses',
title: '首页试听',
description: '首页「自我学习」入口。course_name 决定图片路径。',
fields: [
{ name: 'series_no', label: '序号', type: 'number', hint: '对应免费试听课的 series_no' },
{ name: 'course_name', label: '条目名称', type: 'text' },
{ name: 'intro', label: '简介', type: 'text' },
STATUS_FIELD,
SORT_FIELD,
],
},
{
key: 'banners',
title: '轮播图',
description: '首页顶部轮播。filename 对应 Images/Banner/{文件名}。',
fields: [
{ name: 'filename', label: '图片文件名', type: 'text' },
STATUS_FIELD,
SORT_FIELD,
],
},
{
key: 'articles',
title: '知识速通',
description: '首页「知识速通」文章。legacy_page_id 对应缩略图 WebPages/{id}.png。',
fields: [
{ name: 'title', label: '标题', type: 'text' },
{ name: 'legacy_page_id', label: '图片编号', type: 'text' },
{ name: 'intro', label: '简介', type: 'text' },
{ name: 'publisher', label: '发布人', type: 'text', hideInTable: true },
{ name: 'content', label: '正文', type: 'textarea', hideInTable: true },
{ name: 'published_at', label: '发布日期', type: 'date' },
STATUS_FIELD,
SORT_FIELD,
],
},
{
key: 'products',
title: '支付商品',
description: '课程与微信虚拟支付道具的映射。微信道具 ID 与价格必须先在小程序后台创建。',
fields: [
{ name: 'course_id', label: '课程 ID', type: 'number' },
{ name: 'wx_product_id', label: '微信道具 ID', type: 'text' },
{ name: 'name', label: '商品名称', type: 'text' },
{ name: 'price_fen', label: '价格(分)', type: 'text' },
STATUS_FIELD,
],
},
];
export const findResource = (key: string): ResourceConfig | undefined =>
RESOURCE_CONFIGS.find((r) => r.key === key);
export const ORDER_STATUS_TEXT: Record<number, string> = {
0: '待支付',
1: '已支付',
2: '已开通',
3: '已退款',
4: '已关闭',
};