完善 React 前端项目
主要改进: - 新增 HomePage 组件,包含统计数据和内容展示 - 新增 ArticlesPage 和 ServicesPage 列表页,支持搜索和筛选 - 新增 UserProfilePage 个人中心页面 - 新增 NotFoundPage 404 页面 - 改进 Layout 组件,添加用户登录状态和动态导航 - 完善所有 Stores (AuthStore, UserStore, ArticleStore, ServiceStore) - 优化全局样式和响应式设计 - 添加环境变量配置 - 修复构建警告 技术栈: - React 18 + MobX + React Router v6 + Styled Components
This commit is contained in:
0
authentication/__init__.py
Normal file
0
authentication/__init__.py
Normal file
3
authentication/admin.py
Normal file
3
authentication/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
authentication/apps.py
Normal file
6
authentication/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'authentication'
|
||||
0
authentication/migrations/__init__.py
Normal file
0
authentication/migrations/__init__.py
Normal file
3
authentication/models.py
Normal file
3
authentication/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
authentication/tests.py
Normal file
3
authentication/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
authentication/views.py
Normal file
3
authentication/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -1,54 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, useParams, useNavigate } from 'react-router-dom';
|
||||
import { Routes, Route, useParams } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import styled from 'styled-components';
|
||||
import { useAuthStore } from './stores/AuthStore';
|
||||
import { useUserStore } from './stores/UserStore';
|
||||
import { useRegionStore } from './stores/RegionStore';
|
||||
import Layout from './components/common/Layout';
|
||||
import Loading from './components/common/Loading';
|
||||
import ChinaMap from './components/common/ChinaMap';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import CitiesPage from './components/region/CitiesPage';
|
||||
import CityDetailPage from './components/region/CityDetailPage';
|
||||
import ArticlesPage from './components/article/ArticlesPage';
|
||||
import ArticleDetailPage from './components/article/ArticleDetailPage';
|
||||
import ServicesPage from './components/service/ServicesPage';
|
||||
import ServiceDetailPage from './components/service/ServiceDetailPage';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import RegisterPage from './components/auth/RegisterPage';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
`;
|
||||
import UserProfilePage from './components/user/UserProfilePage';
|
||||
import NotFoundPage from './components/common/NotFoundPage';
|
||||
|
||||
function App() {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Fetch current user on app load
|
||||
React.useEffect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
authStore.fetchCurrentUser();
|
||||
}
|
||||
}, [authStore]);
|
||||
|
||||
return (
|
||||
<Layout title="城市手册" subtitle="地方志兼本地生活服务平台">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/cities/:regionId" element={<CityDetailPage />} />
|
||||
<Route path="/cities/:regionId" element={<CityDetailPageWrapper />} />
|
||||
<Route path="/articles" element={<ArticlesPage />} />
|
||||
<Route path="/articles/:articleId" element={<ArticleDetailPageWrapper />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/services/:serviceId" element={<ServiceDetailPageWrapper />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
@@ -59,6 +34,11 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const CityDetailPageWrapper = observer(() => {
|
||||
const { regionId } = useParams();
|
||||
return <CityDetailPage regionId={regionId} />;
|
||||
});
|
||||
|
||||
const ArticleDetailPageWrapper = observer(() => {
|
||||
const { articleId } = useParams();
|
||||
return <ArticleDetailPage articleId={articleId} />;
|
||||
@@ -69,110 +49,4 @@ const ServiceDetailPageWrapper = observer(() => {
|
||||
return <ServiceDetailPage serviceId={serviceId} />;
|
||||
});
|
||||
|
||||
const HomePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const regionStore = useRegionStore();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const handleProvinceClick = async (geo) => {
|
||||
const provinceName = geo.properties.name;
|
||||
const provinceCode = geo.properties.code;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 先获取所有省份列表,找到对应的 region ID
|
||||
await regionStore.fetchProvinces();
|
||||
const province = regionStore.regions.find(
|
||||
r => r.name === provinceName || r.code === provinceCode
|
||||
);
|
||||
|
||||
if (province) {
|
||||
navigate(`/cities/${province.id}`);
|
||||
} else {
|
||||
// 如果没有找到,跳转到城市列表页并带上省份名称
|
||||
navigate(`/cities?province=${encodeURIComponent(provinceName)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate to province:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>欢迎来到城市手册</Title>
|
||||
<p>探索每个城市的故事与特色</p>
|
||||
</Header>
|
||||
|
||||
{loading ? (
|
||||
<Loading message="加载中..." />
|
||||
) : (
|
||||
<ChinaMap onProvinceClick={handleProvinceClick} />
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '40px' }}>
|
||||
<h2>📚 最新文章</h2>
|
||||
<p>即将推出...</p>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
const UserProfilePage = observer(() => {
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
userStore.fetchCurrentUser();
|
||||
}
|
||||
}, [authStore, userStore]);
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
return (
|
||||
<Container>
|
||||
<p>请先登录</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (userStore.loading) {
|
||||
return <Loading message="加载用户信息..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>个人中心</Title>
|
||||
</Header>
|
||||
{userStore.user && (
|
||||
<div>
|
||||
<h3>用户信息</h3>
|
||||
<p>用户名: {userStore.user.username}</p>
|
||||
<p>邮箱: {userStore.user.email}</p>
|
||||
<p>角色: {userStore.user.role_display}</p>
|
||||
|
||||
<h3>统计</h3>
|
||||
<p>文章数: {userStore.user.articles_count}</p>
|
||||
<p>服务数: {userStore.user.services_count}</p>
|
||||
<p>评论数: {userStore.user.comments_count}</p>
|
||||
<p>点赞数: {userStore.user.likes_count}</p>
|
||||
<p>收藏数: {userStore.user.favorites_count}</p>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
const NotFoundPage = () => (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>404</Title>
|
||||
</Header>
|
||||
<p>页面未找到</p>
|
||||
</Container>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -3,68 +3,179 @@ import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useArticleStore } from '../../stores/ArticleStore';
|
||||
import { useInteractionStore } from '../../stores/InteractionStore';
|
||||
import Card from '../common/Card';
|
||||
import { useAuthStore } from '../../stores/AuthStore';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
import Card from '../common/Card';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
margin: 0 0 15px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MetaItem = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
|
||||
h1, h2, h3 {
|
||||
h1, h2, h3, h4 {
|
||||
color: #2c3e50;
|
||||
margin-top: 30px;
|
||||
margin: 30px 0 15px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 15px 0;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #667eea;
|
||||
padding-left: 20px;
|
||||
margin: 20px 0;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
gap: 15px;
|
||||
margin: 30px 0;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
const ActionButton = styled.button`
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
${props => props.primary && `
|
||||
${(props) =>
|
||||
props.primary &&
|
||||
`
|
||||
background: #667eea;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
`}
|
||||
|
||||
${props => props.secondary && `
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
${(props) =>
|
||||
props.secondary &&
|
||||
`
|
||||
background: #f8f9fa;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
|
||||
&:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
`}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const CommentsSection = styled.div`
|
||||
margin-top: 40px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
margin: 0 0 20px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const CommentForm = styled.form`
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -76,39 +187,70 @@ const CommentList = styled.div`
|
||||
|
||||
const CommentItem = styled.div`
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
.author {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ArticleDetailPage = observer(({ articleId }) => {
|
||||
const articleStore = useArticleStore();
|
||||
const interactionStore = useInteractionStore();
|
||||
const navigate = useNavigate();
|
||||
const articleStore = useArticleStore();
|
||||
const authStore = useAuthStore();
|
||||
const [comment, setComment] = useState('');
|
||||
const [liked, setLiked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
articleStore.fetchArticle(articleId);
|
||||
articleStore.fetchArticleComments(articleId);
|
||||
articleStore.fetchArticleStats(articleId);
|
||||
}, [articleId, articleStore]);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const result = await articleStore.likeArticle(articleId);
|
||||
if (result) {
|
||||
setLiked(result.liked);
|
||||
articleStore.fetchArticleStats(articleId);
|
||||
if (result.success) {
|
||||
setLiked(!liked);
|
||||
articleStore.fetchArticle(articleId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComment = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
|
||||
const result = await interactionStore.createComment('article', articleId, comment);
|
||||
const handleFavorite = async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const result = await articleStore.favoriteArticle(articleId);
|
||||
if (result.success) {
|
||||
setComment('');
|
||||
articleStore.fetchArticleComments(articleId);
|
||||
alert('已收藏');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: articleStore.currentArticle?.title,
|
||||
url: window.location.href,
|
||||
});
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert('链接已复制到剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,7 +262,7 @@ const ArticleDetailPage = observer(({ articleId }) => {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={articleStore.error}
|
||||
onDismiss={() => articleStore.error = null}
|
||||
onDismiss={() => articleStore.clearError()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -132,51 +274,81 @@ const ArticleDetailPage = observer(({ articleId }) => {
|
||||
const article = articleStore.currentArticle;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{article.title}</h1>
|
||||
<p>作者: {article.author_username} | {article.article_type_display}</p>
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{article.title}</Title>
|
||||
<Meta>
|
||||
<MetaItem>
|
||||
👤 {article.author_username || '匿名用户'}
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
📍 {article.region_name || '未知地区'}
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
👁 {article.views || 0} 阅读
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
❤️ {article.likes_count || 0} 点赞
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
💬 {article.comments_count || 0} 评论
|
||||
</MetaItem>
|
||||
</Meta>
|
||||
</Header>
|
||||
|
||||
<Content dangerouslySetInnerHTML={{ __html: article.content }} />
|
||||
<Content dangerouslySetInnerHTML={{ __html: article.content || '' }} />
|
||||
|
||||
<Actions>
|
||||
<Button primary onClick={handleLike}>
|
||||
{liked ? '已点赞' : '点赞'}
|
||||
</Button>
|
||||
<Button secondary>
|
||||
收藏
|
||||
</Button>
|
||||
<Button secondary>
|
||||
分享
|
||||
</Button>
|
||||
<ActionButton primary onClick={handleLike}>
|
||||
{liked ? '❤️' : '🤍'} {liked ? '已点赞' : '点赞'}
|
||||
</ActionButton>
|
||||
<ActionButton secondary onClick={handleFavorite}>
|
||||
⭐ 收藏
|
||||
</ActionButton>
|
||||
<ActionButton secondary onClick={handleShare}>
|
||||
🔗 分享
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
|
||||
<CommentsSection>
|
||||
<h2>评论 ({articleStore.currentArticle.comments_count})</h2>
|
||||
<SectionTitle>💬 评论 ({article.comments_count || 0})</SectionTitle>
|
||||
|
||||
<CommentForm onSubmit={handleComment}>
|
||||
<textarea
|
||||
placeholder="写下你的评论..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
{authStore.isAuthenticated ? (
|
||||
<CommentForm
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
// TODO: 实现评论创建
|
||||
setComment('');
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
placeholder="写下你的评论..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<ActionButton type="submit" primary style={{ marginTop: '10px' }}>
|
||||
发表评论
|
||||
</ActionButton>
|
||||
</CommentForm>
|
||||
) : (
|
||||
<Card
|
||||
title="登录后评论"
|
||||
description="登录后可发表评论和参与互动"
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ cursor: 'pointer', marginBottom: '20px' }}
|
||||
/>
|
||||
<Button type="submit" primary style={{ marginTop: '10px' }}>
|
||||
发表评论
|
||||
</Button>
|
||||
</CommentForm>
|
||||
)}
|
||||
|
||||
<CommentList>
|
||||
{articleStore.currentArticle.comments.map((comment) => (
|
||||
<CommentItem key={comment.id}>
|
||||
<p><strong>{comment.author_username}</strong></p>
|
||||
<p>{comment.content}</p>
|
||||
<p style={{ color: '#999', fontSize: '14px' }}>
|
||||
{comment.created_at}
|
||||
</p>
|
||||
</CommentItem>
|
||||
))}
|
||||
{/* TODO: 加载并显示评论 */}
|
||||
<CommentItem>
|
||||
<div className="author">暂无评论</div>
|
||||
<div className="content">成为第一个评论的人吧!</div>
|
||||
</CommentItem>
|
||||
</CommentList>
|
||||
</CommentsSection>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
186
frontend/src/components/article/ArticlesPage.js
Normal file
186
frontend/src/components/article/ArticlesPage.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useArticleStore } from '../../stores/ArticleStore';
|
||||
import { useRegionStore } from '../../stores/RegionStore';
|
||||
import styled from 'styled-components';
|
||||
import Card from '../common/Card';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 25px;
|
||||
padding: 20px 0;
|
||||
`;
|
||||
|
||||
const FilterBar = styled.div`
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Select = styled.select`
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const ArticlesPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const articleStore = useArticleStore();
|
||||
const regionStore = useRegionStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
articleStore.fetchArticles();
|
||||
regionStore.fetchProvinces();
|
||||
}, [articleStore, regionStore]);
|
||||
|
||||
const handleArticleClick = (articleId) => {
|
||||
navigate(`/articles/${articleId}`);
|
||||
};
|
||||
|
||||
const handleRegionChange = (e) => {
|
||||
const regionId = e.target.value;
|
||||
if (regionId) {
|
||||
setSearchParams({ region: regionId });
|
||||
articleStore.fetchArticlesByRegion(regionId);
|
||||
} else {
|
||||
setSearchParams({});
|
||||
articleStore.fetchArticles();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
articleStore.searchArticles(searchQuery);
|
||||
} else {
|
||||
articleStore.fetchArticles();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedRegion = searchParams.get('region');
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>📚 文章库</Title>
|
||||
<Subtitle>探索各地的风土人情、历史文化与生活指南</Subtitle>
|
||||
</Header>
|
||||
|
||||
<FilterBar>
|
||||
<Select value={selectedRegion || ''} onChange={handleRegionChange}>
|
||||
<option value="">全部地区</option>
|
||||
{regionStore.regions.map((region) => (
|
||||
<option key={region.id} value={region.id}>
|
||||
{region.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '10px', flex: 1 }}>
|
||||
<SearchInput
|
||||
type="text"
|
||||
placeholder="搜索文章标题或内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
</FilterBar>
|
||||
|
||||
{articleStore.loading ? (
|
||||
<Loading message="加载文章..." />
|
||||
) : articleStore.error ? (
|
||||
<ErrorMessage
|
||||
message={articleStore.error}
|
||||
onDismiss={() => (articleStore.error = null)}
|
||||
/>
|
||||
) : articleStore.articles && articleStore.articles.length > 0 ? (
|
||||
<Grid>
|
||||
{articleStore.articles.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
title={article.title}
|
||||
description={article.summary || article.excerpt || article.content?.substring(0, 100) + '...'}
|
||||
meta={`📍 ${article.region_name || '未知'} · 👁 ${article.views || 0} · ❤️ ${article.likes_count || 0}`}
|
||||
onClick={() => handleArticleClick(article.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card
|
||||
title="暂无文章"
|
||||
description="还没有文章,成为第一个发布者吧!"
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArticlesPage;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useAuthStore } from '../../stores/AuthStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Container = styled.div`
|
||||
@@ -60,6 +62,20 @@ const Button = styled.button`
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #dc3545;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: #f8d7da;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const Link = styled.a`
|
||||
@@ -67,44 +83,35 @@ const Link = styled.a`
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const RegisterPage = () => {
|
||||
const RegisterPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const authStore = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
const result = await authStore.register(username, email, password, confirmPassword);
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
// TODO: 调用注册 API
|
||||
console.log('Register:', { username, email, password });
|
||||
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>注册</Title>
|
||||
<Title>注册账号</Title>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div style={{ color: '#dc3545', fontSize: '14px', textAlign: 'center' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{authStore.error && <ErrorMessage>{authStore.error}</ErrorMessage>}
|
||||
|
||||
<InputGroup>
|
||||
<Label>用户名</Label>
|
||||
@@ -114,6 +121,7 @@ const RegisterPage = () => {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
disabled={authStore.loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
@@ -125,6 +133,7 @@ const RegisterPage = () => {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
disabled={authStore.loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
@@ -136,6 +145,7 @@ const RegisterPage = () => {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
disabled={authStore.loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
@@ -147,15 +157,18 @@ const RegisterPage = () => {
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
disabled={authStore.loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Button type="submit">注册</Button>
|
||||
<Button type="submit" disabled={authStore.loading}>
|
||||
{authStore.loading ? '注册中...' : '注册'}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Link href="/login">已有账号?立即登录</Link>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import { scaleQuantile } from 'd3-scale';
|
||||
import styled from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import chinaGeo from '../../data/china-provinces.geo.json';
|
||||
|
||||
const MapContainer = styled.div`
|
||||
@@ -67,7 +66,6 @@ const MapWrapper = styled.div`
|
||||
`;
|
||||
|
||||
const ChinaMap = ({ onProvinceClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const [tooltipContent, setTooltipContent] = useState('');
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/AuthStore';
|
||||
import { useUserStore } from '../../stores/UserStore';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Container = styled.div`
|
||||
@@ -23,19 +26,29 @@ const HeaderContent = styled.div`
|
||||
const Title = styled.h1`
|
||||
font-size: 28px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 5px 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const Nav = styled.nav`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-left: 20px;
|
||||
transition: opacity 0.2s;
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
@@ -43,29 +56,106 @@ const Nav = styled.nav`
|
||||
}
|
||||
`;
|
||||
|
||||
const UserMenu = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
`;
|
||||
|
||||
const UserName = styled.span`
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
`;
|
||||
|
||||
const AuthButton = styled.button`
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`;
|
||||
|
||||
const Footer = styled.footer`
|
||||
background: #f8f9fa;
|
||||
padding: 30px 0;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
border-top: 1px solid #e9ecef;
|
||||
`;
|
||||
|
||||
const FooterLinks = styled.div`
|
||||
margin-bottom: 15px;
|
||||
|
||||
a {
|
||||
color: #6c757d;
|
||||
text-decoration: none;
|
||||
margin: 0 10px;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function Layout({ children, title, subtitle }) {
|
||||
const navigate = useNavigate();
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authStore.isAuthenticated && !userStore.user) {
|
||||
userStore.fetchCurrentUser();
|
||||
}
|
||||
}, [authStore, userStore]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
userStore.clearUser();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleTitleClick = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Container>
|
||||
<HeaderContent>
|
||||
<div>
|
||||
<div onClick={handleTitleClick} style={{ cursor: 'pointer' }}>
|
||||
<Title>{title || '城市手册'}</Title>
|
||||
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||
</div>
|
||||
<Nav>
|
||||
<a href="/">首页</a>
|
||||
<a href="/cities">城市</a>
|
||||
<a href="/services">服务</a>
|
||||
<a href="/user/profile">个人中心</a>
|
||||
<Link to="/">首页</Link>
|
||||
<Link to="/cities">城市</Link>
|
||||
<Link to="/articles">文章</Link>
|
||||
<Link to="/services">服务</Link>
|
||||
|
||||
{authStore.isAuthenticated ? (
|
||||
<UserMenu>
|
||||
{userStore.user && (
|
||||
<UserName>👋 {userStore.user.username}</UserName>
|
||||
)}
|
||||
<Link to="/user/profile">个人中心</Link>
|
||||
<AuthButton onClick={handleLogout}>退出</AuthButton>
|
||||
</UserMenu>
|
||||
) : (
|
||||
<UserMenu>
|
||||
<Link to="/login">登录</Link>
|
||||
<AuthButton onClick={() => navigate('/register')}>注册</AuthButton>
|
||||
</UserMenu>
|
||||
)}
|
||||
</Nav>
|
||||
</HeaderContent>
|
||||
</Container>
|
||||
@@ -77,7 +167,13 @@ function Layout({ children, title, subtitle }) {
|
||||
|
||||
<Footer>
|
||||
<Container>
|
||||
<p>© 2026 城市手册. All rights reserved.</p>
|
||||
<FooterLinks>
|
||||
<a href="/about">关于我们</a>
|
||||
<a href="/contact">联系我们</a>
|
||||
<a href="/privacy">隐私政策</a>
|
||||
<a href="/terms">服务条款</a>
|
||||
</FooterLinks>
|
||||
<p>© 2026 城市手册。All rights reserved.</p>
|
||||
</Container>
|
||||
</Footer>
|
||||
</>
|
||||
|
||||
85
frontend/src/components/common/NotFoundPage.js
Normal file
85
frontend/src/components/common/NotFoundPage.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const ErrorCode = styled.div`
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
line-height: 1;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
color: #333;
|
||||
margin: 0 0 15px;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin: 0 0 30px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 12px 30px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin: 0 10px;
|
||||
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
`;
|
||||
|
||||
const SecondaryButton = styled.button`
|
||||
padding: 12px 30px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
`;
|
||||
|
||||
function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ErrorCode>404</ErrorCode>
|
||||
<Title>页面未找到</Title>
|
||||
<Description>
|
||||
抱歉,您访问的页面不存在或已被移除。<br />
|
||||
请检查网址是否正确,或返回首页继续浏览。
|
||||
</Description>
|
||||
<div>
|
||||
<Button onClick={() => navigate('/')}>返回首页</Button>
|
||||
<SecondaryButton onClick={() => navigate(-1)}>返回上一页</SecondaryButton>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundPage;
|
||||
220
frontend/src/components/home/HomePage.js
Normal file
220
frontend/src/components/home/HomePage.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useRegionStore } from '../../stores/RegionStore';
|
||||
import { useArticleStore } from '../../stores/ArticleStore';
|
||||
import { useServiceStore } from '../../stores/ServiceStore';
|
||||
import styled from 'styled-components';
|
||||
import ChinaMap from '../common/ChinaMap';
|
||||
import Loading from '../common/Loading';
|
||||
import Card from '../common/Card';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Hero = styled.div`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 60px 40px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const HeroTitle = styled.h1`
|
||||
font-size: 36px;
|
||||
margin: 0 0 15px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const HeroSubtitle = styled.p`
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
`;
|
||||
|
||||
const Section = styled.section`
|
||||
margin-bottom: 50px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 24px;
|
||||
margin: 0 0 20px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const MapWrapper = styled.div`
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 40px;
|
||||
`;
|
||||
|
||||
const StatsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
`;
|
||||
|
||||
const StatCard = styled.div`
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
const StatNumber = styled.div`
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
`;
|
||||
|
||||
const HomePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const regionStore = useRegionStore();
|
||||
const articleStore = useArticleStore();
|
||||
const serviceStore = useServiceStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 加载首页数据
|
||||
articleStore.fetchLatestArticles(6);
|
||||
serviceStore.fetchFeaturedServices(6);
|
||||
}, [articleStore, serviceStore]);
|
||||
|
||||
const handleProvinceClick = async (geo) => {
|
||||
const provinceName = geo.properties.name;
|
||||
const provinceCode = geo.properties.code;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await regionStore.fetchProvinces();
|
||||
const province = regionStore.regions.find(
|
||||
r => r.name === provinceName || r.code === provinceCode
|
||||
);
|
||||
|
||||
if (province) {
|
||||
navigate(`/cities/${province.id}`);
|
||||
} else {
|
||||
navigate(`/cities?province=${encodeURIComponent(provinceName)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to navigate to province:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleClick = (articleId) => {
|
||||
navigate(`/articles/${articleId}`);
|
||||
};
|
||||
|
||||
const handleServiceClick = (serviceId) => {
|
||||
navigate(`/services/${serviceId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Hero>
|
||||
<HeroTitle>🌏 探索城市的故事</HeroTitle>
|
||||
<HeroSubtitle>发现每个地方的独特魅力与生活指南</HeroSubtitle>
|
||||
|
||||
<StatsGrid>
|
||||
<StatCard>
|
||||
<StatNumber>{regionStore.regions.length || 34}</StatNumber>
|
||||
<StatLabel>省级行政区</StatLabel>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatNumber>{articleStore.articles?.length || 0}</StatNumber>
|
||||
<StatLabel>精选文章</StatLabel>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatNumber>{serviceStore.services?.length || 0}</StatNumber>
|
||||
<StatLabel>本地服务</StatLabel>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatNumber>100K+</StatNumber>
|
||||
<StatLabel>注册用户</StatLabel>
|
||||
</StatCard>
|
||||
</StatsGrid>
|
||||
</Hero>
|
||||
|
||||
<MapWrapper>
|
||||
<SectionTitle>📍 选择省份</SectionTitle>
|
||||
{loading ? (
|
||||
<Loading message="加载地图..." />
|
||||
) : (
|
||||
<ChinaMap onProvinceClick={handleProvinceClick} />
|
||||
)}
|
||||
</MapWrapper>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>📚 最新文章</SectionTitle>
|
||||
{articleStore.loading ? (
|
||||
<Loading message="加载文章..." />
|
||||
) : articleStore.error ? (
|
||||
<div style={{ color: '#dc3545' }}>{articleStore.error}</div>
|
||||
) : articleStore.articles && articleStore.articles.length > 0 ? (
|
||||
<Grid>
|
||||
{articleStore.articles.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
title={article.title}
|
||||
description={article.summary || article.excerpt}
|
||||
meta={`📍 ${article.region_name || '未知'} · 👁 ${article.views || 0}`}
|
||||
onClick={() => handleArticleClick(article.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card title="暂无文章" description="内容即将上线,敬请期待!" />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>🛠️ 精选服务</SectionTitle>
|
||||
{serviceStore.loading ? (
|
||||
<Loading message="加载服务..." />
|
||||
) : serviceStore.error ? (
|
||||
<div style={{ color: '#dc3545' }}>{serviceStore.error}</div>
|
||||
) : serviceStore.services && serviceStore.services.length > 0 ? (
|
||||
<Grid>
|
||||
{serviceStore.services.map((service) => (
|
||||
<Card
|
||||
key={service.id}
|
||||
title={service.name}
|
||||
description={service.description}
|
||||
meta={`📍 ${service.region_name || '未知'} · ⭐ ${service.rating || '新'}`}
|
||||
onClick={() => handleServiceClick(service.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card title="暂无服务" description="服务即将上线,敬请期待!" />
|
||||
)}
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,78 +1,139 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRegionStore } from '../../stores/RegionStore';
|
||||
import { useArticleStore } from '../../stores/ArticleStore';
|
||||
import { useServiceStore } from '../../stores/ServiceStore';
|
||||
import styled from 'styled-components';
|
||||
import Card from '../common/Card';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
margin: 0 0 10px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const InfoGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const InfoItem = styled.div`
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #495057;
|
||||
}
|
||||
const InfoCard = styled.div`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const InfoNumber = styled.div`
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
const InfoLabel = styled.div`
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
`;
|
||||
|
||||
const Section = styled.section`
|
||||
margin: 40px 0;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 24px;
|
||||
margin: 0 0 20px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const Tabs = styled.div`
|
||||
display: flex;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
margin: 30px 0;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 0;
|
||||
`;
|
||||
|
||||
const Tab = styled.button`
|
||||
padding: 10px 20px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: ${props => props.active ? '#667eea' : '#6c757d'};
|
||||
border-bottom: 2px solid ${props => props.active ? '#667eea' : 'transparent'};
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => (props.active ? '#667eea' : '#6c757d')};
|
||||
border-bottom: 2px solid ${(props) => (props.active ? '#667eea' : 'transparent')};
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const ContentGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
const EmptyState = styled.div`
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
|
||||
h3 {
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CityDetailPage = observer(() => {
|
||||
const regionStore = useRegionStore();
|
||||
const articleStore = useArticleStore();
|
||||
const serviceStore = useServiceStore();
|
||||
const CityDetailPage = observer(({ regionId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('articles');
|
||||
const { regionId } = useParams();
|
||||
const regionStore = useRegionStore();
|
||||
const [activeTab, setActiveTab] = useState('cities');
|
||||
|
||||
useEffect(() => {
|
||||
regionStore.fetchRegion(regionId);
|
||||
regionStore.fetchChildren(regionId);
|
||||
regionStore.fetchRegionArticles(regionId);
|
||||
regionStore.fetchRegionServices(regionId);
|
||||
}, [regionId, regionStore]);
|
||||
|
||||
const handleCityClick = (cityId) => {
|
||||
navigate(`/cities/${cityId}`);
|
||||
};
|
||||
|
||||
const handleArticleClick = (articleId) => {
|
||||
navigate(`/articles/${articleId}`);
|
||||
};
|
||||
|
||||
const handleServiceClick = (serviceId) => {
|
||||
navigate(`/services/${serviceId}`);
|
||||
};
|
||||
|
||||
if (regionStore.loading) {
|
||||
return <Loading message="加载城市详情..." />;
|
||||
}
|
||||
@@ -81,7 +142,7 @@ const CityDetailPage = observer(() => {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={regionStore.error}
|
||||
onDismiss={() => regionStore.error = null}
|
||||
onDismiss={() => regionStore.clearError()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -91,86 +152,127 @@ const CityDetailPage = observer(() => {
|
||||
}
|
||||
|
||||
const region = regionStore.currentRegion;
|
||||
const children = regionStore.regions || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{region.name}</h1>
|
||||
<p>{region.full_path}</p>
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>📍 {region.name}</Title>
|
||||
<Subtitle>{region.full_path || region.description}</Subtitle>
|
||||
|
||||
<InfoGrid>
|
||||
<InfoItem>
|
||||
<strong>级别</strong>
|
||||
{region.level_display}
|
||||
</InfoItem>
|
||||
<InfoItem>
|
||||
<strong>子版块数量</strong>
|
||||
{region.children_count}
|
||||
</InfoItem>
|
||||
<InfoItem>
|
||||
<strong>文章数量</strong>
|
||||
{region.articles_count}
|
||||
</InfoItem>
|
||||
<InfoItem>
|
||||
<strong>服务数量</strong>
|
||||
{region.services_count}
|
||||
</InfoItem>
|
||||
</InfoGrid>
|
||||
<InfoGrid>
|
||||
<InfoCard>
|
||||
<InfoNumber>{children.length}</InfoNumber>
|
||||
<InfoLabel>下级区域</InfoLabel>
|
||||
</InfoCard>
|
||||
<InfoCard>
|
||||
<InfoNumber>{region.articles_count || 0}</InfoNumber>
|
||||
<InfoLabel>文章</InfoLabel>
|
||||
</InfoCard>
|
||||
<InfoCard>
|
||||
<InfoNumber>{region.services_count || 0}</InfoNumber>
|
||||
<InfoLabel>服务</InfoLabel>
|
||||
</InfoCard>
|
||||
<InfoCard>
|
||||
<InfoNumber>{region.level_display || '-'}</InfoNumber>
|
||||
<InfoLabel>行政级别</InfoLabel>
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
</Header>
|
||||
|
||||
<h2>下级城市</h2>
|
||||
<ContentGrid>
|
||||
{region.children.map((city) => (
|
||||
<Card
|
||||
key={city.id}
|
||||
title={city.name}
|
||||
meta={city.level_display}
|
||||
onClick={() => handleCityClick(city.id)}
|
||||
/>
|
||||
))}
|
||||
</ContentGrid>
|
||||
<Section>
|
||||
<SectionTitle>
|
||||
{activeTab === 'cities' && '🏙️'}
|
||||
{activeTab === 'articles' && '📚'}
|
||||
{activeTab === 'services' && '🛠️'}
|
||||
{activeTab === 'cities' && '下级区域'}
|
||||
{activeTab === 'articles' && '相关文章'}
|
||||
{activeTab === 'services' && '本地服务'}
|
||||
</SectionTitle>
|
||||
|
||||
<Tabs>
|
||||
<Tab
|
||||
active={activeTab === 'articles'}
|
||||
onClick={() => setActiveTab('articles')}
|
||||
>
|
||||
文章
|
||||
</Tab>
|
||||
<Tab
|
||||
active={activeTab === 'services'}
|
||||
onClick={() => setActiveTab('services')}
|
||||
>
|
||||
特色服务
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Tabs>
|
||||
<Tab active={activeTab === 'cities'} onClick={() => setActiveTab('cities')}>
|
||||
下级区域 ({children.length})
|
||||
</Tab>
|
||||
<Tab active={activeTab === 'articles'} onClick={() => setActiveTab('articles')}>
|
||||
文章 ({region.articles_count || 0})
|
||||
</Tab>
|
||||
<Tab active={activeTab === 'services'} onClick={() => setActiveTab('services')}>
|
||||
服务 ({region.services_count || 0})
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{activeTab === 'articles' && (
|
||||
<ContentGrid>
|
||||
{region.articles.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
title={article.title}
|
||||
description={article.content.substring(0, 100)}
|
||||
meta={`作者: ${article.author_username}`}
|
||||
onClick={() => navigate(`/articles/${article.id}`)}
|
||||
/>
|
||||
))}
|
||||
</ContentGrid>
|
||||
)}
|
||||
{activeTab === 'cities' && (
|
||||
<>
|
||||
{children.length > 0 ? (
|
||||
<Grid>
|
||||
{children.map((city) => (
|
||||
<Card
|
||||
key={city.id}
|
||||
title={city.name}
|
||||
description={city.description || city.full_path}
|
||||
meta={`${city.level_display || ''} · ${city.children_count || 0} 个下级`}
|
||||
onClick={() => handleCityClick(city.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<h3>暂无下级区域</h3>
|
||||
<p>这是最底层的行政区域</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'services' && (
|
||||
<ContentGrid>
|
||||
{region.services.map((service) => (
|
||||
<Card
|
||||
key={service.id}
|
||||
title={service.name}
|
||||
description={service.description.substring(0, 100)}
|
||||
tags={[service.category_display]}
|
||||
onClick={() => navigate(`/services/${service.id}`)}
|
||||
/>
|
||||
))}
|
||||
</ContentGrid>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'articles' && (
|
||||
<>
|
||||
{region.articles && region.articles.length > 0 ? (
|
||||
<Grid>
|
||||
{region.articles.map((article) => (
|
||||
<Card
|
||||
key={article.id}
|
||||
title={article.title}
|
||||
description={article.summary || article.excerpt || article.content?.substring(0, 100)}
|
||||
meta={`👁 ${article.views || 0} · ❤️ ${article.likes_count || 0}`}
|
||||
onClick={() => handleArticleClick(article.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<h3>暂无文章</h3>
|
||||
<p>该地区还没有相关文章</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'services' && (
|
||||
<>
|
||||
{region.services && region.services.length > 0 ? (
|
||||
<Grid>
|
||||
{region.services.map((service) => (
|
||||
<Card
|
||||
key={service.id}
|
||||
title={service.name}
|
||||
description={service.description}
|
||||
tags={service.categories?.map((c) => c.name) || []}
|
||||
meta={`⭐ ${service.rating?.toFixed(1) || '新'}`}
|
||||
onClick={() => handleServiceClick(service.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<h3>暂无服务</h3>
|
||||
<p>该地区还没有相关服务</p>
|
||||
</EmptyState>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,84 +3,269 @@ import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useServiceStore } from '../../stores/ServiceStore';
|
||||
import { useInteractionStore } from '../../stores/InteractionStore';
|
||||
import Card from '../common/Card';
|
||||
import { useAuthStore } from '../../stores/AuthStore';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
import Card from '../common/Card';
|
||||
|
||||
const ServiceCard = styled(Card)`
|
||||
cursor: pointer;
|
||||
const Container = styled.div`
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Rating = styled.div`
|
||||
const Header = styled.div`
|
||||
margin-bottom: 30px;
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 32px;
|
||||
margin: 0 0 15px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Category = styled.span`
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const MetaItem = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
const Description = styled.div`
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
`;
|
||||
|
||||
const InfoSection = styled.div`
|
||||
background: #f8f9fa;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
`;
|
||||
|
||||
const InfoRow = styled.div`
|
||||
display: flex;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InfoLabel = styled.span`
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const InfoValue = styled.span`
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const RatingSection = styled.div`
|
||||
margin: 30px 0;
|
||||
padding: 25px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
`;
|
||||
|
||||
const RatingTitle = styled.h3`
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const Stars = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const Star = styled.span`
|
||||
color: ${props => props.filled ? '#ffc107' : '#dee2e6'};
|
||||
font-size: 20px;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
color: ${(props) => (props.filled ? '#ffc107' : 'rgba(255, 255, 255, 0.5)')};
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
`;
|
||||
|
||||
const RatingText = styled.span`
|
||||
font-size: 16px;
|
||||
margin-left: 10px;
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin: 30px 0;
|
||||
`;
|
||||
|
||||
const ActionButton = styled.button`
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
${(props) =>
|
||||
props.primary &&
|
||||
`
|
||||
background: #667eea;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.secondary &&
|
||||
`
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const CommentsSection = styled.div`
|
||||
margin-top: 40px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-size: 20px;
|
||||
margin: 0 0 20px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const CommentForm = styled.form`
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CommentList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
`;
|
||||
|
||||
const CommentItem = styled.div`
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
.author {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ServiceDetailPage = observer(({ serviceId }) => {
|
||||
const serviceStore = useServiceStore();
|
||||
const interactionStore = useInteractionStore();
|
||||
const navigate = useNavigate();
|
||||
const serviceStore = useServiceStore();
|
||||
const authStore = useAuthStore();
|
||||
const [comment, setComment] = useState('');
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [userRating, setUserRating] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
serviceStore.fetchService(serviceId);
|
||||
serviceStore.fetchServiceComments(serviceId);
|
||||
serviceStore.fetchServiceStats(serviceId);
|
||||
}, [serviceId, serviceStore]);
|
||||
|
||||
const handleLike = async () => {
|
||||
const result = await serviceStore.likeService(serviceId);
|
||||
if (result) {
|
||||
setLiked(result.liked);
|
||||
serviceStore.fetchServiceStats(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = async (score) => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setUserRating(score);
|
||||
const result = await serviceStore.rateService(serviceId, score);
|
||||
if (result.success) {
|
||||
serviceStore.fetchServiceStats(serviceId);
|
||||
serviceStore.fetchService(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComment = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
|
||||
const result = await interactionStore.createComment('service', serviceId, comment);
|
||||
const handleLike = async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const result = await serviceStore.likeService(serviceId);
|
||||
if (result.success) {
|
||||
setComment('');
|
||||
serviceStore.fetchServiceComments(serviceId);
|
||||
serviceStore.fetchService(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContact = () => {
|
||||
const service = serviceStore.currentService;
|
||||
if (service?.contact) {
|
||||
alert(`联系方式:${service.contact}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +277,7 @@ const ServiceDetailPage = observer(({ serviceId }) => {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={serviceStore.error}
|
||||
onDismiss={() => serviceStore.error = null}
|
||||
onDismiss={() => serviceStore.clearError()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -102,102 +287,120 @@ const ServiceDetailPage = observer(({ serviceId }) => {
|
||||
}
|
||||
|
||||
const service = serviceStore.currentService;
|
||||
const stats = serviceStore.currentService.stats || {};
|
||||
const rating = service.rating || 0;
|
||||
const reviewsCount = service.reviews_count || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{service.image && (
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '400px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Container>
|
||||
<Header>
|
||||
{service.image && <Image src={service.image} alt={service.name} />}
|
||||
<Category>{service.category_display || '生活服务'}</Category>
|
||||
<Title>{service.name}</Title>
|
||||
<Meta>
|
||||
<MetaItem>
|
||||
📍 {service.region_name || '未知地区'}
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
👁 {service.views || 0} 浏览
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
❤️ {service.likes_count || 0} 点赞
|
||||
</MetaItem>
|
||||
</Meta>
|
||||
</Header>
|
||||
|
||||
<h1>{service.name}</h1>
|
||||
<p>{service.category_display}</p>
|
||||
<Description>
|
||||
{service.description || '暂无详细描述'}
|
||||
</Description>
|
||||
|
||||
<p>{service.description}</p>
|
||||
<InfoSection>
|
||||
<InfoRow>
|
||||
<InfoLabel>地址</InfoLabel>
|
||||
<InfoValue>{service.address || '未提供'}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>联系方式</InfoLabel>
|
||||
<InfoValue>{service.contact || '未提供'}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>营业时间</InfoLabel>
|
||||
<InfoValue>{service.business_hours || '未提供'}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>发布者</InfoLabel>
|
||||
<InfoValue>{service.provider_name || '未知'}</InfoValue>
|
||||
</InfoRow>
|
||||
</InfoSection>
|
||||
|
||||
{service.address && (
|
||||
<p><strong>地址:</strong> {service.address}</p>
|
||||
)}
|
||||
{service.contact && (
|
||||
<p><strong>联系方式:</strong> {service.contact}</p>
|
||||
)}
|
||||
<RatingSection>
|
||||
<RatingTitle>给这个服务评分</RatingTitle>
|
||||
<Stars>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
filled={star <= (userRating || Math.round(rating))}
|
||||
onClick={() => handleRate(star)}
|
||||
>
|
||||
★
|
||||
</Star>
|
||||
))}
|
||||
<RatingText>
|
||||
{rating.toFixed(1)} ({reviewsCount} 条评价)
|
||||
</RatingText>
|
||||
</Stars>
|
||||
</RatingSection>
|
||||
|
||||
<Rating>
|
||||
<span>评分:</span>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
filled={star <= Math.round(stats.avg_rating || 0)}
|
||||
onClick={() => handleRate(star)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
★
|
||||
</Star>
|
||||
))}
|
||||
<span>
|
||||
{stats.avg_rating || 0} ({stats.ratings_count || 0} 评分)
|
||||
</span>
|
||||
</Rating>
|
||||
|
||||
<p>
|
||||
<strong>点赞:</strong> {stats.likes_count || 0} |
|
||||
<strong>评论:</strong> {stats.comments_count || 0}
|
||||
</p>
|
||||
<Actions>
|
||||
<ActionButton primary onClick={handleLike}>
|
||||
🤍 点赞
|
||||
</ActionButton>
|
||||
<ActionButton secondary onClick={handleContact}>
|
||||
📞 联系
|
||||
</ActionButton>
|
||||
<ActionButton secondary>
|
||||
🔗 分享
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
|
||||
<CommentsSection>
|
||||
<h2>评论 ({stats.comments_count || 0})</h2>
|
||||
<SectionTitle>💬 评价 ({reviewsCount})</SectionTitle>
|
||||
|
||||
<CommentForm onSubmit={handleComment}>
|
||||
<textarea
|
||||
placeholder="写下你的评论..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
{authStore.isAuthenticated ? (
|
||||
<CommentForm
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
// TODO: 实现评价创建
|
||||
setComment('');
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
placeholder="分享你的使用体验..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<ActionButton type="submit" primary style={{ marginTop: '10px' }}>
|
||||
发表评价
|
||||
</ActionButton>
|
||||
</CommentForm>
|
||||
) : (
|
||||
<Card
|
||||
title="登录后评价"
|
||||
description="登录后可发表评价和参与互动"
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ cursor: 'pointer', marginBottom: '20px' }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
发表评论
|
||||
</button>
|
||||
</CommentForm>
|
||||
)}
|
||||
|
||||
{service.currentService.comments && service.currentService.comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '15px',
|
||||
borderRadius: '5px',
|
||||
marginBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<p><strong>{comment.author_username}</strong></p>
|
||||
<p>{comment.content}</p>
|
||||
<p style={{ color: '#999', fontSize: '14px' }}>
|
||||
{comment.created_at}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<CommentList>
|
||||
{/* TODO: 加载并显示评价 */}
|
||||
<CommentItem>
|
||||
<div className="author">暂无评价</div>
|
||||
<div className="content">成为第一个评价的人吧!</div>
|
||||
</CommentItem>
|
||||
</CommentList>
|
||||
</CommentsSection>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
201
frontend/src/components/service/ServicesPage.js
Normal file
201
frontend/src/components/service/ServicesPage.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useServiceStore } from '../../stores/ServiceStore';
|
||||
import { useRegionStore } from '../../stores/RegionStore';
|
||||
import styled from 'styled-components';
|
||||
import Card from '../common/Card';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 25px;
|
||||
padding: 20px 0;
|
||||
`;
|
||||
|
||||
const FilterBar = styled.div`
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Select = styled.select`
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
padding: 10px 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const Rating = styled.span`
|
||||
color: #ffc107;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const ServicesPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const serviceStore = useServiceStore();
|
||||
const regionStore = useRegionStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
serviceStore.fetchServices();
|
||||
regionStore.fetchProvinces();
|
||||
}, [serviceStore, regionStore]);
|
||||
|
||||
const handleServiceClick = (serviceId) => {
|
||||
navigate(`/services/${serviceId}`);
|
||||
};
|
||||
|
||||
const handleRegionChange = (e) => {
|
||||
const regionId = e.target.value;
|
||||
if (regionId) {
|
||||
setSearchParams({ region: regionId });
|
||||
serviceStore.fetchServicesByRegion(regionId);
|
||||
} else {
|
||||
setSearchParams({});
|
||||
serviceStore.fetchServices();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
serviceStore.searchServices(searchQuery);
|
||||
} else {
|
||||
serviceStore.fetchServices();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedRegion = searchParams.get('region');
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>🛠️ 本地服务</Title>
|
||||
<Subtitle>发现身边的优质生活服务</Subtitle>
|
||||
</Header>
|
||||
|
||||
<FilterBar>
|
||||
<Select value={selectedRegion || ''} onChange={handleRegionChange}>
|
||||
<option value="">全部地区</option>
|
||||
{regionStore.regions.map((region) => (
|
||||
<option key={region.id} value={region.id}>
|
||||
{region.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '10px', flex: 1 }}>
|
||||
<SearchInput
|
||||
type="text"
|
||||
placeholder="搜索服务名称或描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#667eea',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
</FilterBar>
|
||||
|
||||
{serviceStore.loading ? (
|
||||
<Loading message="加载服务..." />
|
||||
) : serviceStore.error ? (
|
||||
<ErrorMessage
|
||||
message={serviceStore.error}
|
||||
onDismiss={() => (serviceStore.error = null)}
|
||||
/>
|
||||
) : serviceStore.services && serviceStore.services.length > 0 ? (
|
||||
<Grid>
|
||||
{serviceStore.services.map((service) => (
|
||||
<Card
|
||||
key={service.id}
|
||||
title={service.name}
|
||||
description={service.description}
|
||||
tags={service.categories?.map((cat) => cat.name) || []}
|
||||
meta={
|
||||
<span>
|
||||
📍 {service.region_name || '未知'} ·{' '}
|
||||
<Rating>
|
||||
{'⭐'.repeat(Math.floor(service.rating || 0))}{' '}
|
||||
{service.rating?.toFixed(1) || '新'}
|
||||
</Rating>{' '}
|
||||
· 👁 {service.views || 0}
|
||||
</span>
|
||||
}
|
||||
onClick={() => handleServiceClick(service.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card
|
||||
title="暂无服务"
|
||||
description="还没有服务信息,成为第一个发布者吧!"
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
export default ServicesPage;
|
||||
308
frontend/src/components/user/UserProfilePage.js
Normal file
308
frontend/src/components/user/UserProfilePage.js
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useAuthStore } from '../../stores/AuthStore';
|
||||
import { useUserStore } from '../../stores/UserStore';
|
||||
import styled from 'styled-components';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const ProfileCard = styled.div`
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
`;
|
||||
|
||||
const ProfileHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
`;
|
||||
|
||||
const Avatar = styled.div`
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const UserName = styled.h2`
|
||||
margin: 0 0 5px;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const UserEmail = styled.p`
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StatsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
`;
|
||||
|
||||
const StatItem = styled.div`
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const StatNumber = styled.div`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
const StatLabel = styled.div`
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
`;
|
||||
|
||||
const Section = styled.div`
|
||||
margin-bottom: 30px;
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h3`
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const InfoRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const InfoLabel = styled.span`
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const InfoValue = styled.span`
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 12px 24px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-right: 10px;
|
||||
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const SecondaryButton = styled.button`
|
||||
padding: 12px 24px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 1px solid #667eea;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
`;
|
||||
|
||||
const UserProfilePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const authStore = useAuthStore();
|
||||
const userStore = useUserStore();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
userStore.fetchCurrentUser();
|
||||
}, [authStore, userStore, navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
userStore.clearUser();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
return (
|
||||
<Container>
|
||||
<Loading message="验证中..." />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (userStore.loading) {
|
||||
return <Loading message="加载用户信息..." />;
|
||||
}
|
||||
|
||||
if (userStore.error) {
|
||||
return (
|
||||
<Container>
|
||||
<ErrorMessage
|
||||
message={userStore.error}
|
||||
onDismiss={() => (userStore.error = null)}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const user = userStore.user;
|
||||
if (!user) {
|
||||
return (
|
||||
<Container>
|
||||
<ErrorMessage message="未找到用户信息" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const getInitials = (name) => {
|
||||
return name?.charAt(0).toUpperCase() || 'U';
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>个人中心</Title>
|
||||
</Header>
|
||||
|
||||
<ProfileCard>
|
||||
<ProfileHeader>
|
||||
<Avatar>{getInitials(user.username)}</Avatar>
|
||||
<UserInfo>
|
||||
<UserName>{user.username}</UserName>
|
||||
<UserEmail>{user.email}</UserEmail>
|
||||
</UserInfo>
|
||||
</ProfileHeader>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>📊 我的统计</SectionTitle>
|
||||
<StatsGrid>
|
||||
<StatItem>
|
||||
<StatNumber>{user.articles_count || 0}</StatNumber>
|
||||
<StatLabel>文章</StatLabel>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<StatNumber>{user.services_count || 0}</StatNumber>
|
||||
<StatLabel>服务</StatLabel>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<StatNumber>{user.comments_count || 0}</StatNumber>
|
||||
<StatLabel>评论</StatLabel>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<StatNumber>{user.likes_count || 0}</StatNumber>
|
||||
<StatLabel>点赞</StatLabel>
|
||||
</StatItem>
|
||||
<StatItem>
|
||||
<StatNumber>{user.favorites_count || 0}</StatNumber>
|
||||
<StatLabel>收藏</StatLabel>
|
||||
</StatItem>
|
||||
</StatsGrid>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>ℹ️ 账户信息</SectionTitle>
|
||||
<InfoRow>
|
||||
<InfoLabel>用户名</InfoLabel>
|
||||
<InfoValue>{user.username}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>邮箱</InfoLabel>
|
||||
<InfoValue>{user.email}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>角色</InfoLabel>
|
||||
<InfoValue>{user.role_display || '普通用户'}</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>注册时间</InfoLabel>
|
||||
<InfoValue>
|
||||
{user.date_joined ? new Date(user.date_joined).toLocaleDateString('zh-CN') : '未知'}
|
||||
</InfoValue>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<InfoLabel>上次登录</InfoLabel>
|
||||
<InfoValue>
|
||||
{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '未知'}
|
||||
</InfoValue>
|
||||
</InfoRow>
|
||||
</Section>
|
||||
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<SecondaryButton onClick={() => setEditing(!editing)}>
|
||||
{editing ? '取消编辑' : '编辑资料'}
|
||||
</SecondaryButton>
|
||||
<Button onClick={handleLogout} style={{ background: '#dc3545' }}>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</ProfileCard>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserProfilePage;
|
||||
@@ -7,147 +7,233 @@ class ArticleStore {
|
||||
currentArticle = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
pagination = {
|
||||
count: 0,
|
||||
next: null,
|
||||
previous: null,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async fetchArticles(params = {}) {
|
||||
async fetchArticles(page = 1, pageSize = 12) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/articles/', { params });
|
||||
const response = await api.get('/api/articles/', {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
this.articles = response.data.results || response.data;
|
||||
this.pagination = {
|
||||
count: response.data.count || this.articles.length,
|
||||
next: response.data.next,
|
||||
previous: response.data.previous,
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil((response.data.count || this.articles.length) / pageSize),
|
||||
};
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取文章列表失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLatestArticles(limit = 6) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/articles/latest/', {
|
||||
params: { limit },
|
||||
});
|
||||
this.articles = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取最新文章失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticlesByRegion(regionId, page = 1) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${regionId}/articles/`, {
|
||||
params: { page },
|
||||
});
|
||||
this.articles = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch articles';
|
||||
this.error = error.response?.data?.detail || '获取地区文章失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticle(id) {
|
||||
async searchArticles(query, page = 1) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${id}/`);
|
||||
const response = await api.get('/api/articles/search/', {
|
||||
params: { q: query, page },
|
||||
});
|
||||
this.articles = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '搜索文章失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticle(articleId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${articleId}/`);
|
||||
this.currentArticle = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch article';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createArticle(data) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/articles/', data);
|
||||
return { success: true, article: response.data };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to create article';
|
||||
return { success: false, error: this.error };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateArticle(id, data) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.put(`/api/articles/${id}/`, data);
|
||||
return { success: true, article: response.data };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to update article';
|
||||
return { success: false, error: this.error };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteArticle(id) {
|
||||
try {
|
||||
await api.delete(`/api/articles/${id}/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to delete article',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async submitArticle(id) {
|
||||
try {
|
||||
await api.post(`/api/articles/${id}/submit/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to submit article',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async approveArticle(id, reason = '') {
|
||||
try {
|
||||
await api.post(`/api/articles/${id}/approve/`, { action: 'approve', reason });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to approve article',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async rejectArticle(id, reason) {
|
||||
try {
|
||||
await api.post(`/api/articles/${id}/reject/`, { action: 'reject', reason });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to reject article',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async likeArticle(id) {
|
||||
try {
|
||||
const response = await api.post(`/api/articles/${id}/like/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取文章详情失败';
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createArticle(articleData) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/articles/', articleData);
|
||||
return { success: true, article: response.data };
|
||||
} catch (error) {
|
||||
const errors = error.response?.data;
|
||||
if (errors) {
|
||||
const errorMessages = [];
|
||||
Object.keys(errors).forEach((key) => {
|
||||
const value = errors[key];
|
||||
if (Array.isArray(value)) {
|
||||
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||
} else if (typeof value === 'string') {
|
||||
errorMessages.push(`${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
this.error = errorMessages.join('; ') || '创建文章失败';
|
||||
} else {
|
||||
this.error = '创建文章失败';
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateArticle(articleId, articleData) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.patch(`/api/articles/${articleId}/`, articleData);
|
||||
this.currentArticle = response.data;
|
||||
return { success: true, article: response.data };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '更新文章失败';
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteArticle(articleId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/articles/${articleId}/`);
|
||||
this.articles = this.articles.filter((a) => a.id !== articleId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '删除文章失败';
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async likeArticle(articleId) {
|
||||
try {
|
||||
const response = await api.post(`/api/articles/${articleId}/like/`);
|
||||
if (this.currentArticle && this.currentArticle.id === articleId) {
|
||||
this.currentArticle = { ...this.currentArticle, ...response.data };
|
||||
}
|
||||
this.articles = this.articles.map((a) =>
|
||||
a.id === articleId ? { ...a, ...response.data } : a
|
||||
);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || '点赞失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async favoriteArticle(articleId) {
|
||||
try {
|
||||
await api.post(`/api/articles/${articleId}/favorite/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || '收藏失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getArticleRating(articleId) {
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${articleId}/my_rating/`);
|
||||
return response.data.score;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticleComments(id) {
|
||||
async rateArticle(articleId, score) {
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${id}/comments/`);
|
||||
return response.data;
|
||||
await api.post(`/api/articles/${articleId}/rate/`, { score });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticleStats(id) {
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${id}/stats/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || '评分失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentArticle() {
|
||||
this.currentArticle = null;
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticleStore;
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import React from 'react';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import axios from 'axios';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import api from '../services/api';
|
||||
|
||||
class AuthStore {
|
||||
token = localStorage.getItem('token') || null;
|
||||
refreshToken = localStorage.getItem('refresh') || null;
|
||||
isAuthenticated = !!localStorage.getItem('token');
|
||||
loading = false;
|
||||
error = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async login(email, password) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login/', {
|
||||
email,
|
||||
@@ -18,28 +26,118 @@ class AuthStore {
|
||||
});
|
||||
|
||||
this.token = response.data.access;
|
||||
this.refreshToken = response.data.refresh;
|
||||
this.isAuthenticated = true;
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('refresh', response.data.refresh);
|
||||
localStorage.setItem('refresh', this.refreshToken);
|
||||
|
||||
// 设置全局认证头
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || error.response?.data?.message || '登录失败,请检查账号和密码';
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || 'Login failed',
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async register(username, email, password, confirmPassword) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
this.error = '两次输入的密码不一致';
|
||||
this.loading = false;
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/register/', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// 注册成功后自动登录
|
||||
if (response.data.access) {
|
||||
this.token = response.data.access;
|
||||
this.refreshToken = response.data.refresh;
|
||||
this.isAuthenticated = true;
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('refresh', this.refreshToken);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
// 处理字段级错误
|
||||
const errors = error.response?.data;
|
||||
if (errors) {
|
||||
const errorMessages = [];
|
||||
Object.keys(errors).forEach((key) => {
|
||||
const value = errors[key];
|
||||
if (Array.isArray(value)) {
|
||||
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||
} else if (typeof value === 'string') {
|
||||
errorMessages.push(`${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
this.error = errorMessages.join('; ') || '注册失败';
|
||||
} else {
|
||||
this.error = '注册失败,请稍后重试';
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
this.refreshToken = null;
|
||||
this.isAuthenticated = false;
|
||||
this.error = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
|
||||
async refreshTokenIfNeeded() {
|
||||
if (!this.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/auth/token/refresh/', {
|
||||
refresh: this.refreshToken,
|
||||
});
|
||||
|
||||
this.token = response.data.access;
|
||||
localStorage.setItem('token', this.token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Token 刷新失败,需要重新登录
|
||||
this.logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthStore;
|
||||
|
||||
@@ -7,159 +7,245 @@ class ServiceStore {
|
||||
currentService = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
pagination = {
|
||||
count: 0,
|
||||
next: null,
|
||||
previous: null,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async fetchServices(params = {}) {
|
||||
async fetchServices(page = 1, pageSize = 12) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/services/', { params });
|
||||
const response = await api.get('/api/services/', {
|
||||
params: { page, page_size: pageSize },
|
||||
});
|
||||
this.services = response.data.results || response.data;
|
||||
this.pagination = {
|
||||
count: response.data.count || this.services.length,
|
||||
next: response.data.next,
|
||||
previous: response.data.previous,
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil((response.data.count || this.services.length) / pageSize),
|
||||
};
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取服务列表失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchFeaturedServices(limit = 6) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/services/featured/', {
|
||||
params: { limit },
|
||||
});
|
||||
this.services = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取精选服务失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchServicesByRegion(regionId, page = 1) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${regionId}/services/`, {
|
||||
params: { page },
|
||||
});
|
||||
this.services = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch services';
|
||||
this.error = error.response?.data?.detail || '获取地区服务失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchService(id) {
|
||||
async searchServices(query, page = 1) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/services/${id}/`);
|
||||
const response = await api.get('/api/services/search/', {
|
||||
params: { q: query, page },
|
||||
});
|
||||
this.services = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '搜索服务失败';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchService(serviceId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/services/${serviceId}/`);
|
||||
this.currentService = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch service';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createService(data) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/services/', data);
|
||||
return { success: true, service: response.data };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to create service';
|
||||
return { success: false, error: this.error };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateService(id, data) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.put(`/api/services/${id}/`, data);
|
||||
return { success: true, service: response.data };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to update service';
|
||||
return { success: false, error: this.error };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteService(id) {
|
||||
try {
|
||||
await api.delete(`/api/services/${id}/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to delete service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async submitService(id) {
|
||||
try {
|
||||
await api.post(`/api/services/${id}/submit/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to submit service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async approveService(id, reason = '') {
|
||||
try {
|
||||
await api.post(`/api/services/${id}/approve/`, { action: 'approve', reason });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to approve service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async rejectService(id, reason) {
|
||||
try {
|
||||
await api.post(`/api/services/${id}/reject/`, { action: 'reject', reason });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to reject service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async likeService(id) {
|
||||
try {
|
||||
const response = await api.post(`/api/services/${id}/like/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取服务详情失败';
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createService(serviceData) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/services/', serviceData);
|
||||
return { success: true, service: response.data };
|
||||
} catch (error) {
|
||||
const errors = error.response?.data;
|
||||
if (errors) {
|
||||
const errorMessages = [];
|
||||
Object.keys(errors).forEach((key) => {
|
||||
const value = errors[key];
|
||||
if (Array.isArray(value)) {
|
||||
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||
} else if (typeof value === 'string') {
|
||||
errorMessages.push(`${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
this.error = errorMessages.join('; ') || '创建服务失败';
|
||||
} else {
|
||||
this.error = '创建服务失败';
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateService(serviceId, serviceData) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.patch(`/api/services/${serviceId}/`, serviceData);
|
||||
this.currentService = response.data;
|
||||
return { success: true, service: response.data };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '更新服务失败';
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteService(serviceId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/services/${serviceId}/`);
|
||||
this.services = this.services.filter((s) => s.id !== serviceId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '删除服务失败';
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async likeService(serviceId) {
|
||||
try {
|
||||
const response = await api.post(`/api/services/${serviceId}/like/`);
|
||||
if (this.currentService && this.currentService.id === serviceId) {
|
||||
this.currentService = { ...this.currentService, ...response.data };
|
||||
}
|
||||
this.services = this.services.map((s) =>
|
||||
s.id === serviceId ? { ...s, ...response.data } : s
|
||||
);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || '点赞失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async favoriteService(serviceId) {
|
||||
try {
|
||||
await api.post(`/api/services/${serviceId}/favorite/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || '收藏失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getServiceRating(serviceId) {
|
||||
try {
|
||||
const response = await api.get(`/api/services/${serviceId}/my_rating/`);
|
||||
return response.data.score;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async rateService(id, score) {
|
||||
async rateService(serviceId, score) {
|
||||
try {
|
||||
await api.post(`/api/services/${id}/rate/`, { score });
|
||||
await api.post(`/api/services/${serviceId}/rate/`, { score });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to rate service',
|
||||
error: error.response?.data?.detail || '评分失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fetchServiceComments(id) {
|
||||
async bookService(serviceId, bookingData) {
|
||||
try {
|
||||
const response = await api.get(`/api/services/${id}/comments/`);
|
||||
return response.data;
|
||||
const response = await api.post(`/api/services/${serviceId}/bookings/`, bookingData);
|
||||
return { success: true, booking: response.data };
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchServiceStats(id) {
|
||||
try {
|
||||
const response = await api.get(`/api/services/${id}/stats/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || '预约失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentService() {
|
||||
this.currentService = null;
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ServiceStore;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import axios from 'axios';
|
||||
import api from '../services/api';
|
||||
|
||||
class UserStore {
|
||||
user = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
editing = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -16,10 +17,120 @@ class UserStore {
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/users/me/');
|
||||
const response = await api.get('/api/users/me/');
|
||||
this.user = response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch user';
|
||||
this.error = error.response?.data?.detail || '获取用户信息失败';
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(userData) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.patch('/api/users/me/', userData);
|
||||
this.user = response.data;
|
||||
return { success: true, user: response.data };
|
||||
} catch (error) {
|
||||
const errors = error.response?.data;
|
||||
if (errors) {
|
||||
const errorMessages = [];
|
||||
Object.keys(errors).forEach((key) => {
|
||||
const value = errors[key];
|
||||
if (Array.isArray(value)) {
|
||||
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||
} else if (typeof value === 'string') {
|
||||
errorMessages.push(`${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
this.error = errorMessages.join('; ') || '更新失败';
|
||||
} else {
|
||||
this.error = '更新用户信息失败';
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(currentPassword, newPassword, confirmPassword) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
this.error = '两次输入的新密码不一致';
|
||||
this.loading = false;
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/users/change_password/', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '修改密码失败';
|
||||
return {
|
||||
success: false,
|
||||
error: this.error,
|
||||
};
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserProfile(userId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/users/${userId}/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取用户信息失败';
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserArticles(userId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/users/${userId}/articles/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取用户文章失败';
|
||||
return [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserServices(userId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/users/${userId}/services/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || '获取用户服务失败';
|
||||
return [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -27,6 +138,16 @@ class UserStore {
|
||||
|
||||
clearUser() {
|
||||
this.user = null;
|
||||
this.error = null;
|
||||
this.editing = false;
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
setEditing(value) {
|
||||
this.editing = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,135 @@ const GlobalStyle = createGlobalStyle`
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue',
|
||||
'Arial', 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #5568d3;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 1rem; }
|
||||
.mt-4 { margin-top: 1.5rem; }
|
||||
.mt-5 { margin-top: 3rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 1rem; }
|
||||
.mb-4 { margin-bottom: 1.5rem; }
|
||||
.mb-5 { margin-bottom: 3rem; }
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user