Files
ForgQi dd7bb7a9ee Refine auth policy and migration compatibility
Centralize authorization into a policy layer so route, field, and user-permission checks all use the same decision point. The frontend now revalidates /v1/me after 403s or dropped connections, and the users page fetches the real role/permission catalog from the backend instead of hardcoding role descriptions. This also tightens field-level protection for sensitive config/template data and avoids exposing user-management controls when auth is off.

The backend and migrations are updated to handle LF/CRLF checkout differences in SQL migration checksums, with compatibility reconciliation for older databases and safeguards to prevent silent drift of previously shipped migrations.
2026-09-24 19:44:35 +08:00

400 lines
14 KiB
TypeScript

'use client'
import './bg-global.css'
import { useGlobalBackgroundInit } from '../lib/useGlobalBackground'
import { useState } from 'react'
import type { ReactNode } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import Image from 'next/image'
import useSWR from 'swr'
import { API_BASE, fetcher } from '../lib/api-streamer'
import { Dropdown, Empty, Spin } from '@douyinfe/semi-ui'
import { ROLE_LABELS, useMe } from '../lib/use-me'
import type { Permission } from '../lib/use-me'
import ChangePasswordModal from '../ui/ChangePasswordModal'
import ThemeButton from '../ui/ThemeButton'
import { useLocalStorageValue, useSystemTheme, useTheme } from '../lib/utils'
import { formatVersion } from '../lib/status'
import { SLOW_REFRESH_MS } from '../lib/use-dashboard'
import { useIsMobile } from '../lib/useIsMobile'
import styles from './layout.module.scss'
/* ============ 导航信息架构:5 组 10 项,按当前角色的权限点过滤 ============ */
function Ic({ d, extra }: { d: string; extra?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
{extra ? <path d={extra} /> : null}
</svg>
)
}
type NavItem = { href: string; label: string; icon: ReactNode; perm: Permission }
const NAV_GROUPS: { title: string; items: NavItem[] }[] = [
{
title: '总览',
items: [
{
href: '/',
perm: 'streamer.view',
label: '控制台',
icon: <Ic d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />,
},
],
},
{
title: '录制',
items: [
{
href: '/streamers',
perm: 'streamer.view',
label: '直播管理',
icon: <Ic d="M3 6h13v12H3zM16 9.5l5-2.5v10l-5-2.5" />,
},
{
href: '/history',
perm: 'file.view',
label: '历史记录',
icon: <Ic d="M4 6h16M4 12h16M4 18h10" extra="M18 15v4m0 0l-2-2m2 2l2-2" />,
},
{
href: '/job',
perm: 'streamer.view',
label: '直播历史',
icon: <Ic d="M12 7v5l3 2" extra="M12 21a9 9 0 110-18 9 9 0 010 18z" />,
},
],
},
{
title: '投稿',
items: [
{
href: '/upload-manager',
perm: 'streamer.view',
label: '投稿管理',
icon: <Ic d="M12 16V4m0 0L8 8m4-4l4 4" extra="M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />,
},
{
href: '/archives',
perm: 'account.manage',
label: 'B站稿件',
icon: <Ic d="M4 5h16v14H4z" extra="M8 15c0-2 1.5-3 4-3s4 1 4 3M12 8a2 2 0 100 4 2 2 0 000-4z" />,
},
],
},
{
title: '设置',
items: [
{
href: '/dashboard',
perm: 'config.view',
label: '空间配置',
icon: <Ic d="M12 15a3 3 0 100-6 3 3 0 000 6z" extra="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.2a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.9 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.2a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9l-.1-.1a2 2 0 112.8-2.8l.1-.1a1.7 1.7 0 001.9.3h0a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.2a1.7 1.7 0 001 1.5h0a1.7 1.7 0 001.9-.3l.1-.1a2 2 0 112.8 2.8l-.1-.1a1.7 1.7 0 00-.3 1.9v0a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.2a1.7 1.7 0 00-1.5 1z" />,
},
],
},
{
title: '系统',
items: [
{
href: '/logviewer',
perm: 'log.view',
label: '实时日志',
icon: <Ic d="M4 5h16M4 12h16M4 19h10" extra="M18 15l3 3-3 3" />,
},
{
href: '/status',
perm: 'streamer.view',
label: '任务平台',
icon: <Ic d="M4 4h16v16H4z" extra="M4 9h16M9 4v5" />,
},
{
href: '/users',
perm: 'user.manage',
label: '用户管理',
icon: (
<Ic
d="M16 19v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1M9.5 10a3.5 3.5 0 100-7 3.5 3.5 0 000 7z"
extra="M21 19v-1a4 4 0 00-3-3.9M16 3.1a3.5 3.5 0 010 6.8"
/>
),
},
],
},
]
/** 不在侧栏里、但同样需要权限的子页面(按前缀匹配,先匹配到的生效) */
const EXTRA_PAGE_PERMS: { prefix: string; perm: Permission }[] = [
{ prefix: '/upload-manager/add', perm: 'template.edit' },
{ prefix: '/upload-manager/edit', perm: 'template.edit' },
]
const matches = (pathname: string, href: string) =>
href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/')
function pagePermission(pathname: string): Permission | undefined {
const extra = EXTRA_PAGE_PERMS.find((p) => matches(pathname, p.prefix))
if (extra) return extra.perm
for (const group of NAV_GROUPS) {
for (const item of group.items) {
if (matches(pathname, item.href)) return item.perm
}
}
return undefined
}
function NoAccess({
title = '没有权限访问此页面',
description = '当前账号的角色不包含这项功能,如需使用请联系超级管理员。',
}: {
title?: string
description?: string
}) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<Empty
image={
<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="var(--semi-color-text-3)" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 118 0v4M12 15v2" />
</svg>
}
title={title}
description={description}
/>
</div>
)
}
function initialOf(name: string) {
return Array.from(name.trim())[0]?.toUpperCase() ?? '?'
}
/* 服务状态:布局级轻量轮询,所有页面共享 */
const SIDER_KEY = 'biliup_sider_collapsed'
export default function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const isMobile = useIsMobile()
const [mobileNavOpen, setMobileNavOpen] = useState(false)
useGlobalBackgroundInit()
// 折叠偏好持久化:localStorage 是数据源,首屏(SSR / 水合)按未折叠渲染,水合后自动切到已保存的值
const [siderPref, setSiderPref] = useLocalStorageValue(SIDER_KEY)
const collapsed = siderPref === '1'
const toggleCollapsed = () => setSiderPref(collapsed ? '0' : '1')
// 主题:同上,未保存过时为 auto
const [savedMode, setMode] = useLocalStorageValue('mode')
const mode = savedMode ?? 'auto'
const systemTheme = useSystemTheme()
useTheme(mode, systemTheme)
// 服务状态指示(离线时变红,不影响页面)
const { data: status, error: statusError } = useSWR('/v1/status', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
revalidateOnFocus: false,
})
const online = status !== undefined && !statusError
const version = (status as { version?: string } | undefined)?.version
const versionText = formatVersion(version)
const navCollapsed = !isMobile && collapsed
const isActive = (href: string) => matches(pathname, href)
// 权限未加载完时整组菜单先不过滤(全部都是公开的静态页面,数据接口本身有后端拦截),
// 避免每次进页面侧栏闪一下
const { me, error: meError, can } = useMe()
const visibleGroups = me
? NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => can(item.perm)) })).filter(
(group) => group.items.length > 0,
)
: NAV_GROUPS.map((group) => ({ ...group, items: group.items.filter((item) => item.perm !== 'user.manage') }))
const requiredPerm = pagePermission(pathname)
const denied = !!me && !!requiredPerm && !can(requiredPerm)
// 需要权限的页面一律等权限加载完再渲染:前端不假设哪个权限点人人都有,
// 免得没有权限的人先发出一串注定 403 的请求。/v1/me 有缓存,只有首次进入会等
const pending = !me && !meError && !!requiredPerm
const [passwordOpen, setPasswordOpen] = useState(false)
const logout = async () => {
try {
await fetch(`${API_BASE}/v1/logout`, { method: 'POST' })
} finally {
window.location.assign('/login')
}
}
const showUser = me?.auth_enabled === true
const username = me?.username ?? ''
const roleLabel = me ? ROLE_LABELS[me.role] : ''
return (
<div className={styles.app}>
{/* 移动端:汉堡按钮 + 遮罩(抽屉打开时隐藏汉堡,点击其位置即点遮罩关闭) */}
{isMobile && !mobileNavOpen && (
<button
className={styles.burger}
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
)}
{isMobile && mobileNavOpen && (
<div className={styles.overlay} onClick={() => setMobileNavOpen(false)} />
)}
<aside
className={`${styles.sider} ${navCollapsed ? styles.collapsed : ''} ${
isMobile ? styles.siderMobile : ''
} ${mobileNavOpen ? styles.open : ''}`}
>
{/* 品牌区 */}
<div className={styles.brand}>
<Link
href="/"
prefetch={false}
onClick={() => isMobile && setMobileNavOpen(false)}
aria-label="回到控制台"
>
<Image
src="/logo.png"
alt="biliup"
width={30}
height={30}
style={{ width: 30, height: 30, objectFit: 'contain' }}
unoptimized
/>
</Link>
{!navCollapsed && (
<span className={styles.brandText}>
<Link
href="/"
prefetch={false}
className={styles.brandName}
onClick={() => isMobile && setMobileNavOpen(false)}
>
biliup
</Link>
<Link href="/changelog" prefetch={false} className={styles.brandVer} title="更新日志">
v{versionText ?? '—'}
</Link>
</span>
)}
</div>
{/* 导航 */}
<nav className={styles.nav}>
{visibleGroups.map((group) => (
<div key={group.title} className={styles.group}>
{!navCollapsed && <div className={styles.groupTitle}>{group.title}</div>}
{group.items.map((item) => {
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
prefetch={false}
className={`${styles.item} ${active ? styles.itemActive : ''}`}
title={navCollapsed ? item.label : undefined}
onClick={() => isMobile && setMobileNavOpen(false)}
>
<span className={styles.itemIcon}>{item.icon}</span>
{!navCollapsed && <span className={styles.itemText}>{item.label}</span>}
</Link>
)
})}
</div>
))}
</nav>
{/* 底部:当前用户 + 服务状态 + 工具 */}
<div className={styles.foot}>
{showUser && (
<Dropdown
trigger="click"
position={navCollapsed ? 'rightBottom' : 'topLeft'}
render={
<Dropdown.Menu>
<Dropdown.Title>
{username} · {roleLabel}
</Dropdown.Title>
<Dropdown.Item onClick={() => setPasswordOpen(true)}>修改密码</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item type="danger" onClick={logout}>
退出登录
</Dropdown.Item>
</Dropdown.Menu>
}
>
<button
type="button"
className={`${styles.userBtn} ${navCollapsed ? styles.userBtnCollapsed : ''}`}
aria-label={`当前用户 ${username},打开账户菜单`}
title={navCollapsed ? `${username} · ${roleLabel}` : undefined}
>
<span className={styles.userAvatar} aria-hidden="true">
{initialOf(username)}
</span>
{!navCollapsed && (
<span className={styles.userText}>
<span className={styles.userName}>{username}</span>
<span className={styles.userRole}>{roleLabel}</span>
</span>
)}
</button>
</Dropdown>
)}
{!navCollapsed && (
<div className={styles.statusRow}>
<span className={`${styles.statusDot} ${online ? styles.online : styles.offline}`} />
<span className={styles.statusText}>{online ? '服务运行中' : '服务未连接'}</span>
</div>
)}
<div className={styles.footBtns}>
<ThemeButton mode={mode} setMode={setMode} systemTheme={systemTheme} />
{!isMobile && (
<button
className={styles.footBtn}
onClick={toggleCollapsed}
aria-label={navCollapsed ? '展开侧栏' : '收起侧栏'}
title={navCollapsed ? '展开侧栏' : '收起侧栏'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{navCollapsed ? <path d="M9 6l6 6-6 6" /> : <path d="M15 6l-6 6 6 6" />}
</svg>
</button>
)}
</div>
</div>
</aside>
<main className={styles.main}>
{pending ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
) : denied ? (
requiredPerm === 'user.manage' && me && !me.auth_enabled ? (
<NoAccess
title="未开启登录认证"
description="当前以零鉴权模式运行,没有登录用户可管理。使用 biliup server --auth 启动后即可在这里添加用户、分配角色。"
/>
) : (
<NoAccess />
)
) : (
children
)}
</main>
{showUser && <ChangePasswordModal visible={passwordOpen} onClose={() => setPasswordOpen(false)} />}
</div>
)
}