Files
YelayMaths/yelaymaths_admin/src/app/(admin)/layout.tsx
T
2026-10-01 15:59:48 +08:00

325 lines
12 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { clearToken, isLoggedIn } from '@/lib/auth';
import { ENV_LABEL, getViewEnv, setViewEnv, type DataSourceEnv } from '@/lib/env';
import { RESOURCE_CONFIGS } from '@/lib/resources';
type DataSource = DataSourceEnv;
const FIXED_NAV = [
{ href: '/', label: '数据看板' },
{ href: '/users', label: '用户' },
{ href: '/orders', label: '订单' },
{ href: '/refund-requests', label: '退款申请' },
{ href: '/notify-logs', label: '通知日志' },
{ href: '/settings', label: '系统配置' },
];
/**
* 底部「数据源」区域预留的高度(px)。
*
* 这个区块用 position:fixed 钉在视口左下角,脱离文档流,
* 所以要在这里留出等高的空白,免得它盖住最后几个菜单项。
* 数值按内容量给足余量,字体缩放时也不至于被遮住。
*/
const FOOTER_RESERVE_PX = 290;
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [ready, setReady] = useState(false);
/** 管理端「查看哪个库」——只影响自己 */
const [viewEnv, setView] = useState<DataSource>('production');
/** 线上小程序实际读的库 */
const [miniEnv, setMiniEnv] = useState<DataSource>('production');
const [developAvailable, setDevelopAvailable] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const [error, setError] = useState('');
/** 小屏(手机)上的侧边栏抽屉开关。桌面端忽略它。 */
const [navOpen, setNavOpen] = useState(false);
const loadState = useCallback(() => {
setView(getViewEnv());
api
.stats()
.then((s) => {
setMiniEnv(s.miniProgramEnv === 'develop' ? 'develop' : 'production');
setDevelopAvailable(s.developAvailable);
})
.catch(() => {
/* 取不到时按生产库显示,不阻塞页面 */
});
}, []);
useEffect(() => {
if (!isLoggedIn()) {
router.replace('/login');
return;
}
setReady(true);
loadState();
}, [router, loadState]);
/** 只切自己的查看视角,不动线上数据来源。 */
const changeViewEnv = (next: DataSource) => {
if (next === viewEnv) return;
setViewEnv(next);
setView(next);
window.location.reload();
};
/** 真正切换线上小程序的数据源,必须二次确认。 */
const applyMiniProgramEnv = () => {
const next: DataSource = miniEnv === 'production' ? 'develop' : 'production';
setSwitching(true);
setError('');
api
.switchMiniProgramDataSource(next)
.then(() => {
setMiniEnv(next);
setConfirmOpen(false);
window.location.reload();
})
.catch((err: { message: string }) => {
setError(err.message || '切换失败');
setSwitching(false);
});
};
if (!ready) {
return <div className="flex min-h-screen items-center justify-center text-sm text-gray-400">加载中…</div>;
}
const nextMiniEnv: DataSource = miniEnv === 'production' ? 'develop' : 'production';
return (
<div className="min-h-screen">
{/* 小屏顶栏:桌面端隐藏,点「菜单」拉出侧边栏 */}
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-gray-200 bg-white px-4 py-3 md:hidden">
<button
type="button"
onClick={() => setNavOpen(true)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700"
aria-label="打开菜单"
>
☰ 菜单
</button>
<span className="truncate text-sm font-semibold text-gray-900">叶雷讲数学 · 管理后台</span>
</header>
{/*
侧边栏:固定定位、高度恒等于视口。导航区自己滚动。
注意它只承载「导航」,底部数据源区不在它里面——见下面那个独立 fixed 区块。
小屏(<md)时它变成抽屉:默认滑出屏幕外,点顶栏「菜单」拉进来,
点遮罩或任一菜单项收回去。
*/}
<aside
className={`fixed left-0 top-0 z-50 h-screen w-60 border-r border-gray-200 bg-white transition-transform duration-200 md:translate-x-0 ${
navOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex h-full flex-col">
<div className="shrink-0 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="min-h-0 flex-1 overflow-y-auto p-2">
{FIXED_NAV.map((item) => (
<NavItem
key={item.href}
href={item.href}
label={item.label}
active={pathname === item.href}
onNavigate={() => setNavOpen(false)}
/>
))}
<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}`}
onNavigate={() => setNavOpen(false)}
/>
))}
</nav>
{/* 给下面那个 fixed 区块让位,避免遮住最后几个菜单 */}
<div className="shrink-0" style={{ height: FOOTER_RESERVE_PX }} aria-hidden="true" />
</div>
</aside>
{/*
数据源区:独立的 fixed 元素,钉在视口左下角。
它不在侧边栏里,也不参与任何 flex 布局,因此页面长短、导航多少、窗口高低都影响不到它。
小屏时它跟着侧边栏一起滑出/滑入(同样的 translate),所以抽屉关着时看不到它。
*/}
<div
className={`fixed bottom-0 left-0 z-50 w-60 border-t border-gray-200 bg-white p-2 transition-transform duration-200 md:translate-x-0 ${
navOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="px-3 pb-1 text-xs text-gray-400">查看的数据源</div>
<div className="flex gap-1 px-2">
{(['production', 'develop'] as DataSource[]).map((key) => {
const disabled = key === 'develop' && !developAvailable;
return (
<button
key={key}
onClick={() => changeViewEnv(key)}
disabled={disabled}
title={disabled ? '服务端未配置开发库' : '只影响你自己的查看与编辑'}
className={`flex-1 rounded-md border px-2 py-1.5 text-xs transition ${
viewEnv === key
? key === 'develop'
? 'border-orange-400 bg-orange-50 font-medium text-orange-700'
: 'border-blue-400 bg-blue-50 font-medium text-blue-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}
>
{ENV_LABEL[key]}
</button>
);
})}
</div>
<p className="px-3 pt-1 pb-2 text-[11px] leading-tight text-gray-400">
只影响你自己的查看与编辑,不影响小程序
</p>
<div className="border-t border-gray-100 px-3 pt-3 pb-1 text-xs text-gray-400">
线上小程序数据源
</div>
<div className="px-2 pb-2">
<div className="mb-1.5 flex items-center gap-1.5 px-1 text-sm">
<span
className={`inline-block h-2 w-2 rounded-full ${
miniEnv === 'develop' ? 'bg-orange-500' : 'bg-green-500'
}`}
/>
<span className={miniEnv === 'develop' ? 'font-medium text-orange-700' : 'text-gray-700'}>
{ENV_LABEL[miniEnv]}
</span>
</div>
<button
onClick={() => setConfirmOpen(true)}
disabled={!developAvailable || switching}
title={developAvailable ? undefined : '服务端未配置开发库'}
className="w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
>
切换到{ENV_LABEL[nextMiniEnv]}
</button>
</div>
<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>
{/* 小屏抽屉打开时的遮罩,点它收回 */}
{navOpen && (
<div className="fixed inset-0 z-40 bg-black/40 md:hidden" onClick={() => setNavOpen(false)} />
)}
<main className="md:ml-60">
{miniEnv === 'develop' && (
<div className="border-b border-orange-300 bg-orange-100 px-6 py-2 text-sm font-medium text-orange-800">
⚠ 线上小程序当前读的是<span className="font-bold">开发库</span>
——所有用户打开小程序看到的都是开发库数据,测完请及时切回生产库。
</div>
)}
{miniEnv !== 'develop' && viewEnv === 'develop' && (
<div className="border-b border-blue-200 bg-blue-50 px-6 py-2 text-sm text-blue-800">
你正在查看<span className="font-bold">开发库</span>的数据(仅影响你自己,小程序仍在读生产库)
</div>
)}
<div className="p-4 md:p-6">{children}</div>
</main>
{confirmOpen && (
<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 text-gray-900">确认切换线上小程序数据源?</h3>
<div className="space-y-2 text-sm text-gray-600">
<p>
即将把线上小程序从
<span className="mx-1 font-medium">{ENV_LABEL[miniEnv]}</span>
切换到
<span className="mx-1 font-medium text-orange-700">{ENV_LABEL[nextMiniEnv]}</span>。
</p>
{nextMiniEnv === 'develop' ? (
<p className="rounded-md bg-orange-50 px-3 py-2 text-orange-800">
切换后,<span className="font-medium">所有用户</span>在微信里打开小程序看到的都是开发库数据
(旧开发库:132 个用户、3 门课程,首页两个栏目为空)。
他们会以为课程丢失,请务必在测试结束后切回生产库。
</p>
) : (
<p className="rounded-md bg-green-50 px-3 py-2 text-green-800">
将恢复为生产库,用户看到的即真实数据。
</p>
)}
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-red-700">{error}</p>}
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={() => setConfirmOpen(false)}
disabled={switching}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
取消
</button>
<button
onClick={applyMiniProgramEnv}
disabled={switching}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700 disabled:opacity-50"
>
{switching ? '切换中…' : '确认切换'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function NavItem({
href,
label,
active,
onNavigate,
}: {
href: string;
label: string;
active: boolean;
/** 小屏:点完菜单就把抽屉收回去 */
onNavigate?: () => void;
}) {
return (
<Link
href={href}
onClick={onNavigate}
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>
);
}