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:
1 parent
ccb016be4f
commit
b438d72415
41 files changed
+2917
-348
No files matched your search
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ? '注册失败' : '登录失败'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user