-
Notifications
You must be signed in to change notification settings - Fork 6
Used AI to write the open-collaborator-award page #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 Walkthrough总体概览引入了一个新的开放协作者奖项页面功能,包含提名管理、投票系统和数据持久化的React组件,配套全面的样式定义。 变更详情
时序图sequenceDiagram
participant User
participant Component as OpenCollaboratorAward<br/>Component
participant LocalStorage as localStorage
participant Modal as NominationModal
User->>Component: 1. 页面加载
Component->>LocalStorage: 初始化userId(如无则生成)
LocalStorage-->>Component: userId
Component->>Component: 初始化nominations状态为预设数据
rect rgb(220, 240, 255)
Note over User,Component: 投票流程
User->>Component: 2. 点击投票按钮
Component->>Component: 验证用户是否已投票
alt 用户未投票
Component->>Component: votes += 1,voters 添加userId
Component->>LocalStorage: 持久化更新
Component->>User: 显示成功提示
Component->>Component: 检测投票数≥10 → 赢家通知
else 用户已投票
Component->>User: 显示已投票提示
end
end
rect rgb(240, 220, 255)
Note over User,Component: 提名提交流程
User->>Modal: 3. 打开提名表单
User->>Modal: 输入表单数据(包括视频URL)
User->>Modal: 点击提交
Modal->>Component: 验证BVID提取
Component->>Component: 构建Nomination对象
Component->>Component: 新提名插入列表顶部
Component->>LocalStorage: 持久化nominations
Component->>User: 成功提示 + 重置表单
Modal->>Modal: 关闭对话框
end
代码审查工作量评估🎯 4 (复杂) | ⏱️ ~40 分钟 审查重点建议:
庆祝诗
✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In @pages/article/open-collaborator-award.tsx:
- Around line 204-206: totalVotes is hard-coded to 666; replace it with a
computed value derived from the nominations array (sum of each nomination.votes)
so the UI reflects actual data, updating the declaration of totalVotes used in
this component; also extract the repeated numeric threshold into a single
constant (e.g., VOTE_THRESHOLD) and replace all occurrences where the literal 10
is used—specifically in the winners filter (currently const winners =
nominations.filter((n) => n.votes >= 10)), any other comparisons against 10 in
this file, and any vote-related logic—to use VOTE_THRESHOLD for consistency.
- Around line 102-119: localStorage accesses in the useEffect and saveData
functions can throw (e.g., private browsing or quota issues) and userId
generation uses deprecated .substr(); wrap all localStorage.getItem/setItem and
JSON.parse/ stringify calls in try-catch blocks inside the useEffect and
saveData to prevent component crashes and provide sensible fallbacks (e.g., skip
persistence or keep in-memory state), and replace .substr(2, 9) with
.substring(2, 11) (or .slice(2, 11)) when constructing the uid in the useEffect
where uid = `user_${Date.now()}_${Math.random().toString(36).substring(2,11)}`;
ensure setUserId(uid), setNominations(initialNominations) and
localStorage.setItem calls are only attempted after successful storage
operations or handled gracefully on error.
- Around line 1-5: The file imports translation utilities but leaves UI text
hard-coded: pull t from I18nContext via const { t } = useContext(I18nContext)
and replace every user-visible literal (e.g., the strings at the locations you
noted around lines 212-213, 234, 276) with t('openCollaboratorAward.<key>')
calls; add corresponding keys and translations into your locale JSONs (both zh
and en) and update any Button, Modal, Badge, Card, Form labels in the
OpenCollaboratorAward component to use those keys so no user-visible text
remains hard-coded and the imported t is actually used.
- Around line 280-286: The iframe embedding Bilibili lacks a title attribute for
accessibility and uses the deprecated frameBorder attribute; update the
iframe(s) (e.g., the one with src
"https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij...") to add a
meaningful title (e.g., "Bilibili video player") and remove frameBorder,
replacing it with a style or CSS rule such as border: none; apply the same
changes to the other iframe instance mentioned.
🧹 Nitpick comments (3)
pages/article/open-collaborator-award.tsx (3)
130-158: 使用原生alert()/confirm()不符合 React Bootstrap 设计规范。当前投票逻辑使用原生弹窗(Line 135、144、154、156),这会:
- 阻塞主线程
- 无法自定义样式
- 不符合无障碍访问标准
建议使用 React Bootstrap 的
Modal或Toast组件替代。♻️ 建议使用确认 Modal 替代 confirm()
// 添加确认 Modal 状态 const [confirmModal, setConfirmModal] = useState<{ show: boolean; message: string; onConfirm: () => void; }>({ show: false, message: '', onConfirm: () => {} }); // 在 JSX 中添加确认 Modal <Modal show={confirmModal.show} onHide={() => setConfirmModal(prev => ({ ...prev, show: false }))}> <Modal.Header closeButton> <Modal.Title>{t('confirm_title')}</Modal.Title> </Modal.Header> <Modal.Body style={{ whiteSpace: 'pre-line' }}>{confirmModal.message}</Modal.Body> <Modal.Footer> <Button variant="secondary" onClick={() => setConfirmModal(prev => ({ ...prev, show: false }))}> {t('cancel')} </Button> <Button variant="primary" onClick={confirmModal.onConfirm}> {t('confirm')} </Button> </Modal.Footer> </Modal>
160-196: 表单数据处理存在类型安全隐患。
formData.get()可能返回null,但代码直接使用as string强制类型断言(Line 165、176-182),这绕过了 TypeScript 的类型检查。♻️ 建议添加空值检查
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const form = e.currentTarget; const formData = new FormData(form); - const videoUrl = formData.get('videoUrl') as string; + const videoUrl = formData.get('videoUrl')?.toString() ?? ''; + const nomineeName = formData.get('nomineeName')?.toString() ?? ''; + const reason = formData.get('reason')?.toString() ?? ''; + const nominator = formData.get('nominator')?.toString() ?? ''; + + if (!nomineeName || !reason || !nominator) { + // 使用 Toast 提示必填项 + return; + } + const bvid = extractBVID(videoUrl); // ...
316-330: 规则列表应使用语义化<ol>元素。根据编码规范,可计数项目应使用
<ol>有序列表。当前规则使用Row/Col渲染,缺少语义化结构。♻️ 建议使用有序列表包装
<ol className="list-unstyled"> <Row as="div"> {rules.map((rule) => ( <Col as="li" md={3} key={rule.num} className="mb-4"> <div className={styles.ruleItem}> <div className={styles.ruleNumber}>{rule.num}</div> <h4>{t(`rule_${rule.num}_title`)}</h4> <p>{t(`rule_${rule.num}_desc`)}</p> </div> </Col> ))} </Row> </ol>
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
pages/article/open-collaborator-award.mdxpages/article/open-collaborator-award.module.lesspages/article/open-collaborator-award.tsx
💤 Files with no reviewable changes (1)
- pages/article/open-collaborator-award.mdx
🧰 Additional context used
📓 Path-based instructions (3)
{pages,components}/**/*.tsx
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
{pages,components}/**/*.tsx: ALWAYS use React Bootstrap components instead of custom HTML elements in UI code
Use semantic HTML structure (article, header, section); usefor countable items,
for navigation; apply list-unstyled on first-level lists
All user-facing text MUST use the i18n t() function (no hardcoded strings)
Use React Bootstrap 2.10 components consistently for responsive design
Files:
pages/article/open-collaborator-award.tsx
**/*.{ts,tsx}
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
**/*.{ts,tsx}: Use optional chaining and modern ECMAScript features
Let TypeScript infer types when possible to avoid verbose annotations
Import from established sources (e.g., ContentModel from mobx-github, utilities from web-utility) rather than reimplementing
Use minimal exports and avoid unnecessary custom implementations
Files:
pages/article/open-collaborator-award.tsx
pages/**/*.tsx
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
For static generation, allow errors to bubble naturally (do not swallow errors)
Files:
pages/article/open-collaborator-award.tsx
🧬 Code graph analysis (1)
pages/article/open-collaborator-award.tsx (1)
models/Translation.ts (1)
I18nContext(46-46)
🔇 Additional comments (3)
pages/article/open-collaborator-award.module.less (1)
1-532: 样式文件结构清晰,响应式设计完善。整体样式模块组织良好,包含:
- Hero 区域渐变动画
- 卡片悬停效果
- 响应式断点处理(768px)
- 合理的 z-index 层级管理
几点小建议:
- Line 111-113 使用了
background-clip: text,建议确认目标浏览器兼容性- Line 169-170 使用
!important覆盖样式,可考虑通过更具体的选择器避免pages/article/open-collaborator-award.tsx (2)
21-92: 初始数据硬编码在组件中。
initialNominations包含大量硬编码数据,适合演示目的。但如果计划生产使用,建议:
- 将数据移至外部 JSON 文件或通过 API 获取
- 使用 Next.js 的
getStaticProps或getServerSideProps进行数据获取根据 PR 描述,这是 AI 生成的演示代码,当前实现可接受。
请确认此页面的数据持久化策略:
- 如果仅用于演示,当前 localStorage 方案可行
- 如果需要真实投票功能,需要后端 API 支持
94-99: 组件状态管理符合预期,但缺少服务端渲染支持。作为 Next.js 页面组件,当前实现完全依赖客户端状态(
useState)。如果需要 SEO,建议考虑:
- 使用
getStaticProps预渲染初始数据- 将静态内容(如规则、FAQ)移至服务端获取
对于演示页面,当前实现可接受。
| import { FC, useContext, useEffect, useState } from 'react'; | ||
| import { Badge, Button, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'; | ||
| import { I18nContext } from '../../models/Translation'; | ||
| import styles from './open-collaborator-award.module.less'; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
导入了 t 但未使用,所有 UI 文本均为硬编码。
根据编码规范,所有用户可见文本必须使用 t() 函数进行国际化处理。当前页面存在大量硬编码中文字符串(如 Line 212-213、234、276 等),违反了 i18n 要求。
🛠️ 建议修复示例
- <h1 className={styles.heroTitle}>致敬每一位开放协作者</h1>
- <p className={styles.heroDesc}>感谢那些在过去一年里给你留下深刻印象、对你有帮助的人</p>
+ <h1 className={styles.heroTitle}>{t('award_hero_title')}</h1>
+ <p className={styles.heroDesc}>{t('award_hero_desc')}</p>需要为所有用户可见文本添加翻译 key,并在翻译文件中定义对应的中英文内容。
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @pages/article/open-collaborator-award.tsx around lines 1 - 5, The file
imports translation utilities but leaves UI text hard-coded: pull t from
I18nContext via const { t } = useContext(I18nContext) and replace every
user-visible literal (e.g., the strings at the locations you noted around lines
212-213, 234, 276) with t('openCollaboratorAward.<key>') calls; add
corresponding keys and translations into your locale JSONs (both zh and en) and
update any Button, Modal, Badge, Card, Form labels in the OpenCollaboratorAward
component to use those keys so no user-visible text remains hard-coded and the
imported t is actually used.
| useEffect(() => { | ||
| // 获取或创建用户ID | ||
| let uid = localStorage.getItem('userId'); | ||
| if (!uid) { | ||
| uid = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | ||
| localStorage.setItem('userId', uid); | ||
| } | ||
| setUserId(uid); | ||
|
|
||
| // 强制使用最新的初始数据(忽略 localStorage 中的旧数据) | ||
| setNominations(initialNominations); | ||
| localStorage.setItem('nominations', JSON.stringify(initialNominations)); | ||
| }, []); | ||
|
|
||
| const saveData = (data: Nomination[]) => { | ||
| setNominations(data); | ||
| localStorage.setItem('nominations', JSON.stringify(data)); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
localStorage 访问缺少错误处理。
localStorage 在隐私浏览模式或存储配额超出时可能抛出异常,导致组件崩溃。
🛠️ 建议添加 try-catch 包装
useEffect(() => {
- let uid = localStorage.getItem('userId');
- if (!uid) {
- uid = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- localStorage.setItem('userId', uid);
+ try {
+ let uid = localStorage.getItem('userId');
+ if (!uid) {
+ uid = `user_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
+ localStorage.setItem('userId', uid);
+ }
+ setUserId(uid);
+ setNominations(initialNominations);
+ localStorage.setItem('nominations', JSON.stringify(initialNominations));
+ } catch (error) {
+ console.error('localStorage access failed:', error);
+ setUserId(`temp_${Date.now()}`);
+ setNominations(initialNominations);
}
- setUserId(uid);
- setNominations(initialNominations);
- localStorage.setItem('nominations', JSON.stringify(initialNominations));
}, []);另外,Line 106 使用了已弃用的 .substr(),建议改用 .substring(2, 11)。
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| // 获取或创建用户ID | |
| let uid = localStorage.getItem('userId'); | |
| if (!uid) { | |
| uid = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| localStorage.setItem('userId', uid); | |
| } | |
| setUserId(uid); | |
| // 强制使用最新的初始数据(忽略 localStorage 中的旧数据) | |
| setNominations(initialNominations); | |
| localStorage.setItem('nominations', JSON.stringify(initialNominations)); | |
| }, []); | |
| const saveData = (data: Nomination[]) => { | |
| setNominations(data); | |
| localStorage.setItem('nominations', JSON.stringify(data)); | |
| }; | |
| useEffect(() => { | |
| try { | |
| let uid = localStorage.getItem('userId'); | |
| if (!uid) { | |
| uid = `user_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; | |
| localStorage.setItem('userId', uid); | |
| } | |
| setUserId(uid); | |
| // 强制使用最新的初始数据(忽略 localStorage 中的旧数据) | |
| setNominations(initialNominations); | |
| localStorage.setItem('nominations', JSON.stringify(initialNominations)); | |
| } catch (error) { | |
| console.error('localStorage access failed:', error); | |
| setUserId(`temp_${Date.now()}`); | |
| setNominations(initialNominations); | |
| } | |
| }, []); | |
| const saveData = (data: Nomination[]) => { | |
| setNominations(data); | |
| localStorage.setItem('nominations', JSON.stringify(data)); | |
| }; |
🤖 Prompt for AI Agents
In @pages/article/open-collaborator-award.tsx around lines 102 - 119,
localStorage accesses in the useEffect and saveData functions can throw (e.g.,
private browsing or quota issues) and userId generation uses deprecated
.substr(); wrap all localStorage.getItem/setItem and JSON.parse/ stringify calls
in try-catch blocks inside the useEffect and saveData to prevent component
crashes and provide sensible fallbacks (e.g., skip persistence or keep in-memory
state), and replace .substr(2, 9) with .substring(2, 11) (or .slice(2, 11)) when
constructing the uid in the useEffect where uid =
`user_${Date.now()}_${Math.random().toString(36).substring(2,11)}`; ensure
setUserId(uid), setNominations(initialNominations) and localStorage.setItem
calls are only attempted after successful storage operations or handled
gracefully on error.
| const winners = nominations.filter((n) => n.votes >= 10); | ||
| const totalVotes = 666; // 固定显示 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
totalVotes 硬编码为 666,与实际数据不符。
当前 totalVotes 是固定值,而非根据 nominations 计算得出,导致显示数据与实际不一致。
🛠️ 建议动态计算
const winners = nominations.filter((n) => n.votes >= 10);
- const totalVotes = 666; // 固定显示
+ const totalVotes = nominations.reduce((sum, n) => sum + n.votes, 0);另外,投票阈值 10 在多处出现(Line 139、142、199-200、319、397),建议提取为常量:
const VOTE_THRESHOLD = 10;🤖 Prompt for AI Agents
In @pages/article/open-collaborator-award.tsx around lines 204 - 206, totalVotes
is hard-coded to 666; replace it with a computed value derived from the
nominations array (sum of each nomination.votes) so the UI reflects actual data,
updating the declaration of totalVotes used in this component; also extract the
repeated numeric threshold into a single constant (e.g., VOTE_THRESHOLD) and
replace all occurrences where the literal 10 is used—specifically in the winners
filter (currently const winners = nominations.filter((n) => n.votes >= 10)), any
other comparisons against 10 in this file, and any vote-related logic—to use
VOTE_THRESHOLD for consistency.
| <iframe | ||
| src="https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij&page=1&high_quality=1&danmaku=0" | ||
| scrolling="no" | ||
| frameBorder="0" | ||
| allowFullScreen | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
iframe 缺少 title 属性,且使用了已弃用的 frameBorder。
title属性对屏幕阅读器用户至关重要(无障碍访问要求)frameBorder已弃用,应使用 CSSborder: none
🛠️ 建议修复
<iframe
src="https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij&page=1&high_quality=1&danmaku=0"
scrolling="no"
- frameBorder="0"
allowFullScreen
+ title={t('initiative_video_title')}
+ style={{ border: 'none' }}
/>Line 405-410 的 iframe 同样需要修复。
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <iframe | |
| src="https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij&page=1&high_quality=1&danmaku=0" | |
| scrolling="no" | |
| frameBorder="0" | |
| allowFullScreen | |
| /> | |
| </div> | |
| <iframe | |
| src="https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij&page=1&high_quality=1&danmaku=0" | |
| scrolling="no" | |
| allowFullScreen | |
| title={t('initiative_video_title')} | |
| style={{ border: 'none' }} | |
| /> | |
| </div> |
🤖 Prompt for AI Agents
In @pages/article/open-collaborator-award.tsx around lines 280 - 286, The iframe
embedding Bilibili lacks a title attribute for accessibility and uses the
deprecated frameBorder attribute; update the iframe(s) (e.g., the one with src
"https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij...") to add a
meaningful title (e.g., "Bilibili video player") and remove frameBorder,
replacing it with a style or CSS rule such as border: none; apply the same
changes to the other iframe instance mentioned.
| interface Nomination { | ||
| id: number; | ||
| awardName: string; | ||
| nomineeName: string; | ||
| nomineeDesc: string; | ||
| videoUrl: string; | ||
| bvid: string; | ||
| reason: string; | ||
| nominator: string; | ||
| contact: string; | ||
| votes: number; | ||
| voters: string[]; | ||
| createdAt: string; | ||
| } | ||
|
|
||
| const initialNominations: Nomination[] = [ | ||
| { | ||
| id: 1, | ||
| awardName: '社区老大爷奖', | ||
| nomineeName: '网名叫唐总', | ||
| nomineeDesc: '开源市集社区老大爷,资深开源贡献者,社区精神领袖', | ||
| videoUrl: 'https://www.bilibili.com/video/BV1S44y1J78o/', | ||
| bvid: 'BV1S44y1J78o', | ||
| reason: '网名叫唐总就像社区里的老大爷一样,永远那么亲切、可靠。他见证了社区的成长,也包容着每一个新人的青涩。无论何时,只要社区需要,他总是第一个站出来。他不仅分享技术知识,更传递着开源的精神和文化。在他身上,我看到了真正的社区领袖该有的样子——不是权威,而是榜样。', | ||
| nominator: '诗杰', | ||
| contact: '', | ||
| votes: 180, | ||
| voters: Array.from({ length: 180 }, (_, i) => `voter_${i + 1}`), | ||
| createdAt: '2025-12-15', | ||
| }, | ||
| { | ||
| id: 2, | ||
| awardName: '星火奖', | ||
| nomineeName: '水歌', | ||
| nomineeDesc: '开源布道者,用点滴努力点燃开源星火', | ||
| videoUrl: 'https://www.bilibili.com/video/BV1Q3411L7zk/', | ||
| bvid: 'BV1Q3411L7zk', | ||
| reason: '水歌就像一颗星火,虽然看起来微小,却有着燎原之势。他通过不断的分享和实践,将开源的理念传播给更多人。每一篇文章、每一次演讲、每一行代码,都在点燃着他人心中的开源热情。正是这样的星星之火,让越来越多的人加入到开源协作的行列中来。他让我相信,每个人的努力都有价值。', | ||
| nominator: 'Miya', | ||
| contact: '', | ||
| votes: 150, | ||
| voters: Array.from({ length: 150 }, (_, i) => `voter_${i + 1}`), | ||
| createdAt: '2025-12-20', | ||
| }, | ||
| { | ||
| id: 3, | ||
| awardName: '最佳观察者奖', | ||
| nomineeName: '止戈', | ||
| nomineeDesc: '敏锐的社区观察者,发现并解决问题的高手', | ||
| videoUrl: 'https://www.bilibili.com/video/BV1dq4y1t73q/', | ||
| bvid: 'BV1dq4y1t73q', | ||
| reason: '止戈有着敏锐的观察力,他总能发现别人忽略的细节和问题。更难能可贵的是,他不仅善于发现问题,还主动寻找解决方案。在社区中,他就像一双明察秋毫的眼睛,帮助我们看到盲区、规避风险、优化流程。他的每一次观察和建议,都让社区变得更好。正是这样的观察者,让我们的协作更加高效。', | ||
| nominator: '诗杰', | ||
| contact: '', | ||
| votes: 120, | ||
| voters: Array.from({ length: 120 }, (_, i) => `voter_${i + 1}`), | ||
| createdAt: '2025-12-18', | ||
| }, | ||
| { | ||
| id: 4, | ||
| awardName: '社区之光奖', | ||
| nomineeName: '诗杰', | ||
| nomineeDesc: '照亮社区的温暖之光,激励他人前行', | ||
| videoUrl: 'https://www.bilibili.com/video/BV1JS4y1k7Um/', | ||
| bvid: 'BV1JS4y1k7Um', | ||
| reason: '诗杰就像一束光,照亮了社区的每一个角落。他的热情、积极和正能量感染着每一个人。当有人遇到困难时,他总是第一时间伸出援手;当社区需要组织活动时,他总是冲在最前面。他不仅自己发光,更激励着其他人一起发光。在他的影响下,整个社区都变得更加温暖、更有活力。他是真正的社区之光。', | ||
| nominator: '网名叫唐总', | ||
| contact: '', | ||
| votes: 116, | ||
| voters: Array.from({ length: 116 }, (_, i) => `voter_${i + 1}`), | ||
| createdAt: '2025-12-22', | ||
| }, | ||
| { | ||
| id: 5, | ||
| awardName: '女王奖', | ||
| nomineeName: 'Miya', | ||
| nomineeDesc: '跨界协作推动者,连接不同领域的开放协作人', | ||
| videoUrl: 'https://www.bilibili.com/video/BV1rq4y1t7Gd/', | ||
| bvid: 'BV1rq4y1t7Gd', | ||
| reason: 'Miya在社区中将不同领域、不同背景的开放协作人聚集在一起,激发跨界碰撞的火花。', | ||
| nominator: '网名叫唐总', | ||
| contact: '', | ||
| votes: 100, | ||
| voters: Array.from({ length: 100 }, (_, i) => `voter_${i + 1}`), | ||
| createdAt: '2025-12-25', | ||
| }, | ||
| ]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
直接引用我定义类型和 API 封装类:07fe075
并将历史数据录入多维表格:https://open-source-bazaar.feishu.cn/wiki/LUKQwz6NEiQOcXksmGacydWVn7c?table=tblmYd5V5BMngAp2&view=vewXyeu1JY
| useEffect(() => { | ||
| // 获取或创建用户ID | ||
| let uid = localStorage.getItem('userId'); | ||
| if (!uid) { | ||
| uid = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | ||
| localStorage.setItem('userId', uid); | ||
| } | ||
| setUserId(uid); | ||
|
|
||
| // 强制使用最新的初始数据(忽略 localStorage 中的旧数据) | ||
| setNominations(initialNominations); | ||
| localStorage.setItem('nominations', JSON.stringify(initialNominations)); | ||
| }, []); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
数据加载已在我写的 pages/award.tsx 中实现,直接将本页主体代码移动到那个文件里即可引用。
| <Form onSubmit={handleSubmit}> | ||
| <Form.Group className="mb-3"> | ||
| <Form.Label>被提名人姓名/网名 *</Form.Label> | ||
| <Form.Control | ||
| type="text" | ||
| name="nomineeName" | ||
| placeholder="请输入被提名人的姓名或网名" | ||
| required | ||
| /> | ||
| </Form.Group> | ||
| <Form.Group className="mb-3"> | ||
| <Form.Label>被提名人简介</Form.Label> | ||
| <Form.Control | ||
| as="textarea" | ||
| name="nomineeDesc" | ||
| rows={2} | ||
| placeholder="简单介绍一下被提名人(选填)" | ||
| /> | ||
| </Form.Group> | ||
| <Form.Group className="mb-3"> | ||
| <Form.Label>提名视频链接 *</Form.Label> | ||
| <Form.Control | ||
| type="url" | ||
| name="videoUrl" | ||
| placeholder="B站视频链接,如:https://www.bilibili.com/video/BV1S44y1J78o/" | ||
| required | ||
| /> | ||
| <Form.Text className="text-muted">请上传视频到B站,然后粘贴链接</Form.Text> | ||
| </Form.Group> | ||
| <Form.Group className="mb-3"> | ||
| <Form.Label>提名理由(文字版) *</Form.Label> | ||
| <Form.Control | ||
| as="textarea" | ||
| name="reason" | ||
| rows={5} | ||
| placeholder="请简要说明为什么提名TA,TA在过去一年里如何帮助了你或给你留下深刻印象" | ||
| required | ||
| /> | ||
| </Form.Group> | ||
| <Form.Group className="mb-3"> | ||
| <Form.Label>你的姓名/网名 *</Form.Label> | ||
| <Form.Control type="text" name="nominator" placeholder="提名人姓名" required /> | ||
| </Form.Group> | ||
| <Form.Group className="mb-3"> | ||
| <Form.Label>联系方式</Form.Label> | ||
| <Form.Control | ||
| type="email" | ||
| name="contact" | ||
| placeholder="邮箱或其他联系方式(选填)" | ||
| /> | ||
| </Form.Group> | ||
| <Button variant="primary" type="submit" className="w-100"> | ||
| 提交提名 | ||
| </Button> | ||
| </Form> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
提名表单直接用 <iframe /> 嵌入多维表格表单视图:https://open-source-bazaar.feishu.cn/share/base/form/shrcniqv1nEnCrygFy0qX4fPcSg
| {[ | ||
| { | ||
| q: 'Q: 谁可以参与提名?', | ||
| a: 'A: 任何人都可以提名在过去一年里对自己有帮助或留下深刻印象的人。', | ||
| }, | ||
| { | ||
| q: 'Q: 如何制作提名视频?', | ||
| a: 'A: 可以用手机或电脑录制,真诚地讲述为什么要提名TA、TA做了什么让你印象深刻的事情。上传到B站后将链接粘贴到提名表单即可。', | ||
| }, | ||
| { | ||
| q: 'Q: 投票后需要支付多少费用?', | ||
| a: 'A: 只有当提名达到10票成立后,投票人才需要分摊奖杯制作费用。具体金额会在投票时说明,通常每人十几元人民币。', | ||
| }, | ||
| { | ||
| q: 'Q: 可以投多个提名吗?', | ||
| a: 'A: 可以。你可以为多个不同的提名投票,但每个提名只能投一票。', | ||
| }, | ||
| { | ||
| q: 'Q: 奖杯会寄给获奖者吗?', | ||
| a: 'A: 是的。达到10票后,我们会联系获奖者确认收件地址,制作完成后寄出。', | ||
| }, | ||
| ].map((faq, idx) => ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
较长静态数据提到组件之外。
| <Card className={`${styles.nomineeCard} ${isWinner ? styles.winner : ''}`}> | ||
| <div className={styles.videoWrapper}> | ||
| <iframe | ||
| src={getBilibiliEmbed(nomination.bvid)} | ||
| scrolling="no" | ||
| frameBorder="0" | ||
| allowFullScreen | ||
| /> | ||
| </div> | ||
| <Card.Body> | ||
| <Badge bg="primary" className="mb-2"> | ||
| {nomination.awardName} | ||
| </Badge> | ||
| <div className="d-flex justify-content-between align-items-start mb-2"> | ||
| <Card.Title className="mb-0">{nomination.nomineeName}</Card.Title> | ||
| {isWinner && ( | ||
| <Badge bg="success" className="ms-2"> | ||
| 已获奖 | ||
| </Badge> | ||
| )} | ||
| </div> | ||
| {nomination.nomineeDesc && ( | ||
| <Card.Text className="text-muted small">{nomination.nomineeDesc}</Card.Text> | ||
| )} | ||
| <div className={styles.reason}> | ||
| <strong>提名理由:</strong> | ||
| <p>{nomination.reason}</p> | ||
| </div> | ||
| <p className="text-muted small mb-2"> | ||
| 提名人:{nomination.nominator} · {nomination.createdAt} | ||
| </p> | ||
| {nomination.voters.length > 0 && ( | ||
| <div className={styles.votersList}> | ||
| <strong>投票人:</strong> | ||
| {displayVoters.map((voter, idx) => ( | ||
| <Badge key={idx} bg="secondary" className="me-1"> | ||
| {voter} | ||
| </Badge> | ||
| ))} | ||
| {remainingCount > 0 && ( | ||
| <Badge bg="warning">+{remainingCount}</Badge> | ||
| )} | ||
| </div> | ||
| )} | ||
| <div className={styles.voteSection}> | ||
| <div className={styles.voteProgress}> | ||
| <div className={styles.progressBar}> | ||
| <div className={styles.progressFill} style={{ width: `${progress}%` }} /> | ||
| </div> | ||
| <div className={styles.voteCount}> | ||
| <span className={styles.current}>{nomination.votes}</span> / 10 票 | ||
| {isWinner && <span className="text-success ms-2">✓ 奖项已成立</span>} | ||
| </div> | ||
| </div> | ||
| <Button | ||
| variant={hasVoted ? 'secondary' : 'primary'} | ||
| disabled={hasVoted} | ||
| onClick={() => handleVote(nomination.id)} | ||
| size="sm" | ||
| > | ||
| {hasVoted ? '已投票' : '投票'} | ||
| </Button> | ||
| </div> | ||
| </Card.Body> | ||
| </Card> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
复杂的单项组件抽出来,放到 components 文件夹下。
Checklist(清单):
This PR is for demonstrating the concept only. All code was generated by AI. Please feel free to close it.
Summary by CodeRabbit
发布说明
✏️ Tip: You can customize this high-level summary in your review settings.