325 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|