diff --git a/api/storage.js b/api/storage.js new file mode 100644 index 00000000..9cce2b8a --- /dev/null +++ b/api/storage.js @@ -0,0 +1,410 @@ +// 统一存储接口 v2.4 - 修复分类移动/置顶后数据不一致问题 +// 支持 EdgeOne Pages / Cloudflare Workers + +import { getKV, getCorsHeaders, verifyAuth, jsonResponse } from './_kvAdapter.js'; + +const STORAGE_KEYS = { + CONFIG_KEY: 'config', + CATEGORIES_CONFIG_KEY: 'cate_config', +}; + +const CONFIG_SECTIONS = ['ai', 'website', 'mastodon', 'weather', 'search', 'icon', 'view', 'ui']; + +async function readConfigSection(kv, section) { + const sectionStr = await kv.get(`config:${section}`); + if (sectionStr) return JSON.parse(sectionStr); + const configStr = await kv.get('config'); + const config = configStr ? JSON.parse(configStr) : {}; + return config[section] || null; +} + +async function mergeAllConfigSections(kv) { + const merged = {}; + let hasAnyIndividual = false; + const results = await Promise.all(CONFIG_SECTIONS.map(async (s) => { + const v = await kv.get(`config:${s}`); + if (v) { hasAnyIndividual = true; return [s, JSON.parse(v)]; } + return null; + })); + for (const r of results) { + if (r) merged[r[0]] = r[1]; + } + if (hasAnyIndividual) { + const configStr = await kv.get('config'); + if (configStr) { + const legacy = JSON.parse(configStr); + for (const s of CONFIG_SECTIONS) { + if (!merged[s] && legacy[s]) merged[s] = legacy[s]; + } + } + return merged; + } + const configStr = await kv.get('config'); + return configStr ? JSON.parse(configStr) : {}; +} + +function categoryLinksKey(categoryId) { + return `links:${categoryId}`; +} + +// 读取所有分类链接(带密码过滤 + 私人书签过滤) +async function readAllCategoryLinks(kv, categories, unlockedCategories = new Set(), isAdmin = false) { + if (categories.length === 0) return []; + + const linkPromises = categories.map(async (cat) => { + const hasPassword = cat.password && cat.password.trim() !== ''; + const isUnlocked = unlockedCategories.has(cat.id); + + if (hasPassword && !isUnlocked && !isAdmin) { + return []; + } + + const data = await kv.get(categoryLinksKey(cat.id)); + return data ? JSON.parse(data) : []; + }); + + const linkArrays = await Promise.all(linkPromises); + return linkArrays.flat(); +} + +// ===== 修复:保存链接时,先清理所有旧的分类链接数据,再重新写入 ===== +async function saveCategoryLinks(kv, links, categories) { + // 1. 先获取当前所有存在的分类 ID(包括传入的 categories 中的分类) + const validCategoryIds = new Set(categories.map(c => c.id)); + + // 2. 从 links 中提取所有被引用的分类 ID + const referencedCategoryIds = new Set(links.map(l => l.categoryId || 'common')); + + // 3. 清理:删除所有不再有效的分类链接 key + // 先读取现有的所有分类,找出那些已经不存在于 categories 数组中的分类 + const existingCategoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingCategoriesData ? JSON.parse(existingCategoriesData) : []; + const allKnownCategoryIds = new Set([ + ...existingCategories.map(c => c.id), + ...validCategoryIds, + ]); + + // 4. 删除所有旧的 links:* key(确保没有残留数据) + const deletePromises = []; + for (const catId of allKnownCategoryIds) { + deletePromises.push(kv.delete(categoryLinksKey(catId))); + } + await Promise.all(deletePromises); + + // 5. 按新的 categoryId 分组链接 + const grouped = {}; + for (const link of links) { + const catId = link.categoryId || 'common'; + if (!grouped[catId]) grouped[catId] = []; + grouped[catId].push(link); + } + + // 6. 写入新的分组数据 + const writes = []; + for (const [catId, catLinks] of Object.entries(grouped)) { + writes.push(kv.put(categoryLinksKey(catId), JSON.stringify(catLinks))); + } + + // 7. 对于空分类(没有链接的分类),写入空数组,确保 key 存在但为空 + for (const catId of validCategoryIds) { + if (!grouped[catId]) { + writes.push(kv.put(categoryLinksKey(catId), JSON.stringify([]))); + } + } + + await Promise.all(writes); +} + +export async function onRequest(context) { + const { request, env } = context; + const corsHeaders = getCorsHeaders(env); + const url = new URL(request.url); + + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + try { + const kv = getKV(env); + + if (request.method === 'GET') { + const checkAuth = url.searchParams.get('checkAuth'); + const getConfig = url.searchParams.get('getConfig'); + const key = url.searchParams.get('key'); + const readOnly = url.searchParams.get('readOnly'); + const category = url.searchParams.get('category'); + const categoryPassword = url.searchParams.get('catPassword'); + + if (checkAuth === 'true') { + return jsonResponse({ + hasPassword: !!env.PASSWORD, + requiresAuth: !!env.PASSWORD, + readOnlyAccess: true, + capabilities: { upload: true }, + }, 200, corsHeaders); + } + + if (getConfig && getConfig.includes(',')) { + const requestedSections = getConfig.split(',').filter(s => CONFIG_SECTIONS.includes(s) || s === 'true'); + const configMap = {}; + + if (requestedSections.includes('true') || requestedSections.length === 0) { + const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const allCategories = categoriesData ? JSON.parse(categoriesData) : []; + + let unlockedCategories = new Set(); + const unlockedParam = url.searchParams.get('unlocked'); + if (unlockedParam) { + try { unlockedCategories = new Set(JSON.parse(unlockedParam)); } catch (e) {} + } + + const providedPassword = request.headers.get('x-auth-password'); + const isAdmin = await verifyAuth({ providedPassword, serverPassword: env.PASSWORD, kv }); + + const links = await readAllCategoryLinks(kv, allCategories, unlockedCategories, isAdmin); + const sanitizedCategories = allCategories.map(({ password, ...rest }) => ({ + ...rest, + hasPassword: !!(password && password.trim() !== '') + })); + + const allConfig = await mergeAllConfigSections(kv); + + return jsonResponse({ + links, + categories: sanitizedCategories, + configs: allConfig, + }, 200, corsHeaders); + } + + await Promise.all(requestedSections.map(async (section) => { + const val = await readConfigSection(kv, section); + const configKey = section === 'mastodon' ? 'ticker' : section; + configMap[configKey] = val || {}; + })); + + return jsonResponse(configMap, 200, corsHeaders); + } + + if (CONFIG_SECTIONS.includes(getConfig)) { + const sectionVal = await readConfigSection(kv, getConfig); + const defaults = { + website: { passwordExpiry: { value: 1, unit: 'week' } }, + }; + return jsonResponse(sectionVal || defaults[getConfig] || {}, 200, corsHeaders); + } + + if (getConfig === 'favicon') { + const domain = url.searchParams.get('domain'); + if (!domain) { + return jsonResponse({ error: 'Domain parameter is required' }, 400, corsHeaders); + } + const cachedIcon = await kv.get(`favicon:${domain}`); + return jsonResponse({ icon: cachedIcon || null, cached: !!cachedIcon }, 200, corsHeaders); + } + + if (getConfig === 'categories') { + const data = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const categories = data ? JSON.parse(data) : []; + const sanitized = categories.map(({ password, ...rest }) => ({ + ...rest, + hasPassword: !!(password && password.trim() !== '') + })); + return jsonResponse(sanitized, 200, corsHeaders); + } + + let unlockedCategories = new Set(); + const unlockedParam = url.searchParams.get('unlocked'); + if (unlockedParam) { + try { + unlockedCategories = new Set(JSON.parse(unlockedParam)); + } catch (e) {} + } + + const providedPassword = request.headers.get('x-auth-password'); + const isAdmin = await verifyAuth({ + providedPassword, + serverPassword: env.PASSWORD, + kv, + }); + + if (getConfig === 'links') { + const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const categories = categoriesData ? JSON.parse(categoriesData) : []; + + if (category) { + const cat = categories.find(c => c.id === category); + if (!cat) { + return jsonResponse({ error: '分类不存在' }, 404, corsHeaders); + } + + const hasPassword = cat.password && cat.password.trim() !== ''; + let isUnlocked = unlockedCategories.has(category); + + if (categoryPassword && hasPassword && !isUnlocked && !isAdmin) { + const inputPwd = categoryPassword.trim(); + const storedPwd = (cat.password || '').trim(); + if (inputPwd === storedPwd) { + isUnlocked = true; + } else { + return jsonResponse({ error: '密码错误' }, 403, corsHeaders); + } + } + + if (hasPassword && !isUnlocked && !isAdmin) { + return jsonResponse({ error: '该分类需要密码访问' }, 403, corsHeaders); + } + + const data = await kv.get(categoryLinksKey(category)); + const links = data ? JSON.parse(data) : []; + return jsonResponse(links, 200, corsHeaders); + } + + const links = await readAllCategoryLinks(kv, categories, unlockedCategories, isAdmin); + return jsonResponse(links, 200, corsHeaders); + } + + if (key) { + if (key === STORAGE_KEYS.CONFIG_KEY) { + const merged = await mergeAllConfigSections(kv); + return jsonResponse({ key, value: JSON.stringify(merged) }, 200, corsHeaders); + } + const value = await kv.get(key); + return jsonResponse({ key, value }, 200, corsHeaders); + } + + if (getConfig === 'true') { + const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const allCategories = categoriesData ? JSON.parse(categoriesData) : []; + + const sanitizedCategories = allCategories.map(({ password, ...rest }) => ({ + ...rest, + hasPassword: !!(password && password.trim() !== '') + })); + + const links = await readAllCategoryLinks(kv, allCategories, unlockedCategories, isAdmin); + + return jsonResponse({ + links, + categories: sanitizedCategories, + }, 200, corsHeaders); + } + + return jsonResponse({ links: [], categories: [] }, 200, corsHeaders); + } + + if (request.method === 'POST') { + const body = await request.json(); + const readOnlyOperations = ['favicon']; + + if (readOnlyOperations.includes(body.operation) || body.saveConfig === 'favicon') { + if (body.saveConfig === 'favicon') { + const { domain, icon } = body; + if (!domain || !icon) { + return jsonResponse({ error: 'Domain and icon are required' }, 400, corsHeaders); + } + await kv.put(`favicon:${domain}`, icon, { expirationTtl: 30 * 24 * 60 * 60 }); + return jsonResponse({ success: true }, 200, corsHeaders); + } + } + + const providedPassword = request.headers.get('x-auth-password'); + const isAuthenticated = await verifyAuth({ + providedPassword, + serverPassword: env.PASSWORD, + kv, + }); + + if (!isAuthenticated) { + return jsonResponse({ error: '管理操作需要密码验证' }, 401, corsHeaders); + } + + if (body.authOnly) { + await kv.put('last_auth_time', Date.now().toString()); + return jsonResponse({ success: true }, 200, corsHeaders); + } + + if (CONFIG_SECTIONS.includes(body.saveConfig)) { + await kv.put(`config:${body.saveConfig}`, JSON.stringify(body.config)); + return jsonResponse({ success: true }, 200, corsHeaders); + } + + if (body.saveConfig === 'categories') { + const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingData ? JSON.parse(existingData) : []; + const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); + + const mergedCategories = body.categories.map(cat => ({ + ...cat, + password: cat.password || existingPasswords.get(cat.id) || undefined, + })); + + await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(mergedCategories)); + return jsonResponse({ success: true }, 200, corsHeaders); + } + + if (body.saveConfig === 'links') { + if (body.categoryId) { + await kv.put(categoryLinksKey(body.categoryId), JSON.stringify(body.links)); + } else { + // 需要传入 categories 才能正确清理 + const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const categories = categoriesData ? JSON.parse(categoriesData) : []; + await saveCategoryLinks(kv, body.links, categories); + } + return jsonResponse({ success: true }, 200, corsHeaders); + } + + if (body.key === STORAGE_KEYS.CONFIG_KEY && body.value) { + await kv.put('config', body.value); + return jsonResponse({ success: true }, 200, corsHeaders); + } + + if (body.key && body.value && body.key !== STORAGE_KEYS.CONFIG_KEY) { + await kv.put(body.key, body.value); + return jsonResponse({ success: true }, 200, corsHeaders); + } + + // ===== 修复:同时保存 links 和 categories 时,传入 categories 进行完整清理 ===== + if (body.links && body.categories) { + await saveCategoryLinks(kv, body.links, body.categories); + + const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingData ? JSON.parse(existingData) : []; + const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); + + const mergedCategories = body.categories.map(cat => ({ + ...cat, + password: cat.password || existingPasswords.get(cat.id) || undefined, + })); + + await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(mergedCategories)); + return jsonResponse({ success: true }, 200, corsHeaders); + } else if (body.links) { + const categoriesData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const categories = categoriesData ? JSON.parse(categoriesData) : []; + await saveCategoryLinks(kv, body.links, categories); + return jsonResponse({ success: true }, 200, corsHeaders); + } else if (body.categories) { + const existingData = await kv.get(STORAGE_KEYS.CATEGORIES_CONFIG_KEY); + const existingCategories = existingData ? JSON.parse(existingData) : []; + const existingPasswords = new Map(existingCategories.map(c => [c.id, c.password])); + + const mergedCategories = body.categories.map(cat => ({ + ...cat, + password: cat.password || existingPasswords.get(cat.id) || undefined, + })); + + await kv.put(STORAGE_KEYS.CATEGORIES_CONFIG_KEY, JSON.stringify(mergedCategories)); + return jsonResponse({ success: true }, 200, corsHeaders); + } + + return jsonResponse({ error: 'Invalid data format' }, 400, corsHeaders); + } + + return jsonResponse({ error: 'Method Not Allowed' }, 405, corsHeaders); + + } catch (err) { + console.error('Storage API error:', err); + return jsonResponse({ error: 'Failed to fetch data', details: err.message }, 500, corsHeaders); + } +} diff --git a/components/BackupModal.tsx b/components/BackupModal.tsx index 838e2f85..6284168d 100644 --- a/components/BackupModal.tsx +++ b/components/BackupModal.tsx @@ -512,4 +512,4 @@ const BackupModal: React.FC = ({ ); }; -export default BackupModal; \ No newline at end of file +export default BackupModal; diff --git a/components/CategoryActionAuthModal.tsx b/components/CategoryActionAuthModal.tsx index 94c03e68..5b7fc9d4 100644 --- a/components/CategoryActionAuthModal.tsx +++ b/components/CategoryActionAuthModal.tsx @@ -1,86 +1,73 @@ import React, { useState } from 'react'; -import { X, Lock, AlertCircle } from 'lucide-react'; +import { Lock, ArrowRight, Loader2, X } from 'lucide-react'; +import { Category } from '../types'; +import { API_ENDPOINTS } from '../src/constants'; -interface CategoryActionAuthModalProps { +interface CategoryAuthModalProps { isOpen: boolean; onClose: () => void; - onVerify: (password: string) => Promise; - onVerified: () => void; - actionType: 'edit' | 'delete'; - categoryName: string; + category: Category | null; + onUnlock: (categoryId: string) => void; } -const CategoryActionAuthModal: React.FC = ({ - isOpen, - onClose, - onVerify, - onVerified, - actionType, - categoryName -}) => { +const CategoryAuthModal: React.FC = ({ isOpen, onClose, category, onUnlock }) => { const [password, setPassword] = useState(''); - const [isVerifying, setIsVerifying] = useState(false); const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); - if (!isOpen) return null; + if (!isOpen || !category) return null; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!password.trim()) { - setError('请输入密码'); - return; - } + if (!password.trim()) return; - setIsVerifying(true); + setIsLoading(true); setError(''); - + try { - const success = await onVerify(password); - if (success) { - setPassword(''); - onVerified(); - } else { - setError('密码错误,请重试'); + // 调用后端验证分类密码 + const res = await fetch(`${API_ENDPOINTS.STORAGE}?getConfig=links&category=${encodeURIComponent(category.id)}&catPassword=${encodeURIComponent(password.trim())}`); + + if (res.status === 403) { + setError('密码错误'); + setIsLoading(false); + return; } + + if (!res.ok) { + setError('验证失败,请重试'); + setIsLoading(false); + return; + } + + // 密码正确,解锁分类 + onUnlock(category.id); + setPassword(''); + setError(''); + setIsLoading(false); + onClose(); } catch (err) { - setError('验证失败,请重试'); - } finally { - setIsVerifying(false); + console.error('Category auth error:', err); + setError('网络错误,请重试'); + setIsLoading(false); } }; - const handleClose = () => { - setPassword(''); - setError(''); - onClose(); - }; - - const actionText = actionType === 'edit' ? '编辑' : '删除'; - const colorClass = actionType === 'edit' - ? 'amber' - : 'red'; - return (
-
-
+
-

验证操作权限

+

解锁 "{category.name}"

- 您正在{actionText}分类 "{categoryName}" + 该目录受密码保护,请输入密码访问

-
- -

- 请输入部署时设置的密码进行验证 -

-
@@ -89,41 +76,32 @@ const CategoryActionAuthModal: React.FC = ({ type="password" value={password} onChange={(e) => setPassword(e.target.value)} - className={`w-full p-3 rounded-xl border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-${colorClass}-500 outline-none transition-all text-center tracking-widest`} - placeholder="请输入密码" + className="w-full p-3 rounded-xl border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-amber-500 outline-none transition-all text-center tracking-widest" + placeholder="目录密码" autoFocus - disabled={isVerifying} + disabled={isLoading} />
- + {error && ( -
- -

{error}

+
+ {error}
)} - -
- - -
+ +
); }; -export default CategoryActionAuthModal; \ No newline at end of file +export default CategoryAuthModal; diff --git a/components/CategoryAuthModal.tsx b/components/CategoryAuthModal.tsx index 33f253df..29095eb2 100644 --- a/components/CategoryAuthModal.tsx +++ b/components/CategoryAuthModal.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Lock, ArrowRight, Loader2, X } from 'lucide-react'; import { Category } from '../types'; +import { API_ENDPOINTS } from '../src/constants'; interface CategoryAuthModalProps { isOpen: boolean; @@ -12,18 +13,45 @@ interface CategoryAuthModalProps { const CategoryAuthModal: React.FC = ({ isOpen, onClose, category, onUnlock }) => { const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); if (!isOpen || !category) return null; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (password === category.password) { - onUnlock(category.id); - setPassword(''); - setError(''); - onClose(); - } else { + if (!password.trim()) return; + + setIsLoading(true); + setError(''); + + try { + // 调用后端验证分类密码 + const res = await fetch( + `${API_ENDPOINTS.STORAGE}?getConfig=links&category=${encodeURIComponent(category.id)}&catPassword=${encodeURIComponent(password.trim())}` + ); + + if (res.status === 403) { setError('密码错误'); + setIsLoading(false); + return; + } + + if (!res.ok) { + setError('验证失败,请重试'); + setIsLoading(false); + return; + } + + // 密码正确,解锁分类 + onUnlock(category.id); + setPassword(''); + setError(''); + setIsLoading(false); + onClose(); + } catch (err) { + console.error('Category auth error:', err); + setError('网络错误,请重试'); + setIsLoading(false); } }; @@ -53,6 +81,7 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, className="w-full p-3 rounded-xl border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:ring-2 focus:ring-amber-500 outline-none transition-all text-center tracking-widest" placeholder="目录密码" autoFocus + disabled={isLoading} />
@@ -64,10 +93,12 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, @@ -75,4 +106,4 @@ const CategoryAuthModal: React.FC = ({ isOpen, onClose, ); }; -export default CategoryAuthModal; \ No newline at end of file +export default CategoryAuthModal; diff --git a/components/CategoryManagerModal.tsx b/components/CategoryManagerModal.tsx index a14a44d1..43d7eb7e 100644 --- a/components/CategoryManagerModal.tsx +++ b/components/CategoryManagerModal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { X, ArrowUp, ArrowDown, Trash2, Edit2, Plus, Check, Lock, Unlock, Palette, Save } from 'lucide-react'; +import { toast } from './Toast'; import { Category } from '../types'; import Icon from './Icon'; import IconSelector from './IconSelector'; @@ -9,8 +10,10 @@ interface CategoryManagerModalProps { isOpen: boolean; onClose: () => void; categories: Category[]; + links: { id: string; categoryId: string; title: string }[]; onUpdateCategories: (newCategories: Category[]) => void; onDeleteCategory: (id: string) => void; + onUpdateLinks?: (links: { id: string; categoryId: string; title: string }[]) => void; onVerifyPassword?: (password: string) => Promise; } @@ -18,8 +21,10 @@ const CategoryManagerModal: React.FC = ({ isOpen, onClose, categories, + links, onUpdateCategories, onDeleteCategory, + onUpdateLinks, onVerifyPassword }) => { // 本地编辑状态 - 不直接修改原始数据 @@ -140,6 +145,7 @@ const CategoryManagerModal: React.FC = ({ onUpdateCategories(sorted); setHasChanges(false); + toast.success('分类更改已保存'); onClose(); } catch (e) { console.error('Save failed:', e); @@ -172,6 +178,16 @@ const CategoryManagerModal: React.FC = ({ if (!onVerifyPassword || hasVerifiedPermissions) { if (confirm(`确定删除"${cat.name}"分类吗?该分类下的书签将移动到"常用推荐"。`)) { const newCats = localCategories.filter(c => c.id !== cat.id); + // 迁移该分类下的书签到 common + if (onUpdateLinks && links) { + const linksToMigrate = links.filter(l => l.categoryId === cat.id); + if (linksToMigrate.length > 0) { + const migratedLinks = links.map(l => + l.categoryId === cat.id ? { ...l, categoryId: 'common' } : l + ); + onUpdateLinks(migratedLinks); + } + } markChanged(newCats); } return; @@ -189,7 +205,17 @@ const CategoryManagerModal: React.FC = ({ if (cat) startEdit(cat); } else if (pendingAction.type === 'delete') { const cat = localCategories.find(c => c.id === pendingAction.categoryId); - if (cat && confirm(`确定删除"${cat.name}"分类吗?`)) { + if (cat && confirm(`确定删除"${cat.name}"分类吗?该分类下的书签将移动到"常用推荐"。`)) { + // 迁移该分类下的书签到 common + if (onUpdateLinks && links) { + const linksToMigrate = links.filter(l => l.categoryId === cat.id); + if (linksToMigrate.length > 0) { + const migratedLinks = links.map(l => + l.categoryId === cat.id ? { ...l, categoryId: 'common' } : l + ); + onUpdateLinks(migratedLinks); + } + } markChanged(localCategories.filter(c => c.id !== cat.id)); } } @@ -227,6 +253,7 @@ const CategoryManagerModal: React.FC = ({ return c; }); markChanged(newCats); + toast.success(`已更新分类「${editName.trim()}」`); setEditingId(null); setEditParentId(''); }; @@ -244,6 +271,7 @@ const CategoryManagerModal: React.FC = ({ weight: maxWeight + 1, }; markChanged([...localCategories, newCat]); + toast.success(`已添加分类「${newCat.name}」`); setNewCatName(''); setNewCatPassword(''); setNewCatIcon('Folder'); diff --git a/components/ImportModal.tsx b/components/ImportModal.tsx index 69f74f8e..8a646071 100644 --- a/components/ImportModal.tsx +++ b/components/ImportModal.tsx @@ -981,4 +981,4 @@ const ImportModal: React.FC = ({ ); }; -export default ImportModal; \ No newline at end of file +export default ImportModal; diff --git a/components/LinkModal.tsx b/components/LinkModal.tsx index 8cc60db5..32c95c70 100644 --- a/components/LinkModal.tsx +++ b/components/LinkModal.tsx @@ -24,6 +24,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete const [description, setDescription] = useState(''); const [categoryId, setCategoryId] = useState(categories[0]?.id || 'common'); const [pinned, setPinned] = useState(false); + const [isPrivate, setIsPrivate] = useState(false); const [icon, setIcon] = useState(''); const [iconType, setIconType] = useState('google'); const [isUploading, setIsUploading] = useState(false); @@ -209,6 +210,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setDescription(initialData.description || ''); setCategoryId(initialData.categoryId); setPinned(initialData.pinned || false); + setIsPrivate(initialData.isPrivate || false); setIcon(initialData.icon || ''); setWeight(initialData.weight || 0); setPinnedOrder(initialData.pinnedOrder || 0); @@ -224,8 +226,13 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete } else { if (initialData.icon?.includes('faviconextractor.com')) { detectedType = 'faviconextractor'; - } else if (initialData.icon?.includes('google.com/s2/favicons') || initialData.icon?.includes('/api/favicon?domain=')) { + } else if (initialData.icon?.includes('google.com/s2/favicons')) { detectedType = 'google'; + } else if (initialData.icon?.includes('/api/favicon?domain=')) { + // 兼容旧数据 + detectedType = 'google'; + } else if (initialData.icon?.includes('api.xinac.net/icon')) { + detectedType = 'xinac'; } else if (initialData.icon?.includes('/api/favicon?key=')) { detectedType = 'upload-edgeone'; } else if (initialData.icon) { @@ -269,6 +276,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setCategoryId(firstAvailableCategory?.id || 'common'); } setPinned(false); + setIsPrivate(false); setIcon(''); setIconType('google'); setCustomIconUrl(''); @@ -294,6 +302,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete const handleDelete = () => { if (!initialData) return; onDelete && onDelete(initialData.id); + toast.success(`「${initialData.title}」已删除`); onClose(); }; @@ -355,8 +364,15 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete iconConfig: iconType === 'customapi' ? { iconType, customApiUrl, customApiParam } : undefined, customIconUrl, edgeoneBlobUrl, - cloudflareR2Url + cloudflareR2Url, + isPrivate }); + + if (!batchMode) { + toast.success(initialData ? `「${title}」已更新` : `「${title}」已添加`); + } else { + toast.success(`「${title}」已添加,可继续添加下一个`); + } // 如果有自定义图标URL,缓存到KV空间 if (icon && !icon.startsWith('/api/favicon') && !icon.includes('faviconextractor.com')) { @@ -438,11 +454,16 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete // 根据选择的图标类型生成图标URL switch (iconType) { case 'faviconextractor': + iconUrl = `https://faviconextractor.com/favicon/${domain}`; + break; case 'google': - iconUrl = `/api/favicon?domain=${domain}`; + iconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; + break; + case 'xinac': + iconUrl = `https://api.xinac.net/icon/?url=${encodeURIComponent(url)}`; break; default: - iconUrl = `/api/favicon?domain=${domain}`; + iconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; } setIcon(iconUrl); @@ -457,10 +478,10 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete if (!isOpen) return null; return ( -
-
-
-
+
+
+
+

{initialData ? '编辑链接' : '添加新链接'}

@@ -477,6 +498,19 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete 置顶 + + {!initialData && (
= ({ isOpen, onClose, onSave, onDelete
-
+
= ({ isOpen, onClose, onSave, onDelete required value={title} onChange={(e) => setTitle(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" + 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 text-base" placeholder="网站名称" />
@@ -531,7 +565,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete required value={url} onChange={(e) => setUrl(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" + 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 text-base" placeholder="example.com 或 https://..." />
@@ -553,14 +587,17 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setIcon(edgeoneBlobUrl); } else if (newType === 'upload-cloudflare') { setIcon(cloudflareR2Url); + } else if (newType === 'xinac') { + setIcon(''); } else { setIcon(''); } }} - 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" + 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 text-base" > + {supportsUpload && } @@ -578,7 +615,7 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete setIcon(e.target.value); setCustomIconUrl(e.target.value); }} - className="flex-1 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" + className="flex-1 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 text-base" placeholder="https://example.com/icon.png" />
@@ -612,13 +649,13 @@ const LinkModal: React.FC = ({ isOpen, onClose, onSave, onDelete type="url" value={customApiUrl} onChange={(e) => setCustomApiUrl(e.target.value)} - className="flex-1 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" + className="flex-1 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 text-base" placeholder="https://api.example.com/icon" /> setIcon(e.target.value)} - className="flex-1 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" + className="flex-1 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 text-base" placeholder="留空自动获取图标" />