修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线
This commit is contained in:
32
front/src/admin/dashboard-state.test.ts
Normal file
32
front/src/admin/dashboard-state.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getInviteCodePanelState } from './dashboard-state';
|
||||
|
||||
test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
|
||||
assert.deepEqual(
|
||||
getInviteCodePanelState({
|
||||
totalUsers: 12,
|
||||
totalFiles: 34,
|
||||
inviteCode: ' AbCd1234 ',
|
||||
}),
|
||||
{
|
||||
inviteCode: 'AbCd1234',
|
||||
canCopy: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('getInviteCodePanelState falls back to a placeholder when summary has no invite code', () => {
|
||||
assert.deepEqual(
|
||||
getInviteCodePanelState({
|
||||
totalUsers: 12,
|
||||
totalFiles: 34,
|
||||
inviteCode: ' ',
|
||||
}),
|
||||
{
|
||||
inviteCode: '未生成',
|
||||
canCopy: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
21
front/src/admin/dashboard-state.ts
Normal file
21
front/src/admin/dashboard-state.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { AdminSummary } from '@/src/lib/types';
|
||||
|
||||
export interface InviteCodePanelState {
|
||||
inviteCode: string;
|
||||
canCopy: boolean;
|
||||
}
|
||||
|
||||
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
|
||||
const inviteCode = summary?.inviteCode?.trim() ?? '';
|
||||
if (!inviteCode) {
|
||||
return {
|
||||
inviteCode: '未生成',
|
||||
canCopy: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
inviteCode,
|
||||
canCopy: true,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { Alert, Button, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { readStoredSession } from '@/src/lib/session';
|
||||
import type { AdminSummary } from '@/src/lib/types';
|
||||
import { getInviteCodePanelState } from './dashboard-state';
|
||||
|
||||
interface DashboardState {
|
||||
summary: AdminSummary | null;
|
||||
@@ -33,12 +37,31 @@ export function PortalAdminDashboard() {
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [copyMessage, setCopyMessage] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const session = readStoredSession();
|
||||
|
||||
async function loadDashboardData() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const summary = await apiRequest<AdminSummary>('/admin/summary');
|
||||
|
||||
setState({
|
||||
summary,
|
||||
});
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadDashboardData() {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
@@ -63,24 +86,52 @@ export function PortalAdminDashboard() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboardData();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const inviteCodePanel = getInviteCodePanelState(state.summary);
|
||||
|
||||
async function handleRefreshInviteCode() {
|
||||
setCopyMessage('');
|
||||
await loadDashboardData();
|
||||
}
|
||||
|
||||
async function handleCopyInviteCode() {
|
||||
if (!inviteCodePanel.canCopy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
setError('当前浏览器不支持复制邀请码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteCodePanel.inviteCode);
|
||||
setCopyMessage('邀请码已复制到剪贴板');
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : '复制邀请码失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={3} sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
YOYUZH Admin
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。
|
||||
</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
YOYUZH Admin
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Button variant="outlined" onClick={() => navigate('/overview')}>
|
||||
返回总览
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
@@ -113,7 +164,7 @@ export function PortalAdminDashboard() {
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
@@ -134,7 +185,7 @@ export function PortalAdminDashboard() {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
@@ -151,6 +202,57 @@ export function PortalAdminDashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
当前邀请码
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
注册成功一次后会自动刷新,后台展示的始终是下一次可用的邀请码。
|
||||
</Typography>
|
||||
<Typography
|
||||
component="code"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
width: 'fit-content',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'action.hover',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
>
|
||||
{inviteCodePanel.inviteCode}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={() => void handleCopyInviteCode()}
|
||||
disabled={!inviteCodePanel.canCopy}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => void handleRefreshInviteCode()}
|
||||
disabled={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Stack>
|
||||
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
|
||||
const request = async () => ({
|
||||
totalUsers: 1,
|
||||
totalFiles: 2,
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
|
||||
await assert.doesNotReject(async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
|
||||
export interface AdminSummary {
|
||||
totalUsers: number;
|
||||
totalFiles: number;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Download,
|
||||
Monitor,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
FileUp,
|
||||
@@ -61,19 +60,15 @@ import {
|
||||
replaceUiFile,
|
||||
syncSelectedFile,
|
||||
} from './files-state';
|
||||
|
||||
const QUICK_ACCESS = [
|
||||
{ name: '桌面', icon: Monitor, path: [] as string[] },
|
||||
{ name: '下载', icon: Download, path: ['下载'] },
|
||||
{ name: '文档', icon: FileText, path: ['文档'] },
|
||||
{ name: '图片', icon: ImageIcon, path: ['图片'] },
|
||||
];
|
||||
|
||||
const DIRECTORIES = [
|
||||
{ name: '下载', icon: Folder },
|
||||
{ name: '文档', icon: Folder },
|
||||
{ name: '图片', icon: Folder },
|
||||
];
|
||||
import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
mergeDirectoryChildren,
|
||||
toDirectoryPath,
|
||||
type DirectoryChildrenMap,
|
||||
type DirectoryTreeNode,
|
||||
} from './files-tree';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -82,7 +77,52 @@ function sleep(ms: number) {
|
||||
}
|
||||
|
||||
function toBackendPath(pathParts: string[]) {
|
||||
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
|
||||
return toDirectoryPath(pathParts);
|
||||
}
|
||||
|
||||
function DirectoryTreeItem({
|
||||
node,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
node: DirectoryTreeNode;
|
||||
onSelect: (path: string[]) => void;
|
||||
onToggle: (path: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-xl px-2 py-1.5 transition-colors',
|
||||
node.active ? 'bg-[#336EFF]/15' : 'hover:bg-white/5',
|
||||
)}
|
||||
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/5 hover:text-white"
|
||||
onClick={() => onToggle(node.path)}
|
||||
aria-label={`${node.expanded ? '收起' : '展开'} ${node.name}`}
|
||||
>
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-1 text-left text-sm transition-colors',
|
||||
node.active ? 'text-[#336EFF]' : 'text-slate-300 hover:text-white',
|
||||
)}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<Folder className={cn('h-4 w-4 shrink-0', node.active ? 'text-[#336EFF]' : 'text-slate-500')} />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{node.expanded ? node.children.map((child) => (
|
||||
<DirectoryTreeItem key={child.id} node={child} onSelect={onSelect} onToggle={onToggle} />
|
||||
)) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number) {
|
||||
@@ -138,6 +178,21 @@ export default function Files() {
|
||||
const uploadMeasurementsRef = useRef(new Map<string, UploadMeasurement>());
|
||||
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
const [directoryChildren, setDirectoryChildren] = useState<DirectoryChildrenMap>(() => {
|
||||
if (initialCachedFiles.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return mergeDirectoryChildren(
|
||||
{},
|
||||
toBackendPath(initialPath),
|
||||
initialCachedFiles.filter((file) => file.directory).map((file) => file.filename),
|
||||
);
|
||||
});
|
||||
const [loadedDirectoryPaths, setLoadedDirectoryPaths] = useState<Set<string>>(
|
||||
() => new Set(initialCachedFiles.length === 0 ? [] : [toBackendPath(initialPath)]),
|
||||
);
|
||||
const [expandedDirectories, setExpandedDirectories] = useState(() => createExpandedDirectorySet(initialPath));
|
||||
const [selectedFile, setSelectedFile] = useState<UiFile | null>(null);
|
||||
const [currentFiles, setCurrentFiles] = useState<UiFile[]>(initialCachedFiles.map(toUiFile));
|
||||
const [uploads, setUploads] = useState<UploadTask[]>([]);
|
||||
@@ -155,21 +210,64 @@ export default function Files() {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState('');
|
||||
|
||||
const recordDirectoryChildren = (pathParts: string[], items: FileMetadata[]) => {
|
||||
setDirectoryChildren((previous) => {
|
||||
let next = mergeDirectoryChildren(
|
||||
previous,
|
||||
toBackendPath(pathParts),
|
||||
items.filter((file) => file.directory).map((file) => file.filename),
|
||||
);
|
||||
|
||||
for (let index = 0; index < pathParts.length; index += 1) {
|
||||
next = mergeDirectoryChildren(
|
||||
next,
|
||||
toBackendPath(pathParts.slice(0, index)),
|
||||
[pathParts[index]],
|
||||
);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const markDirectoryLoaded = (pathParts: string[]) => {
|
||||
const path = toBackendPath(pathParts);
|
||||
setLoadedDirectoryPaths((previous) => {
|
||||
if (previous.has(path)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = new Set(previous);
|
||||
next.add(path);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadCurrentPath = async (pathParts: string[]) => {
|
||||
const response = await apiRequest<PageResponse<FileMetadata>>(
|
||||
`/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100`
|
||||
);
|
||||
writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items);
|
||||
writeCachedValue(getFilesLastPathCacheKey(), pathParts);
|
||||
recordDirectoryChildren(pathParts, response.items);
|
||||
markDirectoryLoaded(pathParts);
|
||||
setCurrentFiles(response.items.map(toUiFile));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
currentPathRef.current = currentPath;
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const path of createExpandedDirectorySet(currentPath)) {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const cachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(currentPath)));
|
||||
writeCachedValue(getFilesLastPathCacheKey(), currentPath);
|
||||
|
||||
if (cachedFiles) {
|
||||
recordDirectoryChildren(currentPath, cachedFiles);
|
||||
setCurrentFiles(cachedFiles.map(toUiFile));
|
||||
}
|
||||
|
||||
@@ -180,6 +278,44 @@ export default function Files() {
|
||||
});
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const missingAncestors = getMissingDirectoryListingPaths(currentPath, loadedDirectoryPaths);
|
||||
|
||||
if (missingAncestors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all(
|
||||
missingAncestors.map(async (pathParts) => {
|
||||
const path = toBackendPath(pathParts);
|
||||
const response = await apiRequest<PageResponse<FileMetadata>>(
|
||||
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
|
||||
);
|
||||
writeCachedValue(getFilesListCacheKey(path), response.items);
|
||||
return { pathParts, items: response.items };
|
||||
}),
|
||||
)
|
||||
.then((responses) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const response of responses) {
|
||||
recordDirectoryChildren(response.pathParts, response.items);
|
||||
markDirectoryLoaded(response.pathParts);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The main content area already loaded the current directory; keep the tree best-effort.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentPath, loadedDirectoryPaths]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directoryInputRef.current) {
|
||||
return;
|
||||
@@ -195,6 +331,38 @@ export default function Files() {
|
||||
setActiveDropdown(null);
|
||||
};
|
||||
|
||||
const handleDirectoryToggle = async (pathParts: string[]) => {
|
||||
const path = toBackendPath(pathParts);
|
||||
let shouldLoadChildren = false;
|
||||
|
||||
setExpandedDirectories((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
return next;
|
||||
}
|
||||
|
||||
next.add(path);
|
||||
shouldLoadChildren = !(path in directoryChildren);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!shouldLoadChildren) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiRequest<PageResponse<FileMetadata>>(
|
||||
`/files/list?path=${encodeURIComponent(path)}&page=0&size=100`
|
||||
);
|
||||
writeCachedValue(getFilesListCacheKey(path), response.items);
|
||||
recordDirectoryChildren(pathParts, response.items);
|
||||
markDirectoryLoaded(pathParts);
|
||||
} catch {
|
||||
// Keep the branch expanded even if lazy loading fails; the main content area remains the source of truth.
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderDoubleClick = (file: UiFile) => {
|
||||
if (file.type === 'folder') {
|
||||
setCurrentPath([...currentPath, file.name]);
|
||||
@@ -574,47 +742,38 @@ export default function Files() {
|
||||
}
|
||||
};
|
||||
|
||||
const directoryTree = buildDirectoryTree(directoryChildren, currentPath, expandedDirectories);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
|
||||
{/* Left Sidebar */}
|
||||
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto">
|
||||
<CardContent className="p-4 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">快速访问</p>
|
||||
{QUICK_ACCESS.map((item) => (
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider">网盘目录</p>
|
||||
<div className="rounded-2xl border border-white/5 bg-black/20 p-2">
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => handleSidebarClick(item.path)}
|
||||
type="button"
|
||||
onClick={() => handleSidebarClick([])}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
currentPath.join('/') === item.path.join('/')
|
||||
? 'bg-[#336EFF]/20 text-[#336EFF]'
|
||||
: 'text-slate-300 hover:text-white hover:bg-white/5'
|
||||
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
|
||||
currentPath.length === 0 ? 'bg-[#336EFF]/15 text-[#336EFF]' : 'text-slate-200 hover:bg-white/5 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn('w-4 h-4', currentPath.join('/') === item.path.join('/') ? 'text-[#336EFF]' : 'text-slate-400')} />
|
||||
{item.name}
|
||||
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
|
||||
<span className="truncate">网盘</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">网盘目录</p>
|
||||
{DIRECTORIES.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => handleSidebarClick([item.name])}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
currentPath.length === 1 && currentPath[0] === item.name
|
||||
? 'bg-[#336EFF]/20 text-[#336EFF]'
|
||||
: 'text-slate-300 hover:text-white hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn('w-4 h-4', currentPath.length === 1 && currentPath[0] === item.name ? 'text-[#336EFF]' : 'text-slate-400')} />
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{directoryTree.map((node) => (
|
||||
<DirectoryTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
onSelect={handleSidebarClick}
|
||||
onToggle={(path) => void handleDirectoryToggle(path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getPostLoginRedirectPath } from '@/src/lib/file-share';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
|
||||
import type { AuthResponse } from '@/src/lib/types';
|
||||
import { buildRegisterPayload, validateRegisterForm } from './login-state';
|
||||
|
||||
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function Login() {
|
||||
const [registerEmail, setRegisterEmail] = useState('');
|
||||
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
|
||||
const [registerPassword, setRegisterPassword] = useState('');
|
||||
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
|
||||
const [registerInviteCode, setRegisterInviteCode] = useState('');
|
||||
|
||||
function switchMode(nextIsLogin: boolean) {
|
||||
setIsLogin(nextIsLogin);
|
||||
@@ -74,18 +77,33 @@ export default function Login() {
|
||||
|
||||
async function handleRegisterSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const validationMessage = validateRegisterForm({
|
||||
username: registerUsername,
|
||||
email: registerEmail,
|
||||
phoneNumber: registerPhoneNumber,
|
||||
password: registerPassword,
|
||||
confirmPassword: registerConfirmPassword,
|
||||
inviteCode: registerInviteCode,
|
||||
});
|
||||
if (validationMessage) {
|
||||
setError(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: registerUsername.trim(),
|
||||
email: registerEmail.trim(),
|
||||
phoneNumber: registerPhoneNumber.trim(),
|
||||
body: buildRegisterPayload({
|
||||
username: registerUsername,
|
||||
email: registerEmail,
|
||||
phoneNumber: registerPhoneNumber,
|
||||
password: registerPassword,
|
||||
},
|
||||
confirmPassword: registerConfirmPassword,
|
||||
inviteCode: registerInviteCode,
|
||||
}),
|
||||
});
|
||||
|
||||
saveStoredSession(createSession(auth));
|
||||
@@ -321,6 +339,36 @@ export default function Login() {
|
||||
至少 10 位,并包含大写字母、小写字母、数字和特殊字符。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300 ml-1">确认密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
|
||||
value={registerConfirmPassword}
|
||||
onChange={(event) => setRegisterConfirmPassword(event.target.value)}
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300 ml-1">邀请码</label>
|
||||
<div className="relative">
|
||||
<UserPlus className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入邀请码"
|
||||
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
|
||||
value={registerInviteCode}
|
||||
onChange={(event) => setRegisterInviteCode(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -592,8 +592,10 @@ export default function TransferReceive({ embedded = false }: TransferReceivePro
|
||||
<Input
|
||||
value={receiveCode}
|
||||
onChange={(event) => setReceiveCode(sanitizeReceiveCode(event.target.value))}
|
||||
placeholder="例如: 849201"
|
||||
className="h-16 bg-black/20 border-white/10 text-center text-3xl tracking-[0.5em] font-mono text-white"
|
||||
inputMode="numeric"
|
||||
aria-label="六位取件码"
|
||||
placeholder="请输入 6 位取件码"
|
||||
className="h-14 rounded-2xl border-white/10 bg-white/[0.03] px-4 text-center text-xl font-semibold tracking-[0.28em] text-slate-100 placeholder:text-slate-500 focus-visible:ring-emerald-400/60"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
104
front/src/pages/files-tree.test.ts
Normal file
104
front/src/pages/files-tree.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildDirectoryTree,
|
||||
createExpandedDirectorySet,
|
||||
getMissingDirectoryListingPaths,
|
||||
mergeDirectoryChildren,
|
||||
} from './files-tree';
|
||||
|
||||
test('createExpandedDirectorySet keeps the root and every ancestor expanded', () => {
|
||||
assert.deepEqual(
|
||||
[...createExpandedDirectorySet(['文档', '课程资料', '实验'])],
|
||||
['/', '/文档', '/文档/课程资料', '/文档/课程资料/实验'],
|
||||
);
|
||||
});
|
||||
|
||||
test('mergeDirectoryChildren keeps directory names unique while preserving existing order', () => {
|
||||
assert.deepEqual(
|
||||
mergeDirectoryChildren(
|
||||
{
|
||||
'/': ['图片'],
|
||||
},
|
||||
'/',
|
||||
['下载', '图片', '文档'],
|
||||
),
|
||||
{
|
||||
'/': ['图片', '下载', '文档'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDirectoryTree marks the active branch and nested folders correctly', () => {
|
||||
const tree = buildDirectoryTree(
|
||||
{
|
||||
'/': ['下载', '文档'],
|
||||
'/文档': ['课程资料'],
|
||||
'/文档/课程资料': ['实验'],
|
||||
},
|
||||
['文档', '课程资料'],
|
||||
createExpandedDirectorySet(['文档', '课程资料']),
|
||||
);
|
||||
|
||||
assert.deepEqual(tree, [
|
||||
{
|
||||
id: '/下载',
|
||||
name: '下载',
|
||||
path: ['/下载'.replace(/^\//, '')].filter(Boolean),
|
||||
depth: 0,
|
||||
active: false,
|
||||
expanded: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '/文档',
|
||||
name: '文档',
|
||||
path: ['文档'],
|
||||
depth: 0,
|
||||
active: false,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
id: '/文档/课程资料',
|
||||
name: '课程资料',
|
||||
path: ['文档', '课程资料'],
|
||||
depth: 1,
|
||||
active: true,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
id: '/文档/课程资料/实验',
|
||||
name: '实验',
|
||||
path: ['文档', '课程资料', '实验'],
|
||||
depth: 2,
|
||||
active: false,
|
||||
expanded: false,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getMissingDirectoryListingPaths requests any unloaded ancestors for a deep current path', () => {
|
||||
assert.deepEqual(
|
||||
getMissingDirectoryListingPaths(
|
||||
['文档', '课程资料', '实验'],
|
||||
new Set(['/文档/课程资料/实验']),
|
||||
),
|
||||
[[], ['文档'], ['文档', '课程资料']],
|
||||
);
|
||||
});
|
||||
|
||||
test('getMissingDirectoryListingPaths ignores ancestors that were only inferred by the tree', () => {
|
||||
assert.deepEqual(
|
||||
getMissingDirectoryListingPaths(
|
||||
['文档', '课程资料'],
|
||||
new Set(['/文档/课程资料']),
|
||||
),
|
||||
[[], ['文档']],
|
||||
);
|
||||
});
|
||||
97
front/src/pages/files-tree.ts
Normal file
97
front/src/pages/files-tree.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export interface DirectoryTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string[];
|
||||
depth: number;
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
children: DirectoryTreeNode[];
|
||||
}
|
||||
|
||||
export type DirectoryChildrenMap = Record<string, string[]>;
|
||||
|
||||
export function toDirectoryPath(pathParts: string[]) {
|
||||
return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
|
||||
}
|
||||
|
||||
export function createExpandedDirectorySet(pathParts: string[]) {
|
||||
const expandedPaths = new Set<string>(['/']);
|
||||
const segments: string[] = [];
|
||||
|
||||
for (const part of pathParts) {
|
||||
segments.push(part);
|
||||
expandedPaths.add(toDirectoryPath(segments));
|
||||
}
|
||||
|
||||
return expandedPaths;
|
||||
}
|
||||
|
||||
export function mergeDirectoryChildren(
|
||||
directoryChildren: DirectoryChildrenMap,
|
||||
parentPath: string,
|
||||
childNames: string[],
|
||||
) {
|
||||
const nextNames = new Set(directoryChildren[parentPath] ?? []);
|
||||
for (const childName of childNames) {
|
||||
const normalizedName = childName.trim();
|
||||
if (normalizedName) {
|
||||
nextNames.add(normalizedName);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...directoryChildren,
|
||||
[parentPath]: [...nextNames],
|
||||
};
|
||||
}
|
||||
|
||||
export function getMissingDirectoryListingPaths(
|
||||
pathParts: string[],
|
||||
loadedDirectoryPaths: Set<string>,
|
||||
) {
|
||||
const missingPaths: string[][] = [];
|
||||
|
||||
for (let depth = 0; depth < pathParts.length; depth += 1) {
|
||||
const ancestorPath = pathParts.slice(0, depth);
|
||||
if (!loadedDirectoryPaths.has(toDirectoryPath(ancestorPath))) {
|
||||
missingPaths.push(ancestorPath);
|
||||
}
|
||||
}
|
||||
|
||||
return missingPaths;
|
||||
}
|
||||
|
||||
export function buildDirectoryTree(
|
||||
directoryChildren: DirectoryChildrenMap,
|
||||
currentPath: string[],
|
||||
expandedPaths: Set<string>,
|
||||
): DirectoryTreeNode[] {
|
||||
function getChildNames(parentPath: string, parentParts: string[]) {
|
||||
const nextNames = new Set(directoryChildren[parentPath] ?? []);
|
||||
const currentChild = currentPath[parentParts.length];
|
||||
if (currentChild) {
|
||||
nextNames.add(currentChild);
|
||||
}
|
||||
return [...nextNames];
|
||||
}
|
||||
|
||||
function buildNodes(parentPath: string, parentParts: string[]): DirectoryTreeNode[] {
|
||||
return getChildNames(parentPath, parentParts).map((name) => {
|
||||
const path = [...parentParts, name];
|
||||
const id = toDirectoryPath(path);
|
||||
const expanded = expandedPaths.has(id);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
depth: parentParts.length,
|
||||
active: path.join('/') === currentPath.join('/'),
|
||||
expanded,
|
||||
children: expanded ? buildNodes(id, path) : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return buildNodes('/', []);
|
||||
}
|
||||
50
front/src/pages/login-state.test.ts
Normal file
50
front/src/pages/login-state.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildRegisterPayload, validateRegisterForm } from './login-state';
|
||||
|
||||
test('validateRegisterForm rejects mismatched passwords', () => {
|
||||
const result = validateRegisterForm({
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
phoneNumber: '13800138000',
|
||||
password: 'StrongPass1!',
|
||||
confirmPassword: 'StrongPass2!',
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
|
||||
assert.equal(result, '两次输入的密码不一致');
|
||||
});
|
||||
|
||||
test('validateRegisterForm rejects blank invite code', () => {
|
||||
const result = validateRegisterForm({
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
phoneNumber: '13800138000',
|
||||
password: 'StrongPass1!',
|
||||
confirmPassword: 'StrongPass1!',
|
||||
inviteCode: ' ',
|
||||
});
|
||||
|
||||
assert.equal(result, '请输入邀请码');
|
||||
});
|
||||
|
||||
test('buildRegisterPayload trims fields and keeps invite code', () => {
|
||||
const payload = buildRegisterPayload({
|
||||
username: ' alice ',
|
||||
email: ' alice@example.com ',
|
||||
phoneNumber: '13800138000',
|
||||
password: 'StrongPass1!',
|
||||
confirmPassword: 'StrongPass1!',
|
||||
inviteCode: ' invite-code ',
|
||||
});
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
phoneNumber: '13800138000',
|
||||
password: 'StrongPass1!',
|
||||
confirmPassword: 'StrongPass1!',
|
||||
inviteCode: 'invite-code',
|
||||
});
|
||||
});
|
||||
40
front/src/pages/login-state.ts
Normal file
40
front/src/pages/login-state.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface RegisterFormValues {
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequestPayload {
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export function validateRegisterForm(values: RegisterFormValues) {
|
||||
if (values.password !== values.confirmPassword) {
|
||||
return '两次输入的密码不一致';
|
||||
}
|
||||
|
||||
if (!values.inviteCode.trim()) {
|
||||
return '请输入邀请码';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildRegisterPayload(values: RegisterFormValues): RegisterRequestPayload {
|
||||
return {
|
||||
username: values.username.trim(),
|
||||
email: values.email.trim(),
|
||||
phoneNumber: values.phoneNumber.trim(),
|
||||
password: values.password,
|
||||
confirmPassword: values.confirmPassword,
|
||||
inviteCode: values.inviteCode.trim(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user