) {
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) <> '');