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