一、修改 types.ts 文件
- 打开 types.ts 文件
- 找到 Category 接口,在 password?: string; 下面添加一行:
parentId?: string; // 父分类ID
level?: number; // 分类层级(0=顶级,1=二级,2=三级...)
sortOrder?: number; // 同级分类的排序顺序
二、修改 components/CategoryManagerModal.tsx 文件
1. 导入的图标新增
找到
import { X, ArrowUp, ArrowDown, Trash2, Edit2, Plus, Check, Lock, Merge, Smile } from 'lucide-react';
替换成
import { X, ArrowUp, ArrowDown, Trash2, Edit2, Plus, Check, Lock, Merge, Smile, ChevronRight, ChevronDown } from 'lucide-react';
2. 在文件顶部添加常量(放在 COMMON_ICONS 下面)
在 COMMON_ICONS 定义之后添加:
// 预定义常用图标列表
const COMMON_ICONS = [
// ... 原有的图标列表 ...
];
// 添加这个常量
const NO_PARENT_VALUE = 'no-parent';
3. 新增状态变量
找到 const [newCatPassword, setNewCatPassword] = useState(‘’); 这行,在后面添加:
const [newCatParentId, setNewCatParentId] = useState<string>(NO_PARENT_VALUE);
const [editParentId, setEditParentId] = useState<string>(NO_PARENT_VALUE);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); // 记录展开的文件夹
4. 修改现有函数 - 添加 parentId 支持
4.1 startEdit 函数
找到 const startEdit = (cat: Category) => { ,在里面添加一行:
const startEdit = (cat: Category) => {
setEditingId(cat.id);
setEditName(cat.name);
setEditIcon(cat.icon || 'Folder');
setEditPassword(cat.password || '');
setEditParentId((cat as any).parentId || NO_PARENT_VALUE); // 👈 添加这一行
setMergingCatId(null);
};
4.2 saveEdit 函数
找到
const saveEdit = () => {
if (!editingId || !editName.trim()) return;
const newCats = categories.map(c => c.id === editingId ? {
...c,
name: editName.trim(),
icon: editIcon.trim(),
password: editPassword.trim() || undefined
} : c);
onUpdateCategories(newCats);
setEditingId(null);
};
替换成
const saveEdit = () => {
if (!editingId || !editName.trim()) return;
const newCats = categories.map(c => c.id === editingId ? {
...c,
name: editName.trim(),
icon: editIcon.trim(),
password: editPassword.trim() || undefined,
parentId: editParentId === NO_PARENT_VALUE ? undefined : editParentId // ← 新增
} : c);
onUpdateCategories(newCats);
setEditingId(null);
};
4.3 handleAdd 函数
找到
const handleAdd = () => {
if (!newCatName.trim()) return;
const newCat: Category = {
id: Date.now().toString(),
name: newCatName.trim(),
icon: newCatIcon.trim() || 'Folder',
password: newCatPassword.trim() || undefined
};
onUpdateCategories([...categories, newCat]);
setNewCatName('');
setNewCatIcon('Folder');
setNewCatPassword('');
};
替换成
const handleAdd = () => {
if (!newCatName.trim()) return;
const newCat: Category = {
id: Date.now().toString(),
name: newCatName.trim(),
icon: newCatIcon.trim() || 'Folder',
password: newCatPassword.trim() || undefined,
parentId: newCatParentId === NO_PARENT_VALUE ? undefined : newCatParentId // ← 新增
};
onUpdateCategories([...categories, newCat]);
setNewCatName('');
setNewCatIcon('Folder');
setNewCatPassword('');
setNewCatParentId(NO_PARENT_VALUE); // ← 新增
};
5. 新增辅助函数
找到
const handleAdd = () => {
// ... 原有内容 ...
};
在它下面添加
// 切换文件夹折叠状态
const toggleFolder = (catId: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedFolders(prev => {
const newSet = new Set(prev);
if (newSet.has(catId)) {
newSet.delete(catId);
} else {
newSet.add(catId);
}
return newSet;
});
};
// 获取分类的所有子分类(递归)
const getAllChildrenIds = (parentId: string): string[] => {
const children = categories.filter(c => c.parentId === parentId);
return children.reduce((acc, child) => {
return [...acc, child.id, ...getAllChildrenIds(child.id)];
}, [] as string[]);
};
6. 渲染部分的重大改动
6.1 分类列表过滤
找到
<div className="flex-1 overflow-y-auto p-4 space-y-2">
... 原有内容 ...
</div>
整个替换成
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{/* 只显示顶级分类(没有 parentId 的) */}
{categories.filter(cat => !cat.parentId).map((cat, index) => {
// 找到这个分类在原始数组中的真实索引(用于上下移动)
const realIndex = categories.findIndex(c => c.id === cat.id);
return (
<div key={cat.id} className="flex flex-col p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg group gap-2 border border-slate-100 dark:border-slate-600">
{/* 第一行:左侧固定区域 + 排序按钮 + 图标 + 名称区域 + 操作按钮 */}
<div className="flex items-start gap-2">
{/* 左侧固定宽度区域:用于显示展开箭头或留白 */}
<div className="w-6 shrink-0 flex justify-center mt-1">
{categories.some(c => c.parentId === cat.id) && editingId !== cat.id && mergingCatId !== cat.id ? (
// 有子分类:显示展开/折叠按钮
<button
onClick={(e) => toggleFolder(cat.id, e)}
className="text-slate-400 hover:text-blue-500"
>
{expandedFolders.has(cat.id) ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : (
// 没有子分类:留出空白占位,保持对齐
editingId !== cat.id && mergingCatId !== cat.id && <div className="w-4"></div>
)}
</div>
{/* 上下箭头 - 所有分类都显示(合并模式下也显示) */}
{editingId !== cat.id && (
<div className="flex flex-col gap-1 mr-1 shrink-0">
<button
onClick={() => handleMove(realIndex, 'up')}
disabled={realIndex === 0}
className="p-0.5 text-slate-400 hover:text-blue-500 disabled:opacity-30"
>
<ArrowUp size={14} />
</button>
<button
onClick={() => handleMove(realIndex, 'down')}
disabled={realIndex === categories.length - 1}
className="p-0.5 text-slate-400 hover:text-blue-500 disabled:opacity-30"
>
<ArrowDown size={14} />
</button>
</div>
)}
{/* 图标和文字区域 */}
<div className="flex-1 min-w-0">
{editingId === cat.id ? (
// 编辑模式
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="relative w-32 shrink-0">
<select
value={editIcon}
onChange={(e) => setEditIcon(e.target.value)}
className="w-full p-1.5 text-sm rounded border border-blue-500 dark:bg-slate-800 dark:text-white outline-none appearance-none"
>
{COMMON_ICONS.map(icon => (
<option key={icon.value} value={icon.value}>{icon.label}</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
<Icon name={editIcon} size={14} />
</div>
</div>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="flex-1 p-1.5 px-2 text-sm rounded border border-blue-500 dark:bg-slate-800 dark:text-white outline-none"
placeholder="分类名称"
autoFocus
/>
</div>
<div className="flex items-center gap-2">
<Lock size={14} className="text-slate-400" />
<input
type="text"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className="flex-1 p-1.5 px-2 text-xs rounded border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white outline-none"
placeholder="设置密码 (留空则不加密)"
/>
</div>
{/* 父分类选择 */}
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-slate-400">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"></path>
</svg>
<select
value={editParentId}
onChange={(e) => setEditParentId(e.target.value)}
className="flex-1 p-1.5 px-2 text-xs rounded border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white outline-none"
>
<option value={NO_PARENT_VALUE}>作为顶级分类</option>
{categories
.filter(c => c.id !== cat.id && !c.parentId)
.map(parent => (
<option key={parent.id} value={parent.id}>
作为「{parent.name}」子分类
</option>
))}
</select>
</div>
{/* 编辑模式下的保存和取消按钮 */}
{editingId === cat.id && (
<div className="flex justify-end gap-2 mt-2">
<button
onClick={saveEdit}
className="px-3 py-1 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors flex items-center gap-1"
>
<Check size={14} /> 保存
</button>
<button
onClick={() => {
setEditingId(null);
setEditName('');
setEditIcon('Folder');
setEditPassword('');
setEditParentId(NO_PARENT_VALUE);
}}
className="px-3 py-1 bg-slate-400 text-white text-sm rounded-lg hover:bg-slate-500 transition-colors flex items-center gap-1"
>
<X size={14} /> 取消
</button>
</div>
)}
</div>
) : mergingCatId === cat.id ? (
// 合并模式
<div className="flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 p-2 rounded">
<span className="text-sm dark:text-slate-200 whitespace-nowrap">合并到 →</span>
<select
value={targetMergeId}
onChange={(e) => setTargetMergeId(e.target.value)}
className="flex-1 text-sm p-1 rounded border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
style={{ maxHeight: '200px', overflowY: 'auto' }}
>
<option value="" disabled>请选择目标分类</option>
{categories
.filter(c => c.id !== cat.id) // 排除自身
.filter(c => !c.parentId) // 先取顶级分类
.map(topCat => {
// 获取这个顶级分类下的所有子分类(排除自身)
const children = categories.filter(c => c.parentId === topCat.id && c.id !== cat.id);
return (
<React.Fragment key={topCat.id}>
{/* 顶级分类 */}
<option value={topCat.id}>{topCat.name}</option>
{/* 子分类 - 缩进显示 */}
{children.map(child => (
<option key={child.id} value={child.id} style={{ paddingLeft: '24px' }}>
└ {child.name}
</option>
))}
</React.Fragment>
);
})}
</select>
<button onClick={executeMerge} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">确认</button>
<button onClick={() => setMergingCatId(null)} className="text-xs text-slate-500 px-2 py-1">取消</button>
</div>
) : (
// 正常显示模式
<div className="flex items-center gap-3">
{/* 图标 */}
<div className="w-8 h-8 rounded bg-white dark:bg-slate-800 flex items-center justify-center text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-600 shrink-0">
{cat.icon && cat.icon.length <= 4 && !/^[a-zA-Z]+$/.test(cat.icon)
? <span className="text-lg">{cat.icon}</span>
: <Icon name={cat.icon} size={16} />
}
</div>
{/* 文字信息 */}
<div className="flex flex-col">
{/* 分类名称 + 密码锁 */}
<div className="flex items-center gap-2">
<span className="font-medium dark:text-slate-200 truncate">{cat.name}</span>
{cat.password && <Lock size={12} className="text-amber-500 shrink-0" />}
</div>
{/* 链接数量 */}
<span className="text-xs text-slate-400">{links.filter(l => l.categoryId === cat.id).length} 个链接</span>
{/* 可见性下拉框 */}
<div className="pt-2">
<select
value={
(cat as any).isVisible === false ? "hidden" :
(cat as any).isAdminOnly === true ? "admin" : "public"
}
onChange={(e) => {
const value = e.target.value;
let isVisible = true;
let isAdminOnly = false;
if (value === "hidden") {
isVisible = false;
isAdminOnly = false;
} else if (value === "admin") {
isVisible = true;
isAdminOnly = true;
} else {
isVisible = true;
isAdminOnly = false;
}
const updatedCategories = categories.map(c =>
c.id === cat.id ? { ...c, isVisible, isAdminOnly } : c
);
onUpdateCategories(updatedCategories);
}}
className="text-xs p-1 pr-6 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none appearance-none cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.25rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.2em 1.2em',
}}
>
<option value="public" className="dark:bg-slate-800">👥 全员可见</option>
<option value="admin" className="dark:bg-slate-800">👑 仅管理员可见</option>
<option value="hidden" className="dark:bg-slate-800">🚫 全员隐藏</option>
</select>
</div>
</div>
</div>
)}
</div>
{/* 操作按钮 - 只在非编辑且非合并模式下显示 */}
{editingId !== cat.id && mergingCatId !== cat.id && (
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => startEdit(cat)} className="p-1.5 text-slate-400 hover:text-blue-500 hover:bg-slate-200 dark:hover:bg-slate-600 rounded" title="编辑">
<Edit2 size={14} />
</button>
<button onClick={() => openMerge(cat.id)} className="p-1.5 text-slate-400 hover:text-purple-500 hover:bg-slate-200 dark:hover:bg-slate-600 rounded" title="合并">
<Merge size={14} />
</button>
<button
onClick={() => { if(confirm(`确定删除"${cat.name}"分类吗?该分类下的书签将移动到"常用推荐"。`)) onDeleteCategory(cat.id); }}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-slate-200 dark:hover:bg-slate-600 rounded"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
{/* 子分类列表 */}
{expandedFolders.has(cat.id) && (
<div className="w-full mt-2 space-y-2">
{categories
.filter(sub => sub.parentId === cat.id)
.map((sub, subIndex) => {
const subCategoryIndex = categories.findIndex(c => c.id === sub.id);
return (
<div
key={sub.id}
className="w-full p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"
>
{/* 第一行:子分类基本信息 + 操作按钮 - 完全仿照顶级分类 */}
<div className="flex items-start gap-2">
{/* 上下箭头 - 子分类移动 */}
<div className="flex flex-col gap-1 mr-1 shrink-0">
<button
onClick={() => {
// 找出当前父分类下的所有子分类
const siblings = categories.filter(c => c.parentId === cat.id);
const currentIndex = siblings.findIndex(s => s.id === sub.id);
if (currentIndex > 0) {
// 和上一个子分类交换位置
const newCategories = [...categories];
const prevSibling = siblings[currentIndex - 1];
// 找到这两个分类在数组中的真实位置
const currentPos = newCategories.findIndex(c => c.id === sub.id);
const prevPos = newCategories.findIndex(c => c.id === prevSibling.id);
// 交换它们的位置
[newCategories[currentPos], newCategories[prevPos]] =
[newCategories[prevPos], newCategories[currentPos]];
onUpdateCategories(newCategories);
}
}}
disabled={(() => {
const siblings = categories.filter(c => c.parentId === cat.id);
const currentIndex = siblings.findIndex(s => s.id === sub.id);
return currentIndex === 0;
})()}
className="p-0.5 text-slate-400 hover:text-blue-500 disabled:opacity-30"
>
<ArrowUp size={14} />
</button>
<button
onClick={() => {
// 找出当前父分类下的所有子分类
const siblings = categories.filter(c => c.parentId === cat.id);
const currentIndex = siblings.findIndex(s => s.id === sub.id);
if (currentIndex < siblings.length - 1) {
// 和下一个子分类交换位置
const newCategories = [...categories];
const nextSibling = siblings[currentIndex + 1];
// 找到这两个分类在数组中的真实位置
const currentPos = newCategories.findIndex(c => c.id === sub.id);
const nextPos = newCategories.findIndex(c => c.id === nextSibling.id);
// 交换它们的位置
[newCategories[currentPos], newCategories[nextPos]] =
[newCategories[nextPos], newCategories[currentPos]];
onUpdateCategories(newCategories);
}
}}
disabled={(() => {
const siblings = categories.filter(c => c.parentId === cat.id);
const currentIndex = siblings.findIndex(s => s.id === sub.id);
return currentIndex === siblings.length - 1;
})()}
className="p-0.5 text-slate-400 hover:text-blue-500 disabled:opacity-30"
>
<ArrowDown size={14} />
</button>
</div>
{/* 图标和名称区域 - 完全仿照顶级分类 */}
<div className="flex-1 min-w-0">
{editingId === sub.id ? (
// 子分类编辑模式 - 和顶级分类保持一致
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="relative w-32 shrink-0">
<select
value={editIcon}
onChange={(e) => setEditIcon(e.target.value)}
className="w-full p-1.5 text-sm rounded border border-blue-500 dark:bg-slate-800 dark:text-white outline-none appearance-none"
>
{COMMON_ICONS.map(icon => (
<option key={icon.value} value={icon.value}>{icon.label}</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
<Icon name={editIcon} size={14} />
</div>
</div>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="flex-1 p-1.5 px-2 text-sm rounded border border-blue-500 dark:bg-slate-800 dark:text-white outline-none"
placeholder="分类名称"
autoFocus
/>
</div>
<div className="flex items-center gap-2">
<Lock size={14} className="text-slate-400" />
<input
type="text"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className="flex-1 p-1.5 px-2 text-xs rounded border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white outline-none"
placeholder="设置密码 (留空则不加密)"
/>
</div>
<div className="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-slate-400">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"></path>
</svg>
<select
value={editParentId}
onChange={(e) => setEditParentId(e.target.value)}
className="flex-1 p-1.5 px-2 text-xs rounded border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white outline-none"
>
<option value={NO_PARENT_VALUE}>作为顶级分类</option>
{categories
.filter(c => c.id !== sub.id && !c.parentId)
.map(parent => (
<option key={parent.id} value={parent.id}>
作为「{parent.name}」子分类
</option>
))}
</select>
</div>
{/* 子分类编辑模式下的保存和取消按钮 */}
<div className="flex justify-end gap-2 mt-2">
<button
onClick={saveEdit}
className="px-3 py-1 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 transition-colors flex items-center gap-1"
>
<Check size={14} /> 保存
</button>
<button
onClick={() => {
setEditingId(null);
setEditName('');
setEditIcon('Folder');
setEditPassword('');
setEditParentId(NO_PARENT_VALUE);
}}
className="px-3 py-1 bg-slate-400 text-white text-sm rounded-lg hover:bg-slate-500 transition-colors flex items-center gap-1"
>
<X size={14} /> 取消
</button>
</div>
</div>
) : (
// 子分类正常显示模式
<div className="flex items-center gap-3">
{/* 图标 */}
<div className="w-6 h-6 rounded bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-slate-500 shrink-0">
{sub.icon && sub.icon.length <= 4 && !/^[a-zA-Z]+$/.test(sub.icon)
? <span className="text-sm">{sub.icon}</span>
: <Icon name={sub.icon} size={12} />
}
</div>
{/* 文字信息 */}
<div className="flex flex-col">
{/* 分类名称 + 密码锁 */}
<div className="flex items-center gap-2">
<span className="font-medium dark:text-slate-200">{sub.name}</span>
{sub.password && <Lock size={10} className="text-amber-500 shrink-0" />}
</div>
{/* 链接数量 */}
<span className="text-xs text-slate-400">
{links.filter(l => l.categoryId === sub.id).length} 个链接
</span>
{/* 第二行:可见性下拉框 */}
{editingId !== sub.id && mergingCatId !== sub.id && (
<div className="pt-2">
<select
value={
(sub as any).isVisible === false ? "hidden" :
(sub as any).isAdminOnly === true ? "admin" : "public"
}
onChange={(e) => {
const value = e.target.value;
let isVisible = true;
let isAdminOnly = false;
if (value === "hidden") {
isVisible = false;
isAdminOnly = false;
} else if (value === "admin") {
isVisible = true;
isAdminOnly = true;
} else {
isVisible = true;
isAdminOnly = false;
}
const updatedCategories = categories.map(c =>
c.id === sub.id ? { ...c, isVisible, isAdminOnly } : c
);
onUpdateCategories(updatedCategories);
}}
className="text-xs p-1 pr-5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 dark:text-white outline-none appearance-none cursor-pointer w-28"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.2rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1em 1em',
}}
>
<option value="public">👥 全员可见</option>
<option value="admin">👑 仅管理员</option>
<option value="hidden">🚫 隐藏</option>
</select>
</div>
)}
</div>
</div>
)}
</div>
{/* 操作按钮 - 完全仿照顶级分类 */}
{editingId !== sub.id && mergingCatId !== sub.id && (
<div className="flex items-center gap-1 ml-auto shrink-0">
<button
onClick={() => startEdit(sub)}
className="p-1.5 text-slate-400 hover:text-blue-500 hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title="编辑"
>
<Edit2 size={14} />
</button>
<button
onClick={() => openMerge(sub.id)}
className="p-1.5 text-slate-400 hover:text-purple-500 hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title="合并"
>
<Merge size={14} />
</button>
<button
onClick={() => {
if(confirm(`确定删除"${sub.name}"分类吗?`)) onDeleteCategory(sub.id);
}}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-slate-200 dark:hover:bg-slate-700 rounded"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
{/* 在这里添加子分类的合并模式显示 */}
{mergingCatId === sub.id && (
<div className="flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 p-2 rounded mt-2">
<span className="text-sm dark:text-slate-200 whitespace-nowrap">合并到 →</span>
<select
value={targetMergeId}
onChange={(e) => setTargetMergeId(e.target.value)}
className="flex-1 text-sm p-1 rounded border border-slate-300 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
style={{ maxHeight: '200px', overflowY: 'auto' }}
>
<option value="" disabled>请选择目标分类</option>
{categories
.filter(c => c.id !== sub.id) // 排除自身
.filter(c => !c.parentId) // 先取顶级分类
.map(topCat => {
// 获取这个顶级分类下的所有子分类(排除自身)
const children = categories.filter(c => c.parentId === topCat.id && c.id !== sub.id);
return (
<React.Fragment key={topCat.id}>
{/* 顶级分类 */}
<option value={topCat.id}>{topCat.name}</option>
{/* 子分类 - 缩进显示 */}
{children.map(child => (
<option key={child.id} value={child.id} style={{ paddingLeft: '24px' }}>
└ {child.name}
</option>
))}
</React.Fragment>
);
})}
</select>
<button onClick={executeMerge} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">确认</button>
<button onClick={() => setMergingCatId(null)} className="text-xs text-slate-500 px-2 py-1">取消</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
6.2 添加新分类
找到
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
... 原有内容 ...
</div>
整个替换成
<div className="p-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<label className="text-xs font-semibold text-slate-500 uppercase mb-2 block">添加新分类</label>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="relative w-32 shrink-0">
<select
value={newCatIcon}
onChange={(e) => setNewCatIcon(e.target.value)}
className="w-full p-2 pl-2 pr-8 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white text-sm outline-none appearance-none"
>
{COMMON_ICONS.map(icon => (
<option key={icon.value} value={icon.value}>
{icon.label}
</option>
))}
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
<Icon name={newCatIcon} size={16} />
</div>
</div>
<input
type="text"
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
placeholder="分类名称"
className="flex-1 p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 relative">
<Lock size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={newCatPassword}
onChange={(e) => setNewCatPassword(e.target.value)}
placeholder="密码 (可选)"
className="w-full pl-8 p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 outline-none"
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
{/* 添加时的父分类选择 */}
<div className="flex items-center gap-2">
<select
value={newCatParentId}
onChange={(e) => setNewCatParentId(e.target.value)}
className="flex-1 p-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={NO_PARENT_VALUE}>作为顶级分类</option>
{categories
.filter(c => !c.parentId)
.map(parent => (
<option key={parent.id} value={parent.id}>
作为「{parent.name}」子分类
</option>
))}
</select>
</div>
<button
onClick={handleAdd}
disabled={!newCatName.trim()}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-4 py-2 rounded-lg transition-colors flex items-center"
>
<Plus size={18} />
</button>
</div>
</div>
</div>
三、修改 App.tsx 文件
1. 导入的图标新增
找到
import {
Search, Plus, Upload, Moon, Sun, Menu,
Trash2, Edit2, Loader2, Cloud, CheckCircle2, AlertCircle,
Pin, Settings, Lock, CloudCog, Github, GitFork, MoreVertical,
QrCode, Copy, LayoutGrid, List, Check, ExternalLink, ArrowRight
} from 'lucide-react';
整个替换成
import {
Search, Plus, Upload, Moon, Sun, Menu,
Trash2, Edit2, Loader2, Cloud, CheckCircle2, AlertCircle,
Pin, Settings, Lock, CloudCog, Github, GitFork, MoreVertical,
QrCode, Copy, LayoutGrid, List, Check, ExternalLink, ArrowRight,
ChevronRight, ChevronDown // ← 新增
} from 'lucide-react';
2. 新增:路由相关
找到
import React, { useState, useEffect, useMemo, useRef } from 'react';
替换成
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
3. 添加路由 Hooks
找到
function App() {
替换成
function App() {
const navigate = useNavigate(); // 新增:用于页面跳转
const { categoryId } = useParams<{ categoryId: string }>(); // 新增:获取URL参数
4. 新增辅助函数
找到
// --- Render Components ---
在上面添加下面代码
// 处理更多按钮点击 - 使用路由跳转
const handleMoreClick = (catId: string) => {
navigate(`/cat/${catId}`);
};
// 处理返回首页 - 使用路由跳转
const handleBackToHome = () => {
navigate('/');
};
5. 新增状态变量
找到
const mainRef = useRef<HTMLDivElement>(null);
在上面添加下面代码
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const [detailCategoryId, setDetailCategoryId] = useState<string | null>(null); // 新增:当前查看的分类详情页
6. 新增辅助函数
找到
// --- Helpers ---
在上面添加下面代码
// 新增:监听URL参数变化,同步 detailCategoryId
useEffect(() => {
if (categoryId) {
setDetailCategoryId(categoryId);
} else {
setDetailCategoryId(null);
}
}, [categoryId]);
// 安全地获取顶级分类
const getTopLevelCategories = useMemo(() => {
return categories.filter(cat => !cat.parentId);
}, [categories]);
// 安全地获取子分类
const getSubCategories = (parentId: string) => {
return categories.filter(cat => cat.parentId === parentId);
};
7. 修改 loadFromLocal 函数
找到
const loadFromLocal = () => {
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
... 原有内容 ...
};
整段替换成
const loadFromLocal = () => {
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
setLinks(parsed.links || INITIAL_LINKS);
setCategories(parsed.categories || DEFAULT_CATEGORIES);
if (parsed.settings) setSiteSettings(prev => ({ ...prev, ...parsed.settings }));
// 设置默认折叠状态(新增)
const initialCollapsed = new Set<string>();
(parsed.categories || DEFAULT_CATEGORIES).forEach((cat: Category) => {
const hasChildren = (parsed.categories || DEFAULT_CATEGORIES).some((c: Category) => c.parentId === cat.id);
if (hasChildren) {
initialCollapsed.add(cat.id);
}
});
setCollapsedFolders(initialCollapsed);
} catch (e) {
setLinks(INITIAL_LINKS);
setCategories(DEFAULT_CATEGORIES);
}
} else {
setLinks(INITIAL_LINKS);
setCategories(DEFAULT_CATEGORIES);
}
};
8. 修改 initData 函数
找到
if (data.links && data.links.length > 0) {
setLinks(data.links);
... 原有内容 ...
return;
}
整段替换成
if (data.links && data.links.length > 0) {
setLinks(data.links);
setCategories(data.categories || DEFAULT_CATEGORIES);
if (data.settings) setSiteSettings(prev => ({ ...prev, ...data.settings }));
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(data));
// 数据加载完成后,设置默认折叠状态(新增)
const initialCollapsed = new Set<string>();
(data.categories || DEFAULT_CATEGORIES).forEach((cat: Category) => {
const hasChildren = (data.categories || DEFAULT_CATEGORIES).some((c: Category) => c.parentId === cat.id);
if (hasChildren) {
initialCollapsed.add(cat.id);
}
});
setCollapsedFolders(initialCollapsed);
return;
}
9. 左侧菜单渲染的重大改动
{/* 左侧菜单过滤,先过滤出可见的分类,然后再渲染 */}
{categories
.filter(cat => {
// 如果是管理员(已登录),能看到"全员可见"和"仅管理员可见"的分类
... 原有内容 ...
</button>
);
})}
整段替换成
{/* 左侧菜单过滤,先过滤出可见的分类,然后再渲染 */}
{getTopLevelCategories
.filter(cat => {
if (authToken) return cat.isVisible !== false;
return cat.isVisible !== false && !cat.isAdminOnly;
})
.map(cat => {
const isLocked = cat.password && !unlockedCategoryIds.has(cat.id);
const isEmoji = cat.icon && cat.icon.length <= 4 && !/^[a-zA-Z]+$/.test(cat.icon);
// 获取子分类
const subCategories = categories.filter(sub =>
sub.parentId === cat.id &&
(authToken ? sub.isVisible !== false : sub.isVisible !== false && !sub.isAdminOnly)
);
const hasChildren = subCategories.length > 0;
// 默认是折叠的(collapsed 为 true)
const isCollapsed = collapsedFolders.has(cat.id);
return (
<div key={cat.id} className="space-y-1">
{/* 顶级分类 */}
<div className="flex items-center w-full">
<button
onClick={() => scrollToCategory(cat.id)}
className={`flex-1 flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all group ${
activeCategory === cat.id
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
<div className={`p-1.5 rounded-lg transition-colors flex items-center justify-center ${activeCategory === cat.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-slate-100 dark:bg-slate-800'}`}>
{isLocked ? <Lock size={16} className="text-amber-500" /> : (isEmoji ? <span className="text-base leading-none">{cat.icon}</span> : <Icon name={cat.icon} size={16} />)}
</div>
<span className="truncate flex-1 text-left">
{cat.name}
{cat.isAdminOnly && authToken && (
<span className="ml-1.5 inline-flex items-center px-1 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 border border-purple-200 dark:border-purple-800">管</span>
)}
</span>
</button>
{/* 箭头移到右侧 */}
{hasChildren && (
<button
onClick={() => {
setCollapsedFolders(prev => {
const newSet = new Set(prev);
if (newSet.has(cat.id)) {
newSet.delete(cat.id); // 如果已经折叠,就展开
} else {
newSet.add(cat.id); // 如果已经展开,就折叠
}
return newSet;
});
}}
className="p-2 ml-1 text-slate-400 hover:text-blue-500 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700"
>
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</button>
)}
</div>
{/* 子分类 - 默认不显示(因为 isCollapsed 初始为 true) */}
{!isCollapsed && hasChildren && (
<div className="ml-6 space-y-1">
{subCategories.map(sub => {
const isSubLocked = sub.password && !unlockedCategoryIds.has(sub.id);
const isSubEmoji = sub.icon && sub.icon.length <= 4 && !/^[a-zA-Z]+$/.test(sub.icon);
return (
<button
key={sub.id}
onClick={() => scrollToCategory(sub.id)}
className={`w-full flex items-center gap-2 px-4 py-1.5 rounded-lg transition-all text-sm ${
activeCategory === sub.id
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
: 'text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
<div className="w-4 h-4 flex items-center justify-center">
{isSubLocked ? <Lock size={12} className="text-amber-500" /> : (isSubEmoji ? <span className="text-xs">{sub.icon}</span> : <Icon name={sub.icon} size={12} />)}
</div>
<span className="truncate flex-1 text-left flex items-center gap-1">
{sub.name}
{/* 新增:二级分类的「管」图标 */}
{sub.isAdminOnly && authToken && (
<span className="inline-flex items-center px-1 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 border border-purple-200 dark:border-purple-800">管</span>
)}
</span>
</button>
);
})}
</div>
)}
</div>
);
})}
10. 右侧内容区域的重构
<div className="p-4 lg:p-8 space-y-8">
... 原有内容 ...
</div>
整段替换成
<div className="p-4 lg:p-8 space-y-8">
{detailCategoryId ? (
/* 分类详情页 - 替换首页内容 */
(() => {
const cat = categories.find(c => c.id === detailCategoryId);
if (!cat) return null;
const subCategories = getSubCategories(cat.id).filter(sub => {
if (authToken) return sub.isVisible !== false;
return sub.isVisible !== false && !sub.isAdminOnly;
});
const catLinks = searchResults.filter(l => l.categoryId === cat.id);
const isLocked = cat.password && !unlockedCategoryIds.has(cat.id);
return (
<div className="space-y-6">
{/* 返回首页按钮 - 使用路由跳转 */}
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/')}
className="flex items-center gap-1 px-2.5 py-1.5 text-sm bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
返回首页
</button>
<div className="text-sm text-slate-500">
当前分类:{catLinks.length} 个链接 · {subCategories.length} 个子分类
</div>
</div>
{/* 分类标题 */}
<div className="flex items-center gap-1">
<div className="w-8 h-8 flex items-center justify-center">
{cat.icon && cat.icon.length <= 4 && !/^[a-zA-Z]+$/.test(cat.icon)
? <span className="text-xl">{cat.icon}</span>
: <Icon name={cat.icon} size={24} />
}
</div>
<div>
<h1 className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{cat.name}
</h1>
</div>
{cat.isAdminOnly && authToken && (
<span className="ml-auto px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 border border-purple-200 dark:border-purple-800">
仅管理员
</span>
)}
</div>
{/* 密码验证 */}
{isLocked ? (
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700 p-12 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mb-4 text-amber-600 dark:text-amber-400">
<Lock size={32} />
</div>
<h3 className="text-xl text-slate-800 dark:text-slate-200 font-medium mb-2">私密目录</h3>
<p className="text-slate-500 dark:text-slate-400 mb-6">该分类已加密,需要验证密码才能查看内容</p>
<button
onClick={() => setCatAuthModalData(cat)}
className="px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-sm font-medium transition-colors"
>
输入密码解锁
</button>
</div>
) : (
<div className="space-y-8">
{/* 顶级分类自己的链接 */}
{catLinks.length > 0 && (
<div>
<div className={`grid gap-3 ${siteSettings.cardStyle === 'simple' ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'}`}>
{catLinks.map(link => renderLinkCard(link))}
</div>
</div>
)}
{/* 子分类 */}
{subCategories.map(sub => {
const subLinks = searchResults.filter(l => l.categoryId === sub.id);
const isSubLocked = sub.password && !unlockedCategoryIds.has(sub.id);
return (
<div key={sub.id}>
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
{sub.icon && sub.icon.length <= 4 && !/^[a-zA-Z]+$/.test(sub.icon)
? <span className="text-lg">{sub.icon}</span>
: <Icon name={sub.icon} size={18} />
}
</div>
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-300">
{sub.name}
</h2>
{sub.isAdminOnly && authToken && (
<span className="px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 border border-purple-200 dark:border-purple-800">管</span>
)}
{isSubLocked && <Lock size={14} className="text-amber-500" />}
</div>
{isSubLocked ? (
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700 p-8 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mb-3 text-amber-600 dark:text-amber-400">
<Lock size={24} />
</div>
<h4 className="text-slate-800 dark:text-slate-200 font-medium mb-1">私密目录</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-3">该子分类已加密,需要验证密码才能查看内容</p>
<button
onClick={() => setCatAuthModalData(sub)}
className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-sm font-medium transition-colors"
>
输入密码解锁
</button>
</div>
) : (
<>
{subLinks.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-sm italic bg-slate-50 dark:bg-slate-800/50 rounded-lg">
暂无链接
</div>
) : (
<div className={`grid gap-3 ${siteSettings.cardStyle === 'simple' ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'}`}>
{subLinks.map(link => renderLinkCard(link))}
</div>
)}
</>
)}
</div>
);
})}
{/* 如果没有内容 */}
{catLinks.length === 0 && subCategories.length === 0 && (
<div className="text-center py-12 text-slate-400 text-sm italic">
暂无内容
</div>
)}
</div>
)}
</div>
);
})()
) : (
/* 首页内容 */
<>
{/* 置顶链接部分 */}
{pinnedLinks.length > 0 && !searchQuery && (
<section>
<div className="flex items-center gap-2 mb-4">
<Pin size={16} className="text-blue-500 fill-blue-500" />
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
置顶 / 常用
</h2>
</div>
<div className={`grid gap-3 ${siteSettings.cardStyle === 'simple' ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'}`}>
{pinnedLinks.map(link => renderLinkCard(link))}
</div>
</section>
)}
{/* 分类列表 */}
{getTopLevelCategories
.filter(cat => {
if (authToken) return cat.isVisible !== false;
return cat.isVisible !== false && !cat.isAdminOnly;
})
.map(cat => {
const subCategories = getSubCategories(cat.id).filter(sub => {
if (authToken) return sub.isVisible !== false;
return sub.isVisible !== false && !sub.isAdminOnly;
});
const catLinks = searchResults.filter(l => l.categoryId === cat.id);
const isLocked = cat.password && !unlockedCategoryIds.has(cat.id);
const hasChildren = subCategories.length > 0;
if (searchQuery && searchMode === 'local' && catLinks.length === 0 && subCategories.length === 0) return null;
return (
<section key={cat.id} id={`cat-${cat.id}`} className="scroll-mt-24">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-slate-100 dark:border-slate-800">
<div className="flex items-center gap-2">
<div className="text-slate-400">
{cat.icon && cat.icon.length <= 4 && !/^[a-zA-Z]+$/.test(cat.icon) ? <span className="text-lg">{cat.icon}</span> : <Icon name={cat.icon} size={20} />}
</div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-slate-800 dark:text-slate-200">
{cat.name}
</h2>
{cat.isAdminOnly && authToken && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 border border-purple-200 dark:border-purple-800">管</span>
)}
{isLocked && <Lock size={16} className="text-amber-500" />}
</div>
</div>
{!isLocked && (
<button
onClick={() => handleMoreClick(cat.id)}
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
<span>更多</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"></path>
</svg>
</button>
)}
</div>
{isLocked ? (
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700 p-8 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mb-4 text-amber-600 dark:text-amber-400">
<Lock size={24} />
</div>
<h3 className="text-slate-800 dark:text-slate-200 font-medium mb-1">私密目录</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">该分类已加密,需要验证密码才能查看内容</p>
<button
onClick={() => setCatAuthModalData(cat)}
className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-sm font-medium transition-colors"
>
输入密码解锁
</button>
</div>
) : (
<>
{/* 修改1:顶级分类自己的链接为0时显示暂无链接提示 */}
{catLinks.length === 0 ? (
<div className="text-center py-4 text-slate-400 text-sm italic mb-6">
暂无链接
</div>
) : (
<div className={`grid gap-3 mb-6 ${siteSettings.cardStyle === 'simple' ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'}`}>
{catLinks.slice(0, 4).map(link => renderLinkCard(link))}
{catLinks.length > 4 && (
<div className="flex items-center justify-center p-4 text-sm text-slate-400 italic col-span-full">
还有 {catLinks.length - 4} 个链接,点击「更多」查看全部
</div>
)}
</div>
)}
{subCategories.slice(0, 2).map(sub => {
const subLinks = searchResults.filter(l => l.categoryId === sub.id);
const isSubLocked = sub.password && !unlockedCategoryIds.has(sub.id);
return (
<div key={sub.id} id={`cat-${sub.id}`} className="mb-6">
<div className="flex items-center gap-2 mb-3">
<div className="text-slate-400">
{sub.icon && sub.icon.length <= 4 && !/^[a-zA-Z]+$/.test(sub.icon) ? <span className="text-lg">{sub.icon}</span> : <Icon name={sub.icon} size={18} />}
</div>
<h3 className="text-md font-semibold text-slate-700 dark:text-slate-300">
{sub.name}
</h3>
{sub.isAdminOnly && authToken && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 border border-purple-200 dark:border-purple-800">管</span>
)}
{isSubLocked && <Lock size={14} className="text-amber-500" />}
</div>
{isSubLocked ? (
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700 p-8 flex flex-col items-center justify-center text-center">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mb-3 text-amber-600 dark:text-amber-400">
<Lock size={20} />
</div>
<h4 className="text-slate-800 dark:text-slate-200 font-medium mb-1">私密目录</h4>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-3">该子分类已加密,需要验证密码才能查看内容</p>
<button
onClick={() => setCatAuthModalData(sub)}
className="px-3 py-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-xs font-medium transition-colors"
>
输入密码解锁
</button>
</div>
) : (
<>
{subLinks.length === 0 ? (
<div className="text-center py-4 text-slate-400 text-sm italic">
暂无链接
</div>
) : (
<div className={`grid gap-3 ${siteSettings.cardStyle === 'simple' ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'}`}>
{subLinks.slice(0, 4).map(link => renderLinkCard(link))}
{subLinks.length > 4 && (
<div className="flex items-center justify-center p-4 text-sm text-slate-400 italic col-span-full">
还有 {subLinks.length - 4} 个链接
</div>
)}
</div>
)}
</>
)}
</div>
);
})}
{/* 修改2:让「还有 X 个子分类」的文字可点击进入详情页 */}
{subCategories.length > 2 && (
<div className="text-center py-2 text-sm text-slate-400 italic border-t border-slate-100 dark:border-slate-800 pt-4">
还有 {subCategories.length - 2} 个子分类,点击
<button
onClick={() => handleMoreClick(cat.id)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 mx-1 underline-offset-2 hover:underline transition-colors"
>
「更多」
</button>
查看全部
</div>
)}
{catLinks.length === 0 && subCategories.length === 0 && (
<div className="text-center py-8 text-slate-400 text-sm italic">
暂无链接
</div>
)}
</>
)}
</section>
);
})}
{/* 搜索空状态 */}
{searchQuery && searchMode === 'local' && searchResults.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
<Search size={40} className="opacity-30 mb-4" />
<p>没有找到相关内容</p>
<button onClick={() => setIsModalOpen(true)} className="mt-4 text-blue-500 hover:underline">添加一个?</button>
</div>
)}
{/* 底部留白 */}
<div className="h-20"></div>
</>
)}
</div>
四、修改 components/LinkModal.tsx 文件
1. 修改分类下拉框显示的层级结构
找到
<div className="flex-1">
<label className="block text-sm font-medium mb-1 dark:text-slate-300">分类</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all"
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
整段替换成
<div className="flex-1">
<label className="block text-sm font-medium mb-1 dark:text-slate-300">分类</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full p-2 rounded-lg border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none transition-all"
style={{ maxHeight: '200px', overflowY: 'auto' }}
>
<option value="" disabled>请选择目标分类</option>
{categories
.filter(cat => !cat.parentId) // 先取顶级分类
.map(topCat => {
// 获取这个顶级分类下的所有子分类
const children = categories.filter(c => c.parentId === topCat.id);
return (
<React.Fragment key={topCat.id}>
{/* 顶级分类 */}
<option value={topCat.id}>{topCat.name}</option>
{/* 子分类 - 缩进显示 */}
{children.map(child => (
<option key={child.id} value={child.id} style={{ paddingLeft: '24px' }}>
└ {child.name}
</option>
))}
</React.Fragment>
);
})}
</select>
</div>
2. 修改置顶按钮显示效果
找到
<div className="flex items-end pb-1">
<button
type="button"
onClick={() => setPinned(!pinned)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg border transition-all h-[42px] ${
替换成
<div className="flex items-end">
<button
type="button"
onClick={() => setPinned(!pinned)}
className={`flex items-center gap-2 px-4 py-2.5 rounded border transition-all h-[39px] ${
五、修改 index.html 文件
直接替换
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://lucide.dev/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title></title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#64748b',
dark: '#0f172a',
card: '#1e293b',
}
}
}
}
</script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Custom scrollbar for sidebar */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Simple CSS-only tooltip transition */
.tooltip-custom {
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
/* 禁用所有元素的长按菜单 */
* {
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
/* 允许输入框可以选中文本 */
input, textarea {
-webkit-user-select: auto !important;
user-select: auto !important;
}
/* 禁用图片保存菜单 */
img {
-webkit-touch-callout: none !important;
pointer-events: none;
}
a img {
pointer-events: none;
}
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"uuid": "https://aistudiocdn.com/uuid@^13.0.0",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"buffer": "https://aistudiocdn.com/buffer@^6.0.3",
"jszip": "https://aistudiocdn.com/jszip@^3.10.1"
}
}
</script>
</head>
<body class="bg-gray-50 dark:bg-slate-900 text-slate-900 dark:text-slate-50 transition-colors duration-300">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
六、修改 package.json 文件
找到
"buffer": "^6.0.3",
替换成
"buffer": "^6.0.3",
"react-router-dom": "^6.22.0",
七、修改 index.tsx 文件
整个替换成
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from './App';
// 定义路由配置
const router = createBrowserRouter([
{
path: '/',
element: <App />,
},
{
path: '/cat/:categoryId',
element: <App />,
},
]);
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);

评论 (0)