Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@
<meta name="theme-color" content="#0b0f17" />
<link rel="canonical" href="https://intelvor.github.io/OpenBox/" />

<!-- hreflang -->
<!-- hreflang:本站为 hash 路由 SPA,语言由 localStorage 记忆、URL 参数不生效,
因此不声明 en/ja 独立地址(避免搜索引擎把同一页面当多语言收录) -->
<link rel="alternate" hreflang="zh-Hans" href="https://intelvor.github.io/OpenBox/" />
<link rel="alternate" hreflang="en" href="https://intelvor.github.io/OpenBox/?lang=en" />
<link rel="alternate" hreflang="x-default" href="https://intelvor.github.io/OpenBox/" />

<!-- Open Graph -->
Expand Down
2 changes: 1 addition & 1 deletion src/components/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function ResourceDetail({ resource }: { resource: Resource }) {
<StatusBadge status={resource.status} />
{resource.official && (
<span className="badge" style={{ color: 'var(--color-primary)', background: 'var(--color-primary-soft)' }}>
{t('detail.favorite')}
{t('common.official')}
</span>
)}
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
Home,
LayoutGrid,
List,
GitBranch,
type LucideIcon,
} from 'lucide-react';

Expand Down Expand Up @@ -84,9 +85,11 @@ const MAP: Record<string, LucideIcon> = {
Home,
LayoutGrid,
List,
GitBranch,
};

export function Icon({ name, size, ...props }: { name: string; size?: number } & SVGProps<SVGSVGElement>) {
const Cmp = MAP[name] ?? Globe;
return <Cmp {...props} />;
// 注意:size 需显式传给图标组件,否则 lucide 默认 24px,调用方的 size 会被静默丢弃
return <Cmp size={size} {...props} />;
}
1 change: 1 addition & 0 deletions src/components/MobileTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
5 changes: 3 additions & 2 deletions src/components/ResourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ export function ResourceCard({ resource, index = 0 }: { resource: Resource; inde
<div className="mt-3 flex flex-wrap items-center gap-2">
<TypeBadge type={resource.type} />
<StatusBadge status={resource.status} />
{/* 标签 chips:移动端最多 2 个 + 溢出计数,桌面端最多 3 个(两组互斥显示,避免重复渲染) */}
{resource.tags.slice(0, 2).map((tag) => (
<span key={tag} className="chip hidden sm:inline-flex" data-active={false}>
<span key={`m-${tag}`} className="chip sm:hidden" data-active={false}>
{tag}
</span>
))}
Expand All @@ -87,7 +88,7 @@ export function ResourceCard({ resource, index = 0 }: { resource: Resource; inde
</span>
)}
{resource.tags.slice(0, 3).map((tag) => (
<span key={tag} className="chip max-sm:hidden" data-active={false}>
<span key={`d-${tag}`} className="chip hidden sm:inline-flex" data-active={false}>
{tag}
</span>
))}
Expand Down
40 changes: 36 additions & 4 deletions src/data/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,25 @@ const legacyResources: Resource[] = sites

// ---- 新分类精选条目(均为稳定、可公开验证的真实项目/产品) ----

/** 已生成的 id 集合:中文名等非 ASCII 名称会被替换成纯连字符,导致多个资源共用同一 id(如
* 「通义千问/文心一言/智谱清言/讯飞星火」此前都是 `cur-ai-apps-----`),这里对冲突项追加
* 内容 hash 保证唯一,同时保持既有非冲突 id 不变(不影响已收藏/已投票数据)。 */
const usedIds = new Set<string>();

/** 简单稳定的字符串 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> = {}): 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,
Expand Down Expand Up @@ -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<string>();
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<string, number> {
Expand Down
8 changes: 7 additions & 1 deletion src/hooks/useHashRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '';
}
}
}

Expand Down
69 changes: 41 additions & 28 deletions src/lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, { result: 'ok' | 'dead'; at: string }>;
const m = JSON.parse(localStorage.getItem(VKEY) ?? '{}') as Record<string, { result: 'ok' | 'dead'; at: string; synced?: boolean }>;
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<string, { result: 'ok' | 'dead'; at: string }>;
m[resourceId] = { result, at: new Date().toISOString() };
const m = JSON.parse(localStorage.getItem(VKEY) ?? '{}') as Record<string, { result: 'ok' | 'dead'; at: string; synced?: boolean }>;
m[resourceId] = { result, at: new Date().toISOString(), synced };
localStorage.setItem(VKEY, JSON.stringify(m));
} catch {
/* localStorage 不可用时静默降级 */
Expand All @@ -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 };
Expand All @@ -290,35 +296,42 @@ export async function submitVerification(
/** 读取某资源的验证统计(总票数 / 可用票 / 失效票 / 最近验证时间) */
export async function getVerificationStats(resourceId: string): Promise<VerificationStats> {
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 };
}

// ============================================================
Expand Down
55 changes: 55 additions & 0 deletions supabase/migrations/0004_harden_anon_writes.sql
Original file line number Diff line number Diff line change
@@ -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) <> '');