修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线
This commit is contained in:
@@ -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