⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

Conversation

@miyaliu666
Copy link
Contributor

@miyaliu666 miyaliu666 commented Jan 7, 2026

PR-50 PR-50 PR-50 Powered by Pull Request Badge

Checklist(清单):

  • Labels
  • Assignees
  • Reviewers

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.

@miyaliu666 miyaliu666 requested a review from TechQuery January 7, 2026 10:30
@miyaliu666 miyaliu666 self-assigned this Jan 7, 2026
@miyaliu666 miyaliu666 added the feature New feature or request label Jan 7, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 7, 2026

📝 Walkthrough

总体概览

引入了一个新的开放协作者奖项页面功能,包含提名管理、投票系统和数据持久化的React组件,配套全面的样式定义。

变更详情

功能模块 文件 变更摘要
页面内容移除 pages/article/open-collaborator-award.mdx 删除了"开放协作人奖"标题及Bilibili视频嵌入内容
页面样式 pages/article/open-collaborator-award.module.less 新增完整的LESS样式模块,涵盖英雄区段、卡片、投票UI、FAQ等响应式设计;包含渐变背景、悬停效果、分层z-index和多断点自适应
核心功能组件 pages/article/open-collaborator-award.tsx 新增React函数组件,实现提名管理、投票系统、表单提交、localStorage持久化、筛选和模态对话框等完整功能逻辑

时序图

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
Loading

代码审查工作量评估

🎯 4 (复杂) | ⏱️ ~40 分钟

审查重点建议

  • 检验状态管理的完整性(nominations、filter、modal、userId)
  • 审查投票逻辑的并发安全性与localStorage同步时序
  • 验证BVID提取与URL验证的健壮性
  • 确认所有UI文本是否通过 t() 国际化处理
  • 检查React Bootstrap组件的正确使用与可访问性
  • 性能评估:大量提名渲染时的列表虚拟化需求
  • TypeScript类型定义的完整性与严格性
  • 响应式设计在各断点的显示效果

庆祝诗

🏆 协作精神在这里闪闪发光,
投票与提名汇聚众人之力,
localStorage 守护每份珍贵的声音,
英雄、卡片、梯度色彩相映成趣,
十票的门槛,赢家的时刻,
开放的舞台,为你而生!✨

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dosubot
Copy link

dosubot bot commented Jan 7, 2026

Related Documentation

Checked 9 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Copy link

@coderabbitai coderabbitai bot left a 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),这会:

  1. 阻塞主线程
  2. 无法自定义样式
  3. 不符合无障碍访问标准

建议使用 React Bootstrap 的 ModalToast 组件替代。

♻️ 建议使用确认 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

📥 Commits

Reviewing files that changed from the base of the PR and between e63932a and 1fa8fa4.

📒 Files selected for processing (3)
  • pages/article/open-collaborator-award.mdx
  • pages/article/open-collaborator-award.module.less
  • pages/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); use

    for 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 层级管理

几点小建议:

  1. Line 111-113 使用了 background-clip: text,建议确认目标浏览器兼容性
  2. Line 169-170 使用 !important 覆盖样式,可考虑通过更具体的选择器避免
pages/article/open-collaborator-award.tsx (2)

21-92: 初始数据硬编码在组件中。

initialNominations 包含大量硬编码数据,适合演示目的。但如果计划生产使用,建议:

  1. 将数据移至外部 JSON 文件或通过 API 获取
  2. 使用 Next.js 的 getStaticPropsgetServerSideProps 进行数据获取

根据 PR 描述,这是 AI 生成的演示代码,当前实现可接受。

请确认此页面的数据持久化策略:

  • 如果仅用于演示,当前 localStorage 方案可行
  • 如果需要真实投票功能,需要后端 API 支持

94-99: 组件状态管理符合预期,但缺少服务端渲染支持。

作为 Next.js 页面组件,当前实现完全依赖客户端状态(useState)。如果需要 SEO,建议考虑:

  1. 使用 getStaticProps 预渲染初始数据
  2. 将静态内容(如规则、FAQ)移至服务端获取

对于演示页面,当前实现可接受。

Comment on lines +1 to +5
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';

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

导入了 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.

Comment on lines +102 to +119
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));
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +204 to +206
const winners = nominations.filter((n) => n.votes >= 10);
const totalVotes = 666; // 固定显示

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +280 to +286
<iframe
src="https://player.bilibili.com/player.html?bvid=BV1c44y1x7ij&page=1&high_quality=1&danmaku=0"
scrolling="no"
frameBorder="0"
allowFullScreen
/>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

iframe 缺少 title 属性,且使用了已弃用的 frameBorder

  1. title 属性对屏幕阅读器用户至关重要(无障碍访问要求)
  2. frameBorder 已弃用,应使用 CSS border: 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.

Suggested change
<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.

Comment on lines +6 to +92
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',
},
];
Copy link
Member

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

Comment on lines +102 to +114
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));
}, []);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

数据加载已在我写的 pages/award.tsx 中实现,直接将本页主体代码移动到那个文件里即可引用。

Comment on lines +527 to +581
<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>
Copy link
Member

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

Comment on lines +487 to +508
{[
{
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) => (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

较长静态数据提到组件之外。

Comment on lines +403 to +467
<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>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复杂的单项组件抽出来,放到 components 文件夹下。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants