first commit
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/node_modules
|
||||||
|
/.env.local
|
||||||
|
/.umirc.local.ts
|
||||||
|
/config/config.local.ts
|
||||||
|
/src/.umi
|
||||||
|
/src/.umi-production
|
||||||
|
/src/.umi-test
|
||||||
|
/dist
|
||||||
|
.swc
|
||||||
44
.umirc.ts
Normal file
44
.umirc.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig } from "umi";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: "index",
|
||||||
|
name: "首页"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/topic/:id",
|
||||||
|
component: "topic/detail",
|
||||||
|
name: "话题详情"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/publish",
|
||||||
|
component: "topic/publish",
|
||||||
|
name: "发布话题"
|
||||||
|
},
|
||||||
|
],
|
||||||
|
npmClient: 'npm',
|
||||||
|
plugins: [
|
||||||
|
'@umijs/plugins/dist/antd',
|
||||||
|
'@umijs/plugins/dist/locale',
|
||||||
|
'@umijs/plugins/dist/layout',
|
||||||
|
],
|
||||||
|
antd: {},
|
||||||
|
theme: {
|
||||||
|
colorPrimary: '#6f8cff',
|
||||||
|
colorPrimaryActive: '#6f8cff',
|
||||||
|
colorPrimaryHover: '#a7bfff',
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
default: 'zh-CN',
|
||||||
|
antd: true,
|
||||||
|
},
|
||||||
|
layout: false,
|
||||||
|
title: 'AutoBeeAgent',
|
||||||
|
favicons: ['/favicon.ico'],
|
||||||
|
metas: [
|
||||||
|
{ name: 'keywords', content: 'AutoBeeAgent,热门话题,讨论,交流' },
|
||||||
|
{ name: 'description', content: 'AutoBeeAgent用户热门话题讨论平台' },
|
||||||
|
],
|
||||||
|
});
|
||||||
20422
package-lock.json
generated
Normal file
20422
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"author": "ltProj <15648664+ltproj@user.noreply.gitee.com>",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "umi dev",
|
||||||
|
"build": "umi build",
|
||||||
|
"postinstall": "umi setup",
|
||||||
|
"setup": "umi setup",
|
||||||
|
"start": "npm run dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.0.2",
|
||||||
|
"@umijs/plugins": "^4.5.0",
|
||||||
|
"antd": "^5.27.4",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
|
"umi": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.33",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"typescript": "^5.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/assets/yay.jpg
Normal file
BIN
src/assets/yay.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
138
src/components/HotTopics/index.less
Normal file
138
src/components/HotTopics/index.less
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
.hotTopicsCard {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.ant-card-head {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
|
||||||
|
.ant-card-head-title {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.fireIcon {
|
||||||
|
color: #ff4d4f;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewAllButton {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
color: #6f8cff;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotTopicsList {
|
||||||
|
.ant-list-item {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotTopicItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicRank {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.rankIcon {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankNumber {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicMeta {
|
||||||
|
width: 45px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.heatScore {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hotTopicsCard {
|
||||||
|
.ant-card-head {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hotTopicsList {
|
||||||
|
.ant-list-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicMeta {
|
||||||
|
.heatScore {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/components/HotTopics/index.tsx
Normal file
80
src/components/HotTopics/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, List, Typography, Button } from 'antd';
|
||||||
|
import { FireOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'umi';
|
||||||
|
import type { Topic } from '../../services/mockData';
|
||||||
|
import styles from './index.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface HotTopicsProps {
|
||||||
|
topics: Topic[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HotTopics: React.FC<HotTopicsProps> = ({ topics, loading = false }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 10000) {
|
||||||
|
return `${(num / 10000).toFixed(1)}w+`;
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHeatScore = (topic: Topic): number => {
|
||||||
|
return topic.likes + topic.views + topic.comments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedTopics = [...topics].sort((a, b) => getHeatScore(b) - getHeatScore(a));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={styles.hotTopicsCard}
|
||||||
|
title={
|
||||||
|
<div className={styles.cardTitle}>
|
||||||
|
<div>
|
||||||
|
<FireOutlined className={styles.fireIcon} />
|
||||||
|
<span>热门话题</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.viewAllButton}>
|
||||||
|
<span>查看全部</span>
|
||||||
|
<span><RightOutlined /></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
className={styles.hotTopicsList}
|
||||||
|
dataSource={sortedTopics}
|
||||||
|
renderItem={(topic, index) => (
|
||||||
|
<List.Item
|
||||||
|
className={styles.hotTopicItem}
|
||||||
|
onClick={() => navigate(`/topic/${topic.id}`)}
|
||||||
|
>
|
||||||
|
<div className={styles.topicRank}>
|
||||||
|
{index === 0 && <FireOutlined className={styles.rankIcon} />}
|
||||||
|
{index > 0 && <span className={styles.rankNumber}>{index + 1}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.topicContent}>
|
||||||
|
<Text
|
||||||
|
className={styles.topicTitle}
|
||||||
|
ellipsis={{ tooltip: topic.title }}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</Text>
|
||||||
|
<div className={styles.topicMeta}>
|
||||||
|
<Text type="secondary" className={styles.heatScore}>
|
||||||
|
{formatNumber(getHeatScore(topic))}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HotTopics;
|
||||||
172
src/components/TopicCard/index.less
Normal file
172
src/components/TopicCard/index.less
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
.topicCard {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-card-actions {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCover {
|
||||||
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryTag {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryTagInline {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favoriteButton {
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #faad14;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #fff7e6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.authorName {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
.ant-space-item {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #6f8cff;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.topicCard {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardCover {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
.header {
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/components/TopicCard/index.tsx
Normal file
160
src/components/TopicCard/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Tag, Avatar, Button, Space, Typography } from 'antd';
|
||||||
|
import { HeartOutlined, HeartFilled, EyeOutlined, MessageOutlined, ShareAltOutlined, StarOutlined, StarFilled } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'umi';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { Topic } from '../../services/mockData';
|
||||||
|
import styles from './index.less';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface TopicCardProps {
|
||||||
|
topic: Topic;
|
||||||
|
onLike?: (topicId: string) => void;
|
||||||
|
onFavorite?: (topicId: string) => void;
|
||||||
|
onShare?: (topicId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopicCard: React.FC<TopicCardProps> = ({
|
||||||
|
topic,
|
||||||
|
onLike,
|
||||||
|
onFavorite,
|
||||||
|
onShare
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
navigate(`/topic/${topic.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLike = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onLike?.(topic.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavorite = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onFavorite?.(topic.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare?.(topic.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 10000) {
|
||||||
|
return `${(num / 10000).toFixed(1)}w+`;
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={styles.topicCard}
|
||||||
|
hoverable
|
||||||
|
onClick={handleCardClick}
|
||||||
|
cover={
|
||||||
|
topic.imageUrl && (
|
||||||
|
<div className={styles.cardCover}>
|
||||||
|
<img src={topic.imageUrl} alt={topic.title} />
|
||||||
|
<div className={styles.categoryTag}>
|
||||||
|
<Tag color="blue">{topic.category}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={topic.isLiked ? <HeartFilled style={{ color: '#ff4d4f' }} /> : <HeartOutlined />}
|
||||||
|
onClick={handleLike}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
{formatNumber(topic.likes)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MessageOutlined />}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
{topic.comments}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
onClick={handleShare}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
{topic.shares}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
{!topic.imageUrl && (
|
||||||
|
<div className={styles.categoryTagInline}>
|
||||||
|
<Tag color="blue">{topic.category}</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
className={styles.title}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={topic.isFavorited ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined />}
|
||||||
|
onClick={handleFavorite}
|
||||||
|
className={styles.favoriteButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
className={styles.description}
|
||||||
|
>
|
||||||
|
{topic.content}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<div className={styles.tags}>
|
||||||
|
{topic.tags.map(tag => (
|
||||||
|
<Tag key={tag} size="small" className={styles.tag}>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<div className={styles.author}>
|
||||||
|
<Avatar
|
||||||
|
src={topic.author.avatar}
|
||||||
|
size="small"
|
||||||
|
icon={<Avatar icon={<Avatar />} />}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" className={styles.authorName}>
|
||||||
|
{topic.author.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.meta}>
|
||||||
|
<Space size="middle">
|
||||||
|
<Space size={4}>
|
||||||
|
<EyeOutlined />
|
||||||
|
<Text type="secondary">{formatNumber(topic.views)}</Text>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary">
|
||||||
|
{dayjs(topic.updatedAt).format('YYYY-MM-DD')}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopicCard;
|
||||||
179
src/layouts/index.less
Normal file
179
src/layouts/index.less
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 40px;
|
||||||
|
|
||||||
|
.logoLink {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6f8cff;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #40a9ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 40px;
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
margin-right: 24px;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #6f8cff;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-right: 40px;
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-suffix {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.userProfile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floatingService {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.serviceButton {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.headerContent {
|
||||||
|
padding: 0 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-right: 16px;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-right: 16px;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
order: 4;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 16px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
order: 3;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floatingService {
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
|
||||||
|
.serviceButton {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/layouts/index.tsx
Normal file
129
src/layouts/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, Outlet, useNavigate } from 'umi';
|
||||||
|
import { Layout as AntLayout, Input, Button, Avatar, Dropdown, Menu, Space, Badge } from 'antd';
|
||||||
|
import { SearchOutlined, UserOutlined, BellOutlined, BookOutlined, MessageOutlined } from '@ant-design/icons';
|
||||||
|
import { userApi } from '../services/api';
|
||||||
|
import type { User } from '../services/mockData';
|
||||||
|
import styles from './index.less';
|
||||||
|
|
||||||
|
const { Header, Content } = AntLayout;
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 获取用户信息
|
||||||
|
userApi.getCurrentUser().then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
setUser(res.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (searchValue.trim()) {
|
||||||
|
navigate(`/?search=${encodeURIComponent(searchValue)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMenu = (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Item key="profile">
|
||||||
|
<UserOutlined /> 个人中心
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item key="settings">
|
||||||
|
设置
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item key="logout">
|
||||||
|
退出登录
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntLayout className={styles.layout}>
|
||||||
|
<Header className={styles.header}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
{/* Logo区域 */}
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<Link to="/" className={styles.logoLink}>
|
||||||
|
AutoBeeAgent
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className={styles.search}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索热门话题..."
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
// suffix={
|
||||||
|
// <Button
|
||||||
|
// type="primary"
|
||||||
|
// size="small"
|
||||||
|
// onClick={handleSearch}
|
||||||
|
// loading={loading}
|
||||||
|
// >
|
||||||
|
// AI搜索
|
||||||
|
// </Button>
|
||||||
|
// }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户信息 */}
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<Space size="middle">
|
||||||
|
<Button type="text" icon={<BookOutlined />}>
|
||||||
|
学习
|
||||||
|
</Button>
|
||||||
|
<Badge count={0}>
|
||||||
|
<Button type="text" icon={<BellOutlined />}>
|
||||||
|
消息
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
{user ? (
|
||||||
|
<Dropdown overlay={userMenu} placement="bottomRight">
|
||||||
|
<div className={styles.userProfile}>
|
||||||
|
<Avatar src={user.avatar} icon={<UserOutlined />} />
|
||||||
|
<span className={styles.userName}>{user.name}</span>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" onClick={() => navigate('/login')}>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Content className={styles.content}>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{/* 浮动客服按钮 */}
|
||||||
|
<div className={styles.floatingService}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<MessageOutlined />}
|
||||||
|
size="large"
|
||||||
|
className={styles.serviceButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AntLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/pages/docs.tsx
Normal file
9
src/pages/docs.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const DocsPage = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>This is umi docs.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocsPage;
|
||||||
180
src/pages/index.less
Normal file
180
src/pages/index.less
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
.homePage {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
background: linear-gradient(90deg, #a7bfff, #6f8cff);
|
||||||
|
color: white;
|
||||||
|
padding: 40px 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.bannerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerText {
|
||||||
|
h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerActions {
|
||||||
|
.publishButton {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicsSection {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.ant-select {
|
||||||
|
.ant-select-selector {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicsList {
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.banner {
|
||||||
|
padding: 24px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.bannerContent {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerText {
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerActions {
|
||||||
|
.publishButton {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicsSection {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.ant-select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.sectionHeader {
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.ant-select,
|
||||||
|
.ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
255
src/pages/index.tsx
Normal file
255
src/pages/index.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Row, Col, Select, Button, Input, Spin, Empty, message } from 'antd';
|
||||||
|
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, useSearchParams } from 'umi';
|
||||||
|
import { topicApi, configApi } from '../services/api';
|
||||||
|
import type { Topic } from '../services/mockData';
|
||||||
|
import TopicCard from '../components/TopicCard';
|
||||||
|
import HotTopics from '../components/HotTopics';
|
||||||
|
import styles from './index.less';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
|
const [hotTopics, setHotTopics] = useState<Topic[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hotTopicsLoading, setHotTopicsLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<Array<{label: string, value: string}>>([]);
|
||||||
|
const [sortOptions, setSortOptions] = useState<Array<{label: string, value: string}>>([]);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
category: 'all',
|
||||||
|
sort: 'hot',
|
||||||
|
search: searchParams.get('search') || '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取分类和排序选项
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
configApi.getCategories(),
|
||||||
|
configApi.getSortOptions()
|
||||||
|
]).then(([categoriesRes, sortRes]) => {
|
||||||
|
if (categoriesRes.success) {
|
||||||
|
setCategories(categoriesRes.data);
|
||||||
|
}
|
||||||
|
if (sortRes.success) {
|
||||||
|
setSortOptions(sortRes.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取话题列表
|
||||||
|
const fetchTopics = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await topicApi.getTopics(filters);
|
||||||
|
if (response.success) {
|
||||||
|
setTopics(response.data.list);
|
||||||
|
} else {
|
||||||
|
message.error('获取话题列表失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('网络错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取热门话题
|
||||||
|
const fetchHotTopics = async () => {
|
||||||
|
setHotTopicsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await topicApi.getHotTopics(5);
|
||||||
|
if (response.success) {
|
||||||
|
setHotTopics(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取热门话题失败:', error);
|
||||||
|
} finally {
|
||||||
|
setHotTopicsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTopics();
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHotTopics();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCategoryChange = (value: string) => {
|
||||||
|
setFilters(prev => ({ ...prev, category: value, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (value: string) => {
|
||||||
|
setFilters(prev => ({ ...prev, sort: value, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setFilters(prev => ({ ...prev, search: value, page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLike = async (topicId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await topicApi.likeTopic(topicId);
|
||||||
|
if (response.success) {
|
||||||
|
// 更新本地状态
|
||||||
|
setTopics(prev => prev.map(topic =>
|
||||||
|
topic.id === topicId
|
||||||
|
? { ...topic, isLiked: response.data.liked, likes: response.data.likes }
|
||||||
|
: topic
|
||||||
|
));
|
||||||
|
setHotTopics(prev => prev.map(topic =>
|
||||||
|
topic.id === topicId
|
||||||
|
? { ...topic, isLiked: response.data.liked, likes: response.data.likes }
|
||||||
|
: topic
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavorite = async (topicId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await topicApi.favoriteTopic(topicId);
|
||||||
|
if (response.success) {
|
||||||
|
// 更新本地状态
|
||||||
|
setTopics(prev => prev.map(topic =>
|
||||||
|
topic.id === topicId
|
||||||
|
? { ...topic, isFavorited: response.data.favorited }
|
||||||
|
: topic
|
||||||
|
));
|
||||||
|
setHotTopics(prev => prev.map(topic =>
|
||||||
|
topic.id === topicId
|
||||||
|
? { ...topic, isFavorited: response.data.favorited }
|
||||||
|
: topic
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = (topicId: string) => {
|
||||||
|
// 实现分享功能
|
||||||
|
message.success('分享链接已复制到剪贴板');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchTopics();
|
||||||
|
fetchHotTopics();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublishTopic = () => {
|
||||||
|
navigate('/publish');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.homePage}>
|
||||||
|
{/* 热门话题横幅 */}
|
||||||
|
<div className={styles.banner}>
|
||||||
|
<div className={styles.bannerContent}>
|
||||||
|
<div className={styles.bannerText}>
|
||||||
|
<h2>热门话题</h2>
|
||||||
|
<p>新版块火热上线,商家都在关注的热点问题看这里!</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.bannerActions}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handlePublishTopic}
|
||||||
|
className={styles.publishButton}
|
||||||
|
>
|
||||||
|
发布话题
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={24}>
|
||||||
|
{/* 左侧话题列表 */}
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<div className={styles.topicsSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h3>热门话题</h3>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<Select
|
||||||
|
value={filters.category}
|
||||||
|
onChange={handleCategoryChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
{categories.map(category => (
|
||||||
|
<Option key={category.value} value={category.value}>
|
||||||
|
{category.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filters.sort}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
{sortOptions.map(option => (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.topicsList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : topics.length > 0 ? (
|
||||||
|
topics.map(topic => (
|
||||||
|
<TopicCard
|
||||||
|
key={topic.id}
|
||||||
|
topic={topic}
|
||||||
|
onLike={handleLike}
|
||||||
|
onFavorite={handleFavorite}
|
||||||
|
onShare={handleShare}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description="暂无话题数据"
|
||||||
|
className={styles.empty}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 右侧热门话题排行 */}
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<HotTopics
|
||||||
|
topics={hotTopics}
|
||||||
|
loading={hotTopicsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
src/pages/topic/detail.less
Normal file
297
src/pages/topic/detail.less
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
.detailPage {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notFound {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicDetail {
|
||||||
|
.topicBanner {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.1));
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.bannerActions {
|
||||||
|
.favoriteButton {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
color: #faad14;
|
||||||
|
border-radius: 20px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: white;
|
||||||
|
color: #faad14;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicContent {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.topicHeader {
|
||||||
|
.categoryTag {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.category {
|
||||||
|
display: inline-block;
|
||||||
|
background: #6f8cff;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTitle {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicMeta {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicBody {
|
||||||
|
.topicDescription {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicTags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicActions {
|
||||||
|
.actionButton {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
color: #6f8cff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsSection {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.commentForm {
|
||||||
|
.commentInput {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsList {
|
||||||
|
.commentItem {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentHeader {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentContent {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentActions {
|
||||||
|
.ant-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyComments {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.detailPage {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicDetail {
|
||||||
|
.topicBanner {
|
||||||
|
height: 200px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.bannerOverlay {
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.bannerActions {
|
||||||
|
.favoriteButton {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicContent {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.topicHeader {
|
||||||
|
.topicTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicMeta {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicBody {
|
||||||
|
.topicDescription {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsSection {
|
||||||
|
.commentForm {
|
||||||
|
.commentInput {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsList {
|
||||||
|
.commentItem {
|
||||||
|
padding: 12px 0;
|
||||||
|
|
||||||
|
.commentHeader {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentContent {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.topicDetail {
|
||||||
|
.topicBanner {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicContent {
|
||||||
|
.topicHeader {
|
||||||
|
.topicTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topicBody {
|
||||||
|
.topicDescription {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
367
src/pages/topic/detail.tsx
Normal file
367
src/pages/topic/detail.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Row, Col, Card, Typography, Button, Avatar, Space, Divider, Input, message, Spin } from 'antd';
|
||||||
|
import { HeartOutlined, HeartFilled, StarOutlined, StarFilled, ShareAltOutlined, ArrowLeftOutlined, EyeOutlined, MessageOutlined } from '@ant-design/icons';
|
||||||
|
import { useParams, useNavigate } from 'umi';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { topicApi, commentApi } from '../../services/api';
|
||||||
|
import type { Topic, Comment } from '../../services/mockData';
|
||||||
|
import HotTopics from '../../components/HotTopics';
|
||||||
|
import styles from './detail.less';
|
||||||
|
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
export default function TopicDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [topic, setTopic] = useState<Topic | null>(null);
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [commentsLoading, setCommentsLoading] = useState(true);
|
||||||
|
const [hotTopics, setHotTopics] = useState<Topic[]>([]);
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [submittingComment, setSubmittingComment] = useState(false);
|
||||||
|
|
||||||
|
// 获取话题详情
|
||||||
|
const fetchTopicDetail = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await topicApi.getTopicDetail(id);
|
||||||
|
if (response.success) {
|
||||||
|
setTopic(response.data);
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '话题不存在');
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取话题详情失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取评论列表
|
||||||
|
const fetchComments = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setCommentsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await topicApi.getTopicComments(id);
|
||||||
|
if (response.success) {
|
||||||
|
setComments(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取评论失败:', error);
|
||||||
|
} finally {
|
||||||
|
setCommentsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取热门话题
|
||||||
|
const fetchHotTopics = async () => {
|
||||||
|
try {
|
||||||
|
const response = await topicApi.getHotTopics(5);
|
||||||
|
if (response.success) {
|
||||||
|
setHotTopics(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取热门话题失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTopicDetail();
|
||||||
|
fetchComments();
|
||||||
|
fetchHotTopics();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleLike = async () => {
|
||||||
|
if (!topic) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await topicApi.likeTopic(topic.id);
|
||||||
|
if (response.success) {
|
||||||
|
setTopic(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
isLiked: response.data.liked,
|
||||||
|
likes: response.data.likes
|
||||||
|
} : null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavorite = async () => {
|
||||||
|
if (!topic) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await topicApi.favoriteTopic(topic.id);
|
||||||
|
if (response.success) {
|
||||||
|
setTopic(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
isFavorited: response.data.favorited
|
||||||
|
} : null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
message.success('分享链接已复制到剪贴板');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitComment = async () => {
|
||||||
|
if (!commentText.trim() || !topic) return;
|
||||||
|
|
||||||
|
setSubmittingComment(true);
|
||||||
|
try {
|
||||||
|
const response = await commentApi.publishComment(topic.id, commentText);
|
||||||
|
if (response.success) {
|
||||||
|
setComments(prev => [response.data, ...prev]);
|
||||||
|
setTopic(prev => prev ? { ...prev, comments: prev.comments + 1 } : null);
|
||||||
|
setCommentText('');
|
||||||
|
message.success('评论发布成功');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('发布评论失败');
|
||||||
|
} finally {
|
||||||
|
setSubmittingComment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLikeComment = async (commentId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await commentApi.likeComment(commentId);
|
||||||
|
if (response.success) {
|
||||||
|
setComments(prev => prev.map(comment =>
|
||||||
|
comment.id === commentId
|
||||||
|
? { ...comment, isLiked: response.data.liked, likes: response.data.likes }
|
||||||
|
: comment
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 10000) {
|
||||||
|
return `${(num / 10000).toFixed(1)}w+`;
|
||||||
|
}
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!topic) {
|
||||||
|
return (
|
||||||
|
<div className={styles.notFound}>
|
||||||
|
<Title level={3}>话题不存在</Title>
|
||||||
|
<Button onClick={() => navigate('/')}>返回首页</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.detailPage}>
|
||||||
|
{/* 返回按钮 */}
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className={styles.backButton}
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Row gutter={24}>
|
||||||
|
{/* 左侧话题详情 */}
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<div className={styles.topicDetail}>
|
||||||
|
{/* 话题横幅 */}
|
||||||
|
{topic.imageUrl && (
|
||||||
|
<div className={styles.topicBanner}>
|
||||||
|
<img src={topic.imageUrl} alt={topic.title} />
|
||||||
|
<div className={styles.bannerOverlay}>
|
||||||
|
<div className={styles.bannerActions}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={topic.isFavorited ? <StarFilled /> : <StarOutlined />}
|
||||||
|
onClick={handleFavorite}
|
||||||
|
className={styles.favoriteButton}
|
||||||
|
>
|
||||||
|
{topic.isFavorited ? '已收藏' : '收藏'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 话题内容 */}
|
||||||
|
<Card className={styles.topicContent}>
|
||||||
|
<div className={styles.topicHeader}>
|
||||||
|
<div className={styles.categoryTag}>
|
||||||
|
<span className={styles.category}>{topic.category}</span>
|
||||||
|
</div>
|
||||||
|
<Title level={2} className={styles.topicTitle}>
|
||||||
|
{topic.title}
|
||||||
|
</Title>
|
||||||
|
<div className={styles.topicMeta}>
|
||||||
|
<Space size="large">
|
||||||
|
<Space size={4}>
|
||||||
|
<Avatar src={topic.author.avatar} size="small" />
|
||||||
|
<Text strong>{topic.author.name}</Text>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary">
|
||||||
|
{dayjs(topic.createdAt).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</Text>
|
||||||
|
<Space size={4}>
|
||||||
|
<EyeOutlined />
|
||||||
|
<Text type="secondary">{formatNumber(topic.views)}</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div className={styles.topicBody}>
|
||||||
|
<Paragraph className={styles.topicDescription}>
|
||||||
|
{topic.content}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{topic.tags.length > 0 && (
|
||||||
|
<div className={styles.topicTags}>
|
||||||
|
{topic.tags.map(tag => (
|
||||||
|
<span key={tag} className={styles.tag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div className={styles.topicActions}>
|
||||||
|
<Space size="large">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={topic.isLiked ? <HeartFilled style={{ color: '#ff4d4f' }} /> : <HeartOutlined />}
|
||||||
|
onClick={handleLike}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
{formatNumber(topic.likes)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MessageOutlined />}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
{topic.comments}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
onClick={handleShare}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
{topic.shares}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 评论区域 */}
|
||||||
|
<Card className={styles.commentsSection} title="评论">
|
||||||
|
{/* 发表评论 */}
|
||||||
|
<div className={styles.commentForm}>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="发表你的看法..."
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
className={styles.commentInput}
|
||||||
|
/>
|
||||||
|
<div className={styles.commentActions}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
loading={submittingComment}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
>
|
||||||
|
发表评论
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 评论列表 */}
|
||||||
|
<div className={styles.commentsList}>
|
||||||
|
{commentsLoading ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : comments.length > 0 ? (
|
||||||
|
comments.map(comment => (
|
||||||
|
<div key={comment.id} className={styles.commentItem}>
|
||||||
|
<div className={styles.commentHeader}>
|
||||||
|
<Space>
|
||||||
|
<Avatar src={comment.author.avatar} size="small" />
|
||||||
|
<Text strong>{comment.author.name}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
{dayjs(comment.createdAt).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className={styles.commentContent}>
|
||||||
|
<Paragraph>{comment.content}</Paragraph>
|
||||||
|
</div>
|
||||||
|
<div className={styles.commentActions}>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={comment.isLiked ? <HeartFilled style={{ color: '#ff4d4f' }} /> : <HeartOutlined />}
|
||||||
|
onClick={() => handleLikeComment(comment.id)}
|
||||||
|
>
|
||||||
|
{comment.likes}
|
||||||
|
</Button>
|
||||||
|
<Button type="text" size="small">
|
||||||
|
回复
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyComments}>
|
||||||
|
<Text type="secondary">暂无评论,快来抢沙发吧!</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 右侧相关话题 */}
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<HotTopics topics={hotTopics} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/pages/topic/publish.less
Normal file
176
src/pages/topic/publish.less
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
.publishPage {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.publishCard {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.publishForm {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.ant-form-item-label > label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleInput {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categorySelect {
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
.ant-select-selector {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentInput {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagsInput {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadComponent {
|
||||||
|
.ant-upload-select-picture-card {
|
||||||
|
width: 200px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #6f8cff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadPlaceholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #d9d9d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadText {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitSection {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 32px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 32px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.publishPage {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.publishCard {
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.publishForm {
|
||||||
|
.uploadComponent {
|
||||||
|
.ant-upload-select-picture-card {
|
||||||
|
width: 150px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitSection {
|
||||||
|
.submitButton,
|
||||||
|
.cancelButton {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.publishForm {
|
||||||
|
.uploadComponent {
|
||||||
|
.ant-upload-select-picture-card {
|
||||||
|
width: 120px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/pages/topic/publish.tsx
Normal file
195
src/pages/topic/publish.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Form, Input, Select, Button, Upload, message, Space, Typography } from 'antd';
|
||||||
|
import { PlusOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'umi';
|
||||||
|
import { topicApi, configApi } from '../../services/api';
|
||||||
|
import type { Topic } from '../../services/mockData';
|
||||||
|
import styles from './publish.less';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
export default function PublishTopicPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<Array<{label: string, value: string}>>([]);
|
||||||
|
const [imageUrl, setImageUrl] = useState<string>('');
|
||||||
|
|
||||||
|
// 获取分类选项
|
||||||
|
useEffect(() => {
|
||||||
|
configApi.getCategories().then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
setCategories(res.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const topicData: Partial<Topic> = {
|
||||||
|
title: values.title,
|
||||||
|
content: values.content,
|
||||||
|
category: values.category,
|
||||||
|
tags: values.tags ? values.tags.split(',').map((tag: string) => tag.trim()) : [],
|
||||||
|
imageUrl: imageUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await topicApi.publishTopic(topicData);
|
||||||
|
if (response.success) {
|
||||||
|
message.success('话题发布成功!');
|
||||||
|
navigate(`/topic/${response.data.id}`);
|
||||||
|
} else {
|
||||||
|
message.error('发布失败,请重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('发布失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = (info: any) => {
|
||||||
|
if (info.file.status === 'done') {
|
||||||
|
// 模拟上传成功,实际项目中需要调用上传API
|
||||||
|
const mockUrl = `https://picsum.photos/400/200?random=${Date.now()}`;
|
||||||
|
setImageUrl(mockUrl);
|
||||||
|
message.success('图片上传成功');
|
||||||
|
} else if (info.file.status === 'error') {
|
||||||
|
message.error('图片上传失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.publishPage}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={handleCancel}
|
||||||
|
className={styles.backButton}
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
<Title level={2}>发布话题</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className={styles.publishCard}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
className={styles.publishForm}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="话题标题"
|
||||||
|
name="title"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入话题标题' },
|
||||||
|
{ max: 100, message: '标题不能超过100个字符' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入话题标题"
|
||||||
|
size="large"
|
||||||
|
className={styles.titleInput}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="话题分类"
|
||||||
|
name="category"
|
||||||
|
rules={[{ required: true, message: '请选择话题分类' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择话题分类"
|
||||||
|
size="large"
|
||||||
|
className={styles.categorySelect}
|
||||||
|
>
|
||||||
|
{categories.map(category => (
|
||||||
|
<Option key={category.value} value={category.value}>
|
||||||
|
{category.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="话题内容"
|
||||||
|
name="content"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入话题内容' },
|
||||||
|
{ min: 10, message: '内容至少需要10个字符' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={8}
|
||||||
|
placeholder="请详细描述你的话题内容..."
|
||||||
|
className={styles.contentInput}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="话题标签"
|
||||||
|
name="tags"
|
||||||
|
extra="多个标签请用逗号分隔"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:AI,智能客服,产品发布"
|
||||||
|
className={styles.tagsInput}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="话题封面"
|
||||||
|
name="cover"
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
listType="picture-card"
|
||||||
|
showUploadList={false}
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
beforeUpload={() => false} // 阻止自动上传,这里只是模拟
|
||||||
|
className={styles.uploadComponent}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt="cover" className={styles.uploadImage} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.uploadPlaceholder}>
|
||||||
|
<PlusOutlined />
|
||||||
|
<div className={styles.uploadText}>上传封面</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Upload>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item className={styles.submitSection}>
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
发布话题
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancel}
|
||||||
|
size="large"
|
||||||
|
className={styles.cancelButton}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/services/api.ts
Normal file
272
src/services/api.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { mockTopics, mockComments, mockUser, categories, sortOptions, Topic, Comment, User } from './mockData';
|
||||||
|
|
||||||
|
// API响应类型
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
list: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟网络延迟
|
||||||
|
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// 话题相关API
|
||||||
|
export const topicApi = {
|
||||||
|
// 获取话题列表
|
||||||
|
async getTopics(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
category?: string;
|
||||||
|
sort?: string;
|
||||||
|
search?: string;
|
||||||
|
} = {}): Promise<ApiResponse<PaginatedResponse<Topic>>> {
|
||||||
|
await delay(500); // 模拟网络请求
|
||||||
|
|
||||||
|
let filteredTopics = [...mockTopics];
|
||||||
|
|
||||||
|
// 分类筛选
|
||||||
|
if (params.category && params.category !== 'all') {
|
||||||
|
filteredTopics = filteredTopics.filter(topic => topic.category === params.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索筛选
|
||||||
|
if (params.search) {
|
||||||
|
const searchLower = params.search.toLowerCase();
|
||||||
|
filteredTopics = filteredTopics.filter(topic =>
|
||||||
|
topic.title.toLowerCase().includes(searchLower) ||
|
||||||
|
topic.content.toLowerCase().includes(searchLower) ||
|
||||||
|
topic.tags.some(tag => tag.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序
|
||||||
|
if (params.sort) {
|
||||||
|
switch (params.sort) {
|
||||||
|
case 'hot':
|
||||||
|
filteredTopics.sort((a, b) => (b.likes + b.views + b.comments) - (a.likes + a.views + a.comments));
|
||||||
|
break;
|
||||||
|
case 'latest':
|
||||||
|
filteredTopics.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
break;
|
||||||
|
case 'comments':
|
||||||
|
filteredTopics.sort((a, b) => b.comments - a.comments);
|
||||||
|
break;
|
||||||
|
case 'likes':
|
||||||
|
filteredTopics.sort((a, b) => b.likes - a.likes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = params.page || 1;
|
||||||
|
const pageSize = params.pageSize || 10;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const end = start + pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list: filteredTopics.slice(start, end),
|
||||||
|
total: filteredTopics.length,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取热门话题排行
|
||||||
|
async getHotTopics(limit: number = 5): Promise<ApiResponse<Topic[]>> {
|
||||||
|
await delay(300);
|
||||||
|
|
||||||
|
const hotTopics = [...mockTopics]
|
||||||
|
.sort((a, b) => (b.likes + b.views + b.comments) - (a.likes + a.views + a.comments))
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: hotTopics
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取话题详情
|
||||||
|
async getTopicDetail(id: string): Promise<ApiResponse<Topic>> {
|
||||||
|
await delay(400);
|
||||||
|
|
||||||
|
const topic = mockTopics.find(t => t.id === id);
|
||||||
|
if (!topic) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null as any,
|
||||||
|
message: '话题不存在'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: topic
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取话题评论
|
||||||
|
async getTopicComments(topicId: string): Promise<ApiResponse<Comment[]>> {
|
||||||
|
await delay(300);
|
||||||
|
|
||||||
|
// 模拟根据话题ID返回评论
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: mockComments
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点赞话题
|
||||||
|
async likeTopic(id: string): Promise<ApiResponse<{ liked: boolean; likes: number }>> {
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
const topic = mockTopics.find(t => t.id === id);
|
||||||
|
if (topic) {
|
||||||
|
topic.isLiked = !topic.isLiked;
|
||||||
|
topic.likes += topic.isLiked ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
liked: topic?.isLiked || false,
|
||||||
|
likes: topic?.likes || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 收藏话题
|
||||||
|
async favoriteTopic(id: string): Promise<ApiResponse<{ favorited: boolean }>> {
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
const topic = mockTopics.find(t => t.id === id);
|
||||||
|
if (topic) {
|
||||||
|
topic.isFavorited = !topic.isFavorited;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
favorited: topic?.isFavorited || false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 发布话题
|
||||||
|
async publishTopic(topic: Partial<Topic>): Promise<ApiResponse<Topic>> {
|
||||||
|
await delay(1000);
|
||||||
|
|
||||||
|
const newTopic: Topic = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: topic.title || '',
|
||||||
|
content: topic.content || '',
|
||||||
|
author: topic.author || mockUser,
|
||||||
|
category: topic.category || '其他',
|
||||||
|
tags: topic.tags || [],
|
||||||
|
imageUrl: topic.imageUrl,
|
||||||
|
likes: 0,
|
||||||
|
views: 0,
|
||||||
|
comments: 0,
|
||||||
|
shares: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTopics.unshift(newTopic);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: newTopic
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 评论相关API
|
||||||
|
export const commentApi = {
|
||||||
|
// 发布评论
|
||||||
|
async publishComment(topicId: string, content: string, parentId?: string): Promise<ApiResponse<Comment>> {
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
const newComment: Comment = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
content,
|
||||||
|
author: mockUser,
|
||||||
|
likes: 0,
|
||||||
|
replies: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
isLiked: false
|
||||||
|
};
|
||||||
|
|
||||||
|
mockComments.unshift(newComment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: newComment
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 点赞评论
|
||||||
|
async likeComment(id: string): Promise<ApiResponse<{ liked: boolean; likes: number }>> {
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
const comment = mockComments.find(c => c.id === id);
|
||||||
|
if (comment) {
|
||||||
|
comment.isLiked = !comment.isLiked;
|
||||||
|
comment.likes += comment.isLiked ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
liked: comment?.isLiked || false,
|
||||||
|
likes: comment?.likes || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用户相关API
|
||||||
|
export const userApi = {
|
||||||
|
// 获取当前用户信息
|
||||||
|
async getCurrentUser(): Promise<ApiResponse<User>> {
|
||||||
|
await delay(200);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: mockUser
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 配置相关API
|
||||||
|
export const configApi = {
|
||||||
|
// 获取分类选项
|
||||||
|
async getCategories(): Promise<ApiResponse<typeof categories>> {
|
||||||
|
await delay(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: categories
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取排序选项
|
||||||
|
async getSortOptions(): Promise<ApiResponse<typeof sortOptions>> {
|
||||||
|
await delay(100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: sortOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
257
src/services/mockData.ts
Normal file
257
src/services/mockData.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
// Mock数据定义
|
||||||
|
export interface Topic {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
imageUrl?: string;
|
||||||
|
likes: number;
|
||||||
|
views: number;
|
||||||
|
comments: number;
|
||||||
|
shares: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
isLiked?: boolean;
|
||||||
|
isFavorited?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
author: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
likes: number;
|
||||||
|
replies: Comment[];
|
||||||
|
createdAt: string;
|
||||||
|
isLiked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock数据
|
||||||
|
export const mockTopics: Topic[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'AutoBeeAgent使用规则详解:如何正确配置和使用智能客服',
|
||||||
|
content: 'AutoBeeAgent作为AI驱动的智能客服解决方案,在使用过程中需要遵循特定的规则和最佳实践。本指南详细介绍了Agent配置、对话流程设计、知识库管理、性能优化等核心使用规则,帮助用户充分发挥AutoBeeAgent的智能化能力。',
|
||||||
|
author: {
|
||||||
|
id: 'user1',
|
||||||
|
name: 'AutoBee专家',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=1'
|
||||||
|
},
|
||||||
|
category: '使用规则',
|
||||||
|
tags: ['AutoBeeAgent', '使用规则', '配置指南'],
|
||||||
|
imageUrl: 'https://picsum.photos/400/200?random=1',
|
||||||
|
likes: 12000,
|
||||||
|
views: 15000,
|
||||||
|
comments: 45,
|
||||||
|
shares: 120,
|
||||||
|
createdAt: '2025-09-05T10:00:00Z',
|
||||||
|
updatedAt: '2025-09-05T10:00:00Z',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'RPA机器人流程自动化入门:从零开始学习RPA技术',
|
||||||
|
content: 'RPA(Robotic Process Automation)机器人流程自动化是当前企业数字化转型的重要技术。本文从基础概念讲起,详细介绍了RPA的工作原理、应用场景、主流工具选择以及实施策略。无论你是技术新手还是有经验的开发者,都能从中获得实用的RPA知识。',
|
||||||
|
author: {
|
||||||
|
id: 'user2',
|
||||||
|
name: 'RPA导师',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=2'
|
||||||
|
},
|
||||||
|
category: 'RPA知识',
|
||||||
|
tags: ['RPA', '流程自动化', '入门教程'],
|
||||||
|
imageUrl: 'https://picsum.photos/400/200?random=2',
|
||||||
|
likes: 8500,
|
||||||
|
views: 12000,
|
||||||
|
comments: 23,
|
||||||
|
shares: 56,
|
||||||
|
createdAt: '2025-09-09T14:30:00Z',
|
||||||
|
updatedAt: '2025-09-09T14:30:00Z',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'AutoBeeAgent最佳实践:如何设计高效的对话流程',
|
||||||
|
content: '对话流程设计是AutoBeeAgent成功应用的关键。本指南分享了对话流程设计的最佳实践,包括意图识别优化、上下文管理、异常处理、用户引导等核心技巧。通过合理的流程设计,可以显著提升用户满意度和问题解决率。',
|
||||||
|
author: {
|
||||||
|
id: 'user3',
|
||||||
|
name: '对话设计师',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=3'
|
||||||
|
},
|
||||||
|
category: '使用规则',
|
||||||
|
tags: ['对话设计', '最佳实践', '流程优化'],
|
||||||
|
imageUrl: 'https://picsum.photos/400/200?random=3',
|
||||||
|
likes: 32000,
|
||||||
|
views: 45000,
|
||||||
|
comments: 156,
|
||||||
|
shares: 890,
|
||||||
|
createdAt: '2025-06-30T09:15:00Z',
|
||||||
|
updatedAt: '2025-06-30T09:15:00Z',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'RPA实施策略:企业如何成功部署RPA解决方案',
|
||||||
|
content: 'RPA项目的成功实施需要科学的策略和方法。本文深入分析了RPA项目的前期准备、流程选择、工具选型、团队建设、风险管控等关键环节。结合真实案例,为企业提供了一套完整的RPA实施方法论,帮助避免常见的实施陷阱。',
|
||||||
|
author: {
|
||||||
|
id: 'user4',
|
||||||
|
name: 'RPA顾问',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=4'
|
||||||
|
},
|
||||||
|
category: 'RPA知识',
|
||||||
|
tags: ['RPA实施', '项目策略', '企业应用'],
|
||||||
|
imageUrl: 'https://picsum.photos/400/200?random=4',
|
||||||
|
likes: 28000,
|
||||||
|
views: 38000,
|
||||||
|
comments: 89,
|
||||||
|
shares: 234,
|
||||||
|
createdAt: '2025-06-23T16:45:00Z',
|
||||||
|
updatedAt: '2025-06-23T16:45:00Z',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'AutoBeeAgent知识库管理:构建高质量的知识体系',
|
||||||
|
content: '知识库是AutoBeeAgent智能客服的核心组件,其质量直接影响服务质量。本指南详细介绍了知识库构建、维护、优化的完整流程,包括内容结构设计、知识分类、更新策略、质量评估等关键环节,帮助用户建立高效的知识管理体系。',
|
||||||
|
author: {
|
||||||
|
id: 'user5',
|
||||||
|
name: '知识管理专家',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=5'
|
||||||
|
},
|
||||||
|
category: '使用规则',
|
||||||
|
tags: ['知识库', '内容管理', '质量优化'],
|
||||||
|
imageUrl: 'https://picsum.photos/400/200?random=5',
|
||||||
|
likes: 15000,
|
||||||
|
views: 22000,
|
||||||
|
comments: 67,
|
||||||
|
shares: 145,
|
||||||
|
createdAt: '2025-08-15T11:20:00Z',
|
||||||
|
updatedAt: '2025-08-15T11:20:00Z',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'RPA工具对比分析:UiPath vs Automation Anywhere vs Blue Prism',
|
||||||
|
content: '选择合适的RPA工具是项目成功的关键因素。本文对三大主流RPA平台进行了全面对比分析,包括功能特性、技术架构、易用性、成本效益、生态系统等多个维度。通过详细的对比表格和实际案例,帮助读者做出明智的工具选择决策。',
|
||||||
|
author: {
|
||||||
|
id: 'user6',
|
||||||
|
name: 'RPA分析师',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=6'
|
||||||
|
},
|
||||||
|
category: 'RPA知识',
|
||||||
|
tags: ['RPA工具', '对比分析', '选型指南'],
|
||||||
|
imageUrl: 'https://picsum.photos/400/200?random=6',
|
||||||
|
likes: 18000,
|
||||||
|
views: 25000,
|
||||||
|
comments: 78,
|
||||||
|
shares: 167,
|
||||||
|
createdAt: '2025-07-10T13:30:00Z',
|
||||||
|
updatedAt: '2025-07-10T13:30:00Z',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockComments: Comment[] = [
|
||||||
|
{
|
||||||
|
id: 'comment1',
|
||||||
|
content: 'AutoBeeAgent的配置规则讲解得很详细,特别是对话流程设计这部分,对我们团队帮助很大!',
|
||||||
|
author: {
|
||||||
|
id: 'user7',
|
||||||
|
name: '技术爱好者',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=7'
|
||||||
|
},
|
||||||
|
likes: 15,
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'reply1',
|
||||||
|
content: '是的,知识库管理的最佳实践也很实用,我们正在按照这个指南优化我们的知识体系',
|
||||||
|
author: {
|
||||||
|
id: 'user8',
|
||||||
|
name: '产品经理',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=8'
|
||||||
|
},
|
||||||
|
likes: 8,
|
||||||
|
replies: [],
|
||||||
|
createdAt: '2025-09-05T12:30:00Z',
|
||||||
|
isLiked: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: '2025-09-05T11:45:00Z',
|
||||||
|
isLiked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment2',
|
||||||
|
content: 'RPA入门教程写得很全面,我们公司正准备引入RPA技术,这篇文章给了我们很好的指导。',
|
||||||
|
author: {
|
||||||
|
id: 'user9',
|
||||||
|
name: '企业IT负责人',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=9'
|
||||||
|
},
|
||||||
|
likes: 12,
|
||||||
|
replies: [],
|
||||||
|
createdAt: '2025-09-09T15:20:00Z',
|
||||||
|
isLiked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment3',
|
||||||
|
content: 'RPA工具对比分析很有价值,帮助我们快速了解了各平台的优劣势,选型决策更有依据了。',
|
||||||
|
author: {
|
||||||
|
id: 'user10',
|
||||||
|
name: 'RPA新手',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=10'
|
||||||
|
},
|
||||||
|
likes: 25,
|
||||||
|
replies: [],
|
||||||
|
createdAt: '2025-06-30T10:30:00Z',
|
||||||
|
isLiked: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockUser: User = {
|
||||||
|
id: 'current_user',
|
||||||
|
name: 'Itcat666666',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=current',
|
||||||
|
email: 'user@example.com',
|
||||||
|
role: 'user'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分类选项
|
||||||
|
export const categories = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '使用规则', value: '使用规则' },
|
||||||
|
{ label: 'RPA知识', value: 'RPA知识' },
|
||||||
|
{ label: '最佳实践', value: '最佳实践' },
|
||||||
|
{ label: '技术教程', value: '技术教程' },
|
||||||
|
{ label: '案例分析', value: '案例分析' },
|
||||||
|
{ label: '工具推荐', value: '工具推荐' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 排序选项
|
||||||
|
export const sortOptions = [
|
||||||
|
{ label: '最热', value: 'hot' },
|
||||||
|
{ label: '最新', value: 'latest' },
|
||||||
|
{ label: '最多评论', value: 'comments' },
|
||||||
|
{ label: '最多点赞', value: 'likes' }
|
||||||
|
];
|
||||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./src/.umi/tsconfig.json"
|
||||||
|
}
|
||||||
1
typings.d.ts
vendored
Normal file
1
typings.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import 'umi/typings';
|
||||||
Reference in New Issue
Block a user