feat(web): 完善账号管理、稿件查询与安全边界 (#1660)

启动 Web 服务时自动登记 CLI 指定的 B 站凭据,并为账号登记、刷新和删除统一补上并发去重、原子写入及引用检查。

同时收紧默认监听、管理员初始化、认证路由、静态文件和日志 WebSocket,恢复 server 的 ds_update.log,并避免凭据及上游响应进入日志。

新增按账号查询远程稿件的 API 与 Web 页面,补齐严格分页和响应校验。

关联 biliup/biliup#1624

关联 biliup/biliup#1658

关联 biliup/biliup#724
This commit is contained in:
Mostly_Harmless authored and GitHub committed 2026-08-16 14:05:20 +00:00
1 parent ccb016be4f
commit b438d72415
41 files changed
+2917 -348

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
'use client'
import { BiliArchive, BiliArchivePage, fetcher } from '@/app/lib/api-streamer'
import { humDate } from '@/app/lib/utils'
import { useBiliUsers } from '@/app/lib/use-streamers'
import { IconUserCardVideo } from '@douyinfe/semi-icons'
import { Banner, Layout, Nav, Pagination, Select, Table, Typography } from '@douyinfe/semi-ui'
import { useState } from 'react'
import useSWR from 'swr'
const statusOptions = [
{ label: '全部状态', value: 'all' },
{ label: '审核中', value: 'is_pubing' },
{ label: '已通过', value: 'pubed' },
{ label: '未通过', value: 'not_pubed' },
]
export default function ArchivesPage() {
const { Header, Content } = Layout
const { Text } = Typography
const { biliUsers, isLoading: usersLoading } = useBiliUsers()
const [userId, setUserId] = useState<number>()
const [status, setStatus] = useState('all')
const [pageNumber, setPageNumber] = useState(1)
const key = userId
? `/v1/users/${userId}/archives?status=${status}&from_page=${pageNumber}&max_pages=1`
: null
const { data, error, isLoading } = useSWR<BiliArchivePage>(key, fetcher)
const columns = [
{
title: 'BV号',
dataIndex: 'bvid',
render: (bvid: string) => (
<a href={`https://www.bilibili.com/video/${bvid}`} target="_blank" rel="noreferrer">
{bvid}
</a>
),
},
{
title: '标题',
dataIndex: 'title',
render: (title: string) => <Text strong>{title}</Text>,
},
{
title: '状态',
dataIndex: 'state_desc',
render: (stateDescription: string, archive: BiliArchive) =>
archive.reject_reason || stateDescription || `状态 ${archive.state}`,
},
{
title: '时长',
dataIndex: 'duration',
render: (duration: number) => `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}`,
},
{
title: '发布时间',
dataIndex: 'ptime',
render: (ptime: number, archive: BiliArchive) => {
const timestamp = ptime || archive.ctime
return timestamp ? humDate(timestamp) : '-'
},
},
]
return (
<>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
style={{ border: 'none' }}
header={
<>
<IconUserCardVideo size="large" />
<h4 style={{ marginLeft: 12 }}>B站稿件</h4>
</>
}
mode="horizontal"
/>
</Header>
<Content style={{ padding: 24, backgroundColor: 'var(--semi-color-bg-0)' }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
<Select
placeholder="请选择 B 站账号"
loading={usersLoading}
value={userId}
style={{ minWidth: 260 }}
onChange={value => {
setUserId(Number(value))
setPageNumber(1)
}}
>
{biliUsers.map(user => (
<Select.Option key={user.id} value={user.id}>
{user.name}
</Select.Option>
))}
</Select>
<Select
value={status}
optionList={statusOptions}
onChange={value => {
setStatus(String(value))
setPageNumber(1)
}}
/>
</div>
{!userId && <Banner type="info" description="请先明确选择一个账号,再读取该账号的远程稿件。" />}
{error && <Banner type="danger" description={error.message || '读取稿件失败'} />}
{userId && (
<>
<Table
rowKey="aid"
size="small"
loading={isLoading}
columns={columns}
dataSource={data?.archives ?? []}
pagination={false}
/>
{!!data && data.total > 0 && (
<Pagination
style={{ marginTop: 16, justifyContent: 'flex-end' }}
currentPage={pageNumber}
pageSize={data.page_size}
total={data.total}
onPageChange={setPageNumber}
/>
)}
</>
)}
</Content>
</>
)
}
+19
View File
@@ -16,6 +16,7 @@ import {
IconHome,
IconSetting,
IconHistory,
IconUserCardVideo,
} from '@douyinfe/semi-icons'
import Image from 'next/image'
import ThemeButton from '../ui/ThemeButton'
@@ -109,6 +110,23 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</div>
),
},
{
itemKey: 'archives',
text: 'B站稿件',
icon: (
<div
style={{
backgroundColor: '#00a1d6',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconUserCardVideo size="small" />
</div>
),
},
{
itemKey: 'dashboard',
text: '空间配置',
@@ -207,6 +225,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
dashboard: '/dashboard',
streamers: '/streamers',
'upload-manager': '/upload-manager',
archives: '/archives',
job: '/job',
status: '/status',
logViewer: '/logviewer',
+2 -2
View File
@@ -66,8 +66,8 @@ export default function Union() {
const handleOk = async () => {
await sendRequest('/v1/uploads', {
arg: {
files: selectFiles,
params: selectEntity,
files: selectFiles.map(String),
template_id: selectEntity?.id,
},
})
setVisibleModal(false)
+15 -4
View File
@@ -55,14 +55,25 @@ const Component = () => {
if (response.ok) {
Toast.success(isRegisterMode ? '注册成功' : '登录成功');
// 获取当前 URL 的查询参数
const urlParams = new URLSearchParams(window.location.search);
// 获取 next 参数的值
const nextPath = urlParams.get('next');
if (nextPath) {
window.location.href = decodeURIComponent(nextPath); // 解码 %2F 等转义字符
try {
// URLSearchParams has already decoded the value. Only
// allow a same-origin destination so a crafted login URL
// cannot become an open redirect after authentication.
const destination = new URL(nextPath, window.location.origin);
if (destination.origin === window.location.origin) {
window.location.assign(
`${destination.pathname}${destination.search}${destination.hash}`
);
return;
}
} catch {
// Fall through to the safe default route.
}
}
// router.push('/');
window.location.assign('/');
} else {
const result = await response.json();
Toast.error(result.message || (isRegisterMode ? '注册失败' : '登录失败'));
+25
View File
@@ -128,6 +128,31 @@ export interface User {
platform: string;
}
export interface BiliArchive {
aid: number;
bvid: string;
title: string;
cover: string;
reject_reason: string;
reject_reason_url: string;
duration: number;
desc: string;
state: number;
state_desc: string;
dtime: number;
ptime: number;
ctime: number;
}
export interface BiliArchivePage {
from_page: number;
page_size: number;
total: number;
total_pages: number;
fetched_pages: number;
archives: BiliArchive[];
}
export interface FileList {
key: number;
name: string;
+4 -9
View File
@@ -3,7 +3,7 @@ import useSWR from "swr";
import {
BiliType,
fetcher,
LiveStreamerEntity, proxy,
LiveStreamerEntity,
User
} from "./api-streamer";
import {useEffect, useState} from "react";
@@ -28,23 +28,18 @@ export function useBiliUsers() {
}
const updateList = async (item: User) => {
try {
const res = await fetcher(`/bili/space/myinfo?user=${item.value}`, undefined);
const pRes = await proxy(`/bili/proxy?url=${res.data?.face}`);
const myBlob = await pRes.blob();
const res = await fetcher(`/v1/users/${item.id}`, undefined);
return {
...item,
name: res.data.name,
face: URL.createObjectURL(myBlob),
face: res.data?.face || "/noface.jpg",
};
} catch (error) {
console.error(error);
const pRes = await proxy("/bili/proxy?url=https://i0.hdslb.com/bfs/face/member/noface.jpg");
const myBlob = await pRes.blob();
return {
...item,
name: "Cookie已失效",
face: URL.createObjectURL(myBlob),
face: "/noface.jpg",
};
}
};
+2 -2
View File
@@ -21,8 +21,8 @@ const AvatarCard: React.FC<ICardProps> = ({ abbr, label, value, onRemove, url })
<div className={styles.email}>{value}</div>
</div>
<Popconfirm
title="确定是否要删除?"
content="此操作将不可逆"
title="删除这个 B 站账号?"
content="若没有上传模板或其他账号记录引用同一凭据,服务器还会物理删除本地凭据文件。此操作不可逆。"
onConfirm={onRemove}
// onCancel={onCancel}
>
+1 -17
View File
@@ -1,15 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useRef, useState } from 'react'
import {
fetcher,
LiveStreamerEntity,
proxy,
requestDelete,
sendRequest,
StudioEntity,
User,
} from '../lib/api-streamer'
import useSWR from 'swr'
import { useRouter } from 'next/router'
import {
Button,
Form,
@@ -50,16 +43,7 @@ const UserList: React.FC<UserListProps> = ({ onCancel, visible }) => {
const addUser = async (value: any) => {
setConfirmLoading(true)
try {
const ret = await fetcher(`/bili/space/myinfo?user=${value}`, undefined)
if (ret.code) {
throw new Error(ret.message)
}
await trigger({
// id: 0,
// name: value,
// value: value,
// platform: 'bilibili-cookies',
key: 'bilibili-cookies',
value: value
})
setVisible(false)