修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线

This commit is contained in:
yoyuzh
2026-03-20 18:08:59 +08:00
parent 43358e29d7
commit f8ea5a6f85
37 changed files with 1541 additions and 100 deletions

View 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,
},
);
});

View 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,
};
}

View File

@@ -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>
);

View File

@@ -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 () => {

View File

@@ -16,6 +16,7 @@ export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminSummary {
totalUsers: number;
totalFiles: number;
inviteCode: string;
}
export interface AdminUser {

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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

View 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(['/文档/课程资料']),
),
[[], ['文档']],
);
});

View 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('/', []);
}

View 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',
});
});

View 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(),
};
}

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}