feat: 实现前端管理后台(React + Vite + Ant Design + Zustand)
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>阿里云 VOD 媒体库管理器</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@vod-manager/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Web frontend for Aliyun VOD Media Library Manager",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext .ts,.tsx"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "^5.17.4",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"dayjs": "^1.11.11",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.11.0",
|
||||||
|
"@typescript-eslint/parser": "^7.11.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from './store/auth';
|
||||||
|
import LoginPage from './pages/Login';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import VideosPage from './pages/Videos';
|
||||||
|
import AccountsPage from './pages/Accounts';
|
||||||
|
import LogsPage from './pages/Logs';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = useAuthStore((state) => state.token);
|
||||||
|
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/videos" replace />} />
|
||||||
|
<Route path="videos" element={<VideosPage />} />
|
||||||
|
<Route path="accounts" element={<AccountsPage />} />
|
||||||
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
region: string;
|
||||||
|
endpoint?: string;
|
||||||
|
isActive: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountInput {
|
||||||
|
name: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
accessKeySecret: string;
|
||||||
|
region: string;
|
||||||
|
endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAccounts(): Promise<Account[]> {
|
||||||
|
const res = await apiClient.get<ApiResponse<Account[]>>('/accounts');
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccount(params: AccountInput): Promise<Account> {
|
||||||
|
const res = await apiClient.post<ApiResponse<Account>>('/accounts', params);
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccount(id: string, params: Partial<AccountInput>): Promise<Account> {
|
||||||
|
const res = await apiClient.put<ApiResponse<Account>>(`/accounts/${id}`, params);
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccount(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/accounts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchAccount(id: string): Promise<Account> {
|
||||||
|
const res = await apiClient.post<ApiResponse<Account>>(`/accounts/${id}/switch`);
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
import { User } from '../store/auth';
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(params: LoginParams): Promise<LoginResult> {
|
||||||
|
const res = await apiClient.post<ApiResponse<LoginResult>>('/auth/login', params);
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<User> {
|
||||||
|
const res = await apiClient.get<ApiResponse<User>>('/auth/me');
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
|
||||||
|
export async function listCategories(): Promise<Record<string, unknown>[]> {
|
||||||
|
const res = await apiClient.get<ApiResponse<Record<string, unknown>[]>>('/categories');
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = useAuthStore.getState().token;
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message?: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
|
||||||
|
export interface OperationLog {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
username: string | null;
|
||||||
|
action: string;
|
||||||
|
targetType: string;
|
||||||
|
targetIds: string;
|
||||||
|
details: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListLogsResult {
|
||||||
|
total: number;
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
logs: OperationLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLogs(params: {
|
||||||
|
pageNo?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
action?: string;
|
||||||
|
}): Promise<ListLogsResult> {
|
||||||
|
const res = await apiClient.get<ApiResponse<ListLogsResult>>('/logs', { params });
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { apiClient, ApiResponse } from './client';
|
||||||
|
|
||||||
|
export interface VideoItem {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
duration?: number;
|
||||||
|
size?: number;
|
||||||
|
coverURL?: string;
|
||||||
|
status?: string;
|
||||||
|
cateId?: number;
|
||||||
|
cateName?: string;
|
||||||
|
tags?: string;
|
||||||
|
creationTime?: string;
|
||||||
|
modificationTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayInfo {
|
||||||
|
playURL: string;
|
||||||
|
definition: string;
|
||||||
|
duration: string;
|
||||||
|
size: number;
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchParams {
|
||||||
|
keyword?: string;
|
||||||
|
cateId?: number;
|
||||||
|
status?: string;
|
||||||
|
pageNo?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'Asc' | 'Desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
total: number;
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
videos: VideoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchVideos(params: SearchParams): Promise<SearchResult> {
|
||||||
|
const res = await apiClient.get<ApiResponse<SearchResult>>('/videos', { params });
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideo(videoId: string): Promise<VideoItem> {
|
||||||
|
const res = await apiClient.get<ApiResponse<VideoItem>>(`/videos/${videoId}`);
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlayInfo(videoId: string, definition?: string): Promise<PlayInfo[]> {
|
||||||
|
const res = await apiClient.post<ApiResponse<PlayInfo[]>>(`/videos/${videoId}/play-info`, {
|
||||||
|
definition,
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchDelete(videoIds: string[]): Promise<{ deleted: number }> {
|
||||||
|
const res = await apiClient.post<ApiResponse<{ deleted: number }>>('/videos/batch-delete', {
|
||||||
|
videoIds,
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadResult {
|
||||||
|
videoId: string;
|
||||||
|
title?: string;
|
||||||
|
urls: { definition: string; url: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchDownload(
|
||||||
|
videoIds: string[],
|
||||||
|
definition?: string
|
||||||
|
): Promise<DownloadResult[]> {
|
||||||
|
const res = await apiClient.post<ApiResponse<DownloadResult[]>>('/videos/batch-download', {
|
||||||
|
videoIds,
|
||||||
|
definition,
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchUpdate(
|
||||||
|
videoIds: string[],
|
||||||
|
updates: { title?: string; description?: string; tags?: string; cateId?: number }
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
const res = await apiClient.post<ApiResponse<{ updated: number }>>('/videos/batch-update', {
|
||||||
|
videoIds,
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Layout as AntLayout, Menu, Dropdown, Space, Button, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
VideoCameraOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = AntLayout;
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, clearAuth } = useAuthStore();
|
||||||
|
const {
|
||||||
|
token: { colorBgContainer, borderRadiusLG },
|
||||||
|
} = theme.useToken();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/videos', icon: <VideoCameraOutlined />, label: '视频库' },
|
||||||
|
{ key: '/accounts', icon: <SettingOutlined />, label: '账号配置' },
|
||||||
|
{ key: '/logs', icon: <FileTextOutlined />, label: '操作日志' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
navigate(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearAuth();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMenuItems = [
|
||||||
|
{ key: 'user', label: user?.username, disabled: true },
|
||||||
|
{ key: 'role', label: `角色: ${user?.role === 'admin' ? '管理员' : '只读'}`, disabled: true },
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntLayout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider trigger={null} collapsible theme="light">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 64,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
VOD 管理器
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<AntLayout>
|
||||||
|
<Header style={{ padding: '0 24px', background: colorBgContainer }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '100%' }}>
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
|
<Button type="text">
|
||||||
|
<Space>
|
||||||
|
<UserOutlined />
|
||||||
|
{user?.username}
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
margin: 24,
|
||||||
|
padding: 24,
|
||||||
|
background: colorBgContainer,
|
||||||
|
borderRadius: borderRadiusLG,
|
||||||
|
minHeight: 280,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</AntLayout>
|
||||||
|
</AntLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
'Noto Sans', sans-serif;
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Tag,
|
||||||
|
Popconfirm,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
listAccounts,
|
||||||
|
createAccount,
|
||||||
|
deleteAccount,
|
||||||
|
switchAccount,
|
||||||
|
Account,
|
||||||
|
AccountInput,
|
||||||
|
} from '../api/accounts';
|
||||||
|
|
||||||
|
export default function AccountsPage() {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const fetchAccounts = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await listAccounts();
|
||||||
|
setAccounts(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: AccountInput) => {
|
||||||
|
try {
|
||||||
|
await createAccount(values);
|
||||||
|
message.success('账号添加成功');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('添加失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAccount(id);
|
||||||
|
message.success('已删除');
|
||||||
|
fetchAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitch = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await switchAccount(id);
|
||||||
|
message.success('已切换当前账号');
|
||||||
|
fetchAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('切换失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{ title: 'AccessKey ID', dataIndex: 'accessKeyId', ellipsis: true },
|
||||||
|
{ title: 'Region', dataIndex: 'region' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'isActive',
|
||||||
|
render: (isActive: number) =>
|
||||||
|
isActive ? <Tag color="green">当前使用</Tag> : <Tag>备用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: unknown, record: Account) => (
|
||||||
|
<Space>
|
||||||
|
{!record.isActive && (
|
||||||
|
<Button type="link" onClick={() => handleSwitch(record.id)}>
|
||||||
|
切换
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||||
|
<Button type="link" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||||
|
添加账号
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table rowKey="id" columns={columns} dataSource={accounts} loading={loading} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="添加阿里云 VOD 账号"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
label="名称"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: '请输入名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="用于区分不同账号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="AccessKey ID"
|
||||||
|
name="accessKeyId"
|
||||||
|
rules={[{ required: true, message: '请输入 AccessKey ID' }]}
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="AccessKey Secret"
|
||||||
|
name="accessKeySecret"
|
||||||
|
rules={[{ required: true, message: '请输入 AccessKey Secret' }]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Region"
|
||||||
|
name="region"
|
||||||
|
rules={[{ required: true, message: '请输入 Region' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如 cn-shanghai" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Endpoint(可选)" name="endpoint">
|
||||||
|
<Input placeholder="默认 vod.{region}.aliyuncs.com" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Form, Input, Button, Card, message } from 'antd';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { login } from '../api/auth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await login(values);
|
||||||
|
setAuth(result.token, result.user);
|
||||||
|
message.success('登录成功');
|
||||||
|
navigate('/videos');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('登录失败,请检查用户名和密码');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#f0f2f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card title="阿里云 VOD 媒体库管理器" style={{ width: 400 }}>
|
||||||
|
<Form name="login" onFinish={handleSubmit} autoComplete="off" layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label="用户名"
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="admin" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="密码"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="admin" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Table, Tag } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { listLogs, OperationLog } from '../api/logs';
|
||||||
|
|
||||||
|
const actionMap: Record<string, string> = {
|
||||||
|
BATCH_DELETE: '批量删除',
|
||||||
|
BATCH_DOWNLOAD: '批量下载',
|
||||||
|
BATCH_UPDATE: '批量更新',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionColor: Record<string, string> = {
|
||||||
|
BATCH_DELETE: 'red',
|
||||||
|
BATCH_DOWNLOAD: 'blue',
|
||||||
|
BATCH_UPDATE: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [logs, setLogs] = useState<OperationLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [pageNo, setPageNo] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
|
||||||
|
const fetchLogs = async (page: number, size: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await listLogs({ pageNo: page, pageSize: size });
|
||||||
|
setLogs(result.logs);
|
||||||
|
setTotal(result.total);
|
||||||
|
setPageNo(result.pageNo);
|
||||||
|
setPageSize(result.pageSize);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs(1, 20);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
},
|
||||||
|
{ title: '用户', dataIndex: 'username', width: 120 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 120,
|
||||||
|
render: (action: string) => <Tag color={actionColor[action] || 'default'}>{actionMap[action] || action}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '目标类型', dataIndex: 'targetType', width: 100 },
|
||||||
|
{
|
||||||
|
title: '目标 ID',
|
||||||
|
dataIndex: 'targetIds',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (ids: string | null) => ids || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
|
dataIndex: 'details',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (details: string | null) => (details ? JSON.stringify(JSON.parse(details)) : '-'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={logs}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: (page, size) => fetchLogs(page, size || 20),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Drawer,
|
||||||
|
Tag,
|
||||||
|
Image,
|
||||||
|
Popconfirm,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { DownloadOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { searchVideos, batchDelete, batchDownload, VideoItem, DownloadResult } from '../api/videos';
|
||||||
|
import { listCategories } from '../api/categories';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return '-';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
let size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(2)} ${units[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VideosPage() {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<VideoItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [pageNo, setPageNo] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [categories, setCategories] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [detail, setDetail] = useState<VideoItem | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const fetchVideos = async (params?: Record<string, unknown>) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const query: Record<string, unknown> = {
|
||||||
|
pageNo,
|
||||||
|
pageSize,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (values.keyword) query.keyword = values.keyword;
|
||||||
|
if (values.cateId) query.cateId = values.cateId;
|
||||||
|
if (values.status) query.status = values.status;
|
||||||
|
if (values.dateRange?.[0] && values.dateRange?.[1]) {
|
||||||
|
query.startTime = values.dateRange[0].toISOString();
|
||||||
|
query.endTime = values.dateRange[1].toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await searchVideos(query);
|
||||||
|
setVideos(result.videos);
|
||||||
|
setTotal(result.total);
|
||||||
|
setPageNo(result.pageNo);
|
||||||
|
setPageSize(result.pageSize);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await listCategories();
|
||||||
|
setCategories(data);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVideos();
|
||||||
|
fetchCategories();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPageNo(1);
|
||||||
|
fetchVideos({ pageNo: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setPageNo(1);
|
||||||
|
fetchVideos({ pageNo: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
const ids = selectedRows.map((v) => v.videoId);
|
||||||
|
try {
|
||||||
|
await batchDelete(ids);
|
||||||
|
message.success(`已删除 ${ids.length} 个视频`);
|
||||||
|
setSelectedRows([]);
|
||||||
|
fetchVideos();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDownload = async () => {
|
||||||
|
const ids = selectedRows.map((v) => v.videoId);
|
||||||
|
try {
|
||||||
|
const results = await batchDownload(ids);
|
||||||
|
const valid = results.filter((r) => r.urls.length > 0);
|
||||||
|
showDownloadResults(valid);
|
||||||
|
setSelectedRows([]);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取下载地址失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDownloadResults = (results: DownloadResult[]) => {
|
||||||
|
Modal.info({
|
||||||
|
title: '批量下载地址',
|
||||||
|
width: 800,
|
||||||
|
content: (
|
||||||
|
<div style={{ maxHeight: 400, overflow: 'auto' }}>
|
||||||
|
{results.map((item) => (
|
||||||
|
<div key={item.videoId} style={{ marginBottom: 12 }}>
|
||||||
|
<Text strong>{item.title || item.videoId}</Text>
|
||||||
|
<ul style={{ margin: 4, paddingLeft: 20 }}>
|
||||||
|
{item.urls.map((url, idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<Text copyable>{url.url}</Text>
|
||||||
|
<Tag style={{ marginLeft: 8 }}>{url.definition}</Tag>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowClick = (record: VideoItem) => {
|
||||||
|
setDetail(record);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '封面',
|
||||||
|
dataIndex: 'coverURL',
|
||||||
|
width: 80,
|
||||||
|
render: (url?: string) =>
|
||||||
|
url ? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} /> : '-',
|
||||||
|
},
|
||||||
|
{ title: '视频 ID', dataIndex: 'videoId', width: 180, ellipsis: true },
|
||||||
|
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
dataIndex: 'cateName',
|
||||||
|
width: 120,
|
||||||
|
render: (name?: string) => name || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时长',
|
||||||
|
dataIndex: 'duration',
|
||||||
|
width: 100,
|
||||||
|
render: (duration?: number) => formatDuration(duration),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '大小',
|
||||||
|
dataIndex: 'size',
|
||||||
|
width: 120,
|
||||||
|
render: (size?: number) => formatSize(size),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status?: string) => <Tag>{status || '-'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'creationTime',
|
||||||
|
width: 180,
|
||||||
|
render: (time?: string) => (time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: VideoItem) => (
|
||||||
|
<Button icon={<EyeOutlined />} type="link" onClick={() => handleRowClick(record)}>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item name="keyword">
|
||||||
|
<Input placeholder="搜索标题 / 视频 ID" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cateId">
|
||||||
|
<Select placeholder="选择分类" allowClear style={{ width: 160 }}>
|
||||||
|
{categories.map((cat: Record<string, unknown>) => (
|
||||||
|
<Select.Option key={String(cat.CateId)} value={cat.CateId as number}>
|
||||||
|
{String(cat.CateName)}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status">
|
||||||
|
<Select placeholder="状态" allowClear style={{ width: 120 }}>
|
||||||
|
<Select.Option value="Normal">正常</Select.Option>
|
||||||
|
<Select.Option value="Uploading">上传中</Select.Option>
|
||||||
|
<Select.Option value="Transcoding">转码中</Select.Option>
|
||||||
|
<Select.Option value="Checking">审核中</Select.Option>
|
||||||
|
<Select.Option value="Blocked">屏蔽</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="dateRange">
|
||||||
|
<RangePicker showTime />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" onClick={handleSearch}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button onClick={handleReset}>重置</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
disabled={selectedRows.length === 0}
|
||||||
|
onClick={handleBatchDownload}
|
||||||
|
>
|
||||||
|
批量下载 ({selectedRows.length})
|
||||||
|
</Button>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description={`确定要删除选中的 ${selectedRows.length} 个视频吗?删除后不可恢复。`}
|
||||||
|
onConfirm={handleBatchDelete}
|
||||||
|
disabled={selectedRows.length === 0}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
disabled={selectedRows.length === 0}
|
||||||
|
>
|
||||||
|
批量删除 ({selectedRows.length})
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="videoId"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={videos}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: (page, size) => {
|
||||||
|
setPageNo(page);
|
||||||
|
setPageSize(size || 20);
|
||||||
|
fetchVideos({ pageNo: page, pageSize: size });
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedRows.map((v) => v.videoId),
|
||||||
|
onChange: (_keys, rows) => setSelectedRows(rows),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="视频详情"
|
||||||
|
width={560}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{detail.coverURL && <Image src={detail.coverURL} style={{ width: '100%' }} />}
|
||||||
|
<p>
|
||||||
|
<Text strong>视频 ID:</Text>
|
||||||
|
<Text copyable>{detail.videoId}</Text>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>标题:</Text>
|
||||||
|
{detail.title}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>描述:</Text>
|
||||||
|
{detail.description || '-'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>分类:</Text>
|
||||||
|
{detail.cateName || '-'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>标签:</Text>
|
||||||
|
{detail.tags || '-'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>时长:</Text>
|
||||||
|
{formatDuration(detail.duration)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>大小:</Text>
|
||||||
|
{formatSize(detail.size)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>状态:</Text>
|
||||||
|
<Tag>{detail.status || '-'}</Tag>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Text strong>创建时间:</Text>
|
||||||
|
{detail.creationTime
|
||||||
|
? dayjs(detail.creationTime).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'readonly';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
user: User | null;
|
||||||
|
setAuth: (token: string, user: User) => void;
|
||||||
|
clearAuth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
setAuth: (token, user) => set({ token, user }),
|
||||||
|
clearAuth: () => set({ token: null, user: null }),
|
||||||
|
}),
|
||||||
|
{ name: 'vod-auth' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user