refactor: Refactor public sharing page

This commit is contained in:
shiyu
2025-08-28 13:22:46 +08:00
parent 62a1c5810d
commit 150f6a77fb
5 changed files with 322 additions and 166 deletions

View File

@@ -1,165 +0,0 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router';
import { Card, message, Spin, List, Typography, Button, Empty, Breadcrumb, Input, Form } from 'antd';
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../api/share';
import { type VfsEntry } from '../api/vfs';
import { format, parseISO } from 'date-fns';
const { Title, Text } = Typography;
const PublicSharePage = memo(function PublicSharePage() {
const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true);
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
const [entries, setEntries] = useState<VfsEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [verified, setVerified] = useState(false);
const loadData = useCallback(async (p: string, pwd?: string) => {
if (!token) return;
setLoading(true);
setError('');
try {
let info = shareInfo;
if (!info) {
info = await shareApi.get(token);
setShareInfo(info);
}
if (info?.access_type === 'password' && !verified) {
// Do not load files until password is verified
setLoading(false);
return;
}
const currentPassword = pwd || password;
const listing = await shareApi.listDir(token, p, currentPassword);
setEntries(listing.entries || []);
setCurrentPath(p);
} catch (e: any) {
setError(e.message || '加载分享失败');
if (e.message === '需要密码') {
setVerified(false);
}
} finally {
setLoading(false);
}
}, [token, shareInfo, password, verified]);
useEffect(() => {
loadData(currentPath);
}, [loadData, currentPath]);
const handleEntryClick = (entry: VfsEntry) => {
if (entry.is_dir) {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
loadData(newPath);
} else {
// Preview logic can be added here
message.info('暂不支持预览');
}
};
const handleBreadcrumbClick = (path: string) => {
loadData(path);
};
const renderBreadcrumb = () => {
const parts = currentPath.split('/').filter(Boolean);
const items = [{ title: '全部文件', path: '/' }];
parts.forEach((part, i) => {
const path = '/' + parts.slice(0, i + 1).join('/');
items.push({ title: part, path });
});
return (
<Breadcrumb>
{items.map((item, i) => (
<Breadcrumb.Item key={i}>
{i === items.length - 1 ? (
<span>{item.title}</span>
) : (
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
)}
</Breadcrumb.Item>
))}
</Breadcrumb>
);
};
const handlePasswordSubmit = async (values: { password_input: string }) => {
if (!token) return;
try {
await shareApi.verifyPassword(token, values.password_input);
setPassword(values.password_input);
setVerified(true);
setError('');
loadData(currentPath, values.password_input);
} catch (e: any) {
message.error(e.message || '密码错误');
}
};
if (loading && !shareInfo) {
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
}
if (error && !error.includes('需要密码')) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
}
if (shareInfo?.access_type === 'password' && !verified) {
return (
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
<Card title="需要密码">
<Form onFinish={handlePasswordSubmit}>
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
<Card>
<Title level={4}>{shareInfo?.name}</Title>
<Text type="secondary">
{shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
</Text>
<div style={{ margin: '16px 0' }}>
{renderBreadcrumb()}
</div>
<List
loading={loading}
dataSource={entries}
renderItem={item => (
<List.Item
actions={[
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
]}
>
<List.Item.Meta
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
/>
</List.Item>
)}
/>
</Card>
</div>
);
});
export default PublicSharePage;

View File

@@ -0,0 +1,110 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd';
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { format, parseISO } from 'date-fns';
const { Title, Text } = Typography;
interface DirectoryViewerProps {
token: string;
shareInfo: ShareInfo;
password?: string;
}
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) {
const [loading, setLoading] = useState(true);
const [entries, setEntries] = useState<VfsEntry[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [error, setError] = useState('');
const loadData = useCallback(async (p: string) => {
setLoading(true);
setError('');
try {
const listing = await shareApi.listDir(token, p, password);
setEntries(listing.entries || []);
setCurrentPath(p);
} catch (e: any) {
setError(e.message || '加载分享失败');
} finally {
setLoading(false);
}
}, [token, password]);
useEffect(() => {
loadData(currentPath);
}, [loadData, currentPath]);
const handleEntryClick = (entry: VfsEntry) => {
if (entry.is_dir) {
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
loadData(newPath);
} else {
message.info('暂不支持预览');
}
};
const handleBreadcrumbClick = (path: string) => {
loadData(path);
};
const renderBreadcrumb = () => {
const parts = currentPath.split('/').filter(Boolean);
const items = [{ title: '全部文件', path: '/' }];
parts.forEach((part, i) => {
const path = '/' + parts.slice(0, i + 1).join('/');
items.push({ title: part, path });
});
return (
<Breadcrumb>
{items.map((item, i) => (
<Breadcrumb.Item key={i}>
{i === items.length - 1 ? (
<span>{item.title}</span>
) : (
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
)}
</Breadcrumb.Item>
))}
</Breadcrumb>
);
};
if (error) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
}
return (
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
<Card>
<Title level={4}>{shareInfo?.name}</Title>
<Text type="secondary">
{shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
</Text>
<div style={{ margin: '16px 0' }}>
{renderBreadcrumb()}
</div>
<List
loading={loading}
dataSource={entries}
renderItem={item => (
<List.Item
actions={[
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
]}
>
<List.Item.Meta
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
/>
</List.Item>
)}
/>
</Card>
</div>
);
});

View File

@@ -0,0 +1,102 @@
import { memo, useState, useEffect } from 'react';
import { Card, Spin, Button, Typography, Empty } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { format, parseISO } from 'date-fns';
import ReactMarkdown from 'react-markdown';
const { Title, Text } = Typography;
interface FileViewerProps {
token: string;
shareInfo: ShareInfo;
entry: VfsEntry;
password?: string;
}
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) {
const [loading, setLoading] = useState(true);
const [content, setContent] = useState<string>('');
const [error, setError] = useState('');
useEffect(() => {
const loadFileContent = async () => {
setLoading(true);
setError('');
try {
const url = shareApi.downloadUrl(token, entry.name, password);
const response = await fetch(url);
if (!response.ok) {
throw new Error('无法加载文件');
}
const text = await response.text();
setContent(text);
} catch (e: any) {
setError(e.message || '加载文件失败');
} finally {
setLoading(false);
}
};
if (entry.name.endsWith('.md')) {
loadFileContent();
} else {
setLoading(false);
}
}, [token, entry.name, password]);
const renderContent = () => {
if (loading) {
return <div style={{ textAlign: 'center', padding: 50 }}><Spin /></div>;
}
if (error) {
return <Empty description={error} />;
}
if (entry.name.endsWith('.md')) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}
return (
<Empty
description={
<div>
<p>线</p>
<Button
type="primary"
icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)}
download
>
</Button>
</div>
}
/>
);
};
return (
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
<Card>
<Title level={4}>{entry.name}</Title>
<Text type="secondary">
{shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
</Text>
<div style={{ marginTop: 16 }}>
<Button
style={{ marginBottom: 16 }}
icon={<DownloadOutlined />}
href={shareApi.downloadUrl(token, entry.name, password)}
download
>
</Button>
</div>
<Card>
{renderContent()}
</Card>
</Card>
</div>
);
});

View File

@@ -0,0 +1,109 @@
import { memo, useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router';
import { Card, message, Spin, Button, Empty, Input, Form } from 'antd';
import { shareApi, type ShareInfo } from '../../api/share';
import { type VfsEntry } from '../../api/vfs';
import { DirectoryViewer } from './DirectoryViewer';
import { FileViewer } from './FileViewer';
const PublicSharePage = memo(function PublicSharePage() {
const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true);
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
const [entry, setEntry] = useState<VfsEntry | null>(null);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [verified, setVerified] = useState(false);
const loadData = useCallback(async (pwd?: string) => {
if (!token) return;
setLoading(true);
setError('');
try {
let info = shareInfo;
if (!info) {
info = await shareApi.get(token);
setShareInfo(info);
}
if (info?.access_type === 'password' && !verified) {
setLoading(false);
return;
}
const currentPassword = pwd || password;
if (info.paths.length === 1) {
const listing = await shareApi.listDir(token, '/', currentPassword);
if (listing.entries.length === 1) {
const singleEntry = listing.entries[0];
setEntry(singleEntry);
}
}
} catch (e: any) {
setError(e.message || '加载分享失败');
if (e.message === '需要密码') {
setVerified(false);
}
} finally {
setLoading(false);
}
}, [token, shareInfo, password, verified]);
useEffect(() => {
loadData();
}, [loadData]);
const handlePasswordSubmit = async (values: { password_input: string }) => {
if (!token) return;
try {
await shareApi.verifyPassword(token, values.password_input);
setPassword(values.password_input);
setVerified(true);
setError('');
loadData(values.password_input);
} catch (e: any) {
message.error(e.message || '密码错误');
}
};
if (loading && !shareInfo) {
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
}
if (error && !error.includes('需要密码')) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
}
if (shareInfo?.access_type === 'password' && !verified) {
return (
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
<Card title="需要密码">
<Form onFinish={handlePasswordSubmit}>
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
if (!shareInfo) {
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
}
if (entry && !entry.is_dir) {
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />;
} else {
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
}
});
export default PublicSharePage;

View File

@@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router';
import LayoutShell from './LayoutShell.tsx'; import LayoutShell from './LayoutShell.tsx';
import LoginPage from '../pages/LoginPage.tsx'; import LoginPage from '../pages/LoginPage.tsx';
import SetupPage from '../pages/SetupPage.tsx'; import SetupPage from '../pages/SetupPage.tsx';
import PublicSharePage from '../pages/PublicSharePage.tsx'; import PublicSharePage from '../pages/PublicSharePage';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import type { JSX } from 'react'; import type { JSX } from 'react';