Initial commit: React + Django 城市手册项目
- Django 4.2 + DRF + JWT + GraphQL - React 18 + MobX + styled-components - PostgreSQL 数据库 - Docker + Docker Compose + Nginx - 完整的功能模块(用户、版块、文章、服务、交互、版主管理) - 完整的文档(需求、部署、测试)
This commit is contained in:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
REACT_APP_ENV=development
|
||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM node:18-alpine as build
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy project
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /graphql {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /media {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
location /static {
|
||||
proxy_pass http://backend:8000;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
}
|
||||
48
frontend/package.json
Normal file
48
frontend/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "react-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react-lite": "^4.0.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"styled-components": "^6.1.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"eslint": "^8.55.0",
|
||||
"prettier": "^3.1.0"
|
||||
},
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
15
frontend/public/index.html
Normal file
15
frontend/public/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="React + Django App" />
|
||||
<title>React + Django App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
144
frontend/src/App.js
Normal file
144
frontend/src/App.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
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 Layout from './components/common/Layout';
|
||||
import Loading from './components/common/Loading';
|
||||
import CitiesPage from './components/region/CitiesPage';
|
||||
import CityDetailPage from './components/region/CityDetailPage';
|
||||
import ArticleDetailPage from './components/article/ArticleDetailPage';
|
||||
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;
|
||||
`;
|
||||
|
||||
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="/articles/:articleId" element={<ArticleDetailPageWrapper />} />
|
||||
<Route path="/services/:serviceId" element={<ServiceDetailPageWrapper />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/user/profile" element={<UserProfilePage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const ArticleDetailPageWrapper = observer(() => {
|
||||
const { articleId } = useParams();
|
||||
return <ArticleDetailPage articleId={articleId} />;
|
||||
});
|
||||
|
||||
const ServiceDetailPageWrapper = observer(() => {
|
||||
const { serviceId } = useParams();
|
||||
return <ServiceDetailPage serviceId={serviceId} />;
|
||||
});
|
||||
|
||||
const HomePage = observer(() => {
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>欢迎来到城市手册</Title>
|
||||
<p>探索每个城市的故事与特色</p>
|
||||
</Header>
|
||||
<div>
|
||||
<h2>热门城市</h2>
|
||||
<p>即将推出...</p>
|
||||
</div>
|
||||
<div>
|
||||
<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;
|
||||
183
frontend/src/components/article/ArticleDetailPage.js
Normal file
183
frontend/src/components/article/ArticleDetailPage.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const Content = styled.div`
|
||||
line-height: 1.8;
|
||||
color: #333;
|
||||
|
||||
h1, h2, h3 {
|
||||
color: #2c3e50;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Actions = styled.div`
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
${props => props.primary && `
|
||||
background: #667eea;
|
||||
color: white;
|
||||
`}
|
||||
|
||||
${props => props.secondary && `
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
`}
|
||||
`;
|
||||
|
||||
const CommentsSection = styled.div`
|
||||
margin-top: 40px;
|
||||
`;
|
||||
|
||||
const CommentForm = styled.form`
|
||||
margin-bottom: 20px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
resize: vertical;
|
||||
}
|
||||
`;
|
||||
|
||||
const CommentList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
`;
|
||||
|
||||
const CommentItem = styled.div`
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
const ArticleDetailPage = observer(({ articleId }) => {
|
||||
const articleStore = useArticleStore();
|
||||
const interactionStore = useInteractionStore();
|
||||
const navigate = useNavigate();
|
||||
const [comment, setComment] = useState('');
|
||||
const [liked, setLiked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
articleStore.fetchArticle(articleId);
|
||||
articleStore.fetchArticleComments(articleId);
|
||||
articleStore.fetchArticleStats(articleId);
|
||||
}, [articleId, articleStore]);
|
||||
|
||||
const handleLike = async () => {
|
||||
const result = await articleStore.likeArticle(articleId);
|
||||
if (result) {
|
||||
setLiked(result.liked);
|
||||
articleStore.fetchArticleStats(articleId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComment = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
|
||||
const result = await interactionStore.createComment('article', articleId, comment);
|
||||
if (result.success) {
|
||||
setComment('');
|
||||
articleStore.fetchArticleComments(articleId);
|
||||
}
|
||||
};
|
||||
|
||||
if (articleStore.loading) {
|
||||
return <Loading message="加载文章详情..." />;
|
||||
}
|
||||
|
||||
if (articleStore.error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={articleStore.error}
|
||||
onDismiss={() => articleStore.error = null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!articleStore.currentArticle) {
|
||||
return <ErrorMessage message="文章不存在" />;
|
||||
}
|
||||
|
||||
const article = articleStore.currentArticle;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{article.title}</h1>
|
||||
<p>作者: {article.author_username} | {article.article_type_display}</p>
|
||||
|
||||
<Content dangerouslySetInnerHTML={{ __html: article.content }} />
|
||||
|
||||
<Actions>
|
||||
<Button primary onClick={handleLike}>
|
||||
{liked ? '已点赞' : '点赞'}
|
||||
</Button>
|
||||
<Button secondary>
|
||||
收藏
|
||||
</Button>
|
||||
<Button secondary>
|
||||
分享
|
||||
</Button>
|
||||
</Actions>
|
||||
|
||||
<CommentsSection>
|
||||
<h2>评论 ({articleStore.currentArticle.comments_count})</h2>
|
||||
|
||||
<CommentForm onSubmit={handleComment}>
|
||||
<textarea
|
||||
placeholder="写下你的评论..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</CommentList>
|
||||
</CommentsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArticleDetailPage;
|
||||
143
frontend/src/components/auth/LoginPage.js
Normal file
143
frontend/src/components/auth/LoginPage.js
Normal file
@@ -0,0 +1,143 @@
|
||||
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`
|
||||
max-width: 400px;
|
||||
margin: 50px auto;
|
||||
padding: 30px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
`;
|
||||
|
||||
const InputGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: #dc3545;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const Link = styled.a`
|
||||
text-align: center;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const LoginPage = observer(() => {
|
||||
const authStore = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const result = await authStore.login(email, password);
|
||||
if (result.success) {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>登录</Title>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{authStore.error && (
|
||||
<ErrorMessage>{authStore.error}</ErrorMessage>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Button type="submit" disabled={authStore.loading}>
|
||||
{authStore.loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Link href="/register">没有账号?立即注册</Link>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
export default LoginPage;
|
||||
161
frontend/src/components/auth/RegisterPage.js
Normal file
161
frontend/src/components/auth/RegisterPage.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 400px;
|
||||
margin: 50px auto;
|
||||
padding: 30px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
`;
|
||||
|
||||
const InputGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
`;
|
||||
|
||||
const Link = styled.a`
|
||||
text-align: center;
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
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;
|
||||
}
|
||||
|
||||
// TODO: 调用注册 API
|
||||
console.log('Register:', { username, email, password });
|
||||
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Title>注册</Title>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div style={{ color: '#dc3545', fontSize: '14px', textAlign: 'center' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<Label>用户名</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<Label>确认密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Button type="submit">注册</Button>
|
||||
</Form>
|
||||
|
||||
<Link href="/login">已有账号?立即登录</Link>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
67
frontend/src/components/common/Card.js
Normal file
67
frontend/src/components/common/Card.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const CardWrapper = styled.div`
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 0 0 10px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
color: #666;
|
||||
margin: 0 0 15px;
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
`;
|
||||
|
||||
const Tags = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const Tag = styled.span`
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
function Card({ title, description, meta, tags, children, onClick }) {
|
||||
return (
|
||||
<CardWrapper onClick={onClick}>
|
||||
{title && <Title>{title}</Title>}
|
||||
{description && <Description>{description}</Description>}
|
||||
{tags && (
|
||||
<Tags>
|
||||
{tags.map((tag, index) => (
|
||||
<Tag key={index}>{tag}</Tag>
|
||||
))}
|
||||
</Tags>
|
||||
)}
|
||||
{children}
|
||||
{meta && <Meta>{meta}</Meta>}
|
||||
</CardWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
34
frontend/src/components/common/ErrorMessage.js
Normal file
34
frontend/src/components/common/ErrorMessage.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const ErrorWrapper = styled.div`
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
function ErrorMessage({ message, onDismiss }) {
|
||||
return (
|
||||
<ErrorWrapper>
|
||||
{message}
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
float: 'right',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</ErrorWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorMessage;
|
||||
87
frontend/src/components/common/Layout.js
Normal file
87
frontend/src/components/common/Layout.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
`;
|
||||
|
||||
const Header = styled.header`
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 30px;
|
||||
`;
|
||||
|
||||
const HeaderContent = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 28px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
margin: 5px 0 0;
|
||||
opacity: 0.9;
|
||||
`;
|
||||
|
||||
const Nav = styled.nav`
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-left: 20px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Footer = styled.footer`
|
||||
background: #f8f9fa;
|
||||
padding: 30px 0;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
`;
|
||||
|
||||
function Layout({ children, title, subtitle }) {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Container>
|
||||
<HeaderContent>
|
||||
<div>
|
||||
<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>
|
||||
</Nav>
|
||||
</HeaderContent>
|
||||
</Container>
|
||||
</Header>
|
||||
|
||||
<Container>
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
<Footer>
|
||||
<Container>
|
||||
<p>© 2026 城市手册. All rights reserved.</p>
|
||||
</Container>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
37
frontend/src/components/common/Loading.js
Normal file
37
frontend/src/components/common/Loading.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
color: #6c757d;
|
||||
`;
|
||||
|
||||
const Spinner = styled.div`
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 10px;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function Loading({ message = '加载中...' }) {
|
||||
return (
|
||||
<LoadingWrapper>
|
||||
<Spinner />
|
||||
{message}
|
||||
</LoadingWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
65
frontend/src/components/region/CitiesPage.js
Normal file
65
frontend/src/components/region/CitiesPage.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRegionStore } from '../../stores/RegionStore';
|
||||
import Card from '../common/Card';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px 0;
|
||||
`;
|
||||
|
||||
const ProvinceCard = styled(Card)`
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
`;
|
||||
|
||||
const CitiesPage = observer(() => {
|
||||
const regionStore = useRegionStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
regionStore.fetchProvinces();
|
||||
}, [regionStore]);
|
||||
|
||||
const handleProvinceClick = (regionId) => {
|
||||
navigate(`/cities/${regionId}`);
|
||||
};
|
||||
|
||||
if (regionStore.loading) {
|
||||
return <Loading message="加载城市列表..." />;
|
||||
}
|
||||
|
||||
if (regionStore.error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={regionStore.error}
|
||||
onDismiss={() => regionStore.error = null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{regionStore.regions.map((province) => (
|
||||
<ProvinceCard
|
||||
key={province.id}
|
||||
title={province.name}
|
||||
meta={`共 ${province.children_count} 个城市`}
|
||||
onClick={() => handleProvinceClick(province.id)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
});
|
||||
|
||||
export default CitiesPage;
|
||||
177
frontend/src/components/region/CityDetailPage.js
Normal file
177
frontend/src/components/region/CityDetailPage.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRegionStore } from '../../stores/RegionStore';
|
||||
import { useArticleStore } from '../../stores/ArticleStore';
|
||||
import { useServiceStore } from '../../stores/ServiceStore';
|
||||
import Card from '../common/Card';
|
||||
import Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const InfoGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 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 Tabs = styled.div`
|
||||
display: flex;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
margin: 30px 0;
|
||||
`;
|
||||
|
||||
const Tab = styled.button`
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: ${props => props.active ? '#667eea' : '#6c757d'};
|
||||
border-bottom: 2px solid ${props => props.active ? '#667eea' : 'transparent'};
|
||||
margin-bottom: -2px;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
`;
|
||||
|
||||
const ContentGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
`;
|
||||
|
||||
const CityDetailPage = observer(() => {
|
||||
const regionStore = useRegionStore();
|
||||
const articleStore = useArticleStore();
|
||||
const serviceStore = useServiceStore();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState('articles');
|
||||
const { regionId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
regionStore.fetchRegion(regionId);
|
||||
regionStore.fetchChildren(regionId);
|
||||
regionStore.fetchRegionArticles(regionId);
|
||||
regionStore.fetchRegionServices(regionId);
|
||||
}, [regionId, regionStore]);
|
||||
|
||||
const handleCityClick = (cityId) => {
|
||||
navigate(`/cities/${cityId}`);
|
||||
};
|
||||
|
||||
if (regionStore.loading) {
|
||||
return <Loading message="加载城市详情..." />;
|
||||
}
|
||||
|
||||
if (regionStore.error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={regionStore.error}
|
||||
onDismiss={() => regionStore.error = null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!regionStore.currentRegion) {
|
||||
return <ErrorMessage message="城市不存在" />;
|
||||
}
|
||||
|
||||
const region = regionStore.currentRegion;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{region.name}</h1>
|
||||
<p>{region.full_path}</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h2>下级城市</h2>
|
||||
<ContentGrid>
|
||||
{region.children.map((city) => (
|
||||
<Card
|
||||
key={city.id}
|
||||
title={city.name}
|
||||
meta={city.level_display}
|
||||
onClick={() => handleCityClick(city.id)}
|
||||
/>
|
||||
))}
|
||||
</ContentGrid>
|
||||
|
||||
<Tabs>
|
||||
<Tab
|
||||
active={activeTab === 'articles'}
|
||||
onClick={() => setActiveTab('articles')}
|
||||
>
|
||||
文章
|
||||
</Tab>
|
||||
<Tab
|
||||
active={activeTab === 'services'}
|
||||
onClick={() => setActiveTab('services')}
|
||||
>
|
||||
特色服务
|
||||
</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 === '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>
|
||||
);
|
||||
});
|
||||
|
||||
export default CityDetailPage;
|
||||
204
frontend/src/components/service/ServiceDetailPage.js
Normal file
204
frontend/src/components/service/ServiceDetailPage.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 Loading from '../common/Loading';
|
||||
import ErrorMessage from '../common/ErrorMessage';
|
||||
|
||||
const ServiceCard = styled(Card)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Rating = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 10px 0;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
}
|
||||
`;
|
||||
|
||||
const Star = styled.span`
|
||||
color: ${props => props.filled ? '#ffc107' : '#dee2e6'};
|
||||
font-size: 20px;
|
||||
`;
|
||||
|
||||
const CommentsSection = styled.div`
|
||||
margin-top: 40px;
|
||||
`;
|
||||
|
||||
const CommentForm = styled.form`
|
||||
margin-bottom: 20px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
resize: vertical;
|
||||
}
|
||||
`;
|
||||
|
||||
const ServiceDetailPage = observer(({ serviceId }) => {
|
||||
const serviceStore = useServiceStore();
|
||||
const interactionStore = useInteractionStore();
|
||||
const navigate = useNavigate();
|
||||
const [comment, setComment] = useState('');
|
||||
const [liked, setLiked] = useState(false);
|
||||
|
||||
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) => {
|
||||
const result = await serviceStore.rateService(serviceId, score);
|
||||
if (result.success) {
|
||||
serviceStore.fetchServiceStats(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComment = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!comment.trim()) return;
|
||||
|
||||
const result = await interactionStore.createComment('service', serviceId, comment);
|
||||
if (result.success) {
|
||||
setComment('');
|
||||
serviceStore.fetchServiceComments(serviceId);
|
||||
}
|
||||
};
|
||||
|
||||
if (serviceStore.loading) {
|
||||
return <Loading message="加载服务详情..." />;
|
||||
}
|
||||
|
||||
if (serviceStore.error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={serviceStore.error}
|
||||
onDismiss={() => serviceStore.error = null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!serviceStore.currentService) {
|
||||
return <ErrorMessage message="服务不存在" />;
|
||||
}
|
||||
|
||||
const service = serviceStore.currentService;
|
||||
const stats = serviceStore.currentService.stats || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{service.image && (
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '400px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h1>{service.name}</h1>
|
||||
<p>{service.category_display}</p>
|
||||
|
||||
<p>{service.description}</p>
|
||||
|
||||
{service.address && (
|
||||
<p><strong>地址:</strong> {service.address}</p>
|
||||
)}
|
||||
{service.contact && (
|
||||
<p><strong>联系方式:</strong> {service.contact}</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<CommentsSection>
|
||||
<h2>评论 ({stats.comments_count || 0})</h2>
|
||||
|
||||
<CommentForm onSubmit={handleComment}>
|
||||
<textarea
|
||||
placeholder="写下你的评论..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</CommentsSection>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ServiceDetailPage;
|
||||
35
frontend/src/index.js
Normal file
35
frontend/src/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'mobx-react-lite';
|
||||
import App from './App';
|
||||
import './styles/global';
|
||||
|
||||
// Import stores
|
||||
import AuthStore from './stores/AuthStore';
|
||||
import UserStore from './stores/UserStore';
|
||||
import RegionStore from './stores/RegionStore';
|
||||
import ArticleStore from './stores/ArticleStore';
|
||||
import ServiceStore from './stores/ServiceStore';
|
||||
import InteractionStore from './stores/InteractionStore';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
const stores = {
|
||||
authStore: new AuthStore(),
|
||||
userStore: new UserStore(),
|
||||
regionStore: new RegionStore(),
|
||||
articleStore: new ArticleStore(),
|
||||
serviceStore: new ServiceStore(),
|
||||
interactionStore: new InteractionStore(),
|
||||
};
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Provider {...stores}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
54
frontend/src/services/api.js
Normal file
54
frontend/src/services/api.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add token to requests
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Handle token refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh');
|
||||
const response = await axios.post('/api/token/refresh/', {
|
||||
refresh: refreshToken,
|
||||
});
|
||||
|
||||
const newToken = response.data.access;
|
||||
localStorage.setItem('token', newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
5
frontend/src/setupTests.js
Normal file
5
frontend/src/setupTests.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
152
frontend/src/stores/ArticleStore.js
Normal file
152
frontend/src/stores/ArticleStore.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import api from '../services/api';
|
||||
|
||||
class ArticleStore {
|
||||
articles = [];
|
||||
currentArticle = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async fetchArticles(params = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/articles/', { params });
|
||||
this.articles = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch articles';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticle(id) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${id}/`);
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticleComments(id) {
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${id}/comments/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchArticleStats(id) {
|
||||
try {
|
||||
const response = await api.get(`/api/articles/${id}/stats/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentArticle() {
|
||||
this.currentArticle = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticleStore;
|
||||
44
frontend/src/stores/AuthStore.js
Normal file
44
frontend/src/stores/AuthStore.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import axios from 'axios';
|
||||
|
||||
class AuthStore {
|
||||
token = localStorage.getItem('token') || null;
|
||||
isAuthenticated = !!localStorage.getItem('token');
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async login(email, password) {
|
||||
try {
|
||||
const response = await axios.post('/api/auth/login/', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
this.token = response.data.access;
|
||||
this.isAuthenticated = true;
|
||||
localStorage.setItem('token', this.token);
|
||||
localStorage.setItem('refresh', response.data.refresh);
|
||||
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || 'Login failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = null;
|
||||
this.isAuthenticated = false;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthStore;
|
||||
164
frontend/src/stores/InteractionStore.js
Normal file
164
frontend/src/stores/InteractionStore.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import api from '../services/api';
|
||||
|
||||
class InteractionStore {
|
||||
comments = [];
|
||||
ratings = [];
|
||||
likes = [];
|
||||
favorites = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// Comments
|
||||
async createComment(targetType, targetId, content) {
|
||||
try {
|
||||
const response = await api.post('/api/comments/', {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
content,
|
||||
});
|
||||
return { success: true, comment: response.data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to create comment',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fetchComments(targetType, targetId) {
|
||||
try {
|
||||
const response = await api.get('/api/comments/', {
|
||||
params: { target_type: targetType, target_id: targetId },
|
||||
});
|
||||
this.comments = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch comments';
|
||||
}
|
||||
}
|
||||
|
||||
async approveComment(commentId) {
|
||||
try {
|
||||
await api.post(`/api/comments/${commentId}/approve_ai/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to approve comment',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async rejectComment(commentId, reason) {
|
||||
try {
|
||||
await api.post(`/api/comments/${commentId}/reject_ai/`, { reason });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to reject comment',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ratings
|
||||
async createRating(targetType, targetId, score) {
|
||||
try {
|
||||
const response = await api.post('/api/ratings/', {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
score,
|
||||
});
|
||||
return { success: true, rating: response.data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to create rating',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRatings(params = {}) {
|
||||
try {
|
||||
const response = await api.get('/api/ratings/', { params });
|
||||
this.ratings = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch ratings';
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMyRatings() {
|
||||
try {
|
||||
const response = await api.get('/api/ratings/my_ratings/');
|
||||
this.ratings = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch my ratings';
|
||||
}
|
||||
}
|
||||
|
||||
// Likes
|
||||
async toggleLike(targetType, targetId) {
|
||||
try {
|
||||
const response = await api.post('/api/likes/toggle/', {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMyLikes() {
|
||||
try {
|
||||
const response = await api.get('/api/likes/my_likes/');
|
||||
this.likes = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch my likes';
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites
|
||||
async toggleFavorite(targetType, targetId) {
|
||||
try {
|
||||
const response = await api.post('/api/favorites/toggle/', {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMyFavorites() {
|
||||
try {
|
||||
const response = await api.get('/api/favorites/my_favorites/');
|
||||
this.favorites = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch my favorites';
|
||||
}
|
||||
}
|
||||
|
||||
clearComments() {
|
||||
this.comments = [];
|
||||
}
|
||||
|
||||
clearRatings() {
|
||||
this.ratings = [];
|
||||
}
|
||||
|
||||
clearLikes() {
|
||||
this.likes = [];
|
||||
}
|
||||
|
||||
clearFavorites() {
|
||||
this.favorites = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default InteractionStore;
|
||||
139
frontend/src/stores/RegionStore.js
Normal file
139
frontend/src/stores/RegionStore.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import api from '../services/api';
|
||||
|
||||
class RegionStore {
|
||||
regions = [];
|
||||
currentRegion = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async fetchRegions() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/regions/');
|
||||
this.regions = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch regions';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRegion(id) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${id}/`);
|
||||
this.currentRegion = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch region';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchProvinces() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/regions/provinces/');
|
||||
this.regions = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch provinces';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchChildren(parentId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${parentId}/children/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch children';
|
||||
return [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRegionArticles(regionId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${regionId}/articles/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch region articles';
|
||||
return [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRegionServices(regionId) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${regionId}/services/`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch region services';
|
||||
return [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async rateRegion(regionId, score) {
|
||||
try {
|
||||
await api.post(`/api/regions/${regionId}/rate/`, { score });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to rate region',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getRegionRating(regionId) {
|
||||
try {
|
||||
const response = await api.get(`/api/regions/${regionId}/my_rating/`);
|
||||
return response.data.score;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async favoriteRegion(regionId) {
|
||||
try {
|
||||
await api.post(`/api/regions/${regionId}/favorite/`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to favorite region',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentRegion() {
|
||||
this.currentRegion = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default RegionStore;
|
||||
164
frontend/src/stores/ServiceStore.js
Normal file
164
frontend/src/stores/ServiceStore.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import api from '../services/api';
|
||||
|
||||
class ServiceStore {
|
||||
services = [];
|
||||
currentService = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async fetchServices(params = {}) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get('/api/services/', { params });
|
||||
this.services = response.data.results || response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch services';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchService(id) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/services/${id}/`);
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async rateService(id, score) {
|
||||
try {
|
||||
await api.post(`/api/services/${id}/rate/`, { score });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || 'Failed to rate service',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fetchServiceComments(id) {
|
||||
try {
|
||||
const response = await api.get(`/api/services/${id}/comments/`);
|
||||
return 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;
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentService() {
|
||||
this.currentService = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ServiceStore;
|
||||
32
frontend/src/stores/UserStore.js
Normal file
32
frontend/src/stores/UserStore.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import axios from 'axios';
|
||||
|
||||
class UserStore {
|
||||
user = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async fetchCurrentUser() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/users/me/');
|
||||
this.user = response.data;
|
||||
} catch (error) {
|
||||
this.error = error.response?.data || 'Failed to fetch user';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearUser() {
|
||||
this.user = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default UserStore;
|
||||
24
frontend/src/styles/global.js
Normal file
24
frontend/src/styles/global.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
`;
|
||||
|
||||
export default GlobalStyle;
|
||||
13
frontend/start.sh
Executable file
13
frontend/start.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🚀 Starting React Frontend..."
|
||||
|
||||
# 检查 node_modules
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "📦 Installing dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 启动开发服务器
|
||||
echo "🎉 Starting development server on http://localhost:3000"
|
||||
npm start
|
||||
Reference in New Issue
Block a user