diff --git a/README.md b/README.md index 20eba6a..6696926 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,12 @@ GoodAction Hub 是一个聚合公益活动信息与无障碍餐饮指南的开源站点,包含两大模块: -- 公益慈善活动截止日期(路径:`/deadlines`):基于 [hust-open-atom-club/open-source-deadlines](https://github.com/hust-open-atom-club/open-source-deadlines) 改造与扩展,汇总会议、竞赛与活动的关键时间节点,帮助公益从业者、志愿者和爱心人士不再错过参与机会。 +[![CI & CD][1]][2] + +[![Open in GitHub Codespaces][3]][4] +[![Open in Gitpod][5]][6] + +- 公益慈善活动截止日期(路径:`/deadlines`):基于 [hust-open-atom-club/open-source-deadlines][7] 改造与扩展,汇总会议、竞赛与活动的关键时间节点,帮助公益从业者、志愿者和爱心人士不再错过参与机会。 - 无障碍友好美食指南(路径:`/Barrier-Free-Bites`):原创功能模块,聚焦无障碍就餐体验与友好餐饮空间,提供更易获取的线索与导航入口(路径大小写需一致)。 ## 模块来源说明 @@ -10,7 +15,7 @@ GoodAction Hub 是一个聚合公益活动信息与无障碍餐饮指南的开 为便于理解项目结构与来源,现对两大模块的来源说明如下: - 公益慈善活动截止日期(改造自开源项目) - - 基于 [hust-open-atom-club/open-source-deadlines](https://github.com/hust-open-atom-club/open-source-deadlines) 改造开发 + - 基于 [hust-open-atom-club/open-source-deadlines][7] 改造开发 - 我们的改动: - 🎯 主题转换:从开源技术活动切换为公益慈善活动 - 🎨 UI 增强:视觉与交互优化 @@ -28,141 +33,55 @@ GoodAction Hub 是一个聚合公益活动信息与无障碍餐饮指南的开 - 公益慈善活动截止日期(路径:`/deadlines`) - 汇总公益会议、竞赛与活动的关键时间节点,支持时区展示与人类可读日期 - 提供「日历添加」与「倒计时」等便捷功能(组件参考:`components/AddToCalendar.tsx`、`components/CountdownTimer.tsx`、`components/TimelineItem.tsx`) - - 活动来源数据在 `data/activities.yml`、`data/competitions.yml`、`data/conferences.yml`,通过统一数据模型渲染 + - 活动数据由独立数据仓库统一维护并提供 - 无障碍友好美食指南(路径:`/Barrier-Free-Bites`) - 聚焦无障碍就餐体验与友好餐饮空间,提供更易获取的线索与导航入口 - 建议使用「大小写一致」的路径访问:`/Barrier-Free-Bites`(注意大小写),以保证在不同托管平台上的最佳路由兼容性 +## 数据贡献指引 -## 如何添加活动 - -我们非常欢迎社区贡献!如果您发现有未收录的公益慈善会议、竞赛及活动,或者信息有误,请通过提交 Pull Request 的方式来帮助我们更新。 - -我们会定期查看并处理这些提交,感谢您对公益事业的支持! - -所有活动数据都存储在 `/data` 目录下的 YAML 文件中。 - -- **会议**: 请添加到 `data/conferences.yml` -- **竞赛**: 请添加到 `data/competitions.yml` -- **活动**: 请添加到 `data/activities.yml` - -### 数据结构 - -请在对应的 YAML 文件中,仿照以下格式添加新条目: - -```yaml -- title: 活动名称 (例如:第三届老龄志愿与公益服务学术论坛) - description: 探讨AI时代应对人口老龄化的新策略,推动我国老龄志愿与公益服务事业多元发展 - category: conference # 会议请使用 "conference",竞赛请使用 "competition",活动请使用 "activity" - tags: - - 老龄服务 - - 志愿公益 - - 人工智能 - - 学术论坛 - events: - - year: 2025 # 年份 - id: aging-volunteer-forum-2025 # 全局唯一的ID - link: https://mp.weixin.qq.com/s/qi9gF1ETgk6UvFnnGNSVlg # 链接 - timeline: - - deadline: '2025-10-19T23:59:00' # 关键日期 (ISO 8601 格式) - comment: '论文征集截止' # 日期说明 - - deadline: '2025-11-15T09:00:00' - comment: '学术年会开始' - - deadline: '2025-11-16T17:00:00' - comment: '学术年会结束' - timezone: Asia/Shanghai # 所在时区 - date: 2025年11月15日-11月16日 # 人类可读的日期范围 - place: 中国,北京 # 地点 -``` - -**注意事项:** - -- `category`: 必须是 `conference` 、 `competition` 或 `activity` -- `timeline.deadline`: 请使用 ISO 8601 标准格式 - `YYYY-MM-DDTHH:mm:ss` -- `timezone`: 请使用标准的 IANA 时区名称(例如 `Asia/Shanghai`),否则会影响时区转换 -- `date`: 请使用人类可读的单个日期或日期范围,如 `2025 年 4 月 30 日` 或 `2025 年 4 月 30 日 - 9 月 30 日` -- `place`: 活动地址,如 `中国,上海`(`国家,城市`);如果是线上活动,直接写 `线上` - -💡 **新手友好提示**:如果您不熟悉 Pull Request 流程,也可以通过Discussions和Issues方式提交活动信息,我们来帮您添加 -> -> 🎉 **每一份贡献都很珍贵,欢迎您的参与!** +我们非常欢迎社区贡献。活动数据与无障碍友好美食数据现已统一迁移到独立数据仓库维护,请在以下仓库提交与更新: + +- https://github.com/GoodAction-Hub/GoodAction-data + +适用范围: -## 如何添加美食地(无障碍友好美食指南) - -我们欢迎社区补充无障碍友好餐饮空间的信息。暂时数据以页面内配置与文案翻译驱动(`app/Barrier-Free-Bites/page.tsx` 与 `public/locales/*/translation.json`)。 - -### 数据放置位置 - -- 页面组件:`app/Barrier-Free-Bites/page.tsx` - - 可在 `restaurantCoords` 中为餐厅添加坐标与地址(用于导航按钮),键需与文案 key 对齐 - - 在页面 JSX 中增添一张餐厅卡片,引用对应的 i18n 文案 key -- 文案翻译:`public/locales/zh-CN/translation.json` 与 `public/locales/en/translation.json` - - 在 `bites.restaurants.` 下新增: - - `name`: 餐厅名称 - - `description`: 简介 - - `features`: 特色服务(数组) - - `food`: 特色美食 - - `experience` / `highlights`(可选):特色体验/亮点 - - `value`: 社会价值或人均消费等 - - `address`: 地址 - -### 最小示例(翻译 JSON) - -```json -"bites": { - "restaurants": { - "your_place_key": { - "name": "示例餐厅", - "description": "简短介绍", - "features": ["手语服务", "无障碍通道"], - "food": "咖啡、甜品", - "value": "提供残障友好服务与就业机会", - "address": "示例市示例区示例路" - } - } -} -``` - -### 最小示例(页面坐标) - -```ts -// app/Barrier-Free-Bites/page.tsx -const restaurantCoords = { - your_place_key: { lat: 39.9, lng: 116.4, address: "示例市示例区示例路" } -} -``` - -### 注意事项 - -- 路径大小写:页面路径为 `/Barrier-Free-Bites`,请使用一致大小写 -- key 一致性:`restaurantCoords` 的 key 应与 `translation.json` 中 `bites.restaurants.` 一致 -- 信息准确:尽可能提供官方链接或来源,便于核验 -- 无障碍要素:优先补充实际可用的设施与服务(如通道、菜单、手语、导视、情绪支持等) -- 多语言支持:建议同时更新 `zh-CN` 与 `en` 两个翻译文件 - -如对页面结构或数据流程有改进建议,欢迎提交 PR 或在 Discussions 中讨论。 +- 新增或修正公益活动(会议、竞赛、活动)信息 +- 新增或修正无障碍友好美食地信息 +- 补充无障碍要素、来源链接与说明 + +提交方式: + +- 在数据仓库发起 Pull Request +- 或在数据仓库提交 Issue,由维护者协助跟进 + +如对本仓库页面结构或展示逻辑有改进建议,欢迎在本仓库继续提交 PR 或在 Discussions 讨论。 + +💡 **新手友好提示**:如果您不熟悉 Pull Request 流程,也可以通过 Discussions 和 Issues 提交信息,我们来协助整理。 + +> 🎉 **每一份贡献都很珍贵,欢迎您的参与!** ## 开发指南 ### 环境准备 -**Bun**: 本项目使用 [Bun](https://bun.sh/) 作为包管理器和运行时。 +**Bun**: 本项目使用 [Bun][8] 作为包管理器和运行时。 ### 本地启动 1. **克隆项目** - ```bash - git clone - cd GoodAction-Hub - ``` + ```bash + git clone + cd GoodAction-Hub + ``` 2. **安装依赖** - ```bash - bun install - ``` + ```bash + bun install + ``` 3. **激活 Git Hook(此步骤会在安装依赖后自动执行)** @@ -172,17 +91,17 @@ const restaurantCoords = { 4. **启动开发服务器** - ```bash - bun run dev - ``` + ```bash + bun run dev + ``` 5. **(可选)剪枝** - ```bash - bun run knip - ``` + ```bash + bun run knip + ``` -现在,在浏览器中打开 [http://localhost:3000](http://localhost:3000) 即可看到项目页面。 +现在,在浏览器中打开 http://localhost:3000 即可看到项目页面。 ### 部署与运维 @@ -199,7 +118,21 @@ const restaurantCoords = { ### 技术栈 -- **框架**: [Next.js](https://nextjs.org/) -- **UI**: [Tailwind CSS](https://tailwindcss.com/) & [shadcn/ui](https://ui.shadcn.com/) -- **状态管理**: [Zustand](https://github.com/pmndrs/zustand) -- **搜索**: [Fuse.js](https://github.com/krisk/fuse) +- **框架**: [Next.js][9] +- **UI**: [Tailwind CSS][10] & [shadcn/ui][11] +- **状态管理**: [Zustand][12] +- **搜索**: [Fuse.js][13] + +[1]: https://github.com/GoodAction-Hub/GoodAction-Hub.github.io/actions/workflows/deploy.yml/badge.svg +[2]: https://github.com/GoodAction-Hub/GoodAction-Hub.github.io/actions/workflows/deploy.yml +[3]: https://github.com/codespaces/badge.svg +[4]: https://codespaces.new/GoodAction-Hub/GoodAction-Hub.github.io +[5]: https://gitpod.io/button/open-in-gitpod.svg +[6]: https://gitpod.io/?autostart=true#https://github.com/GoodAction-Hub/GoodAction-Hub.github.io +[7]: https://github.com/hust-open-atom-club/open-source-deadlines +[8]: https://bun.sh/ +[9]: https://nextjs.org/ +[10]: https://tailwindcss.com/ +[11]: https://ui.shadcn.com/ +[12]: https://github.com/pmndrs/zustand +[13]: https://github.com/krisk/fuse diff --git a/app/Barrier-Free-Bites/page.module.css b/app/Barrier-Free-Bites/page.module.css new file mode 100644 index 0000000..07d2989 --- /dev/null +++ b/app/Barrier-Free-Bites/page.module.css @@ -0,0 +1,294 @@ +:global(:root) { + --color-white: rgba(255, 255, 255, 1); + --color-cream-50: rgba(252, 252, 249, 1); + --color-cream-100: rgba(255, 255, 253, 1); + --color-gray-300: rgba(167, 169, 169, 1); + --color-slate-500: rgba(98, 108, 113, 1); + --color-brown-600: rgba(94, 82, 64, 1); + --color-charcoal-700: rgba(31, 33, 33, 1); + --color-charcoal-800: rgba(38, 40, 40, 1); + --color-slate-900: rgba(19, 52, 59, 1); + --color-teal-300: rgba(50, 184, 198, 1); + --color-teal-500: rgba(33, 128, 141, 1); + --color-teal-600: rgba(29, 116, 128, 1); + --color-teal-700: rgba(8, 145, 178, 1); + --color-gray-200: rgba(245, 245, 245, 1); + --color-orange-500: rgba(168, 75, 47, 1); + --color-background: var(--color-cream-50); + --color-surface: var(--color-cream-100); + --color-text: var(--color-slate-900); + --color-text-secondary: var(--color-slate-500); + --color-primary: rgba(147, 51, 234, 1); + --color-primary-hover: rgba(126, 34, 206, 1); + --color-accent: rgba(219, 39, 119, 1); + --color-secondary: rgba(8, 145, 178, 1); + --color-card-border: rgba(255, 255, 255, 0.2); + --color-border: rgba(255, 255, 255, 0.2); + --font-size-sm: 12px; + --font-size-base: 14px; + --font-size-lg: 16px; + --font-size-xl: 18px; + --font-size-2xl: 20px; + --font-size-3xl: 24px; + --font-weight-medium: 500; + --font-weight-semibold: 550; + --font-weight-bold: 600; + --space-8: 8px; + --space-12: 12px; + --space-16: 16px; + --space-20: 20px; + --space-24: 24px; + --space-32: 32px; + --radius-base: 8px; + --radius-lg: 12px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.04), + 0 2px 4px -1px rgba(0, 0, 0, 0.02); +} + +@media (prefers-color-scheme: dark) { + :global(:root) { + --color-background: var(--color-charcoal-700); + --color-surface: var(--color-charcoal-800); + --color-text: var(--color-gray-200); + --color-primary: rgba(192, 132, 252, 1); + --color-card-border: rgba(255, 255, 255, 0.15); + --color-border: rgba(255, 255, 255, 0.3); + } +} + +.header { + text-align: center; + margin-bottom: var(--space-32); + padding: var(--space-24) 0; +} + +.subtitle { + font-size: var(--font-size-lg); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); +} + +.filterSection { + display: flex; + gap: var(--space-12); + margin-bottom: var(--space-32); + flex-wrap: wrap; + justify-content: center; +} + +.filterBtn { + padding: var(--space-8) var(--space-20); + border: 2px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + border-radius: var(--radius-base); + cursor: pointer; + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + transition: all 0.3s ease; +} + +.filterBtn:hover, +.filterBtnActive { + border-color: var(--color-primary); + background: linear-gradient(90deg, var(--color-primary), var(--color-accent)); + color: var(--color-white); +} + +.restaurantsGrid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-24); + margin-bottom: var(--space-32); +} + +.restaurantCard { + background: var(--color-surface); + border: 1px solid var(--color-card-border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: all 0.3s ease; + display: flex; + flex-direction: column; +} + +.restaurantCard:hover { + box-shadow: var(--shadow-md); + transform: translateY(-4px); +} + +.cardHeader { + background: linear-gradient( + 135deg, + var(--color-primary) 0%, + var(--color-accent) 50%, + var(--color-secondary) 100% + ); + padding: var(--space-20); + color: var(--color-white); +} + +.restaurantName { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + margin-bottom: var(--space-8); +} + +.accessibilityTags { + display: flex; + gap: var(--space-8); + flex-wrap: wrap; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px var(--space-12); + background: rgba(255, 255, 255, 0.2); + border-radius: 20px; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); +} + +.cardBody { + padding: var(--space-20); + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.description { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + margin-bottom: var(--space-16); + line-height: 1.7; +} + +.features { + margin-bottom: var(--space-16); +} + +.featuresTitle { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + margin-bottom: var(--space-8); + color: var(--color-text); +} + +.featuresList { + list-style: none; + padding: 0; +} + +.featuresList li { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + padding: 4px 0 4px var(--space-16); + position: relative; +} + +.featuresList li::before { + content: '✓'; + position: absolute; + left: 0; + color: var(--color-primary); + font-weight: var(--font-weight-bold); +} + +.infoSection { + margin-top: auto; + padding-top: var(--space-16); + border-top: 1px solid var(--color-card-border); +} + +.infoItem { + display: flex; + align-items: flex-start; + gap: var(--space-8); + margin-bottom: var(--space-8); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.infoLabel { + font-weight: var(--font-weight-semibold); + color: var(--color-text); + min-width: 60px; +} + +.aboutSection { + position: relative; + overflow: hidden; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.92), + rgba(255, 255, 255, 0.96) + ); + border: 1px solid var(--color-card-border); + border-radius: var(--radius-lg); + padding: var(--space-24); + margin-top: var(--space-32); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08); +} + +.aboutHeader { + display: flex; + align-items: center; + gap: var(--space-12); + margin-bottom: var(--space-12); +} + +.aboutIcon { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-white); + background: linear-gradient( + 135deg, + var(--color-primary) 0%, + var(--color-accent) 50%, + var(--color-secondary) 100% + ); + font-size: var(--font-size-lg); +} + +.aboutTitle { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text); +} + +.aboutContent { + font-size: var(--font-size-base); + color: var(--color-text-secondary); + line-height: 1.85; +} + +.aboutContent p { + margin-bottom: var(--space-12); +} + +.icon { + font-size: var(--font-size-lg); +} + +@media (max-width: 768px) { + .restaurantsGrid { + grid-template-columns: 1fr; + } + + .filterSection { + flex-direction: column; + } + + .filterBtn { + width: 100%; + } +} diff --git a/app/Barrier-Free-Bites/page.tsx b/app/Barrier-Free-Bites/page.tsx index 1830095..c6d8c21 100644 --- a/app/Barrier-Free-Bites/page.tsx +++ b/app/Barrier-Free-Bites/page.tsx @@ -1,939 +1,336 @@ -"use client" - -import { useState, useEffect } from "react" -import FoodAIDialog from "@/components/FoodAIDialog" -import { useTranslation } from 'react-i18next' - -// 安全翻译组件,避免水合错误 -function SafeTranslation({ tKey, fallback }: { tKey: string; fallback: string }) { - const [mounted, setMounted] = useState(false) - const { t } = useTranslation('translation') - - useEffect(() => { - setMounted(true) - }, []) - - return <>{mounted ? t(tKey) : fallback} +'use client'; + +import { useState, useEffect } from 'react'; +import { Loader2, MapPin } from 'lucide-react'; +import FoodAIDialog from '@/components/FoodAIDialog'; +import SafeTranslation from '@/components/SafeTranslation'; +import { fetchBitesCatalog, BitesRestaurant } from '@/lib/bitesCatalog'; +import styles from './page.module.css'; + +type FilterType = 'all' | 'hearing' | 'visual' | 'wheelchair' | 'cognitive'; + +function getAccessibilityTypes(r: BitesRestaurant): FilterType[] { + const types: FilterType[] = []; + if (r.accessibility.deafFriendly) types.push('hearing'); + if (r.accessibility.blindFriendly) types.push('visual'); + const text = r.tags.join(' '); + if (/轮椅|坡道|无障碍通/.test(text)) types.push('wheelchair'); + if (/认知|自闭|学习障碍/.test(text)) types.push('cognitive'); + return types; } export default function BarrierFreeBitesPage() { - const [filter, setFilter] = useState<"all" | "hearing" | "visual" | "wheelchair" | "cognitive">("all") - - // 餐厅坐标信息 - const restaurantCoords = { - // 更新:培哥烟囱面包(合肥庐阳区),与翻译文案一致 - peige: { lat: 31.863, lng: 117.281, address: "安徽省合肥市庐阳区含山路29号105-3室" }, - muma: { lat: 39.9365, lng: 116.4477, address: "北京市朝阳区工体北路" }, - starbucks: { lat: 23.1291, lng: 113.2644, address: "广州市天河区" }, - // 更新:星巴克东方文德手语门店(广州越秀 文德北路68号 东方文德广场一层),用于页面中的 'starbucks_wende' key - starbucks_wende: { lat: 23.129, lng: 113.264, address: "广州市越秀区文德北路68号东方文德广场一层" } - } - - const [navigationLoading, setNavigationLoading] = useState(null) - const openAmapNavigation = (place: keyof typeof restaurantCoords | string, name?: string) => { - const isKnownKey = typeof place === 'string' && (place in restaurantCoords) - const restaurant = isKnownKey - ? restaurantCoords[place as keyof typeof restaurantCoords] - : typeof place !== 'string' - ? restaurantCoords[place] - : undefined - - // 记录加载状态使用字符串,便于任意地址对比 - setNavigationLoading(String(place)) + const [filter, setFilter] = useState('all'); + const [restaurants, setRestaurants] = useState([]); + const [loading, setLoading] = useState(true); + const [navigationLoading, setNavigationLoading] = useState( + null, + ); - if (!restaurant) { - // 未预设坐标:使用网页版标记链接进行导航/定位 - const address = typeof place === 'string' ? place : (name || '') - const markerUrl = `https://uri.amap.com/marker?address=${encodeURIComponent(address)}&name=${encodeURIComponent(name || address)}` - window.open(markerUrl, '_blank', 'noopener,noreferrer') - setNavigationLoading(null) - return - } - - // 构建高德地图 APP 协议链接 - const appUrl = `amapuri://route/plan/?dlat=${restaurant.lat}&dlon=${restaurant.lng}&dname=${encodeURIComponent(name || restaurant.address)}&dev=0&t=0` - - // 构建网页版链接作为回退 - const webUrl = `https://uri.amap.com/navigation?to=${restaurant.lng},${restaurant.lat},${encodeURIComponent(name || restaurant.address)}&mode=car&policy=1&src=mypage` - - // 尝试打开 APP,如果失败则打开网页版 - const tryOpenApp = () => { - const iframe = document.createElement('iframe') - iframe.style.display = 'none' - iframe.src = appUrl - document.body.appendChild(iframe) - - // 设置超时,如果 APP 没有响应则打开网页版 - const timeout = setTimeout(() => { - document.body.removeChild(iframe) - window.open(webUrl, '_blank', 'noopener,noreferrer') - setNavigationLoading(null) - }, 2000) - - // 监听页面可见性变化,如果用户切换到其他应用说明 APP 打开成功 - const handleVisibilityChange = () => { - if (document.hidden) { - clearTimeout(timeout) - document.body.removeChild(iframe) - document.removeEventListener('visibilitychange', handleVisibilityChange) - setNavigationLoading(null) - } - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - - // 清理函数 - setTimeout(() => { - try { - if (iframe.parentNode) { - document.body.removeChild(iframe) + useEffect(() => { + fetchBitesCatalog().then((data) => { + setRestaurants(data); + setLoading(false); + }); + }, []); + + const filteredRestaurants = restaurants.filter((r) => { + if (filter === 'all') return true; + return getAccessibilityTypes(r).includes(filter); + }); + + const openAmapNavigation = (restaurant: BitesRestaurant) => { + const { name, address, lat, lng } = restaurant; + setNavigationLoading(name); + + if (lat && lng) { + const appUrl = `amapuri://route/plan/?dlat=${lat}&dlon=${lng}&dname=${encodeURIComponent(name)}&dev=0&t=0`; + const webUrl = `https://uri.amap.com/navigation?to=${lng},${lat},${encodeURIComponent(name)}&mode=car&policy=1&src=mypage`; + + const tryOpenApp = () => { + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = appUrl; + document.body.appendChild(iframe); + + const timeout = setTimeout(() => { + document.body.removeChild(iframe); + window.open(webUrl, '_blank', 'noopener,noreferrer'); + setNavigationLoading(null); + }, 2000); + + const handleVisibilityChange = () => { + if (document.hidden) { + clearTimeout(timeout); + document.body.removeChild(iframe); + document.removeEventListener( + 'visibilitychange', + handleVisibilityChange, + ); + setNavigationLoading(null); } - document.removeEventListener('visibilitychange', handleVisibilityChange) - } catch { - // 忽略清理错误 - } - }, 3000) - } - - // 检测是否为移动设备 - const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) - - if (isMobile) { - tryOpenApp() + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + setTimeout(() => { + try { + if (iframe.parentNode) document.body.removeChild(iframe); + document.removeEventListener( + 'visibilitychange', + handleVisibilityChange, + ); + } catch { + // ignore cleanup errors + } + }, 3000); + }; + + const isMobile = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); + + if (isMobile) { + tryOpenApp(); + } else { + window.open(webUrl, '_blank', 'noopener,noreferrer'); + setNavigationLoading(null); + } } else { - // 桌面端直接打开网页版 - window.open(webUrl, '_blank', 'noopener,noreferrer') - setNavigationLoading(null) + const markerUrl = `https://uri.amap.com/marker?address=${encodeURIComponent(address)}&name=${encodeURIComponent(name)}`; + window.open(markerUrl, '_blank', 'noopener,noreferrer'); + setNavigationLoading(null); } - } - const isVisible = (type: ("hearing" | "visual" | "wheelchair" | "cognitive") | Array<"hearing" | "visual" | "wheelchair" | "cognitive">) => { - if (filter === "all") return true; - const types = Array.isArray(type) ? type : [type]; - return types.includes(filter); - } - - const renderFeatures = (key: string, fallbackItems: string[]) => { - return fallbackItems.map((fallback, idx) => ( -
  • - -
  • - )); - } + }; return (
    - - -
    +

    - 🌟 + 🌟{' '} +

    -

    +

    + +

    -
    +
    -
    - {/* 培哥烟囱面包 */} -
    -
    -

    -
    - - 👂 - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.peige.features', ['手语服务', '视觉菜单', '无障碍设施'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    +
    + {loading ? ( +
    +
    -
    - - {/* 木马童话黑暗餐厅 */} -
    -
    -

    -
    - - 👁️ - - -
    + ) : filteredRestaurants.length === 0 ? ( +
    +
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.muma_dark.features', ['黑暗用餐体验', '专业引导服务', '感官训练'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    - - {/* 星巴克东方文德手语门店(广州) */} -
    -
    -

    -
    - - 👂 - - - - - 咖啡 - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.starbucks_wende.features', ['手语服务', '写字板沟通', '视觉菜单'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    - - {/* 全聚德前门店(北京) */} -
    -
    -

    -
    - - 👁️ - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.quanjude_qianmen.features', ['语音菜单', '服务员引导', '触觉辅助'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    -
    - - {/* 并排展示 那伽树 与 无声饭店 */} -
    - {/* 那伽树无障碍咖啡披萨集合店(北京大栅栏) */} -
    -
    -

    -
    - - 👁️ - - - - - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.naga_tree.features', ['无障碍通道', '盲文菜单', '轮椅友好'])} -
    -
    -
    -
    - - -
    -
    - - - -
    -
    -
    -
    - - {/* 无声饭店(云南玉溪) */} -
    -
    -

    -
    - - 👂 - - - {/* 移除认知友好标签 */} -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.silent_yuxi.features', ['手语服务', '图片菜单', '耐心服务'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    +
    +
    + + + + {restaurant.address} + +
    +
    +
    +
    + ); + }) + )}
    - {/* 并排展示 圆亮798 与 彩虹天使 */} -
    - {/* 圆亮798(北京) */} -
    -
    -

    -
    - - 👂 - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.yuanliang_798.features', ['手语服务', '艺术氛围', '创意菜品'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    - {/* 彩虹天使咖啡屋(北京昌平辛庄村) */} -
    -
    -

    -
    - - 👂 - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.rainbow_angel.features', ['手语服务', '温馨环境', '贴心服务'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    - {/* 米娜餐厅(北京通州) */} -
    -
    -

    -
    - - 👂 - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.mina_tongzhou.features', ['手语服务', '写字板沟通', '耐心服务'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    + {/* 关于部分 */} +
    +
    + +

    + +

    - {/* 无声火锅(北京) */} -
    -
    -

    -
    - - 👂 - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.silent_hotpot.features', ['手语服务', '图片菜单', '无声点餐'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    +
    +

    + +

    +

    + +

    - - {/* 并排展示 春厨 与 星巴克DC店 */} -
    - {/* 春厨(北京) */} -
    -
    -

    -
    - - 🧠 - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.chunchu.features', ['简化菜单', '耐心服务', '清晰标识'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    - - {/* 星巴克DC店(北京) */} -
    -
    -

    -
    - - - - -
    -
    -
    -

    -
    -

    -
      - {renderFeatures('bites.restaurants.starbucks_dc.features', ['无障碍通道', '轮椅友好桌椅', '便民设施'])} -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - -
    -
    -
    -
    -
    -
    - - {/* 关于部分 */} -
    -
    - -

    -
    -
    -

    - -

    -

    - -

    -
    -
    -
    {/* AI 美食推荐对话框触发器 */}
    - ) + ); } diff --git a/app/api/ai/recommend/route.ts b/app/api/ai/recommend/route.ts index 32d96f3..201b1b7 100644 --- a/app/api/ai/recommend/route.ts +++ b/app/api/ai/recommend/route.ts @@ -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 @@ -17,17 +22,30 @@ interface ModelRecommendation extends Partial { // 严格将模型输出限定到本地候选集,并应用地点/无障碍过滤 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[] = [] @@ -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) @@ -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推荐失败', }) } } diff --git a/app/api/data/route.ts b/app/api/data/route.ts index 8412570..8ca5db2 100644 --- a/app/api/data/route.ts +++ b/app/api/data/route.ts @@ -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) } diff --git a/app/deadlines/page.tsx b/app/deadlines/page.tsx index 08697bc..d51763f 100644 --- a/app/deadlines/page.tsx +++ b/app/deadlines/page.tsx @@ -1,22 +1,24 @@ -'use client' +'use client'; -import { EventCard } from '@/components/EventCard' -import { FilterBar } from '@/components/FilterBar' +import { EventCard } from '@/components/EventCard'; +import { FilterBar } from '@/components/FilterBar'; +import { GitCodeIcon } from '@/components/icons/GitCodeIcon'; +import { GitHubIcon } from '@/components/icons/GitHubIcon'; -import { DeadlineItem, EventData } from '@/lib/data' -import { useEventStore } from '@/lib/store' -import Fuse from 'fuse.js' +import { DeadlineItem, EventData } from '@/lib/data'; +import { useEventStore } from '@/lib/store'; +import Fuse from 'fuse.js'; -import { DateTime } from 'luxon' -import Link from 'next/link' -import { useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' +import { DateTime } from 'luxon'; +import Link from 'next/link'; +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; interface FlatEvent { - item: DeadlineItem - event: EventData - nextDeadline: DateTime - timeRemaining: number + item: DeadlineItem; + event: EventData; + nextDeadline: DateTime; + timeRemaining: number; } export default function Home() { @@ -30,76 +32,88 @@ export default function Home() { searchQuery, favorites, showOnlyFavorites, - } = useEventStore() + } = useEventStore(); useEffect(() => { - fetchItems() - }, [fetchItems]) + fetchItems(); + }, [fetchItems]); const { t } = useTranslation(); - const flatEvents: FlatEvent[] = useMemo(() => items.flatMap(item => - item.events.map(event => { - const now = DateTime.now().setZone("Asia/Shanghai") - const upcomingDeadlines = event.timeline - .map(t => DateTime.fromISO(t.deadline, { zone: event.timezone })) - .filter(d => d > now) - .sort((a, b) => a.toMillis() - b.toMillis()) - - const nextDeadline = upcomingDeadlines[0] || - DateTime.fromISO(event.timeline[event.timeline.length - 1].deadline, { zone: event.timezone }) - const timeRemaining = nextDeadline.toMillis() - now.toMillis() - - return { item, event, nextDeadline, timeRemaining } - }) - ), [items]) + const flatEvents: FlatEvent[] = useMemo( + () => + items.flatMap((item) => + item.events.map((event) => { + const now = DateTime.now().setZone('Asia/Shanghai'); + const upcomingDeadlines = event.timeline + .map((t) => DateTime.fromISO(t.deadline, { zone: event.timezone })) + .filter((d) => d > now) + .sort((a, b) => a.toMillis() - b.toMillis()); + const nextDeadline = + upcomingDeadlines[0] || + DateTime.fromISO( + event.timeline[event.timeline.length - 1].deadline, + { zone: event.timezone }, + ); + const timeRemaining = nextDeadline.toMillis() - now.toMillis(); + return { item, event, nextDeadline, timeRemaining }; + }), + ), + [items], + ); // 为每个事件添加搜索用的日期字段 const eventsWithSearchDates = useMemo(() => { - return flatEvents.map(flatEvent => ({ + return flatEvents.map((flatEvent) => ({ ...flatEvent, searchableDate: flatEvent.nextDeadline.toFormat('yyyy-MM-dd'), searchableMonth: flatEvent.nextDeadline.toFormat('MM'), searchableYear: flatEvent.nextDeadline.toFormat('yyyy'), - })) - }, [flatEvents]) - - + })); + }, [flatEvents]); const filteredEvents = useMemo(() => { - let filtered = eventsWithSearchDates + let filtered = eventsWithSearchDates; // 分类过滤 if (selectedCategory) { - filtered = filtered.filter(flatEvent => flatEvent.item.category === selectedCategory) + filtered = filtered.filter( + (flatEvent) => flatEvent.item.category === selectedCategory, + ); } // 标签过滤 if (selectedTags.length > 0) { - filtered = filtered.filter(flatEvent => - selectedTags.some(tag => flatEvent.item.tags?.includes(tag)) - ) + filtered = filtered.filter((flatEvent) => + selectedTags.some((tag) => flatEvent.item.tags?.includes(tag)), + ); } // 地点过滤 if (selectedLocations.length > 0) { - filtered = filtered.filter(flatEvent => - selectedLocations.includes(flatEvent.event.place) - ) + filtered = filtered.filter((flatEvent) => + selectedLocations.includes(flatEvent.event.place), + ); } // 收藏过滤 if (showOnlyFavorites) { - console.log('Filtering favorites:', { favorites, showOnlyFavorites, totalEvents: filtered.length }) - filtered = filtered.filter(flatEvent => { - const eventId = `${flatEvent.event.id}` - const isFavorited = favorites.includes(eventId) - console.log(`Event ${eventId}: ${isFavorited ? 'favorited' : 'not favorited'}`) - return isFavorited - }) - console.log('Filtered favorites result:', filtered.length) + console.log('Filtering favorites:', { + favorites, + showOnlyFavorites, + totalEvents: filtered.length, + }); + filtered = filtered.filter((flatEvent) => { + const eventId = `${flatEvent.event.id}`; + const isFavorited = favorites.includes(eventId); + console.log( + `Event ${eventId}: ${isFavorited ? 'favorited' : 'not favorited'}`, + ); + return isFavorited; + }); + console.log('Filtered favorites result:', filtered.length); } // 搜索过滤 @@ -115,40 +129,48 @@ export default function Home() { ], threshold: 0.3, includeScore: true, - }) + }); - const results = fuse.search(searchQuery) - filtered = results.map(result => result.item) + const results = fuse.search(searchQuery); + filtered = results.map((result) => result.item); } // 排序逻辑:未结束的活动按 timeRemaining 升序,已结束的活动放在最后 return filtered.sort((a, b) => { - const aCompleted = a.timeRemaining < 0 - const bCompleted = b.timeRemaining < 0 - + const aCompleted = a.timeRemaining < 0; + const bCompleted = b.timeRemaining < 0; + // 如果一个已结束,一个未结束,未结束的排在前面 - if (aCompleted && !bCompleted) return 1 - if (!aCompleted && bCompleted) return -1 - + if (aCompleted && !bCompleted) return 1; + if (!aCompleted && bCompleted) return -1; + // 如果都未结束,按 timeRemaining 升序(即将到期的在前) if (!aCompleted && !bCompleted) { - return a.timeRemaining - b.timeRemaining + return a.timeRemaining - b.timeRemaining; } - + // 如果都已结束,按 timeRemaining 降序(最近结束的在前) - return b.timeRemaining - a.timeRemaining - }) - }, [eventsWithSearchDates, selectedCategory, selectedTags, selectedLocations, searchQuery, showOnlyFavorites, favorites]) + return b.timeRemaining - a.timeRemaining; + }); + }, [ + eventsWithSearchDates, + selectedCategory, + selectedTags, + selectedLocations, + searchQuery, + showOnlyFavorites, + favorites, + ]); if (loading) { return (
    -

    {t("events.loading")}

    +

    {t('events.loading')}

    - ) + ); } return ( @@ -160,7 +182,6 @@ export default function Home() {
    - {/* Header Section */}

    @@ -173,10 +194,7 @@ export default function Home() { rel="noopener noreferrer" className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:from-cyan-600 hover:to-blue-600 transition-all duration-300 shadow-lg hover:shadow-xl transform hover:scale-105" > - - - + GitCode - - - + GitHub -

    - 公益慈善会议、竞赛和活动重要截止日期概览,不再错过参与公益事业、奉献爱心和社会服务的机会 -

    + 公益慈善会议、竞赛和活动重要截止日期概览,不再错过参与公益事业、奉献爱心和社会服务的机会 +

    所有截止日期均默认转换为北京时间,如果您不知道当前所在时区,请点击时区选择器右侧的“自动检测” @@ -213,20 +228,18 @@ export default function Home() { {/* Events List */}

    {filteredEvents.map(({ item, event }) => ( - + ))}
    {filteredEvents.length === 0 && (
    🔍
    -

    {t("events.notFound")}

    +

    + {t('events.notFound')} +

    - {t("events.hint")} + {t('events.hint')}

    )} @@ -239,8 +252,7 @@ export default function Home() {

    -
    - ) -} \ No newline at end of file + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 131ba8b..c32fc4c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ @@ -29,22 +30,30 @@ export default function RootLayout({ return ( - + - +
    diff --git a/app/recommend/page.tsx b/app/recommend/page.tsx index c729121..1899340 100644 --- a/app/recommend/page.tsx +++ b/app/recommend/page.tsx @@ -56,8 +56,6 @@ function RecommendPageContent() { // Fetch data once on mount useEffect(() => { const ac = new AbortController() - setLoading(true) - setError(null) getData() .then((data) => { @@ -80,23 +78,30 @@ function RecommendPageContent() { } }, []) - // Compute flat events - const flatEvents: FlatEvent[] = useMemo(() => allDeadlines.flatMap(item => - item.events.map(event => { - const now = DateTime.now().setZone("Asia/Shanghai") - const upcomingDeadlines = event.timeline - .map(t => DateTime.fromISO(t.deadline, { zone: event.timezone })) - .filter(d => d > now) - .sort((a, b) => a.toMillis() - b.toMillis()) - - const nextDeadline = upcomingDeadlines[0] || - DateTime.fromISO(event.timeline[event.timeline.length - 1].deadline, { zone: event.timezone }) - const timeRemaining = nextDeadline.toMillis() - now.toMillis() - - return { item, event, nextDeadline, timeRemaining } - }) - ), [allDeadlines]) + const flatEvents: FlatEvent[] = useMemo( + () => + allDeadlines.flatMap((item) => + item.events.map((event) => { + const now = DateTime.now().setZone('Asia/Shanghai') + const upcomingDeadlines = event.timeline + .map((t) => DateTime.fromISO(t.deadline, { zone: event.timezone })) + .filter((d) => d > now) + .sort((a, b) => a.toMillis() - b.toMillis()) + + const nextDeadline = + upcomingDeadlines[0] || + DateTime.fromISO( + event.timeline[event.timeline.length - 1].deadline, + { zone: event.timezone }, + ) + const timeRemaining = nextDeadline.toMillis() - now.toMillis() + + return { item, event, nextDeadline, timeRemaining } + }), + ), + [allDeadlines], + ) // Fuse for fuzzy search const fuse = useMemo(() => { @@ -111,22 +116,21 @@ function RecommendPageContent() { let results: FlatEvent[] if (query.trim() && fuse) { - results = fuse.search(query.trim()).map(result => result.item) + results = fuse.search(query.trim()).map((result) => result.item) } else { results = flatEvents } - return results - .sort((a, b) => { - const aEnded = a.timeRemaining < 0 - const bEnded = b.timeRemaining < 0 + return results.sort((a, b) => { + const aEnded = a.timeRemaining < 0 + const bEnded = b.timeRemaining < 0 - if (aEnded && !bEnded) return 1 - if (!aEnded && bEnded) return -1 - if (aEnded && bEnded) return b.timeRemaining - a.timeRemaining + if (aEnded && !bEnded) return 1 + if (!aEnded && bEnded) return -1 + if (aEnded && bEnded) return b.timeRemaining - a.timeRemaining - return a.timeRemaining - b.timeRemaining - }) + return a.timeRemaining - b.timeRemaining + }) }, [flatEvents, query, fuse]) if (loading) { @@ -134,7 +138,7 @@ function RecommendPageContent() {
    -

    {"events.loading"}

    +

    {'events.loading'}

    ) @@ -154,24 +158,19 @@ function RecommendPageContent() { {/* Recommendations List */}
    {recommendations.map(({ item, event }) => ( - + ))}
    {recommendations.length === 0 && (
    🔍
    -

    {"events.notFound"}

    -

    - {"events.hint"} -

    +

    + {'events.notFound'} +

    +

    {'events.hint'}

    )} -
    ) diff --git a/bun.lock b/bun.lock index d6b78d2..7619346 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "eslint": "^9.39.4", "eslint-config-next": "16.1.6", "husky": "^9.1.7", + "jiti": "^2.6.1", "knip": "^5.86.0", "lint-staged": "^16.3.2", "prettier": "^3.8.1", diff --git a/components/EventCard.tsx b/components/EventCard.tsx index 72cb769..45f710b 100644 --- a/components/EventCard.tsx +++ b/components/EventCard.tsx @@ -1,45 +1,46 @@ -'use client' +'use client'; -import { CountdownTimer } from '@/components/CountdownTimer' -import { TimelineItem } from '@/components/TimelineItem' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent } from '@/components/ui/card' -import { ScrollArea } from '@/components/ui/scroll-area' -import { DeadlineItem, EventData, isEventEnded } from '@/lib/data' -import { useEventStore } from '@/lib/store' -import { formatTimezoneToUTC } from '@/lib/utils' -import { Calendar, Clock, ExternalLink, MapPin, Star } from 'lucide-react' -import { DateTime } from "luxon" -import Link from 'next/link' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { AddToCalendar } from './AddToCalendar' +import { CountdownTimer } from '@/components/CountdownTimer'; +import { TimelineItem } from '@/components/TimelineItem'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { DeadlineItem, EventData, isEventEnded } from '@/lib/data'; +import { useEventStore } from '@/lib/store'; +import { formatTimezoneToUTC } from '@/lib/utils'; +import { + ArrowRight, + Calendar, + Clock, + ExternalLink, + MapPin, + Star, +} from 'lucide-react'; +import { DateTime } from 'luxon'; +import Link from 'next/link'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AddToCalendar } from './AddToCalendar'; interface EventCardProps { - item: DeadlineItem - event: EventData + item: DeadlineItem; + event: EventData; } - - export function EventCard({ item, event }: EventCardProps) { const { t } = useTranslation('common'); - const { - favorites, - toggleFavorite, - mounted, - displayTimezone - } = useEventStore() + const { favorites, toggleFavorite, mounted, displayTimezone } = + useEventStore(); - const cardId = `${event.id}` - const isFavorited = favorites.includes(cardId) + const cardId = `${event.id}`; + const isFavorited = favorites.includes(cardId); useEffect(() => { - useEventStore.setState({ mounted: true }) - }, []) + useEventStore.setState({ mounted: true }); + }, []); - const ended = isEventEnded(event) - const now = DateTime.now().setZone(displayTimezone) + const ended = isEventEnded(event); + const now = DateTime.now().setZone(displayTimezone); // 找到下一个截止日期 const upcomingDeadlines = event.timeline @@ -47,19 +48,19 @@ export function EventCard({ item, event }: EventCardProps) { ...t, // 正确处理时区:将原始字符串解析为指定时区的日期 date: DateTime.fromISO(t.deadline, { zone: event.timezone }), - index + index, })) // 转换到显示时区进行比较 - .filter(t => t.date.setZone(displayTimezone) > now) - .sort((a, b) => a.date.toMillis() - b.date.toMillis()) + .filter((t) => t.date.setZone(displayTimezone) > now) + .sort((a, b) => a.date.toMillis() - b.date.toMillis()); - const nextDeadline = upcomingDeadlines[0] + const nextDeadline = upcomingDeadlines[0]; // 转换时区为UTC偏移格式 const displayTimezoneUTC = formatTimezoneToUTC(displayTimezone); const eventTimezoneUTC = formatTimezoneToUTC(event.timezone); - const upcomingIndexes = upcomingDeadlines.map(t => t.index); + const upcomingIndexes = upcomingDeadlines.map((t) => t.index); // timeline 横向滑动相关逻辑 // scrollContentRef: timeline 主体容器,用于检测内容宽度 @@ -81,41 +82,29 @@ export function EventCard({ item, event }: EventCardProps) { } }; setTimeout(checkOverflow, 0); - window.addEventListener("resize", checkOverflow); - return () => window.removeEventListener("resize", checkOverflow); + window.addEventListener('resize', checkOverflow); + return () => window.removeEventListener('resize', checkOverflow); }, [event.timeline]); // timeline 溢出时,自动将 isActive 节点平滑滚动到 ScrollArea Viewport 的中间位置 useEffect(() => { - if ( - showScrollHint && - scrollViewportRef.current && - activeDotRef.current - ) { + if (showScrollHint && scrollViewportRef.current && activeDotRef.current) { const viewport = scrollViewportRef.current; const active = activeDotRef.current; const viewportWidth = viewport.offsetWidth; const activeLeft = active.offsetLeft; const activeWidth = active.offsetWidth; - const targetScrollLeft = activeLeft - (viewportWidth / 2) + (activeWidth / 2); - viewport.scrollTo({ left: targetScrollLeft, behavior: "smooth" }); + const targetScrollLeft = activeLeft - viewportWidth / 2 + activeWidth / 2; + viewport.scrollTo({ left: targetScrollLeft, behavior: 'smooth' }); } }, [showScrollHint, event.timeline]); - // 类别标签组件 - const CategoryBadge = () => ( -
    - {t(`filter.category_${item.category}`)} -
    - ); + // 类别标签内联渲染 return ( - +
    {/* 左侧内容区域 */} @@ -128,10 +117,29 @@ export function EventCard({ item, event }: EventCardProps) { {/* 类别标签与标题 */}
    - +
    + {t(`filter.category_${item.category}`)} +
    - +

    {item.title}

    @@ -148,10 +156,11 @@ export function EventCard({ item, event }: EventCardProps) { )} {mounted && ( toggleFavorite(cardId)} /> )} @@ -199,17 +208,19 @@ export function EventCard({ item, event }: EventCardProps) {
    - {t("events.timeline")} + + {t('events.timeline')} + {!ended && nextDeadline && (
    @@ -230,7 +241,11 @@ export function EventCard({ item, event }: EventCardProps) { isUpcoming={upcomingIndexes.slice(1).includes(index)} totalEvents={event.timeline.length} index={index} - ref={nextDeadline?.index === index ? activeDotRef : undefined} + ref={ + nextDeadline?.index === index + ? activeDotRef + : undefined + } /> ))}
    @@ -251,7 +266,10 @@ export function EventCard({ item, event }: EventCardProps) { {nextDeadline.comment}
    - {nextDeadline.date.setZone(displayTimezone).toFormat('yyyy-MM-dd HH:mm:ss')} ({displayTimezoneUTC}) + {nextDeadline.date + .setZone(displayTimezone) + .toFormat('yyyy-MM-dd HH:mm:ss')}{' '} + ({displayTimezoneUTC})
    @@ -265,10 +283,10 @@ export function EventCard({ item, event }: EventCardProps) {
    - {t("events.ended")} + {t('events.ended')}
    - {t("events.allDeadlinesPassed")} + {t('events.allDeadlinesPassed')}
    @@ -283,17 +301,21 @@ export function EventCard({ item, event }: EventCardProps) {
    - {t("events.timeline")} + + {t('events.timeline')} + {!ended && nextDeadline && (
    @@ -305,7 +327,10 @@ export function EventCard({ item, event }: EventCardProps) { {/* 左右渐变遮罩,z-10,避免遮挡 tooltip */}
    - +
    ))}
    @@ -333,10 +362,10 @@ export function EventCard({ item, event }: EventCardProps) {
    {showScrollHint && (
    - {t('events.swipe')} - - - + + {t('events.swipe')} + +
    )}
    @@ -355,7 +384,10 @@ export function EventCard({ item, event }: EventCardProps) { {nextDeadline.comment}
    - {nextDeadline.date.setZone(displayTimezone).toFormat('yyyy-MM-dd HH:mm:ss')} ({displayTimezoneUTC}) + {nextDeadline.date + .setZone(displayTimezone) + .toFormat('yyyy-MM-dd HH:mm:ss')}{' '} + ({displayTimezoneUTC})
    @@ -379,5 +411,5 @@ export function EventCard({ item, event }: EventCardProps) {
    - ) -} \ No newline at end of file + ); +} diff --git a/components/FoodAIDialog.tsx b/components/FoodAIDialog.tsx index fcc413d..556f914 100644 --- a/components/FoodAIDialog.tsx +++ b/components/FoodAIDialog.tsx @@ -1,84 +1,75 @@ -"use client" - -import { useState, useEffect } from "react" -import { useTranslation } from "react-i18next" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogClose } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { cn } from "@/lib/utils" -import { Loader2, WandSparkles } from "lucide-react" -import { filterBitesCatalog } from "@/lib/bitesCatalog" +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { Loader2, WandSparkles } from 'lucide-react'; +import SafeTranslation from '@/components/SafeTranslation'; interface Recommendation { - name: string - address?: string - city?: string - tags?: string[] - description?: string -} - -// 安全翻译组件,避免水合错误 -function SafeTranslation({ tKey, fallback }: { tKey: string; fallback: string }) { - const [mounted, setMounted] = useState(false) - const { t } = useTranslation('translation') - - useEffect(() => { - setMounted(true) - }, []) - - return <>{mounted ? t(tKey) : fallback} + name: string; + address?: string; + city?: string; + tags?: string[]; + description?: string; } export default function FoodAIDialog() { - const [open, setOpen] = useState(false) - const [location, setLocation] = useState("") - const [preferences, setPreferences] = useState("") - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [results, setResults] = useState([]) + const [open, setOpen] = useState(false); + const [location, setLocation] = useState(''); + const [preferences, setPreferences] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState([]); const onSubmit = async () => { - setError(null) - setLoading(true) - setResults([]) + setError(null); + setLoading(true); + setResults([]); try { - const res = await fetch("/api/ai/recommend", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await fetch('/api/ai/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location, preferences, }), - }) + }); if (!res.ok) { - throw new Error(`请求失败: ${res.status}`) + throw new Error(`请求失败: ${res.status}`); } - const data = await res.json() - const recs: Recommendation[] = data?.recommendations || [] + const data = await res.json(); + const recs: Recommendation[] = data?.recommendations || []; if (recs.length > 0) { - setResults(recs) - if (data?.source && data.source !== "spark") { - setError("AI服务暂不可用,已为您展示本地推荐") + setResults(recs); + if (data?.source && data.source !== 'spark') { + setError('AI服务暂不可用,已为您展示推荐'); } - return + return; } - const localRecs = filterBitesCatalog(location, {}).slice(0, 5) - setResults(localRecs) - setError("AI服务暂不可用,已为您展示本地推荐") + setError('未找到符合条件的推荐'); } catch { - const localRecs = filterBitesCatalog(location, {}).slice(0, 5) - setResults(localRecs) - setError("AI服务暂不可用,已为您展示本地推荐") + setError('AI服务暂不可用,请稍后重试'); } finally { - setLoading(false) + setLoading(false); } - } + }; return (
    @@ -91,19 +82,37 @@ export default function FoodAIDialog() { onClick={() => setOpen(true)} > - + - - + + + + + +
    - +
    - +