Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
34 changes: 34 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Files that have not yet been migrated to the project's Prettier style.
# Remove a file's entry here when it is next meaningfully changed.
.github/scripts/count-reward.ts
.github/scripts/share-reward.ts
.github/scripts/type.ts
app/Barrier-Free-Bites/layout.tsx
app/api/ai/health/route.ts
app/deadlines/page.tsx
app/home/page.tsx
app/origin/page.tsx
app/page.tsx
components/AddToCalendar.tsx
components/Aggregation.tsx
components/CountdownTimer.tsx
components/FilterBar.tsx
components/I18nProvider.tsx
components/TimelineItem.tsx
components/ui/badge.tsx
components/ui/button.tsx
components/ui/card.tsx
components/ui/dialog.tsx
components/ui/dropdown-menu.tsx
components/ui/input.tsx
components/ui/label.tsx
components/ui/scroll-area.tsx
components/ui/switch.tsx
eslint.config.mjs
i18n/config.ts
lib/data.ts
lib/spark.ts
lib/store.ts
lib/utils.ts
next.config.ts
postcss.config.mjs
1,108 changes: 314 additions & 794 deletions app/Barrier-Free-Bites/page.tsx
Comment thread
TechQuery marked this conversation as resolved.

Large diffs are not rendered by default.

128 changes: 86 additions & 42 deletions app/api/ai/recommend/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { NextResponse } from "next/server"
import { BITES_CATALOG, AccessibilityFilter, BitesRestaurant, filterBitesCatalog } from "@/lib/bitesCatalog"
import { chatSpark, SparkMessage } from "@/lib/spark"
import { NextResponse } from 'next/server'
import {
fetchBitesCatalog,
AccessibilityFilter,
BitesRestaurant,
filterBitesCatalog,
} from '@/lib/bitesCatalog'
import { chatSpark, SparkMessage } from '@/lib/spark'

export const runtime = "nodejs"
export const runtime = 'nodejs'

interface RecommendRequestBody {
location?: string
Expand All @@ -17,17 +22,30 @@ interface ModelRecommendation extends Partial<BitesRestaurant> {

// 严格将模型输出限定到本地候选集,并应用地点/无障碍过滤
function normalize(s: string) {
return (s || "").replace(/[()()]/g, "").replace(/\s+/g, "").trim()
return (s || '')
.replace(/[()()]/g, '')
.replace(/\s+/g, '')
.trim()
}

function enforceCatalog(recs: ModelRecommendation[], location: string, accessibility: AccessibilityFilter) {
const candidates = filterBitesCatalog(location, accessibility)
function enforceCatalog(
recs: ModelRecommendation[],
catalog: BitesRestaurant[],
location: string,
accessibility: AccessibilityFilter,
) {
const candidates = filterBitesCatalog(catalog, location, accessibility)
const byNameOrAddr = (r: ModelRecommendation, c: BitesRestaurant) => {
const rn = normalize(r?.name || "")
const cn = normalize(c?.name || "")
const ra = normalize(r?.address || "")
const ca = normalize(c?.address || "")
return (rn && rn === cn) || (ra && ra === ca) || (rn && cn.includes(rn)) || (ra && ca.includes(ra))
const rn = normalize(r?.name || '')
const cn = normalize(c?.name || '')
const ra = normalize(r?.address || '')
const ca = normalize(c?.address || '')
return (
(rn && rn === cn) ||
(ra && ra === ca) ||
(rn && cn.includes(rn)) ||
(ra && ca.includes(ra))
)
}

const matched: BitesRestaurant[] = []
Expand All @@ -40,11 +58,11 @@ function enforceCatalog(recs: ModelRecommendation[], location: string, accessibi
}

function safeParseJson(input: string): any {
const cleaned = (input || "")
const cleaned = (input || '')
.trim()
.replace(/^```json/gi, "")
.replace(/^```/gi, "")
.replace(/```$/gi, "")
.replace(/^```json/gi, '')
.replace(/^```/gi, '')
.replace(/```$/gi, '')

try {
return JSON.parse(cleaned)
Expand All @@ -60,57 +78,83 @@ function safeParseJson(input: string): any {
}

export async function POST(req: Request) {
let location = ""
let preferences = ""
let location = ''
let preferences = ''
let accessibility: AccessibilityFilter = {}

const catalog = await fetchBitesCatalog()

try {
const body = (await req.json()) as RecommendRequestBody
location = body?.location || ""
preferences = body?.preferences || ""
location = body?.location || ''
preferences = body?.preferences || ''
accessibility = body?.accessibility || {}
} catch {
return NextResponse.json({ recommendations: filterBitesCatalog("", {}).slice(0, 5), source: "fallback_bad_request" })
return NextResponse.json({
recommendations: filterBitesCatalog(catalog, '', {}).slice(0, 5),
source: 'fallback_bad_request',
})
}

const filtersText = `听障友好: ${accessibility?.deafFriendly ? "是" : "否"}; 视障友好: ${accessibility?.blindFriendly ? "是" : "否"}`
const filtersText = `听障友好: ${accessibility?.deafFriendly ? '是' : '否'}; 视障友好: ${accessibility?.blindFriendly ? '是' : '否'}`

const system: SparkMessage = {
role: "system",
role: 'system',
content: [
"你是无障碍友好美食推荐助手。",
"你的数据来源仅限于下列候选餐厅(来自页面 Barrier-Free-Bites 的静态内容),不可调用任何联网搜索或外部知识:",
JSON.stringify(BITES_CATALOG),
"严格只从上述候选中进行筛选与排序,不要发明新的餐厅。",
"只返回合法JSON字符串,不要任何说明、注释或代码块,不要使用中文标点。",
"字段名与示例完全一致:{ recommendations: [{ name, address, city, tags, description }] },按匹配度高到低排序,最多5条。",
].join("\n"),
'你是无障碍友好美食推荐助手。',
'你的数据来源仅限于下列候选餐厅(来自页面 Barrier-Free-Bites 的静态内容),不可调用任何联网搜索或外部知识:',
JSON.stringify(catalog),
'严格只从上述候选中进行筛选与排序,不要发明新的餐厅。',
'只返回合法JSON字符串,不要任何说明、注释或代码块,不要使用中文标点。',
'字段名与示例完全一致:{ recommendations: [{ name, address, city, tags, description }] },按匹配度高到低排序,最多5条。',
].join('\n'),
}

const user: SparkMessage = {
role: "user",
role: 'user',
content: `地点: ${location}\n偏好: ${preferences}\n无障碍偏好: ${filtersText}`,
}

try {
const text = await chatSpark({ messages: [system, user], temperature: 0.3, maxTokens: 1200 })
const parsed = safeParseJson(text) || { recommendations: filterBitesCatalog(location, accessibility) }
const recommendations: ModelRecommendation[] = Array.isArray(parsed?.recommendations) ? parsed.recommendations : []
const strict = enforceCatalog(recommendations, location, accessibility)
const text = await chatSpark({
messages: [system, user],
temperature: 0.3,
maxTokens: 1200,
})
const parsed = safeParseJson(text) || {
recommendations: filterBitesCatalog(catalog, location, accessibility),
}
const recommendations: ModelRecommendation[] = Array.isArray(
parsed?.recommendations,
)
? parsed.recommendations
: []
const strict = enforceCatalog(
recommendations,
catalog,
location,
accessibility,
)

if (!strict.length) {
const fallback = filterBitesCatalog(location, accessibility)
return NextResponse.json({ recommendations: fallback.slice(0, 5), source: "fallback" })
const fallback = filterBitesCatalog(catalog, location, accessibility)
return NextResponse.json({
recommendations: fallback.slice(0, 5),
source: 'fallback',
})
}

return NextResponse.json({ recommendations: strict.slice(0, 5), source: "spark" })
return NextResponse.json({
recommendations: strict.slice(0, 5),
source: 'spark',
})
} catch (err: any) {
console.error("[AI Recommend] Error:", err)
const fallback = filterBitesCatalog(location, accessibility)
console.error('[AI Recommend] Error:', err)
const fallback = filterBitesCatalog(catalog, location, accessibility)
return NextResponse.json({
recommendations: fallback.slice(0, 5),
source: "fallback_error",
error: err?.message || "AI推荐失败",
source: 'fallback_error',
error: err?.message || 'AI推荐失败',
})
}
}
84 changes: 66 additions & 18 deletions app/api/data/route.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,79 @@
import { NextResponse } from 'next/server'
import yaml from 'yaml'
import fs from 'fs'
import path from 'path'
import { DeadlineItem } from '@/lib/data'
import { DeadlineItem, EventData } from '@/lib/data'

export const dynamic = 'force-static'

let STATIC_DATA: DeadlineItem[] = []
let INIT_ERROR: unknown = null
const DATA_API_URL =
'https://goodaction-hub.github.io/GoodAction-data/activities.json'

try {
const conferencesPath = path.join(process.cwd(), 'data', 'conferences.yml')
const competitionsPath = path.join(process.cwd(), 'data', 'competitions.yml')
const activitiesPath = path.join(process.cwd(), 'data', 'activities.yml')
interface ExternalEventData {
id: string
link: string
start_time?: string
end_time?: string
timeline: { deadline: string; comment: string }[]
timezone: string
place: string
}

interface ExternalDeadlineItem {
title: string
description: string
category: 'meetup' | 'conference' | 'competition'
tags: string[]
events: ExternalEventData[]
}

const conferencesData = yaml.parse(fs.readFileSync(conferencesPath, 'utf8')) as DeadlineItem[]
const competitionsData = yaml.parse(fs.readFileSync(competitionsPath, 'utf8')) as DeadlineItem[]
const activitiesData = yaml.parse(fs.readFileSync(activitiesPath, 'utf8')) as DeadlineItem[]
function transformEvent(event: ExternalEventData): EventData {
const startTime = event.start_time ?? event.timeline[0]?.deadline ?? ''
const startDate = startTime ? new Date(startTime.replace(' ', 'T')) : null
const year = startDate ? startDate.getFullYear() : new Date().getFullYear()

const formatDateToChinese = (d: Date) =>
`${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
let date = startDate ? formatDateToChinese(startDate) : ''
if (startDate && event.end_time) {
const endDate = new Date(event.end_time.replace(' ', 'T'))
if (endDate.getTime() !== startDate.getTime()) {
date = `${date}-${endDate.getMonth() + 1}月${endDate.getDate()}日`
}
}

STATIC_DATA = [...conferencesData, ...competitionsData, ...activitiesData]
} catch (err) {
INIT_ERROR = err
return {
year,
id: event.id,
link: event.link,
timeline: event.timeline,
timezone: event.timezone,
date,
place: event.place,
}
}

function transformItem(item: ExternalDeadlineItem): DeadlineItem {
return {
title: item.title,
description: item.description,
category: item.category === 'meetup' ? 'activity' : item.category,
tags: item.tags ?? [],
events: item.events.map(transformEvent),
}
}

export async function GET() {
if (INIT_ERROR) {
try {
const res = await fetch(DATA_API_URL, { cache: 'force-cache' })
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to fetch data from external API' },
{ status: 502 },
)
}
const externalData = (await res.json()) as ExternalDeadlineItem[]
const data: DeadlineItem[] = externalData.map(transformItem)
return NextResponse.json(data)
} catch (err) {
console.error('Failed to fetch data from external API:', err)
return NextResponse.json({ error: 'Failed to load data' }, { status: 500 })
}
return NextResponse.json(STATIC_DATA)
}
49 changes: 29 additions & 20 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import I18nProvider from '@/components/I18nProvider';
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Link from 'next/link';
import { SwitchLanguage } from '@/components/SwitchLanguage';

const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
variable: '--font-inter',
subsets: ['latin'],
});

// 使用 Inter 字体替代 Geist 字体以避免 Turbopack 兼容性问题
const fontMono = Inter({
variable: "--font-geist-mono",
subsets: ["latin"],
variable: '--font-geist-mono',
subsets: ['latin'],
});

export const metadata: Metadata = {
title: "GoodAction-Hub",
description: "追踪公益慈善会议、竞赛和活动重要截止日期的网站,帮助公益从业者、志愿者和爱心人士及时了解最新的公益慈善活动动态,不再错过参与公益事业、奉献爱心和社会服务的机会。",
title: 'GoodAction-Hub',
description:
'追踪公益慈善会议、竞赛和活动重要截止日期的网站,帮助公益从业者、志愿者和爱心人士及时了解最新的公益慈善活动动态,不再错过参与公益事业、奉献爱心和社会服务的机会。',
};

export default function RootLayout({
Expand All @@ -29,22 +30,30 @@ export default function RootLayout({
return (
<html lang="en">
<head>
<script defer src="https://umami.rkd.icu/script.js" data-website-id="78225323-cc05-46af-9a51-6c670b9a804a"></script>
<script
defer
src="https://umami.rkd.icu/script.js"
data-website-id="78225323-cc05-46af-9a51-6c670b9a804a"
></script>
</head>
<body
className={`${inter.variable} ${fontMono.variable} antialiased`}
>
<body className={`${inter.variable} ${fontMono.variable} antialiased`}>
<I18nProvider>
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-sm shadow-sm border-b border-white/20">
<div className="container mx-auto px-4 py-3 flex items-center justify-between">
<nav className="flex items-center gap-3">
<Link href="/" className="text-sm md:text-base font-semibold bg-gradient-to-r from-pink-600 via-pink-500 to-purple-600 bg-clip-text text-transparent hover:brightness-110">
公益慈善活动截止日期
</Link>
<Link
href="/"
className="text-sm md:text-base font-semibold bg-gradient-to-r from-pink-600 via-pink-500 to-purple-600 bg-clip-text text-transparent hover:brightness-110"
>
公益慈善活动截止日期
</Link>
<span className="text-gray-300">|</span>
<Link href="/Barrier-Free-Bites" className="text-sm md:text-base font-semibold bg-gradient-to-r from-pink-600 via-pink-500 to-purple-600 bg-clip-text text-transparent hover:brightness-110">
无障碍友好美食指南
</Link>
<Link
href="/Barrier-Free-Bites"
className="text-sm md:text-base font-semibold bg-gradient-to-r from-pink-600 via-pink-500 to-purple-600 bg-clip-text text-transparent hover:brightness-110"
>
无障碍友好美食指南
</Link>
</nav>
<div className="flex items-center gap-3">
<SwitchLanguage />
Expand Down
Loading