四端上传
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
@@ -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() !== '';
|
||||
}
|
||||
|
||||
@@ -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: '已关闭',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user