Merge branch 'feature_bugfix/251113_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251113_ui
This commit is contained in:
92
.eslintrc.js
Normal file
92
.eslintrc.js
Normal file
@@ -0,0 +1,92 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
|
||||
/* 环境配置 */
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
|
||||
/* 扩展配置 */
|
||||
extends: [
|
||||
'react-app', // Create React App 默认规则
|
||||
'react-app/jest', // Jest 测试规则
|
||||
'eslint:recommended', // ESLint 推荐规则
|
||||
'plugin:react/recommended', // React 推荐规则
|
||||
'plugin:react-hooks/recommended', // React Hooks 规则
|
||||
'plugin:prettier/recommended', // Prettier 集成
|
||||
],
|
||||
|
||||
/* 解析器选项 */
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
|
||||
/* 插件 */
|
||||
plugins: ['react', 'react-hooks', 'prettier'],
|
||||
|
||||
/* 规则配置 */
|
||||
rules: {
|
||||
// React
|
||||
'react/react-in-jsx-scope': 'off', // React 17+ 不需要导入 React
|
||||
'react/prop-types': 'off', // 使用 TypeScript 类型检查,不需要 PropTypes
|
||||
'react/display-name': 'off', // 允许匿名组件
|
||||
|
||||
// 通用
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }], // 仅警告 console.log
|
||||
'no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_', // 忽略以 _ 开头的未使用参数
|
||||
varsIgnorePattern: '^_', // 忽略以 _ 开头的未使用变量
|
||||
}],
|
||||
'prettier/prettier': ['warn', {}, { usePrettierrc: true }], // 使用项目的 Prettier 配置
|
||||
},
|
||||
|
||||
/* 设置 */
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect', // 自动检测 React 版本
|
||||
},
|
||||
},
|
||||
|
||||
/* TypeScript 文件特殊配置 */
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'], // 仅对 TS 文件应用以下配置
|
||||
parser: '@typescript-eslint/parser', // 使用 TypeScript 解析器
|
||||
parserOptions: {
|
||||
project: './tsconfig.json', // 关联 tsconfig.json
|
||||
},
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended', // TypeScript 推荐规则
|
||||
],
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
// TypeScript 特定规则
|
||||
'@typescript-eslint/no-explicit-any': 'warn', // 警告使用 any(允许但提示)
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off', // 不强制导出函数类型
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
}],
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn', // 警告使用 !(非空断言)
|
||||
|
||||
// 覆盖基础规则(避免与 TS 规则冲突)
|
||||
'no-unused-vars': 'off', // 使用 TS 版本的规则
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
/* 忽略文件(与 .eslintignore 等效)*/
|
||||
ignorePatterns: [
|
||||
'node_modules/',
|
||||
'build/',
|
||||
'dist/',
|
||||
'*.config.js',
|
||||
'public/mockServiceWorker.js',
|
||||
],
|
||||
};
|
||||
159
CLAUDE.md
159
CLAUDE.md
@@ -20,6 +20,7 @@
|
||||
|
||||
**开发指南**:
|
||||
- [开发工作流](#开发工作流) - 路由、组件、API、Redux 开发指南
|
||||
- [TypeScript 接入](#typescript-接入) - TypeScript 渐进式迁移方案与指南
|
||||
- [常见开发任务](#常见开发任务) - 5 个详细的开发任务教程
|
||||
- [技术路径与开发指南](#技术路径与开发指南) - UI 框架选型、技术栈演进、最佳实践
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
|
||||
**前端**
|
||||
- **核心框架**: React 18.3.1
|
||||
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
|
||||
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **状态管理**: Redux Toolkit 2.9.2
|
||||
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
|
||||
@@ -81,6 +83,10 @@ npm test # 运行 React 测试套件(CRACO)
|
||||
|
||||
npm run lint:check # 检查 ESLint 规则(退出码 0)
|
||||
npm run lint:fix # 自动修复 ESLint 问题
|
||||
|
||||
npm run type-check # TypeScript 类型检查(不生成输出)
|
||||
npm run type-check:watch # TypeScript 类型检查监听模式
|
||||
|
||||
npm run clean # 删除 node_modules 和 package-lock.json
|
||||
npm run reinstall # 清洁安装(运行 clean + install)
|
||||
```
|
||||
@@ -1386,6 +1392,159 @@ src/views/Community/components/
|
||||
|
||||
---
|
||||
|
||||
## TypeScript 接入
|
||||
|
||||
### 概述
|
||||
|
||||
项目已于 **2025-11-13** 完成 TypeScript 环境配置,采用**渐进式迁移策略**,支持 JavaScript 和 TypeScript 混合开发。
|
||||
|
||||
**当前状态**: 环境配置完成,准备开始代码迁移
|
||||
**迁移策略**: 新代码使用 TypeScript,旧代码按优先级逐步迁移
|
||||
**类型严格度**: 推荐模式(`noImplicitAny: true`,其他严格检查待后续开启)
|
||||
|
||||
### 已完成的环境配置
|
||||
|
||||
✅ **TypeScript 编译器**: v5.9.3
|
||||
✅ **tsconfig.json**: 推荐模式配置,支持 JS/TS 混合开发
|
||||
✅ **CRACO 配置**: 支持 `.ts` 和 `.tsx` 文件编译
|
||||
✅ **ESLint 配置**: 支持 TypeScript 语法检查
|
||||
✅ **路径别名**: 与现有 `@/` 别名保持一致
|
||||
✅ **全局类型定义**: 基础类型文件已创建在 `src/types/`
|
||||
|
||||
### 可用类型定义
|
||||
|
||||
项目已创建以下基础类型定义文件:
|
||||
|
||||
**`src/types/api.ts`** - API 相关类型
|
||||
- `ApiResponse<T>` - 通用 API 响应结构
|
||||
- `PaginatedResponse<T>` - 分页响应
|
||||
- `ApiError` - API 错误类型
|
||||
- `ListQueryParams` - 列表查询参数
|
||||
|
||||
**`src/types/stock.ts`** - 股票相关类型
|
||||
- `StockInfo` - 股票基础信息
|
||||
- `StockQuote` - 股票行情数据
|
||||
- `KLineData` - K 线数据
|
||||
- `StockFinancials` - 财务指标
|
||||
- `StockPosition` - 股票持仓
|
||||
- `Sector` - 概念/行业板块
|
||||
|
||||
**`src/types/user.ts`** - 用户相关类型
|
||||
- `UserInfo` - 用户基础信息
|
||||
- `AuthInfo` - 认证信息
|
||||
- `LoginParams` / `RegisterParams` - 登录/注册参数
|
||||
- `UserSubscription` - 订阅信息
|
||||
- `UserAccount` - 资金账户
|
||||
- `UserSettings` - 用户设置
|
||||
|
||||
**使用方式**:
|
||||
```typescript
|
||||
// 统一导入
|
||||
import type { StockQuote, UserInfo, ApiResponse } from '@/types';
|
||||
|
||||
// 或从具体文件导入
|
||||
import type { StockQuote } from '@/types/stock';
|
||||
```
|
||||
|
||||
### TypeScript 命令
|
||||
|
||||
```bash
|
||||
# 类型检查(不生成输出文件)
|
||||
npm run type-check
|
||||
|
||||
# 类型检查 + 监听模式
|
||||
npm run type-check:watch
|
||||
|
||||
# ESLint 检查(包含 TS 文件)
|
||||
npm run lint:check
|
||||
|
||||
# ESLint 自动修复
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
### 迁移路线图
|
||||
|
||||
详细的迁移指南请参考 **[TYPESCRIPT_MIGRATION.md](./TYPESCRIPT_MIGRATION.md)** 文档。
|
||||
|
||||
**简要路线图**:
|
||||
|
||||
1. **优先级 1️⃣**: 工具层(`src/utils/`, `src/constants/`)
|
||||
- 纯函数,迁移成本低,收益高
|
||||
- 提供类型定义给其他模块使用
|
||||
|
||||
2. **优先级 2️⃣**: 类型定义层(扩展 `src/types/`)
|
||||
- 添加 `trading.ts`, `community.ts`, `chart.ts` 等
|
||||
|
||||
3. **优先级 3️⃣**: 服务层(`src/services/`)
|
||||
- 定义 API 请求/响应类型
|
||||
- 使用 `ApiResponse<T>` 包装响应
|
||||
|
||||
4. **优先级 4️⃣**: Redux 状态层(`src/store/slices/`)
|
||||
- 定义 `RootState` 和 `AppDispatch` 类型
|
||||
- 创建类型化的 hooks
|
||||
|
||||
5. **优先级 5️⃣**: 自定义 Hooks(`src/hooks/`)
|
||||
- 添加泛型支持
|
||||
- 定义完整返回值类型
|
||||
|
||||
6. **优先级 6️⃣**: 组件层(`src/components/`, `src/views/`)
|
||||
- Atoms → Molecules → Organisms → Pages
|
||||
- 优先迁移复用度高的组件
|
||||
|
||||
### 开发规范
|
||||
|
||||
**新代码**:
|
||||
- ✅ **必须使用 TypeScript**(`.ts` 或 `.tsx`)
|
||||
- ✅ 所有函数参数和返回值添加类型
|
||||
- ✅ 组件 Props 使用 `interface` 定义
|
||||
- ✅ 避免使用 `any`(特殊情况需添加注释说明)
|
||||
|
||||
**旧代码迁移**:
|
||||
- 按优先级迁移,不强制一次性完成
|
||||
- 迁移前先阅读 [TYPESCRIPT_MIGRATION.md](./TYPESCRIPT_MIGRATION.md)
|
||||
- 迁移后运行 `npm run type-check` 验证
|
||||
|
||||
**类型定义**:
|
||||
- 公共类型定义导出到 `src/types/`
|
||||
- 组件内部类型可定义在组件文件中
|
||||
- 使用 `type` 还是 `interface` 参考 [迁移指南](./TYPESCRIPT_MIGRATION.md)
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 路径别名 `@/types` 无法识别?**
|
||||
A: 确保在 `tsconfig.json` 中配置了 `paths`,并重启 IDE。使用 `npm run type-check` 而非命令行 `tsc`。
|
||||
|
||||
**Q: 如何处理第三方库没有类型定义?**
|
||||
A:
|
||||
1. 尝试安装 `@types/library-name`
|
||||
2. 创建自定义类型声明文件 `src/types/library-name.d.ts`
|
||||
3. 临时使用 `as any`(需添加 TODO 注释)
|
||||
|
||||
**Q: 迁移期间如何处理 any 类型?**
|
||||
A: 添加 ESLint 禁用注释和 TODO 说明:
|
||||
```typescript
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// TODO: 待完善类型定义
|
||||
const legacyFunction = (data: any): any => { ... };
|
||||
```
|
||||
|
||||
**Q: React 组件的 children 类型如何定义?**
|
||||
A: 使用 `React.ReactNode`:
|
||||
```typescript
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
```
|
||||
|
||||
### 参考资源
|
||||
|
||||
- [TYPESCRIPT_MIGRATION.md](./TYPESCRIPT_MIGRATION.md) - 完整迁移指南
|
||||
- [TypeScript 官方文档](https://www.typescriptlang.org/docs/)
|
||||
- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/)
|
||||
- [Redux Toolkit TypeScript 指南](https://redux-toolkit.js.org/usage/usage-with-typescript)
|
||||
|
||||
---
|
||||
|
||||
## 更新本文档
|
||||
|
||||
本 CLAUDE.md 文件是一个持续更新的文档。在以下情况下应更新它:
|
||||
|
||||
@@ -133,8 +133,8 @@ module.exports = {
|
||||
'@variables': path.resolve(__dirname, 'src/variables'),
|
||||
'@views': path.resolve(__dirname, 'src/views'),
|
||||
},
|
||||
// 减少文件扩展名搜索
|
||||
extensions: ['.js', '.jsx', '.json'],
|
||||
// 减少文件扩展名搜索(优先 TypeScript)
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
||||
// 优化模块查找路径
|
||||
modules: [
|
||||
path.resolve(__dirname, 'src'),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@assets/*": ["assets/*"],
|
||||
"@components/*": ["components/*"],
|
||||
"@constants/*": ["constants/*"],
|
||||
"@contexts/*": ["contexts/*"],
|
||||
"@data/*": ["data/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@layouts/*": ["layouts/*"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@mocks/*": ["mocks/*"],
|
||||
"@providers/*": ["providers/*"],
|
||||
"@routes/*": ["routes/*"],
|
||||
"@services/*": ["services/*"],
|
||||
"@store/*": ["store/*"],
|
||||
"@styles/*": ["styles/*"],
|
||||
"@theme/*": ["theme/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
"@variables/*": ["variables/*"],
|
||||
"@views/*": ["views/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "build", "dist"]
|
||||
}
|
||||
12
package.json
12
package.json
@@ -106,13 +106,20 @@
|
||||
"deploy": "bash scripts/deploy-from-local.sh",
|
||||
"deploy:setup": "bash scripts/setup-deployment.sh",
|
||||
"rollback": "bash scripts/rollback-from-local.sh",
|
||||
"lint:check": "eslint . --ext=js,jsx; exit 0",
|
||||
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
|
||||
"lint:check": "eslint . --ext=js,jsx,ts,tsx; exit 0",
|
||||
"lint:fix": "eslint . --ext=js,jsx,ts,tsx --fix; exit 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check:watch": "tsc --noEmit --watch",
|
||||
"clean": "rm -rf node_modules/ package-lock.json",
|
||||
"reinstall": "npm run clean && npm install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"ajv": "^8.17.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^8.2.2",
|
||||
@@ -131,6 +138,7 @@
|
||||
"react-error-overlay": "6.0.9",
|
||||
"sharp": "^0.34.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"yn": "^5.1.0"
|
||||
},
|
||||
|
||||
77
src/components/EventCommentSection/CommentInput.js
Normal file
77
src/components/EventCommentSection/CommentInput.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// src/components/EventCommentSection/CommentInput.js
|
||||
/**
|
||||
* 评论输入框组件
|
||||
* 功能:输入评论内容、字数限制、发布按钮
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Textarea,
|
||||
Button,
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
const CommentInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
maxLength = 500,
|
||||
placeholder = '说点什么...',
|
||||
}) => {
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const countColor = useColorModeValue('gray.500', 'gray.500');
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
// Ctrl/Cmd + Enter 快捷键提交
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
bg={bgColor}
|
||||
borderColor={borderColor}
|
||||
color={textColor}
|
||||
rows={3}
|
||||
maxLength={maxLength}
|
||||
resize="vertical"
|
||||
_hover={{
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
_focus={{
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)',
|
||||
}}
|
||||
/>
|
||||
<HStack justify="space-between" mt={2}>
|
||||
<Text fontSize="sm" color={countColor}>
|
||||
{value.length}/{maxLength}
|
||||
{value.length === 0 && ' · Ctrl+Enter 快速发布'}
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={onSubmit}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!value.trim() || isSubmitting}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentInput;
|
||||
89
src/components/EventCommentSection/CommentItem.js
Normal file
89
src/components/EventCommentSection/CommentItem.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// src/components/EventCommentSection/CommentItem.js
|
||||
/**
|
||||
* 单条评论组件
|
||||
* 功能:显示用户头像、昵称、时间、评论内容
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Avatar,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const usernameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const timeColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const contentColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return '刚刚';
|
||||
} else if (diffMinutes < 60) {
|
||||
return `${diffMinutes}分钟前`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}小时前`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else {
|
||||
return time.format('MM-DD HH:mm');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg={itemBg}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'sm',
|
||||
}}
|
||||
>
|
||||
<HStack align="start" spacing={3}>
|
||||
{/* 用户头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={comment.author?.username || 'Anonymous'}
|
||||
src={comment.author?.avatar}
|
||||
/>
|
||||
|
||||
{/* 评论内容区 */}
|
||||
<VStack align="stretch" flex={1} spacing={1}>
|
||||
{/* 用户名和时间 */}
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" color={usernameColor}>
|
||||
{comment.author?.username || 'Anonymous'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={timeColor}>
|
||||
{formatTime(comment.created_at)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<Text fontSize="sm" color={contentColor} lineHeight="1.6">
|
||||
{comment.content}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentItem;
|
||||
67
src/components/EventCommentSection/CommentList.js
Normal file
67
src/components/EventCommentSection/CommentList.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// src/components/EventCommentSection/CommentList.js
|
||||
/**
|
||||
* 评论列表组件
|
||||
* 功能:展示评论列表、加载状态、空状态
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Spinner,
|
||||
Center,
|
||||
Text,
|
||||
Box,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChatIcon } from '@chakra-ui/icons';
|
||||
import CommentItem from './CommentItem';
|
||||
|
||||
const CommentList = ({ comments, loading }) => {
|
||||
const emptyTextColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const emptyBgColor = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="md" color="blue.500" thickness="3px" />
|
||||
<Text fontSize="sm" color={emptyTextColor}>
|
||||
加载评论中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (!comments || comments.length === 0) {
|
||||
return (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Box
|
||||
p={4}
|
||||
bg={emptyBgColor}
|
||||
borderRadius="full"
|
||||
>
|
||||
<ChatIcon boxSize={6} color={emptyTextColor} />
|
||||
</Box>
|
||||
<Text fontSize="sm" color={emptyTextColor}>
|
||||
还没有评论,快来发表第一条吧~
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// 评论列表
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{comments.map((comment) => (
|
||||
<CommentItem key={comment.id} comment={comment} />
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentList;
|
||||
245
src/components/EventCommentSection/EventCommentSection.tsx
Normal file
245
src/components/EventCommentSection/EventCommentSection.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// src/components/EventCommentSection/EventCommentSection.tsx
|
||||
/**
|
||||
* 事件评论区主组件(TypeScript 版本)
|
||||
* 功能:整合评论列表 + 评论输入框,管理评论数据
|
||||
* 使用 usePagination Hook 实现分页功能
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Badge,
|
||||
HStack,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Button,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import type { Comment, CreateCommentParams } from '@/types';
|
||||
import type { PaginationLoadResult } from '@/types';
|
||||
import CommentList from './CommentList';
|
||||
import CommentInput from './CommentInput';
|
||||
|
||||
/**
|
||||
* 组件 Props
|
||||
*/
|
||||
interface EventCommentSectionProps {
|
||||
/** 事件 ID */
|
||||
eventId: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件评论区组件
|
||||
*/
|
||||
const EventCommentSection: React.FC<EventCommentSectionProps> = ({ eventId }) => {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
// 评论输入状态
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* 加载评论数据的函数
|
||||
* @param page 页码
|
||||
* @param append 是否追加到已有数据
|
||||
* @returns 分页响应数据
|
||||
*/
|
||||
const loadCommentsFunction = useCallback(
|
||||
async (page: number, append: boolean): Promise<PaginationLoadResult<Comment>> => {
|
||||
try {
|
||||
const result = await eventService.getPosts(
|
||||
eventId,
|
||||
'latest',
|
||||
page,
|
||||
5 // 每页 5 条评论
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('EventCommentSection', '评论加载成功', {
|
||||
eventId,
|
||||
page,
|
||||
count: result.data?.length || 0,
|
||||
total: result.pagination?.total || 0,
|
||||
append,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data || [],
|
||||
pagination: result.pagination,
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.message || '加载评论失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('EventCommentSection', 'loadCommentsFunction', error, {
|
||||
eventId,
|
||||
page,
|
||||
});
|
||||
toast({
|
||||
title: '加载评论失败',
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[eventId, toast]
|
||||
);
|
||||
|
||||
// 使用 usePagination Hook
|
||||
const {
|
||||
data: comments,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
totalCount,
|
||||
loadMore,
|
||||
setData: setComments,
|
||||
setTotalCount,
|
||||
} = usePagination<Comment>(loadCommentsFunction, {
|
||||
pageSize: 5,
|
||||
autoLoad: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 发表评论
|
||||
*/
|
||||
const handleSubmitComment = useCallback(async () => {
|
||||
if (!commentText.trim()) {
|
||||
toast({
|
||||
title: '请输入评论内容',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const params: CreateCommentParams = {
|
||||
content: commentText.trim(),
|
||||
content_type: 'text',
|
||||
};
|
||||
|
||||
const result = await eventService.createPost(eventId, params);
|
||||
|
||||
if (result.success) {
|
||||
// 乐观更新:立即将新评论添加到本地 state,避免重新加载导致的闪烁
|
||||
const newComment: Comment = {
|
||||
id: result.data?.id || `comment_optimistic_${Date.now()}`,
|
||||
content: commentText.trim(),
|
||||
content_type: 'text',
|
||||
author: {
|
||||
id: user?.id || 'current_user',
|
||||
username: user?.username || '当前用户',
|
||||
avatar: user?.avatar || null,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
likes_count: 0,
|
||||
is_liked: false,
|
||||
};
|
||||
|
||||
// 将新评论追加到列表末尾(最新评论在底部)
|
||||
setComments((prevComments) => [...prevComments, newComment]);
|
||||
// 总评论数 +1
|
||||
setTotalCount((prevTotal) => prevTotal + 1);
|
||||
|
||||
toast({
|
||||
title: '评论发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
setCommentText(''); // 清空输入框
|
||||
|
||||
logger.info('EventCommentSection', '评论发布成功(乐观更新)', {
|
||||
eventId,
|
||||
content: commentText.trim(),
|
||||
commentId: newComment.id,
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.message || '评论发布失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('EventCommentSection', 'handleSubmitComment', error, { eventId });
|
||||
toast({
|
||||
title: '评论发布失败',
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [eventId, commentText, toast, user, setComments, setTotalCount]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 标题栏 */}
|
||||
<HStack spacing={3} mb={4} p={3} bg={sectionBg} borderRadius="md">
|
||||
<Heading size="sm" color={headingColor}>
|
||||
讨论区
|
||||
</Heading>
|
||||
<Badge colorScheme="blue" fontSize="sm" borderRadius="full" px={2}>
|
||||
{totalCount} 条评论
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<Divider borderColor={dividerColor} mb={4} />
|
||||
|
||||
{/* 评论列表 */}
|
||||
<Box mb={4}>
|
||||
<CommentList comments={comments} loading={loading} />
|
||||
</Box>
|
||||
|
||||
{/* 加载更多按钮(仅当有更多评论时显示) */}
|
||||
{hasMore && (
|
||||
<Center mb={4}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
isLoading={loadingMore}
|
||||
loadingText="加载中..."
|
||||
>
|
||||
加载更多评论
|
||||
</Button>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 评论输入框(仅登录用户显示) */}
|
||||
{user && (
|
||||
<Box>
|
||||
<Divider borderColor={dividerColor} mb={4} />
|
||||
<CommentInput
|
||||
value={commentText}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setCommentText(e.target.value)
|
||||
}
|
||||
onSubmit={handleSubmitComment}
|
||||
isSubmitting={submitting}
|
||||
maxLength={500}
|
||||
placeholder="说点什么..."
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventCommentSection;
|
||||
10
src/components/EventCommentSection/index.js
Normal file
10
src/components/EventCommentSection/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/components/EventCommentSection/index.js
|
||||
/**
|
||||
* 事件评论区组件统一导出
|
||||
*/
|
||||
|
||||
export { default } from './EventCommentSection';
|
||||
export { default as EventCommentSection } from './EventCommentSection';
|
||||
export { default as CommentList } from './CommentList';
|
||||
export { default as CommentItem } from './CommentItem';
|
||||
export { default as CommentInput } from './CommentInput';
|
||||
@@ -64,6 +64,9 @@ export const NotificationProvider = ({ children }) => {
|
||||
const adaptEventToNotificationRef = useRef(null);
|
||||
const isFirstConnect = useRef(true); // 标记是否首次连接
|
||||
|
||||
// ⚡ 事件更新回调列表(用于在收到 new_event 时通知其他组件刷新数据)
|
||||
const eventUpdateCallbacks = useRef(new Set());
|
||||
|
||||
// ⚡ 使用权限引导管理 Hook
|
||||
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
|
||||
|
||||
@@ -160,6 +163,37 @@ export const NotificationProvider = ({ children }) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 注册事件更新回调(用于在收到新事件时通知其他组件刷新)
|
||||
* @param {Function} callback - 回调函数,接收 eventData 参数
|
||||
* @returns {Function} 取消注册函数
|
||||
*/
|
||||
const registerEventUpdateCallback = useCallback((callback) => {
|
||||
eventUpdateCallbacks.current.add(callback);
|
||||
logger.info('NotificationContext', 'Event update callback registered', {
|
||||
totalCallbacks: eventUpdateCallbacks.current.size
|
||||
});
|
||||
|
||||
// 返回取消注册函数
|
||||
return () => {
|
||||
eventUpdateCallbacks.current.delete(callback);
|
||||
logger.info('NotificationContext', 'Event update callback unregistered', {
|
||||
totalCallbacks: eventUpdateCallbacks.current.size
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 取消注册事件更新回调(已废弃,建议使用 registerEventUpdateCallback 返回的函数)
|
||||
* @param {Function} callback - 要取消的回调函数
|
||||
*/
|
||||
const unregisterEventUpdateCallback = useCallback((callback) => {
|
||||
eventUpdateCallbacks.current.delete(callback);
|
||||
logger.info('NotificationContext', 'Event update callback unregistered (manual)', {
|
||||
totalCallbacks: eventUpdateCallbacks.current.size
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 请求浏览器通知权限
|
||||
*/
|
||||
@@ -764,6 +798,21 @@ export const NotificationProvider = ({ children }) => {
|
||||
console.log('[NotificationContext] 准备添加通知到队列...');
|
||||
addNotificationRef.current(notification);
|
||||
console.log('[NotificationContext] ✅ 通知已添加到队列');
|
||||
|
||||
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
|
||||
if (eventUpdateCallbacks.current.size > 0) {
|
||||
console.log(`[NotificationContext] 🔔 触发 ${eventUpdateCallbacks.current.size} 个事件更新回调...`);
|
||||
eventUpdateCallbacks.current.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', 'Event update callback error', error);
|
||||
console.error('[NotificationContext] ❌ 事件更新回调执行失败:', error);
|
||||
}
|
||||
});
|
||||
console.log('[NotificationContext] ✅ 所有事件更新回调已触发');
|
||||
}
|
||||
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
});
|
||||
|
||||
@@ -1040,6 +1089,9 @@ export const NotificationProvider = ({ children }) => {
|
||||
showWelcomeGuide,
|
||||
showCommunityGuide,
|
||||
showFirstFollowGuide,
|
||||
// ⚡ 新增:事件更新回调注册方法
|
||||
registerEventUpdateCallback,
|
||||
unregisterEventUpdateCallback,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
137
src/hooks/usePagination.ts
Normal file
137
src/hooks/usePagination.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* usePagination - 通用分页 Hook
|
||||
*
|
||||
* 封装分页逻辑,支持初始加载、加载更多、重置等功能
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* data: comments,
|
||||
* loading,
|
||||
* loadingMore,
|
||||
* hasMore,
|
||||
* totalCount,
|
||||
* loadMore,
|
||||
* setData,
|
||||
* setTotalCount,
|
||||
* } = usePagination<Comment>(loadCommentsFunction, { pageSize: 5 });
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type {
|
||||
LoadFunction,
|
||||
PaginationLoadResult,
|
||||
UsePaginationOptions,
|
||||
UsePaginationResult,
|
||||
} from '@/types/pagination';
|
||||
|
||||
/**
|
||||
* usePagination Hook
|
||||
* @template T 数据项类型
|
||||
* @param loadFunction 加载函数,接收 (page, append) 参数
|
||||
* @param options 配置选项
|
||||
* @returns 分页状态和操作方法
|
||||
*/
|
||||
export function usePagination<T>(
|
||||
loadFunction: LoadFunction<T>,
|
||||
options: UsePaginationOptions = {}
|
||||
): UsePaginationResult<T> {
|
||||
const { pageSize = 10, autoLoad = true } = options;
|
||||
|
||||
// 状态管理
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
/**
|
||||
* 加载数据
|
||||
* @param page 页码
|
||||
* @param append 是否追加(true: 追加,false: 替换)
|
||||
*/
|
||||
const loadData = useCallback(
|
||||
async (page: number, append: boolean = false) => {
|
||||
// 设置加载状态
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const result: PaginationLoadResult<T> = await loadFunction(page, append);
|
||||
|
||||
// 更新数据
|
||||
if (append) {
|
||||
setData((prevData) => [...prevData, ...(result.data || [])]);
|
||||
} else {
|
||||
setData(result.data || []);
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
const total = result.pagination?.total || result.data?.length || 0;
|
||||
setTotalCount(total);
|
||||
|
||||
// 计算是否还有更多数据
|
||||
const currentTotal = append
|
||||
? data.length + (result.data?.length || 0)
|
||||
: result.data?.length || 0;
|
||||
setHasMore(currentTotal < total);
|
||||
} catch (error) {
|
||||
console.error('[usePagination] 加载数据失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (append) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[loadFunction, data.length]
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载更多数据
|
||||
*/
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
await loadData(nextPage, true);
|
||||
setCurrentPage(nextPage);
|
||||
}, [currentPage, loadData, loadingMore, hasMore]);
|
||||
|
||||
/**
|
||||
* 重置到第一页
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setCurrentPage(1);
|
||||
setData([]);
|
||||
setTotalCount(0);
|
||||
setHasMore(false);
|
||||
loadData(1, false);
|
||||
}, [loadData]);
|
||||
|
||||
// 自动加载第一页
|
||||
useEffect(() => {
|
||||
if (autoLoad) {
|
||||
loadData(1, false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoLoad]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
loadingMore,
|
||||
currentPage,
|
||||
hasMore,
|
||||
totalCount,
|
||||
loadMore,
|
||||
reset,
|
||||
setData,
|
||||
setTotalCount,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,91 @@ import { generatePopularConcepts } from './concept';
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// ==================== 评论内存存储 ====================
|
||||
// 用于在 Mock 环境下持久化评论数据(按 eventId 分组)
|
||||
const commentsStore = new Map();
|
||||
|
||||
/**
|
||||
* 初始化某个事件的 mock 评论列表
|
||||
* @param {string} eventId - 事件 ID
|
||||
* @returns {Array} 初始的 15 条 mock 评论
|
||||
*/
|
||||
const initializeMockComments = (eventId) => {
|
||||
const comments = [];
|
||||
const users = [
|
||||
{ username: '张三', avatar: null },
|
||||
{ username: '李四', avatar: null },
|
||||
{ username: '王五', avatar: null },
|
||||
{ username: '赵六', avatar: null },
|
||||
{ username: '投资达人', avatar: null },
|
||||
{ username: '价值投资者', avatar: null },
|
||||
{ username: '技术分析师', avatar: null },
|
||||
{ username: '基本面研究员', avatar: null },
|
||||
{ username: '量化交易员', avatar: null },
|
||||
{ username: '市场观察者', avatar: null },
|
||||
{ username: '行业分析师', avatar: null },
|
||||
{ username: '财经评论员', avatar: null },
|
||||
{ username: '趋势跟踪者', avatar: null },
|
||||
{ username: '价值发现者', avatar: null },
|
||||
{ username: '理性投资人', avatar: null },
|
||||
];
|
||||
|
||||
const commentTemplates = [
|
||||
'这个事件对相关板块影响很大,值得关注后续发展',
|
||||
'相关概念股已经开始异动了,市场反应很快',
|
||||
'感谢分享,这个事件我之前没注意到',
|
||||
'从基本面来看,这个事件会带来实质性利好',
|
||||
'需要观察后续政策落地情况,现在下结论还太早',
|
||||
'相关产业链的龙头企业值得重点关注',
|
||||
'这类事件一般都是短期刺激,长期影响有限',
|
||||
'建议大家理性对待,不要盲目追高',
|
||||
'这个消息已经在预期之中,股价可能提前反应了',
|
||||
'关键要看后续的执行力度和落地速度',
|
||||
'建议关注产业链上下游的投资机会',
|
||||
'短期可能会有波动,但长期逻辑依然成立',
|
||||
'市场情绪很高涨,需要警惕追高风险',
|
||||
'从历史数据来看,类似事件后续表现都不错',
|
||||
'这是一个结构性机会,需要精选个股',
|
||||
];
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const hoursAgo = Math.floor(Math.random() * 48) + 1; // 1-48 小时前
|
||||
const createdAt = new Date(Date.now() - hoursAgo * 60 * 60 * 1000);
|
||||
const user = users[i % users.length];
|
||||
|
||||
comments.push({
|
||||
id: `comment_${eventId}_${i + 1}`,
|
||||
content: commentTemplates[i % commentTemplates.length],
|
||||
content_type: 'text',
|
||||
author: {
|
||||
id: `user_${i + 1}`,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
created_at: createdAt.toISOString(),
|
||||
likes_count: Math.floor(Math.random() * 20),
|
||||
is_liked: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间升序排序(最旧的在前)
|
||||
comments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
|
||||
return comments;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取或初始化评论列表
|
||||
* @param {string} eventId - 事件 ID
|
||||
* @returns {Array} 评论列表
|
||||
*/
|
||||
const getOrInitComments = (eventId) => {
|
||||
if (!commentsStore.has(eventId)) {
|
||||
commentsStore.set(eventId, initializeMockComments(eventId));
|
||||
}
|
||||
return commentsStore.get(eventId);
|
||||
};
|
||||
|
||||
export const eventHandlers = [
|
||||
// ==================== 事件列表相关 ====================
|
||||
|
||||
@@ -1249,4 +1334,111 @@ export const eventHandlers = [
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 评论相关 ====================
|
||||
|
||||
// 获取事件评论列表
|
||||
http.get('/api/events/:eventId/posts', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { eventId } = params;
|
||||
const url = new URL(request.url);
|
||||
const sort = url.searchParams.get('sort') || 'latest';
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const perPage = parseInt(url.searchParams.get('per_page') || '20');
|
||||
|
||||
console.log('[Mock] 获取评论列表, eventId:', eventId, 'sort:', sort);
|
||||
|
||||
try {
|
||||
// 从内存存储获取评论列表
|
||||
const allComments = getOrInitComments(eventId);
|
||||
|
||||
// ✅ 创建副本并排序(避免直接修改原数组)
|
||||
let sortedComments = [...allComments];
|
||||
if (sort === 'hot') {
|
||||
sortedComments.sort((a, b) => b.likes_count - a.likes_count);
|
||||
} else {
|
||||
// 默认按时间升序(oldest first)- 最旧评论在前,最新在后
|
||||
sortedComments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
}
|
||||
|
||||
// 分页处理(使用排序后的副本)
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedComments = sortedComments.slice(startIndex, endIndex);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: paginatedComments,
|
||||
pagination: {
|
||||
page: page,
|
||||
per_page: perPage,
|
||||
total: allComments.length,
|
||||
pages: Math.ceil(allComments.length / perPage),
|
||||
has_prev: page > 1,
|
||||
has_next: endIndex < allComments.length,
|
||||
},
|
||||
message: '获取评论成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取评论列表失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取评论失败',
|
||||
data: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 发表评论
|
||||
http.post('/api/events/:eventId/posts', async ({ params, request }) => {
|
||||
await delay(500);
|
||||
|
||||
const { eventId } = params;
|
||||
const body = await request.json();
|
||||
|
||||
console.log('[Mock] 发表评论, eventId:', eventId, 'content:', body.content);
|
||||
|
||||
try {
|
||||
// 模拟创建新评论
|
||||
const newComment = {
|
||||
id: `comment_${eventId}_${Date.now()}`,
|
||||
content: body.content,
|
||||
content_type: body.content_type || 'text',
|
||||
author: {
|
||||
id: 'current_user',
|
||||
username: '当前用户',
|
||||
avatar: null,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
likes_count: 0,
|
||||
is_liked: false,
|
||||
};
|
||||
|
||||
// 将新评论添加到内存存储(插入到列表开头)
|
||||
const comments = getOrInitComments(eventId);
|
||||
comments.unshift(newComment);
|
||||
|
||||
console.log('[Mock] 评论已添加到内存存储, 当前评论总数:', comments.length);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: newComment,
|
||||
message: '评论发布成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 发表评论失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '评论发布失败',
|
||||
message: '系统错误,请稍后重试',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
68
src/types/api.ts
Normal file
68
src/types/api.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* API 相关类型定义
|
||||
*
|
||||
* 包含通用的 API 请求/响应类型、错误类型等
|
||||
*/
|
||||
|
||||
/**
|
||||
* API 响应基础结构
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 错误类型
|
||||
*/
|
||||
export interface ApiError {
|
||||
code: number;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应数据
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
pagination: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序参数
|
||||
*/
|
||||
export interface SortParams {
|
||||
field: string;
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤参数
|
||||
*/
|
||||
export interface FilterParams {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用列表查询参数
|
||||
*/
|
||||
export interface ListQueryParams extends Partial<PaginationParams> {
|
||||
sort?: SortParams;
|
||||
filters?: FilterParams;
|
||||
}
|
||||
42
src/types/comment.ts
Normal file
42
src/types/comment.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 评论相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 评论作者信息
|
||||
*/
|
||||
export interface CommentAuthor {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评论数据结构
|
||||
*/
|
||||
export interface Comment {
|
||||
/** 评论 ID */
|
||||
id: string;
|
||||
/** 评论内容 */
|
||||
content: string;
|
||||
/** 内容类型 */
|
||||
content_type: 'text' | 'image' | 'video';
|
||||
/** 作者信息 */
|
||||
author: CommentAuthor;
|
||||
/** 创建时间(ISO 8601 格式) */
|
||||
created_at: string;
|
||||
/** 点赞数 */
|
||||
likes_count: number;
|
||||
/** 当前用户是否已点赞 */
|
||||
is_liked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建评论请求参数
|
||||
*/
|
||||
export interface CreateCommentParams {
|
||||
/** 评论内容 */
|
||||
content: string;
|
||||
/** 内容类型 */
|
||||
content_type?: 'text' | 'image' | 'video';
|
||||
}
|
||||
55
src/types/index.ts
Normal file
55
src/types/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 全局类型定义汇总导出
|
||||
*
|
||||
* 使用方式:
|
||||
* import { StockQuote, UserInfo, ApiResponse } from '@/types';
|
||||
*/
|
||||
|
||||
// API 相关类型
|
||||
export type {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
PaginationParams,
|
||||
PaginatedResponse,
|
||||
SortParams,
|
||||
FilterParams,
|
||||
ListQueryParams,
|
||||
} from './api';
|
||||
|
||||
// 股票相关类型
|
||||
export type {
|
||||
StockInfo,
|
||||
StockQuote,
|
||||
KLineData,
|
||||
StockFinancials,
|
||||
StockPosition,
|
||||
Sector,
|
||||
StockSearchResult,
|
||||
} from './stock';
|
||||
|
||||
// 用户相关类型
|
||||
export type {
|
||||
UserInfo,
|
||||
AuthInfo,
|
||||
LoginParams,
|
||||
RegisterParams,
|
||||
UserPermissions,
|
||||
UserSubscription,
|
||||
UserAccount,
|
||||
UserSettings,
|
||||
} from './user';
|
||||
|
||||
// 分页相关类型
|
||||
export type {
|
||||
LoadFunction,
|
||||
PaginationLoadResult,
|
||||
UsePaginationOptions,
|
||||
UsePaginationResult,
|
||||
} from './pagination';
|
||||
|
||||
// 评论相关类型
|
||||
export type {
|
||||
Comment,
|
||||
CommentAuthor,
|
||||
CreateCommentParams,
|
||||
} from './comment';
|
||||
72
src/types/pagination.ts
Normal file
72
src/types/pagination.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 分页相关类型定义
|
||||
*
|
||||
* 用于 usePagination Hook 和其他分页功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 分页加载函数类型
|
||||
* @template T 数据项类型
|
||||
* @param page 页码(从 1 开始)
|
||||
* @param append 是否追加到已有数据(true: 追加,false: 替换)
|
||||
* @returns Promise,解析为分页响应数据
|
||||
*/
|
||||
export type LoadFunction<T> = (
|
||||
page: number,
|
||||
append: boolean
|
||||
) => Promise<PaginationLoadResult<T>>;
|
||||
|
||||
/**
|
||||
* 分页加载结果
|
||||
* @template T 数据项类型
|
||||
*/
|
||||
export interface PaginationLoadResult<T> {
|
||||
/** 数据列表 */
|
||||
data: T[];
|
||||
/** 分页信息 */
|
||||
pagination?: {
|
||||
/** 总数据量 */
|
||||
total?: number;
|
||||
/** 当前页码 */
|
||||
page?: number;
|
||||
/** 每页数量 */
|
||||
per_page?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* usePagination Hook 配置选项
|
||||
*/
|
||||
export interface UsePaginationOptions {
|
||||
/** 每页数据量,默认 10 */
|
||||
pageSize?: number;
|
||||
/** 是否自动加载第一页,默认 true */
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* usePagination Hook 返回值
|
||||
* @template T 数据项类型
|
||||
*/
|
||||
export interface UsePaginationResult<T> {
|
||||
/** 当前数据列表 */
|
||||
data: T[];
|
||||
/** 是否正在加载第一页 */
|
||||
loading: boolean;
|
||||
/** 是否正在加载更多 */
|
||||
loadingMore: boolean;
|
||||
/** 当前页码 */
|
||||
currentPage: number;
|
||||
/** 是否还有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 总数据量 */
|
||||
totalCount: number;
|
||||
/** 加载更多数据 */
|
||||
loadMore: () => Promise<void>;
|
||||
/** 重置到第一页 */
|
||||
reset: () => void;
|
||||
/** 手动设置数据(用于乐观更新) */
|
||||
setData: React.Dispatch<React.SetStateAction<T[]>>;
|
||||
/** 手动设置总数(用于乐观更新) */
|
||||
setTotalCount: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
103
src/types/stock.ts
Normal file
103
src/types/stock.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 股票相关类型定义
|
||||
*
|
||||
* 包含股票数据、行情、指标等类型
|
||||
*/
|
||||
|
||||
/**
|
||||
* 股票基础信息
|
||||
*/
|
||||
export interface StockInfo {
|
||||
code: string; // 股票代码(如 '600000.SH')
|
||||
name: string; // 股票名称
|
||||
market: 'SH' | 'SZ' | 'BJ'; // 市场(上海/深圳/北京)
|
||||
industry?: string; // 所属行业
|
||||
concept?: string[]; // 所属概念
|
||||
listingDate?: string; // 上市日期
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票行情数据
|
||||
*/
|
||||
export interface StockQuote {
|
||||
code: string; // 股票代码
|
||||
name: string; // 股票名称
|
||||
current: number; // 当前价格
|
||||
open: number; // 开盘价
|
||||
close: number; // 收盘价(前一交易日)
|
||||
high: number; // 最高价
|
||||
low: number; // 最低价
|
||||
volume: number; // 成交量
|
||||
amount: number; // 成交额
|
||||
change: number; // 涨跌额
|
||||
changePercent: number; // 涨跌幅(百分比)
|
||||
turnoverRate?: number; // 换手率
|
||||
timestamp: number; // 时间戳
|
||||
}
|
||||
|
||||
/**
|
||||
* K 线数据(日线/分钟线)
|
||||
*/
|
||||
export interface KLineData {
|
||||
date: string; // 日期(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)
|
||||
open: number; // 开盘价
|
||||
high: number; // 最高价
|
||||
low: number; // 最低价
|
||||
close: number; // 收盘价
|
||||
volume: number; // 成交量
|
||||
amount?: number; // 成交额
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票财务指标
|
||||
*/
|
||||
export interface StockFinancials {
|
||||
code: string; // 股票代码
|
||||
reportDate: string; // 报告期
|
||||
revenue: number; // 营业收入
|
||||
netProfit: number; // 净利润
|
||||
eps: number; // 每股收益(EPS)
|
||||
roe: number; // 净资产收益率(ROE)
|
||||
pe: number; // 市盈率(PE)
|
||||
pb: number; // 市净率(PB)
|
||||
grossProfitMargin?: number; // 毛利率
|
||||
debtToAssetRatio?: number; // 资产负债率
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票持仓
|
||||
*/
|
||||
export interface StockPosition {
|
||||
code: string; // 股票代码
|
||||
name: string; // 股票名称
|
||||
quantity: number; // 持仓数量
|
||||
availableQuantity: number; // 可用数量(T+1)
|
||||
costPrice: number; // 成本价
|
||||
currentPrice: number; // 当前价
|
||||
marketValue: number; // 市值
|
||||
profit: number; // 盈亏金额
|
||||
profitPercent: number; // 盈亏比例
|
||||
}
|
||||
|
||||
/**
|
||||
* 概念/行业板块
|
||||
*/
|
||||
export interface Sector {
|
||||
id: string; // 板块 ID
|
||||
name: string; // 板块名称
|
||||
type: 'industry' | 'concept'; // 类型(行业/概念)
|
||||
changePercent: number; // 板块涨跌幅
|
||||
leadingStock?: string; // 领涨股
|
||||
stockCount?: number; // 成分股数量
|
||||
description?: string; // 描述
|
||||
}
|
||||
|
||||
/**
|
||||
* 股票搜索结果
|
||||
*/
|
||||
export interface StockSearchResult {
|
||||
code: string;
|
||||
name: string;
|
||||
market: string;
|
||||
industry?: string;
|
||||
}
|
||||
108
src/types/user.ts
Normal file
108
src/types/user.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 用户相关类型定义
|
||||
*
|
||||
* 包含用户信息、认证、权限等类型
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户基础信息
|
||||
*/
|
||||
export interface UserInfo {
|
||||
id: string | number; // 用户 ID
|
||||
username: string; // 用户名
|
||||
email?: string; // 邮箱
|
||||
phone?: string; // 手机号
|
||||
avatar?: string; // 头像 URL
|
||||
nickname?: string; // 昵称
|
||||
realName?: string; // 真实姓名
|
||||
gender?: 'male' | 'female' | 'other'; // 性别
|
||||
birthday?: string; // 生日
|
||||
createdAt?: string; // 注册时间
|
||||
updatedAt?: string; // 更新时间
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户认证信息
|
||||
*/
|
||||
export interface AuthInfo {
|
||||
token: string; // 访问令牌
|
||||
refreshToken?: string; // 刷新令牌
|
||||
expiresIn?: number; // 过期时间(秒)
|
||||
tokenType?: string; // 令牌类型(如 'Bearer')
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录请求参数
|
||||
*/
|
||||
export interface LoginParams {
|
||||
username: string; // 用户名或手机号
|
||||
password: string; // 密码
|
||||
captcha?: string; // 验证码
|
||||
remember?: boolean; // 记住登录状态
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册请求参数
|
||||
*/
|
||||
export interface RegisterParams {
|
||||
username: string; // 用户名
|
||||
password: string; // 密码
|
||||
confirmPassword?: string; // 确认密码
|
||||
phone?: string; // 手机号
|
||||
email?: string; // 邮箱
|
||||
verificationCode?: string; // 短信验证码
|
||||
agreeTerm?: boolean; // 是否同意条款
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户权限
|
||||
*/
|
||||
export interface UserPermissions {
|
||||
roles: string[]; // 角色列表
|
||||
permissions: string[]; // 权限列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户订阅信息
|
||||
*/
|
||||
export interface UserSubscription {
|
||||
userId: string | number; // 用户 ID
|
||||
plan: 'free' | 'basic' | 'pro' | 'enterprise'; // 订阅套餐
|
||||
status: 'active' | 'expired' | 'cancelled'; // 状态
|
||||
startDate: string; // 开始日期
|
||||
endDate: string; // 结束日期
|
||||
autoRenew?: boolean; // 是否自动续费
|
||||
features?: string[]; // 可用功能列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户资金账户
|
||||
*/
|
||||
export interface UserAccount {
|
||||
userId: string | number; // 用户 ID
|
||||
balance: number; // 可用余额
|
||||
frozenBalance: number; // 冻结资金
|
||||
totalAssets: number; // 总资产
|
||||
marketValue: number; // 持仓市值
|
||||
profit: number; // 盈亏金额
|
||||
profitPercent: number; // 盈亏比例
|
||||
currency: string; // 币种(默认 'CNY')
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户设置
|
||||
*/
|
||||
export interface UserSettings {
|
||||
userId: string | number; // 用户 ID
|
||||
theme?: 'light' | 'dark'; // 主题
|
||||
language?: 'zh-CN' | 'en-US'; // 语言
|
||||
notifications?: {
|
||||
email: boolean; // 邮件通知
|
||||
sms: boolean; // 短信通知
|
||||
push: boolean; // 推送通知
|
||||
};
|
||||
privacy?: {
|
||||
showProfile: boolean; // 显示个人资料
|
||||
showPositions: boolean; // 显示持仓
|
||||
};
|
||||
}
|
||||
90
src/utils/debounce.ts
Normal file
90
src/utils/debounce.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// src/utils/debounce.ts
|
||||
// 防抖工具函数(TypeScript 版本)
|
||||
|
||||
/**
|
||||
* 防抖函数返回类型
|
||||
* @template T - 原函数类型
|
||||
*/
|
||||
export interface DebouncedFunction<T extends (...args: any[]) => any> {
|
||||
/**
|
||||
* 执行防抖后的函数
|
||||
* @param args - 原函数的参数
|
||||
*/
|
||||
(...args: Parameters<T>): void;
|
||||
|
||||
/**
|
||||
* 取消待执行的函数调用
|
||||
*/
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数 - 延迟执行,短时间内多次调用只执行最后一次
|
||||
*
|
||||
* 工作原理:
|
||||
* 1. 调用防抖函数时,清除之前的定时器
|
||||
* 2. 设置新的定时器,延迟 delay 毫秒后执行
|
||||
* 3. 如果在延迟期间再次调用,重复步骤 1-2
|
||||
* 4. 只有最后一次调用会在延迟后实际执行
|
||||
*
|
||||
* 使用场景:
|
||||
* - 搜索框输入:用户停止输入后才发送请求
|
||||
* - 窗口 resize:窗口调整结束后才重新计算布局
|
||||
* - Socket 事件:短时间内收到多个事件,只处理最后一个
|
||||
*
|
||||
* @template T - 函数类型(泛型约束:任意函数)
|
||||
* @param {T} func - 要防抖的函数
|
||||
* @param {number} delay - 延迟时间(毫秒)
|
||||
* @returns {DebouncedFunction<T>} 防抖后的函数(带 cancel 方法)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 示例 1:无参数函数
|
||||
* const debouncedSave = debounce(() => {
|
||||
* console.log('保存数据');
|
||||
* }, 1000);
|
||||
*
|
||||
* debouncedSave(); // 1秒后执行
|
||||
* debouncedSave(); // 取消上次,重新计时 1 秒
|
||||
* debouncedSave.cancel(); // 取消执行
|
||||
*
|
||||
* // 示例 2:带参数函数
|
||||
* const debouncedSearch = debounce((keyword: string) => {
|
||||
* console.log('搜索:', keyword);
|
||||
* }, 500);
|
||||
*
|
||||
* debouncedSearch('react'); // 500ms 后执行
|
||||
* debouncedSearch('redux'); // 取消上次,重新计时
|
||||
* ```
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): DebouncedFunction<T> {
|
||||
// 使用 NodeJS.Timeout 类型(支持浏览器和 Node 环境)
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 防抖函数主体
|
||||
const debouncedFn = (...args: Parameters<T>): void => {
|
||||
// 清除之前的定时器(防抖核心逻辑)
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
timerId = setTimeout(() => {
|
||||
func(...args);
|
||||
timerId = null; // 执行后重置定时器 ID
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// 添加 cancel 方法(用于组件卸载时清理)
|
||||
debouncedFn.cancel = (): void => {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debouncedFn;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsCard.js
|
||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef, useImperativeHandle } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
@@ -44,8 +44,9 @@ import {
|
||||
selectFourRowEventsWithLoading
|
||||
} from '../../../store/slices/communityDataSlice';
|
||||
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
|
||||
import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
|
||||
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants';
|
||||
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
||||
import { debounce } from '../../../utils/debounce';
|
||||
|
||||
// 🔍 调试:渲染计数器
|
||||
let dynamicNewsCardRenderCount = 0;
|
||||
@@ -84,6 +85,7 @@ const DynamicNewsCard = forwardRef(({
|
||||
// Refs
|
||||
const cardHeaderRef = useRef(null);
|
||||
const cardBodyRef = useRef(null);
|
||||
const virtualizedGridRef = useRef(null); // ⚡ VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
@@ -208,6 +210,124 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
setCurrentMode(mode);
|
||||
}, [mode]);
|
||||
|
||||
/**
|
||||
* ⚡【核心逻辑】执行刷新的回调函数(包含原有的智能刷新逻辑)
|
||||
*
|
||||
* 此函数会被 debounce 包装,避免短时间内频繁刷新
|
||||
*/
|
||||
const executeRefresh = useCallback(() => {
|
||||
const state = {
|
||||
mode,
|
||||
currentPage: pagination?.current_page || 1,
|
||||
};
|
||||
|
||||
console.log('[DynamicNewsCard] ⏰ executeRefresh() 执行(防抖延迟后)', state);
|
||||
|
||||
if (mode === 'vertical') {
|
||||
// ========== 纵向模式 ==========
|
||||
// 只在第1页时刷新,避免打断用户浏览其他页
|
||||
if (state.currentPage === 1) {
|
||||
console.log('[DynamicNewsCard] 纵向模式 + 第1页 → 刷新列表');
|
||||
handlePageChange(1); // 清空缓存并刷新第1页
|
||||
toast({
|
||||
title: '检测到新事件',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
console.log(`[DynamicNewsCard] 纵向模式 + 第${state.currentPage}页 → 不刷新(避免打断用户)`);
|
||||
}
|
||||
} else if (mode === 'four-row') {
|
||||
// ========== 平铺模式 ==========
|
||||
// 检查滚动位置,只有在顶部时才刷新
|
||||
const scrollPos = virtualizedGridRef.current?.getScrollPosition();
|
||||
|
||||
if (scrollPos?.isNearTop) {
|
||||
// 用户在顶部 10% 区域,安全刷新
|
||||
console.log('[DynamicNewsCard] 平铺模式 + 滚动在顶部 → 刷新列表');
|
||||
handlePageChange(1); // 清空并刷新
|
||||
toast({
|
||||
title: '检测到新事件,已刷新',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
// 用户不在顶部,显示提示但不自动刷新
|
||||
console.log('[DynamicNewsCard] 平铺模式 + 滚动不在顶部 → 仅提示,不刷新');
|
||||
toast({
|
||||
title: '有新事件发布',
|
||||
description: '滚动到顶部查看',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [mode, pagination, handlePageChange, toast]);
|
||||
|
||||
/**
|
||||
* ⚡【防抖包装】创建防抖版本的刷新函数
|
||||
*
|
||||
* 使用 useMemo 确保防抖函数在 executeRefresh 不变时保持引用稳定
|
||||
* 防抖延迟:REFRESH_DEBOUNCE_DELAY (2000ms)
|
||||
*
|
||||
* 效果:短时间内收到多个新事件,只执行最后一次刷新
|
||||
*/
|
||||
const debouncedRefresh = useMemo(
|
||||
() => debounce(executeRefresh, REFRESH_DEBOUNCE_DELAY),
|
||||
[executeRefresh]
|
||||
);
|
||||
|
||||
/**
|
||||
* ⚡ 暴露方法给父组件(用于 Socket 自动刷新)
|
||||
*/
|
||||
useImperativeHandle(ref, () => ({
|
||||
/**
|
||||
* 智能刷新方法(带防抖,避免频繁刷新)
|
||||
*
|
||||
* 调用此方法时:
|
||||
* 1. 清除之前的定时器(如果有)
|
||||
* 2. 设置新的定时器(延迟 REFRESH_DEBOUNCE_DELAY 后执行)
|
||||
* 3. 如果在延迟期间再次调用,重复步骤 1-2
|
||||
* 4. 只有最后一次调用会在延迟后实际执行 executeRefresh()
|
||||
*/
|
||||
refresh: () => {
|
||||
console.log('[DynamicNewsCard] 🔔 refresh() 被调用(设置防抖定时器)', {
|
||||
mode,
|
||||
currentPage: pagination?.current_page || 1,
|
||||
debounceDelay: `${REFRESH_DEBOUNCE_DELAY}ms`,
|
||||
});
|
||||
|
||||
// 调用防抖包装后的函数
|
||||
debouncedRefresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前状态(用于调试)
|
||||
*/
|
||||
getState: () => ({
|
||||
mode,
|
||||
currentPage: pagination?.current_page || 1,
|
||||
totalPages: pagination?.total_pages || 1,
|
||||
total: pagination?.total || 0,
|
||||
loading,
|
||||
}),
|
||||
}), [mode, pagination, loading, debouncedRefresh]);
|
||||
|
||||
/**
|
||||
* ⚡【清理逻辑】组件卸载时取消待执行的防抖函数
|
||||
*
|
||||
* 作用:避免组件卸载后仍然执行刷新操作(防止内存泄漏和潜在错误)
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('[DynamicNewsCard] 🧹 组件卸载,取消待执行的防抖刷新');
|
||||
debouncedRefresh.cancel();
|
||||
};
|
||||
}, [debouncedRefresh]);
|
||||
|
||||
// 监听 error 状态,显示空数据提示
|
||||
useEffect(() => {
|
||||
if (error && error.includes('暂无更多数据')) {
|
||||
@@ -578,6 +698,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
hasMore={hasMore}
|
||||
virtualizedGridRef={virtualizedGridRef} // ⚡ 传递 ref 给 VirtualizedFourRowGrid
|
||||
/>
|
||||
</Box>
|
||||
</CardBody>
|
||||
|
||||
@@ -28,6 +28,7 @@ import VerticalModeLayout from './VerticalModeLayout';
|
||||
* @param {boolean} hasMore - 是否还有更多数据
|
||||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
* @param {React.Ref} virtualizedGridRef - VirtualizedFourRowGrid 的 ref(用于获取滚动位置)
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
events,
|
||||
@@ -46,7 +47,8 @@ const EventScrollList = ({
|
||||
mode = 'vertical',
|
||||
hasMore = true,
|
||||
eventFollowStatus = {},
|
||||
onToggleFollow
|
||||
onToggleFollow,
|
||||
virtualizedGridRef
|
||||
}) => {
|
||||
const scrollContainerRef = useRef(null);
|
||||
|
||||
@@ -111,6 +113,7 @@ const EventScrollList = ({
|
||||
>
|
||||
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
||||
<VirtualizedFourRowGrid
|
||||
ref={virtualizedGridRef} // ⚡ 传递 ref(用于获取滚动位置)
|
||||
display={mode === 'four-row' ? 'block' : 'none'}
|
||||
columnsPerRow={4} // 每行显示4列
|
||||
events={displayEvents || events} // 使用累积列表(如果有)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
|
||||
// 虚拟化网格组件(支持多列布局 + 纵向滚动 + 无限滚动)
|
||||
|
||||
import React, { useRef, useMemo, useEffect } from 'react';
|
||||
import React, { useRef, useMemo, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Box, Grid, Spinner, Text, VStack, Center, HStack, IconButton, useBreakpointValue } from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
@@ -25,7 +25,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||||
* @param {boolean} props.loading - 加载状态
|
||||
*/
|
||||
const VirtualizedFourRowGrid = ({
|
||||
const VirtualizedFourRowGrid = forwardRef(({
|
||||
display = 'block',
|
||||
events,
|
||||
columnsPerRow = 4,
|
||||
@@ -42,7 +42,7 @@ const VirtualizedFourRowGrid = ({
|
||||
loading,
|
||||
error, // 新增:错误状态
|
||||
onRetry, // 新增:重试回调
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const parentRef = useRef(null);
|
||||
const isLoadingMore = useRef(false); // 防止重复加载
|
||||
const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖)
|
||||
@@ -81,6 +81,31 @@ const VirtualizedFourRowGrid = ({
|
||||
overscan: 2, // 预加载2行(上下各1行)
|
||||
});
|
||||
|
||||
/**
|
||||
* ⚡ 暴露方法给父组件(用于 Socket 刷新判断)
|
||||
*/
|
||||
useImperativeHandle(ref, () => ({
|
||||
/**
|
||||
* 获取当前滚动位置信息
|
||||
* @returns {Object|null} 滚动位置信息
|
||||
*/
|
||||
getScrollPosition: () => {
|
||||
const scrollElement = parentRef.current;
|
||||
if (!scrollElement) return null;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
const isNearTop = scrollTop < clientHeight * 0.1; // 顶部 10% 区域
|
||||
|
||||
return {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
isNearTop,
|
||||
scrollPercentage: ((scrollTop + clientHeight) / scrollHeight) * 100,
|
||||
};
|
||||
},
|
||||
}), []);
|
||||
|
||||
/**
|
||||
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
|
||||
*
|
||||
@@ -360,6 +385,8 @@ const VirtualizedFourRowGrid = ({
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
VirtualizedFourRowGrid.displayName = 'VirtualizedFourRowGrid';
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
|
||||
@@ -38,3 +38,21 @@ export const TOAST_CONFIG = {
|
||||
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
|
||||
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
|
||||
};
|
||||
|
||||
// ========== Socket 刷新防抖配置 ==========
|
||||
/**
|
||||
* Socket 新事件刷新防抖延迟(毫秒)
|
||||
*
|
||||
* 作用:避免短时间内收到多个新事件时频繁刷新列表
|
||||
*
|
||||
* 场景示例:
|
||||
* - 第 1 秒:收到新事件 → 延迟 2 秒刷新
|
||||
* - 第 2 秒:收到新事件 → 取消上次,重新延迟 2 秒
|
||||
* - 第 3 秒:收到新事件 → 取消上次,重新延迟 2 秒
|
||||
* - 第 5 秒:触发刷新 → 只发送 1 次 API 请求
|
||||
*
|
||||
* 推荐值:2000ms (2 秒)
|
||||
* - 太短(如 500ms)→ 仍可能触发多次刷新
|
||||
* - 太长(如 5000ms)→ 用户感知延迟过高
|
||||
*/
|
||||
export const REFRESH_DEBOUNCE_DELAY = 2000;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
@@ -32,6 +33,7 @@ import TransmissionChainAnalysis from '../../../EventDetail/components/Transmiss
|
||||
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
|
||||
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
|
||||
import EventCommentSection from '../../../../components/EventCommentSection';
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
@@ -414,6 +416,11 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
eventService={eventService}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 讨论区(评论区) - 所有登录用户可用 */}
|
||||
<Box mt={4}>
|
||||
<EventCommentSection eventId={event.id} />
|
||||
</Box>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ const KeywordsCarousel = ({
|
||||
whiteSpace="nowrap"
|
||||
textShadow="0 0 20px rgba(255, 195, 0, 0.3)"
|
||||
>
|
||||
{currentKeyword}
|
||||
{typeof currentKeyword === 'string'
|
||||
? currentKeyword
|
||||
: currentKeyword?.concept || '未知标签'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
@@ -40,6 +40,7 @@ import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme';
|
||||
|
||||
const Community = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation(); // ⚡ 获取当前路由信息(用于判断是否在 /community 页面)
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux状态
|
||||
@@ -71,7 +72,10 @@ const Community = () => {
|
||||
});
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
const { browserPermission, requestBrowserPermission, registerEventUpdateCallback } = useNotification();
|
||||
|
||||
// ⚡ DynamicNewsCard 的 ref(用于触发刷新)
|
||||
const dynamicNewsCardRef = useRef(null);
|
||||
|
||||
// 通知横幅显示状态
|
||||
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
|
||||
@@ -160,6 +164,63 @@ const Community = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
/**
|
||||
* ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. Socket 收到 'new_event' 事件 → NotificationContext 触发所有注册的回调
|
||||
* 2. 本回调被触发 → 检查当前路由是否为 /community
|
||||
* 3. 如果在 /community 页面 → 调用 DynamicNewsCard.refresh() 方法
|
||||
* 4. DynamicNewsCard 根据模式和滚动位置决定是否刷新:
|
||||
* - 纵向模式 + 第1页 → 刷新列表
|
||||
* - 纵向模式 + 其他页 → 不刷新(避免打断用户)
|
||||
* - 平铺模式 + 滚动在顶部 → 刷新列表
|
||||
* - 平铺模式 + 滚动不在顶部 → 仅显示 Toast 提示
|
||||
*
|
||||
* 设计要点:
|
||||
* - 使用 registerEventUpdateCallback 注册回调,返回的函数用于清理
|
||||
* - 路由检查:只在 /community 页面触发刷新
|
||||
* - 智能刷新:由 DynamicNewsCard 根据上下文决定刷新策略
|
||||
* - 自动清理:组件卸载时自动注销回调
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 定义回调函数
|
||||
const handleNewEvent = (eventData) => {
|
||||
console.log('[Community] 🔔 收到新事件通知', {
|
||||
currentPath: location.pathname,
|
||||
eventData,
|
||||
});
|
||||
|
||||
// 检查是否在 /community 页面
|
||||
if (location.pathname === '/community') {
|
||||
console.log('[Community] ✅ 当前在事件中心页面,触发 DynamicNewsCard 刷新');
|
||||
|
||||
// 调用 DynamicNewsCard 的 refresh 方法(智能刷新)
|
||||
if (dynamicNewsCardRef.current) {
|
||||
dynamicNewsCardRef.current.refresh();
|
||||
} else {
|
||||
console.warn('[Community] ⚠️ DynamicNewsCard ref 不可用,无法触发刷新');
|
||||
}
|
||||
} else {
|
||||
console.log('[Community] ⏭️ 当前不在事件中心页面,跳过刷新', {
|
||||
currentPath: location.pathname,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 注册回调(返回清理函数)
|
||||
const unregister = registerEventUpdateCallback(handleNewEvent);
|
||||
console.log('[Community] ✅ 已注册 Socket 事件更新回调');
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
if (unregister) {
|
||||
unregister();
|
||||
console.log('[Community] 🧹 已注销 Socket 事件更新回调');
|
||||
}
|
||||
};
|
||||
}, [location.pathname, registerEventUpdateCallback]); // 依赖路由变化重新注册
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 主内容区域 */}
|
||||
@@ -206,6 +267,7 @@ const Community = () => {
|
||||
|
||||
{/* 实时要闻·动态追踪 - 横向滚动 */}
|
||||
<DynamicNewsCard
|
||||
ref={dynamicNewsCardRef} // ⚡ 传递 ref(用于触发刷新)
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
|
||||
81
tsconfig.json
Normal file
81
tsconfig.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* 基础配置 */
|
||||
"target": "ES2020", // 编译目标(支持现代浏览器)
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"], // 类型库
|
||||
"jsx": "react-jsx", // JSX 编译模式(React 17+ 新 JSX 转换)
|
||||
"module": "ESNext", // 模块系统
|
||||
"moduleResolution": "node", // 模块解析策略
|
||||
|
||||
/* 类型检查 - 推荐模式(渐进式严格化)*/
|
||||
"strict": false, // 关闭全部严格检查(初期)
|
||||
"noImplicitAny": true, // 禁止隐式 any(推荐启用)
|
||||
"strictNullChecks": false, // 暂不启用空值检查(后续开启)
|
||||
"strictFunctionTypes": false, // 暂不启用函数类型严格检查
|
||||
"strictBindCallApply": false, // 暂不启用 bind/call/apply 严格检查
|
||||
"strictPropertyInitialization": false, // 暂不启用属性初始化检查
|
||||
"noImplicitThis": true, // 禁止隐式 this
|
||||
"alwaysStrict": true, // 总是以严格模式解析
|
||||
|
||||
/* 额外检查 */
|
||||
"noUnusedLocals": false, // 初期不检查未使用的局部变量
|
||||
"noUnusedParameters": false, // 初期不检查未使用的参数
|
||||
"noImplicitReturns": false, // 初期不强制所有路径都返回值
|
||||
"noFallthroughCasesInSwitch": true, // 检查 switch 语句穿透
|
||||
|
||||
/* 互操作性 - 支持 JS/TS 共存 */
|
||||
"allowJs": true, // 允许编译 JS 文件
|
||||
"checkJs": false, // 不检查 JS 文件(避免大量错误)
|
||||
"isolatedModules": true, // 确保每个文件都可以独立编译
|
||||
"esModuleInterop": true, // ES 模块互操作性
|
||||
"allowSyntheticDefaultImports": true, // 允许默认导入
|
||||
"skipLibCheck": true, // 跳过库文件类型检查(加快编译)
|
||||
"forceConsistentCasingInFileNames": true, // 强制文件名大小写一致
|
||||
"resolveJsonModule": true, // 支持导入 JSON 文件
|
||||
|
||||
/* 输出配置 */
|
||||
"noEmit": true, // 不生成输出文件(由 Babel/Webpack 处理)
|
||||
"declaration": false, // 不生成类型声明文件
|
||||
"sourceMap": false, // 不生成 source map(由 Webpack 处理)
|
||||
|
||||
/* 路径别名 - 与 craco.config.js 保持一致 */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@assets/*": ["src/assets/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@constants/*": ["src/constants/*"],
|
||||
"@contexts/*": ["src/contexts/*"],
|
||||
"@data/*": ["src/data/*"],
|
||||
"@hooks/*": ["src/hooks/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@lib/*": ["src/lib/*"],
|
||||
"@mocks/*": ["src/mocks/*"],
|
||||
"@providers/*": ["src/providers/*"],
|
||||
"@routes/*": ["src/routes/*"],
|
||||
"@services/*": ["src/services/*"],
|
||||
"@store/*": ["src/store/*"],
|
||||
"@styles/*": ["src/styles/*"],
|
||||
"@theme/*": ["src/theme/*"],
|
||||
"@utils/*": ["src/utils/*"],
|
||||
"@variables/*": ["src/variables/*"],
|
||||
"@views/*": ["src/views/*"]
|
||||
}
|
||||
},
|
||||
|
||||
/* 包含/排除文件 */
|
||||
"include": [
|
||||
"src/**/*", // 包含 src 目录下所有 .ts, .tsx, .js, .jsx 文件
|
||||
"src/**/*.json" // 包含 JSON 文件
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules", // 排除依赖
|
||||
"build", // 排除构建输出
|
||||
"dist", // 排除打包输出
|
||||
"scripts", // 排除脚本
|
||||
"**/*.spec.ts", // 排除测试文件(可选)
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user