feat: add the open-collaborator-award page - #50
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📝 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.
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)移至服务端获取
对于演示页面,当前实现可接受。
| <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.
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.
0ff5336 to
cd2371d
Compare
|
谢谢 @TechQuery 校对,我最近会处理。抱歉,一直比较忙。 |
@miyaliu666 我又做了另一个 PR 可供你参考: #100 |
|
@miyaliu666 详见 #113 |
| @@ -1,3 +1,5 @@ | |||
| // cspell:ignore Bilibili Feishu | |||
| const relationCountOf = (value: TableCellValue) => { | ||
| if (!(value instanceof Array)) return value; | ||
|
|
||
| return value.reduce( | ||
| (count, relation) => | ||
| count + (((relation as TableCellRelation)?.record_ids || []) as string[]).length, | ||
| 0, | ||
| ); | ||
| }; |
There was a problem hiding this comment.
| const relationCountOf = (value: TableCellValue) => { | |
| if (!(value instanceof Array)) return value; | |
| return value.reduce( | |
| (count, relation) => | |
| count + (((relation as TableCellRelation)?.record_ids || []) as string[]).length, | |
| 0, | |
| ); | |
| }; | |
| const relationCountOf = (value: TableCellValue) => | |
| value instanceof Array | |
| ? value.reduce( | |
| (count, relation) => | |
| count + (((relation as TableCellRelation)?.record_ids || []) as string[]).length, | |
| 0, | |
| ) | |
| : value; |
There was a problem hiding this comment.
优先用各类 Bootstrap CSS 工具类:https://getbootstrap.com/docs/5.3/utilities/
| iframe { | ||
| position: absolute; | ||
| inset: 0; | ||
| border: 0; | ||
| width: 100%; | ||
| height: 100%; | ||
| } |
There was a problem hiding this comment.
| iframe { | |
| position: absolute; | |
| inset: 0; | |
| border: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| iframe { | |
| border: 0; | |
| width: 100%; | |
| height: 100%; | |
| } |
为何一定要悬浮呢?
| import { Button, Modal } from 'react-bootstrap'; | ||
|
|
||
| import { I18nContext } from '../../models/Translation'; | ||
| import styles from './Award.module.less'; | ||
|
|
||
| export const AWARD_NOMINATION_FORM_URL = | ||
| 'https://open-source-bazaar.feishu.cn/share/base/form/shrcniqv1nEnCrygFy0qX4fPcSg'; | ||
|
|
||
| export interface NominationFormProps { | ||
| show: boolean; | ||
| onHide: () => void; | ||
| } | ||
|
|
||
| export const NominationForm: FC<NominationFormProps> = ({ show, onHide }) => { | ||
| const { t } = useContext(I18nContext); | ||
|
|
||
| return ( | ||
| <Modal show={show} size="lg" centered scrollable onHide={onHide}> |
There was a problem hiding this comment.
| import { Button, Modal } from 'react-bootstrap'; | |
| import { I18nContext } from '../../models/Translation'; | |
| import styles from './Award.module.less'; | |
| export const AWARD_NOMINATION_FORM_URL = | |
| 'https://open-source-bazaar.feishu.cn/share/base/form/shrcniqv1nEnCrygFy0qX4fPcSg'; | |
| export interface NominationFormProps { | |
| show: boolean; | |
| onHide: () => void; | |
| } | |
| export const NominationForm: FC<NominationFormProps> = ({ show, onHide }) => { | |
| const { t } = useContext(I18nContext); | |
| return ( | |
| <Modal show={show} size="lg" centered scrollable onHide={onHide}> | |
| import { Button, Modal, ModalProps } from 'react-bootstrap'; | |
| import { I18nContext } from '../../models/Translation'; | |
| import styles from './Award.module.less'; | |
| export const AWARD_NOMINATION_FORM_URL = | |
| 'https://open-source-bazaar.feishu.cn/share/base/form/shrcniqv1nEnCrygFy0qX4fPcSg'; | |
| export type NominationFormProps = Pick<ModalProps, 'show' | 'onHide'>; | |
| export const NominationForm: FC<NominationFormProps> = observer(props => { | |
| const { t } = useContext(I18nContext); | |
| return ( | |
| <Modal size="lg" centered scrollable {...props}> |
使用 MobX-i18n 的所有组件都要变成 MobX 的 observer,具体可参考其它组件。
| {bilibiliId ? ( | ||
| <div className={styles.videoWrapper}> | ||
| <iframe | ||
| src={`https://player.bilibili.com/player.html?bvid=${bilibiliId}&page=1&high_quality=1&danmaku=0&autoplay=0`} | ||
| title={`${t('award_nomination_video_title')}: ${nominee}`} | ||
| loading="lazy" | ||
| allowFullScreen | ||
| /> | ||
| </div> | ||
| ) : videoURL ? ( | ||
| <div className={`${styles.videoFallback} d-flex align-items-center justify-content-center`}> | ||
| <Button href={videoURL.href} target="_blank" rel="noreferrer" variant="light"> | ||
| {t('award_watch_nomination_video')} | ||
| </Button> | ||
| </div> | ||
| ) : null} |
There was a problem hiding this comment.
| {bilibiliId ? ( | |
| <div className={styles.videoWrapper}> | |
| <iframe | |
| src={`https://player.bilibili.com/player.html?bvid=${bilibiliId}&page=1&high_quality=1&danmaku=0&autoplay=0`} | |
| title={`${t('award_nomination_video_title')}: ${nominee}`} | |
| loading="lazy" | |
| allowFullScreen | |
| /> | |
| </div> | |
| ) : videoURL ? ( | |
| <div className={`${styles.videoFallback} d-flex align-items-center justify-content-center`}> | |
| <Button href={videoURL.href} target="_blank" rel="noreferrer" variant="light"> | |
| {t('award_watch_nomination_video')} | |
| </Button> | |
| </div> | |
| ) : null} | |
| <div className={styles.videoWrapper}> | |
| <iframe | |
| src={`https://player.bilibili.com/player.html?bvid=${bilibiliId}&page=1&high_quality=1&danmaku=0&autoplay=0`} | |
| title={`${t('award_nomination_video_title')}: ${nominee}`} | |
| loading="lazy" | |
| allowFullScreen | |
| /> | |
| </div> |
<iframe /> 就是个嵌入式网页,能兼容任何浏览器可识别的 URL。
| <div className={styles.progressTrack}> | ||
| <div className={styles.progressFill} style={{ width: `${progress}%` }} /> | ||
| </div> |
There was a problem hiding this comment.
如果没有很特殊的样式,可以直接用 HTML 5 的 <progress /> 或 Bootstrap 的 <Progress />。
| return true; | ||
| }); | ||
|
|
||
| const stats: Array<[number, I18nKey]> = [ |
There was a problem hiding this comment.
| const stats: Array<[number, I18nKey]> = [ | |
| const stats: [number, I18nKey][] = [ |
| <Row className="mt-5 justify-content-center"> | ||
| {stats.map(([value, label]) => ( | ||
| <Col key={label} xs={4} md={3}> |
There was a problem hiding this comment.
| <Row className="mt-5 justify-content-center"> | |
| {stats.map(([value, label]) => ( | |
| <Col key={label} xs={4} md={3}> | |
| <Row className="mt-5 justify-content-center" xs={3} md={4}> | |
| {stats.map(([value, label]) => ( | |
| <Col key={label}> |
均分列可以在行上设置列数。
| {recognizedAwards.map(award => ( | ||
| <Col key={award.id?.toString()} md={6}> | ||
| <Card className={`${styles.recipientCard} h-100`} body> | ||
| <Badge className="align-self-start mb-2" bg="success"> | ||
| {award.awardName?.toString() || t('open_collaborator_award')} | ||
| </Badge> | ||
| <Card.Title as="h3"> | ||
| {award.nomineeName?.toString() || t('award_unnamed_nominee')} | ||
| </Card.Title> | ||
| <Card.Text> | ||
| {voteCountOf(award)} {t('award_support_count')} | ||
| </Card.Text> | ||
| <Button | ||
| className="align-self-start" | ||
| href={`#award-${award.id}`} |
There was a problem hiding this comment.
| {recognizedAwards.map(award => ( | |
| <Col key={award.id?.toString()} md={6}> | |
| <Card className={`${styles.recipientCard} h-100`} body> | |
| <Badge className="align-self-start mb-2" bg="success"> | |
| {award.awardName?.toString() || t('open_collaborator_award')} | |
| </Badge> | |
| <Card.Title as="h3"> | |
| {award.nomineeName?.toString() || t('award_unnamed_nominee')} | |
| </Card.Title> | |
| <Card.Text> | |
| {voteCountOf(award)} {t('award_support_count')} | |
| </Card.Text> | |
| <Button | |
| className="align-self-start" | |
| href={`#award-${award.id}`} | |
| {recognizedAwards.map(({ id, awardName, nomineeName, votes }) => ( | |
| <Col key={id?.toString()} md={6}> | |
| <Card className={`${styles.recipientCard} h-100`} body> | |
| <Badge className="align-self-start mb-2" bg="success"> | |
| {awardName?.toString() || t('open_collaborator_award')} | |
| </Badge> | |
| <Card.Title as="h3"> | |
| {nomineeName?.toString() || t('award_unnamed_nominee')} | |
| </Card.Title> | |
| <Card.Text> | |
| {(votes as TableCellRelation[]).length} {t('award_support_count')} | |
| </Card.Text> | |
| <Button | |
| className="align-self-start" | |
| href={`#award-${id}`} |


Checklist(清单):
close #52
Summary by CodeRabbit
发布说明
✏️ Tip: You can customize this high-level summary in your review settings.