diff --git a/eslint.config.js b/eslint.config.js index aff9da3..a6ee0be 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' export default [ js.configs.recommended, - { ignores: ['dist', 'scripts', 'api', '**/*.ts', '**/*.tsx', '*.mjs'] }, + { ignores: ['dist', 'scripts', 'api', 'docs', 'public', '**/*.ts', '**/*.tsx', '*.mjs'] }, { files: ['**/*.{js,mjs}'], languageOptions: { diff --git a/index.html b/index.html index 5a361e0..58cde5d 100644 --- a/index.html +++ b/index.html @@ -35,9 +35,9 @@ - + - diff --git a/src/components/DetailView.tsx b/src/components/DetailView.tsx index 49d3ad4..d51b445 100644 --- a/src/components/DetailView.tsx +++ b/src/components/DetailView.tsx @@ -57,7 +57,7 @@ export function ResourceDetail({ resource }: { resource: Resource }) { {resource.official && ( - {t('detail.favorite')} + {t('common.official')} )} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 5de6e77..9d912df 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -40,6 +40,7 @@ import { Home, LayoutGrid, List, + GitBranch, type LucideIcon, } from 'lucide-react'; @@ -84,9 +85,11 @@ const MAP: Record = { Home, LayoutGrid, List, + GitBranch, }; export function Icon({ name, size, ...props }: { name: string; size?: number } & SVGProps) { const Cmp = MAP[name] ?? Globe; - return ; + // 注意:size 需显式传给图标组件,否则 lucide 默认 24px,调用方的 size 会被静默丢弃 + return ; } diff --git a/src/components/MobileTabBar.tsx b/src/components/MobileTabBar.tsx index 7a135d7..c7546ae 100644 --- a/src/components/MobileTabBar.tsx +++ b/src/components/MobileTabBar.tsx @@ -14,6 +14,7 @@ export function MobileTabBar() { const tabs = [ { name: 'home', label: t('nav.home'), icon: 'Home', href: '#/home', match: ['home', 'landing'] }, { name: 'search', label: t('nav.search'), icon: 'Search', href: '#/search', match: ['search'] }, + { name: 'ranking', label: t('nav.ranking'), icon: 'TrendingUp', href: '#/ranking', match: ['ranking'] }, { name: 'favorites', label: t('nav.favorites'), icon: 'Heart', href: '#/favorites', match: ['favorites'] }, { name: 'submit', label: t('nav.submit'), icon: 'Plus', href: '#/submit', match: ['submit'] }, { name: 'about', label: t('nav.about'), icon: 'Info', href: '#/about', match: ['about'] }, diff --git a/src/components/ResourceCard.tsx b/src/components/ResourceCard.tsx index e56fa86..73cc561 100644 --- a/src/components/ResourceCard.tsx +++ b/src/components/ResourceCard.tsx @@ -76,8 +76,9 @@ export function ResourceCard({ resource, index = 0 }: { resource: Resource; inde
+ {/* 标签 chips:移动端最多 2 个 + 溢出计数,桌面端最多 3 个(两组互斥显示,避免重复渲染) */} {resource.tags.slice(0, 2).map((tag) => ( - + {tag} ))} @@ -87,7 +88,7 @@ export function ResourceCard({ resource, index = 0 }: { resource: Resource; inde )} {resource.tags.slice(0, 3).map((tag) => ( - + {tag} ))} diff --git a/src/data/seed.ts b/src/data/seed.ts index 9c853fa..02ebfbc 100644 --- a/src/data/seed.ts +++ b/src/data/seed.ts @@ -72,9 +72,25 @@ const legacyResources: Resource[] = sites // ---- 新分类精选条目(均为稳定、可公开验证的真实项目/产品) ---- +/** 已生成的 id 集合:中文名等非 ASCII 名称会被替换成纯连字符,导致多个资源共用同一 id(如 + * 「通义千问/文心一言/智谱清言/讯飞星火」此前都是 `cur-ai-apps-----`),这里对冲突项追加 + * 内容 hash 保证唯一,同时保持既有非冲突 id 不变(不影响已收藏/已投票数据)。 */ +const usedIds = new Set(); + +/** 简单稳定的字符串 hash(djb2),用于为冲突 id 追加唯一后缀 */ +function strHash(s: string): string { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; + return h.toString(36); +} + function mk(subType: string, name: string, url: string, extra: Partial = {}): Resource { + const base = `cur-${subType}-${name}`.replace(/[^a-zA-Z0-9\-]/g, '-').toLowerCase(); + let id = base; + if (usedIds.has(id)) id = `${base}-${strHash(name)}`; + usedIds.add(id); return { - id: `cur-${subType}-${name}`.replace(/[^a-zA-Z0-9\-]/g, '-').toLowerCase(), + id, subType, scenarios: SUBTYPE_SCENARIOS[subType] ?? [], name, @@ -663,9 +679,25 @@ const curatedRanked: Resource[] = curated.map((r) => ({ })); /** 全站种子资源(旧数据映射 + 社区精选 + 人气分;黑名单死链一律过滤,防止回流) */ -export const seedResources: Resource[] = [...legacyResources, ...curatedRanked, ...curatedResources].filter( - (r) => !isBlacklisted(r.url), -); +export const seedResources: Resource[] = (() => { + // 统一保证 id 唯一:curated.ts 为手工维护,个别条目 id 曾重复(如两条 "ob-api-"), + // 重复会导致详情页/收藏/验证/评论按 id 关联时串数据。这里对重复项追加序号后缀, + // 使每个资源都有稳定唯一的 id(首个出现者保持原 id,不影响既有收藏)。 + const seen = new Set(); + const dedupe = (r: Resource): Resource => { + let id = r.id; + if (seen.has(id)) { + let n = 2; + while (seen.has(`${id}-${n}`)) n++; + id = `${id}-${n}`; + } + seen.add(id); + return id === r.id ? r : { ...r, id }; + }; + return [...legacyResources, ...curatedRanked, ...curatedResources] + .filter((r) => !isBlacklisted(r.url)) + .map(dedupe); +})(); /** 统计各子类型资源数(用于首页卡片角标) */ export function countBySubType(): Record { diff --git a/src/hooks/useHashRoute.ts b/src/hooks/useHashRoute.ts index 5ee718f..c9b67f6 100644 --- a/src/hooks/useHashRoute.ts +++ b/src/hooks/useHashRoute.ts @@ -31,7 +31,13 @@ export function parseHash(): Route { if (queryPart) { for (const pair of queryPart.split('&')) { const [k, v] = pair.split('='); - if (k) query[decodeURIComponent(k)] = decodeURIComponent(v ?? ''); + if (!k) continue; + try { + query[decodeURIComponent(k)] = decodeURIComponent(v ?? ''); + } catch { + // 非法的百分号编码(如手输 #/search?q=%zz)不阻塞路由,按原文保留 + query[k] = v ?? ''; + } } } diff --git a/src/lib/data.ts b/src/lib/data.ts index 0c8fbb5..907ced7 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -6,7 +6,7 @@ // - 投稿提交(submitResource)在配置 Supabase 时写入云端审核库,否则落本地草稿。 // 上层页面只依赖本文件的异步接口,无需关心数据来自哪里 —— 单一入口、可替换、易测试。 import { supabase, hasSupabase } from './supabase'; -import { subTypes, scenarios } from '@/data/taxonomy'; +import { subTypes, scenarios, resolveScenarios } from '@/data/taxonomy'; import { seedResources } from '@/data/seed'; import type { Resource, ResourceStatus, ResourceType, Scenario, SubType, Submission } from './types'; @@ -45,7 +45,10 @@ function filterResources(list: Resource[], query: ResourceQuery): Resource[] { let out = [...list]; if (query.subType) out = out.filter((r) => r.subType === query.subType); - if (query.scenario) out = out.filter((r) => (r.scenarios ?? []).includes(query.scenario!)); + // 场景过滤必须与首页场景树(buildScenarioTree 用 resolveScenarios)保持同一套判定: + // 资源未显式声明 scenarios(如 curated.ts 中 scenarios:[])时回退到子类型默认映射, + // 否则这部分资源在场景页会全部丢失,造成首页计数与场景页内容严重不符。 + if (query.scenario) out = out.filter((r) => resolveScenarios(r).includes(query.scenario!)); if (query.q) { const q = query.q.trim().toLowerCase(); if (q) { @@ -248,20 +251,20 @@ export interface VerificationStats { const VKEY = 'ob_verifications'; /** 读取本设备的投票记录(未投返回 null) */ -function localVote(resourceId: string): { result: 'ok' | 'dead'; at: string } | null { +function localVote(resourceId: string): { result: 'ok' | 'dead'; at: string; synced?: boolean } | null { try { - const m = JSON.parse(localStorage.getItem(VKEY) ?? '{}') as Record; + const m = JSON.parse(localStorage.getItem(VKEY) ?? '{}') as Record; return m[resourceId] ?? null; } catch { return null; } } -/** 记录本设备投票 */ -function saveLocalVote(resourceId: string, result: 'ok' | 'dead') { +/** 记录本设备投票;synced=true 表示该票已成功写入云端(统计时不再与云端重复计数) */ +function saveLocalVote(resourceId: string, result: 'ok' | 'dead', synced = false) { try { - const m = JSON.parse(localStorage.getItem(VKEY) ?? '{}') as Record; - m[resourceId] = { result, at: new Date().toISOString() }; + const m = JSON.parse(localStorage.getItem(VKEY) ?? '{}') as Record; + m[resourceId] = { result, at: new Date().toISOString(), synced }; localStorage.setItem(VKEY, JSON.stringify(m)); } catch { /* localStorage 不可用时静默降级 */ @@ -273,15 +276,18 @@ export async function submitVerification( resourceId: string, result: 'ok' | 'dead', ): Promise<{ ok: boolean; message?: string }> { - saveLocalVote(resourceId, result); + // 先落本地(无论云端成败都保留本设备记录,用于防重复 + 未上云时的兜底统计) + saveLocalVote(resourceId, result, false); if (hasSupabase && supabase) { try { const { error } = await supabase .from('verifications') .insert({ resource_id: resourceId, result, created_at: new Date().toISOString() }); if (error) return { ok: false, message: error.message }; + // 云端写入成功:标记本地票已上云,统计时以云端为准,避免同票被计两次 + saveLocalVote(resourceId, result, true); } catch { - /* 云端失败不阻塞:本地已记录,下次可重试 */ + /* 云端失败不阻塞:本地已记录(未上云),统计时作兜底计入 */ } } return { ok: true }; @@ -290,35 +296,42 @@ export async function submitVerification( /** 读取某资源的验证统计(总票数 / 可用票 / 失效票 / 最近验证时间) */ export async function getVerificationStats(resourceId: string): Promise { const local = localVote(resourceId); - const base = { ok: 0, dead: 0, total: 0, lastAt: null as string | null }; - // 本地票并入统计(乐观展示;云端模式也计入本设备这一次) - if (local) { - if (local.result === 'ok') base.ok += 1; - else base.dead += 1; - base.lastAt = local.at; - } + // 本地模式(未配置 Supabase):本设备票并入统计 if (!(hasSupabase && supabase)) { + const base = { ok: 0, dead: 0, total: 0, lastAt: null as string | null }; + if (local) { + if (local.result === 'ok') base.ok += 1; + else base.dead += 1; + base.lastAt = local.at; + } return { ...base, total: base.ok + base.dead }; } + // Supabase 模式:以云端统计为准 + let ok = 0; + let dead = 0; + let lastAt: string | null = null; try { const { data, error } = await supabase .from('verifications') .select('result, created_at') .eq('resource_id', resourceId); - if (error || !data) return { ...base, total: base.ok + base.dead }; - const rows = data as { result: string; created_at: string }[]; - let ok = base.ok; - let dead = base.dead; - let lastAt = base.lastAt; - for (const r of rows) { - if (r.result === 'ok') ok += 1; - else if (r.result === 'dead') dead += 1; - if (!lastAt || r.created_at > lastAt) lastAt = r.created_at; + if (!error && data) { + for (const r of data as { result: string; created_at: string }[]) { + if (r.result === 'ok') ok += 1; + else if (r.result === 'dead') dead += 1; + if (!lastAt || r.created_at > lastAt) lastAt = r.created_at; + } } - return { ok, dead, total: ok + dead, lastAt }; } catch { - return { ...base, total: base.ok + base.dead }; + /* 云端读取失败:继续用下面的本地兜底 */ + } + // 本设备「未成功上云」的票兜底计入(如云端 insert 失败但本地已记录),已上云的不重复计 + if (local && local.synced !== true) { + if (local.result === 'ok') ok += 1; + else dead += 1; + if (!lastAt || local.at > lastAt) lastAt = local.at; } + return { ok, dead, total: ok + dead, lastAt }; } // ============================================================ diff --git a/supabase/migrations/0004_harden_anon_writes.sql b/supabase/migrations/0004_harden_anon_writes.sql new file mode 100644 index 0000000..775da60 --- /dev/null +++ b/supabase/migrations/0004_harden_anon_writes.sql @@ -0,0 +1,55 @@ +-- ============================================================ +-- OpenBox —— 匿名写接口温和加固(0004) +-- +-- 背景:0001-0003 中 verifications / comments / submissions / reports +-- 的 insert policy 均为 with check(true),且 created_at 可由客户端 +-- 任意指定,存在「伪造验证时间 / 超长昵称撑库」等滥用空间。 +-- +-- 本次加固不改变「匿名可投票 / 可留言 / 可投稿 / 可反馈」的产品设计, +-- 仅做三件保守的事: +-- 1) 服务端强制 created_at = now(),客户端传的时间一律覆盖(防时间造假); +-- 2) 评论昵称加长度上限(防超长文本撑库); +-- 3) 评论内容必须非空(配合已有的 500 字符上限)。 +-- +-- 用法:在 Supabase 控制台 SQL Editor 执行一次即可(幂等,可重复执行)。 +-- ============================================================ + +-- ---------- 1) 强制 created_at 由服务端写入 ---------- +create or replace function public.force_created_at() +returns trigger language plpgsql as $$ +begin + new.created_at = now(); + return new; +end; +$$; + +drop trigger if exists trg_verifications_created_at on public.verifications; +create trigger trg_verifications_created_at + before insert on public.verifications + for each row execute function public.force_created_at(); + +drop trigger if exists trg_comments_created_at on public.comments; +create trigger trg_comments_created_at + before insert on public.comments + for each row execute function public.force_created_at(); + +drop trigger if exists trg_submissions_created_at on public.submissions; +create trigger trg_submissions_created_at + before insert on public.submissions + for each row execute function public.force_created_at(); + +drop trigger if exists trg_reports_created_at on public.reports; +create trigger trg_reports_created_at + before insert on public.reports + for each row execute function public.force_created_at(); + +-- ---------- 2) 评论:昵称长度上限 + 内容非空 ---------- +alter table public.comments + drop constraint if exists comments_nickname_len_check; +alter table public.comments + add constraint comments_nickname_len_check check (char_length(nickname) <= 30); + +alter table public.comments + drop constraint if exists comments_content_not_empty; +alter table public.comments + add constraint comments_content_not_empty check (btrim(content) <> '');