merge
Change-Id: I5227831adac7f85854cbe7321c2a3aa39d8c1d7a
diff --git a/src/views/frame/frame.tsx b/src/views/frame/frame.tsx
index f41e53f..83106a1 100644
--- a/src/views/frame/frame.tsx
+++ b/src/views/frame/frame.tsx
@@ -12,8 +12,8 @@
import logo from "&/assets/logo.png";
import { useAppDispatch } from "@/hooks/store";
import { useSelector } from "react-redux";
-
import { checkAndRefreshToken } from "@/utils/jwt";
+import { useNavigate } from "react-router-dom";
const Frame:React.FC = () => {
useEffect(() => {
@@ -23,6 +23,11 @@
const showSearch = useSelector((state: any) => state.setting.showSearch);
const theme= useSelector((state: any) => state.setting.theme);
+
+ const navigate = useNavigate(); // ✅ 用于跳转
+ const [searchText, setSearchText] = useState(""); // ✅ 存储搜索输入内容
+
+
const toggleSearch = () => {
dispatch({ type: "setting/toggleSearch" });
}
@@ -35,12 +40,27 @@
dispatch({ type: "setting/toggleTheme" });
};
+ // ✅ 用于跳转
+ const handleSearch = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter" && searchText.trim() !== "") {
+ navigate(`/search?keyword=${encodeURIComponent(searchText.trim())}`);
+ }
+ };
+
return (
<div style={{ display: 'block', height: '100vh' }}>
<header className={style.header}>
<img className={style.logo} src={logo} alt="website logo"></img>
- {showSearch && (<input className={style.searchInput} placeholder="输入关键词进行搜索"/>)}
+ {showSearch && (
+ // <input className={style.searchInput} placeholder="输入关键词进行搜索"/>
+ <input
+ className={style.searchInput}
+ placeholder="输入关键词进行搜索"
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ onKeyDown={handleSearch} // ⌨️ 按下回车时执行跳转
+ />)}
<div className={style.toollist}>
<SearchOutlined onClick={toggleSearch}/>
<FontSizeOutlined onClick={toggleFontSize}/>
diff --git a/src/views/homepage/homepage.module.css b/src/views/homepage/homepage.module.css
new file mode 100644
index 0000000..fdcc060
--- /dev/null
+++ b/src/views/homepage/homepage.module.css
@@ -0,0 +1,249 @@
+/* 主题色变量 */
+/* :root {
+ --primary-color: #3498db;
+ --primary-hover: #2980b9;
+ --secondary-color: #f1c40f;
+ --dark-color: #2c3e50;
+ --light-color: #ecf0f1;
+ --text-color: #333;
+ --text-secondary: #7f8c8d;
+ --border-color: #ddd;
+ } */
+
+ /* --bg-color: #2b2b2b;
+ --text-color: #f1f1f1;
+ --card-bg: #1e1e1e;
+ --border-color: #444444; */
+
+ .container {
+ min-height: 100vh;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ }
+
+ /* 顶部导航栏 */
+ .header {
+ display: flex;
+ align-items: center;
+ padding: 15px 30px;
+ background-color: solid var(--card-bg);
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ }
+
+ .logo {
+ height: 40px;
+ cursor: pointer;
+ transition: transform 0.3s;
+ }
+
+ .logo:hover {
+ transform: scale(1.05);
+ }
+
+ .siteTitle {
+ margin: 0 0 0 15px;
+ color: var(--text-color);
+ font-size: 24px;
+ }
+
+ /* 主内容区 */
+ .mainContent {
+ display: flex;
+ min-height: calc(100vh - 70px);
+ padding: 20px;
+ gap: 20px;
+ }
+
+ /* 左侧用户信息区 */
+ .userProfile {
+ flex: 2;
+ background-color: var(--card-bg);
+ border-radius: 10px;
+ padding: 25px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
+ }
+
+ .userHeader {
+ display: flex;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+
+ .userAvatar {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 3px solid var(--primary-color);
+ margin-right: 20px;
+ }
+
+ .userInfo {
+ flex: 1;
+ }
+
+ .username {
+ margin: 0 0 5px 0;
+ color: var(--text-color);
+ font-size: 22px;
+ }
+
+ .inviteCode {
+ color: var(--text-color);
+ font-size: 14px;
+ margin-bottom: 10px;
+ }
+
+ .editButton {
+ padding: 8px 20px;
+ background-color: var(--primary-color);
+ color: white;
+ border: none;
+ border-radius: 20px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.3s;
+ }
+
+ .editButton:hover {
+ background-color: var(--primary-hover);
+ }
+
+ /* 用户统计 */
+ .userStats {
+ display: flex;
+ justify-content: space-between;
+ margin: 25px 0;
+ padding: 15px 0;
+ border-top: 1px solid var(--border-color);
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .statItem {
+ text-align: center;
+ padding: 0 10px;
+ }
+
+ .statNumber {
+ font-size: 22px;
+ font-weight: bold;
+ color: var(--primary-color);
+ }
+
+ .statLabel {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin-top: 5px;
+ }
+
+ /* 用户数据 */
+ .userData {
+ background-color: var(--light-color);
+ padding: 15px;
+ border-radius: 8px;
+ margin-bottom: 25px;
+ }
+
+ .dataItem {
+ margin-bottom: 8px;
+ font-size: 15px;
+ }
+
+ .dataItem strong {
+ color: var(--primary-color);
+ }
+
+ /* 作品区 */
+ .worksSection {
+ margin-top: 30px;
+ }
+
+ .sectionTitle {
+ margin: 0 0 20px 0;
+ color: var(--dark-color);
+ font-size: 18px;
+ padding-bottom: 10px;
+ border-bottom: 2px solid var(--primary-color);
+ }
+
+ .workItem {
+ padding: 15px;
+ margin-bottom: 15px;
+ background-color: var(--primary-card);
+ border-radius: 8px;
+ border-left: 4px solid var(--primary-color);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ transition: transform 0.3s, box-shadow 0.3s;
+ }
+
+ .workItem:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
+ }
+
+ .workTitle {
+ margin: 0 0 10px 0;
+ color: var(--text-color);
+ font-size: 16px;
+ }
+
+ .workMeta {
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ color: var(--text-color);
+ }
+
+ /* 右侧内容区 */
+ .rightContent {
+ flex: 1;
+ min-width: 300px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ }
+
+ .petSection, .trafficSection {
+ background-color: var(--card-bg);
+ border-radius: 10px;
+ padding: 20px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
+ }
+
+ .petContainer {
+ background-color: var(--card-bg);
+ border-radius: 8px;
+ padding: 15px;
+ text-align: center;
+ }
+
+ .petImage {
+ max-width: 100%;
+ height: auto;
+ border-radius: 5px;
+ }
+
+ .trafficContainer {
+ background-color: white;
+ border-radius: 8px;
+ padding: 10px;
+ text-align: center;
+ border: 1px solid var(--border-color);
+ }
+
+ .trafficImage {
+ max-width: 100%;
+ height: auto;
+ }
+
+ /* 加载和错误状态 */
+ .loading, .error {
+ padding: 20px;
+ text-align: center;
+ color: var(--text-color);
+ }
+
+ .error {
+ color: #e74c3c;
+ }
\ No newline at end of file
diff --git a/src/views/homepage/homepage.tsx b/src/views/homepage/homepage.tsx
new file mode 100644
index 0000000..58c910f
--- /dev/null
+++ b/src/views/homepage/homepage.tsx
@@ -0,0 +1,164 @@
+import React, { useCallback, useEffect } from 'react';
+import styles from './homepage.module.css';
+import { useApi } from '@/hooks/request';
+import { useSelector } from 'react-redux';
+import { RootState } from '@/store';
+import { useNavigate } from 'react-router';
+import logo from '&/assets/logo.png';
+import { getUserMessage } from '@/api/homepage';
+import request from '@/utils/request'
+import { hotPosts } from '@/api/post';
+import { postUserLogin } from '@/api/auth';
+
+interface WorkItem {
+ postId: number,
+ userId: number,
+ postTitle: string,
+ postContent: string,
+ createdAt: number,
+ postType: string,
+ viewCount: number,
+ hotScore: number,
+ lastCalculated: number
+
+}
+
+interface UserStats {
+ likes: number;
+ following: number;
+ followers: number;
+ mutualFollows: number;
+}
+
+interface UserResponse {
+ username: string;
+ inviteCode: string;
+ stats: UserStats;
+ upload: string;
+ level: string;
+ works: WorkItem[];
+ petImage: string;
+ trafficImage: string;
+}
+
+
+const Homepage: React.FC =() => {
+ const navigate = useNavigate();
+ const userInfo = useSelector((state: RootState) => state.user);
+
+ // 获取用户作品数据
+ // const {data: response, loading, error, refresh} =useApi(()=>request.get(getUserMessage), false);
+ const { data: response, loading, error, refresh } = useApi<UserResponse>(() => request.get(getUserMessage), false);
+ useEffect(() => {
+ refresh(); // 页面首次加载时触发请求
+ }, []);
+ // 用户统计数据
+ const userStats = {
+ likes: response?.stats?.likes ?? 0,
+ following: response?.stats?.following ?? 0,
+ followers: response?.stats?.followers ?? 0,
+ mutualFollows: response?.stats?.mutualFollows ?? 0,
+ uploadAmount: response?.upload ?? '--',
+ level: response?.level ?? '--'
+ };
+
+ const handleLogoClick = () => {
+ navigate('/');
+ };
+
+ return (
+ <div className={styles.container}>
+
+
+ {/* 用户信息主区域 */}
+ <div className={styles.mainContent}>
+ {/* 左侧用户信息区 */}
+ <div className={styles.userProfile}>
+ <div className={styles.userHeader}>
+ <img
+ src={userInfo.avatar || '/default-avatar.png'}
+ alt="用户头像"
+ className={styles.userAvatar}
+ />
+ <div className={styles.userInfo}>
+ <h2 className={styles.username}>阳菜,放睛!</h2>
+ <div className={styles.inviteCode}>邀请码:1314520</div>
+ <button className={styles.editButton}>编辑主页</button>
+ <button
+ className={styles.editButton}
+ onClick={() => navigate('/postDetails', {
+ state: { isNewPost: true }
+ })}
+ >
+ 发布种子
+ </button>
+ </div>
+ </div>
+
+ <div className={styles.userStats}>
+ <div className={styles.statItem}>
+ <div className={styles.statNumber}>{userStats.likes}</div>
+ <div className={styles.statLabel}>获赞</div>
+ </div>
+ <div className={styles.statItem}>
+ <div className={styles.statNumber}>{userStats.following}</div>
+ <div className={styles.statLabel}>关注</div>
+ </div>
+ <div className={styles.statItem}>
+ <div className={styles.statNumber}>{userStats.followers}</div>
+ <div className={styles.statLabel}>粉丝</div>
+ </div>
+ <div className={styles.statItem}>
+ <div className={styles.statNumber}>{userStats.mutualFollows}</div>
+ <div className={styles.statLabel}>互关</div>
+ </div>
+ </div>
+
+ <div className={styles.userData}>
+ <div className={styles.dataItem}>
+ <span>您的总上传量为:</span>
+ <strong>{userStats.uploadAmount}</strong>
+ </div>
+ <div className={styles.dataItem}>
+ <span>您的用户等级为:</span>
+ <strong>{userStats.level}</strong>
+ </div>
+ </div>
+
+ <div className={styles.worksSection}>
+ <h3 className={styles.sectionTitle}>我的作品</h3>
+ {loading && <div className={styles.loading}>加载中...</div>}
+ {error && <div className={styles.error}>{error.message}</div>}
+
+ {response && response.works.map(work => (
+ <div key={work.postId} className={styles.workItem}>
+ <h4 className={styles.workTitle}>{work.postTitle}</h4>
+ <div className={styles.workMeta}>
+ <span>发布时间:{work.createdAt}</span>
+ <span>下载量:{work.viewCount} 做种数:{'待定'}</span>
+ </div>
+ </div>
+ )) }
+ </div>
+ </div>
+
+ {/* 右侧内容区 */}
+ <div className={styles.rightContent}>
+ <div className={styles.petSection}>
+ <h3 className={styles.sectionTitle}>宠物图</h3>
+ <div className={styles.petContainer}>
+ <img
+ src="/assets/pet-blue-star.png"
+ alt="蓝色星星宠物"
+ className={styles.petImage}
+ />
+ </div>
+ </div>
+
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default Homepage;
\ No newline at end of file
diff --git a/src/views/login/login.module.css b/src/views/login/login.module.css
index dca1df4..a7fc6f4 100644
--- a/src/views/login/login.module.css
+++ b/src/views/login/login.module.css
@@ -74,6 +74,18 @@
max-width: 300px;
}
+.sendCode {
+ padding:5px;
+ border-radius: 5px;
+ border: none;
+ font-size: 8px;
+ background-color: #ff7300; /* Blue */
+ color: white;
+ cursor: pointer;
+ width: 60px;
+ max-width: 300px;
+}
+
.form .register:hover {
background-color: #0056b3; /* Darker blue */
}
@@ -90,4 +102,20 @@
text-decoration: underline;
align-content: flex-end;
color: #007BFF; /* Blue */
+}
+
+.back {
+ background: none;
+ border: none;
+ text-decoration: underline;
+ cursor: pointer;
+ margin-top: 8px;
+ font-size: 16px;
+ padding: 0;
+ transition: color 0.2s;
+}
+
+.back:hover {
+ color: #0056b3;
+ text-decoration: underline;
}
\ No newline at end of file
diff --git a/src/views/login/login.tsx b/src/views/login/login.tsx
index 562f178..fef6e32 100644
--- a/src/views/login/login.tsx
+++ b/src/views/login/login.tsx
@@ -1,37 +1,46 @@
-import React, { useEffect } from 'react';
+import React, { useState } from 'react';
import { useApi } from '@/hooks/request';
import request from '@/utils/request';
-import { postUserLogin} from '@/api/auth';
+import { postUserLogin } from '@/api/auth';
import { useAppDispatch } from '@/hooks/store';
-import { RootState } from '@/store';
import style from './login.module.css';
-import { useState } from 'react';
-import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router';
import logo from '&/assets/logo.png';
import { getUserInfo } from '@/api/user';
import debounce from 'lodash/debounce';
-import {message} from 'antd';
-
+import { message } from 'antd';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
- const dispatch = useAppDispatch();
- const { refresh: postUserLoginRefresh } = useApi(() => request.post(postUserLogin, {email, password}), false);
- const { refresh: getUserInfoRefresh } = useApi(() => request.get(getUserInfo), false);
- const [messageApi, contextHolder] = message.useMessage();
+ const [showRegister, setShowRegister] = useState(false);
+ const [inviteCode, setInviteCode] = useState('');
+ const [registerEmail, setRegisterEmail] = useState('');
+ const [registerPassword, setRegisterPassword] = useState('');
+ const [emailCode, setEmailCode] = useState('');
+ const [codeBtnDisabled, setCodeBtnDisabled] = useState(false);
+ const [codeBtnText, setCodeBtnText] = useState('发送验证码');
+ const [codeTimer, setCodeTimer] = useState<NodeJS.Timeout | null>(null);
+ const dispatch = useAppDispatch();
+ const [messageApi, contextHolder] = message.useMessage();
+ const { refresh: postUserLoginRefresh } = useApi(
+ () => request.post(postUserLogin, { email, password}), false);
+ const { refresh: getUserInfoRefresh } = useApi(
+ () => request.get(getUserInfo), false);
const nav = useNavigate();
- const showErrorMessage = async (message: string) => {
- messageApi.error(message);
+ const showErrorMessage = async (msg: string) => {
+ messageApi.error(msg);
};
+
+ // 登录逻辑
const handleLogin = debounce(async () => {
try {
- const res = await postUserLoginRefresh();
- console.log(res);
- if (res == null || (res as any).error) {
- throw new Error('登录失败');
+ const res =await postUserLoginRefresh({email, password});
+ console.log("res", res);
+ if (res==null ||(res as any).error) {
+ alert('Login failed. Please check your credentials.');
+ return;
}
dispatch({ type: "user/login", payload: res });
@@ -42,23 +51,133 @@
dispatch({ type: "user/getUserInfo", payload: userInfo });
nav('/');
} catch (error) {
- // 将错误信息传递给一个异步函数
showErrorMessage('登录失败,请检查您的用户名和密码');
}
}, 1000) as () => void;
+ // 发送验证码逻辑
+ const handleSendCode = async () => {
+ if (!registerEmail) {
+ showErrorMessage('请填写邮箱');
+ return;
+ }
+ setCodeBtnDisabled(true);
+ let seconds = 60;
+ setCodeBtnText(`已发送(${seconds}s)`);
+ const timer = setInterval(() => {
+ seconds -= 1;
+ setCodeBtnText(`已发送(${seconds}s)`);
+ if (seconds <= 0) {
+ clearInterval(timer);
+ setCodeBtnDisabled(false);
+ setCodeBtnText('发送验证码');
+ }
+ }, 1000);
+ setCodeTimer(timer);
+ // TODO: 调用发送验证码接口
+ message.success('验证码已发送');
+ };
+
+ // 切换回登录
+ const handleBackToLogin = () => {
+ setShowRegister(false);
+ setInviteCode('');
+ setRegisterEmail('');
+ setRegisterPassword('');
+ setEmailCode('');
+ if (codeTimer) clearInterval(codeTimer);
+ setCodeBtnDisabled(false);
+ setCodeBtnText('发送验证码');
+ };
+
+ // 注册逻辑(仅前端校验,实际应调用注册接口)
+ const handleRegister = async () => {
+ if (!inviteCode || !registerEmail || !registerPassword || !emailCode) {
+ showErrorMessage('请填写完整信息');
+ return;
+ }
+ // TODO: 调用注册接口
+ message.success('注册成功,请登录');
+ handleBackToLogin();
+ };
+
const handleLogoClick = () => {
nav('/');
- }
+ };
+
return (
<div className={style.form}>
{contextHolder}
- <img className={style.logo} src={logo} alt="logo" onClick={handleLogoClick}></img>
- <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className={style.email} placeholder="Enter your email" />
- <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={style.password} placeholder="Enter your password" />
- <button className={style.submit} onClick={() => handleLogin()}>登录</button>
- <button className={style.register}>注册</button>
- <button className={style.forget}> 忘记密码</button>
+ <img className={style.logo} src={logo} alt="logo" onClick={handleLogoClick} />
+ {!showRegister ? (
+ <>
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ className={style.email}
+ placeholder="Enter your email"
+ />
+ <input
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ className={style.password}
+ placeholder="Enter your password"
+ />
+ <button className={style.submit} onClick={() => handleLogin()}>登录</button>
+ <button className={style.register} onClick={() => setShowRegister(true)}>注册</button>
+ <button className={style.forget}>忘记密码</button>
+ </>
+ ) : (
+ <>
+ <input
+ type="text"
+ value={inviteCode}
+ onChange={(e) => setInviteCode(e.target.value)}
+ className={style.invite}
+ placeholder="邀请码"
+ />
+ <input
+ type="email"
+ value={registerEmail}
+ onChange={(e) => setRegisterEmail(e.target.value)}
+ className={style.email}
+ placeholder="邮箱"
+ />
+ <input
+ type="password"
+ value={registerPassword}
+ onChange={(e) => setRegisterPassword(e.target.value)}
+ className={style.password}
+ placeholder="密码"
+ />
+ <div style={{ display: 'flex',width:'80%', alignItems: 'center', gap: 8, padding:'10px' }}>
+ <input
+ type="text"
+ value={emailCode}
+ onChange={(e) => setEmailCode(e.target.value)}
+ className={style.code}
+ placeholder="邮箱验证码"
+ style={{ flex: 1 }}
+ />
+ <button
+ className={style.sendCode}
+ onClick={handleSendCode}
+ disabled={codeBtnDisabled}
+ style={{
+ background: codeBtnDisabled ? '#ccc' : undefined,
+ color: codeBtnDisabled ? '#888' : undefined,
+ cursor: codeBtnDisabled ? 'not-allowed' : 'pointer'
+ }}
+ >
+ {codeBtnText}
+ </button>
+ </div>
+ <button className={style.submit} onClick={handleRegister}>注册</button>
+ <button className={style.back} onClick={handleBackToLogin}>返回登录</button>
+ </>
+ )}
</div>
);
};
diff --git a/src/views/pet/pet.module.css b/src/views/pet/pet.module.css
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/views/pet/pet.module.css
diff --git a/src/views/pet/pet.tsx b/src/views/pet/pet.tsx
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/views/pet/pet.tsx
diff --git a/src/views/postDetail/postDetail.module.css b/src/views/postDetail/postDetail.module.css
index e69de29..46c2176 100644
--- a/src/views/postDetail/postDetail.module.css
+++ b/src/views/postDetail/postDetail.module.css
@@ -0,0 +1,89 @@
+.commentList .ant-list-item {
+ min-height: 300px;
+ height: 300px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: flex-start;
+ /* 可选:让内容垂直居中可用 align-items: center; */
+}
+.contentArea {
+ width: 100%;
+ max-width: 900px;
+ margin: 32px auto 0 auto;
+ padding: 0 16px 32px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.card {
+ border-radius: 10px;
+ background: var(--card-bg);
+}
+
+.metaRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ margin-bottom: 8px;
+}
+
+.locked {
+ margin: 12px 0;
+ color: #d4380d;
+ font-weight: bold;
+}
+
+.contentText {
+ font-size: 17px;
+ color: var(--text-color);
+ line-height: 1.8;
+ margin-top: 12px;
+}
+
+.addCommentCard {
+ border-radius: 10px;
+ background: var(--card-bg);
+}
+
+.textarea {
+ font-size: 15px;
+}
+
+.commentListCard {
+ border-radius: 10px;
+ background: var(--card-bg);
+}
+
+.commentList .ant-list-item,
+.replyList .ant-list-item {
+ min-height: 120px;
+ height: auto;
+ box-sizing: border-box;
+ display: flex;
+ align-items: flex-start;
+ border-bottom: 1px solid var(--border-color);
+ padding: 24px 16px;
+}
+
+.replyList {
+ margin-left: 32px;
+ background: transparent;
+}
+
+.replyItem {
+ background: #f6f8fa;
+ border-radius: 6px;
+ margin-bottom: 8px;
+ padding: 12px 16px;
+}
+
+@media (max-width: 600px) {
+ .contentArea {
+ max-width: 100%;
+ padding: 0 4px 24px 4px;
+ }
+ .card, .addCommentCard, .commentListCard {
+ padding: 0;
+ }
+}
\ No newline at end of file
diff --git a/src/views/postDetail/postDetail.tsx b/src/views/postDetail/postDetail.tsx
index a40fb68..d6029a8 100644
--- a/src/views/postDetail/postDetail.tsx
+++ b/src/views/postDetail/postDetail.tsx
@@ -1,114 +1,206 @@
import React, { useEffect, useState } from 'react';
-import { useParams } from 'react-router-dom';
import styles from './PostDetail.module.css';
-import { Card, List, Typography, Button, Input, Spin, Empty } from 'antd';
-type CommentProps = {
- children?: React.ReactNode;
-};
+import { Card, List, Typography, Button, Input, Spin, Empty, Divider } from 'antd';
import { getPostDetail } from '@/api/post';
import { getPostComments } from '@/api/comment';
import { useSearchParams } from 'react-router-dom';
import request from '@/utils/request';
import { useApi } from '@/hooks/request';
import Navbar from '@/components/navbar/navbar';
+import { DownloadOutlined, LikeOutlined, LikeFilled } from '@ant-design/icons';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
export interface PostResponse {
- createdAt?: number;
- hotScore?: number;
- lastCalculated?: number;
- postContent?: string;
- postId?: number;
- postTitle?: string;
- postType?: string;
- userId?: number;
- viewCount?: number;
- [property: string]: any;
+ postId?: number;
+ userId?: number;
+ postTitle?: string;
+ postContent?: string;
+ createdAt?: number;
+ postType?: string;
+ isLocked?: boolean;
+ lockedReason?: string;
+ lockedAt?: string;
+ lockedBy?: number;
+ viewCount?: number;
+ hotScore?: number;
+ lastCalculated?: number;
+ [property: string]: any;
}
export interface CommentResponse {
- commentId?: number;
- content?: string;
- createdAt?: number;
- parentCommentId?: number | null;
- postId?: number;
- replies?: CommentResponse[];
- userId?: number;
- [property: string]: any;
+ commentId?: number;
+ content?: string;
+ createdAt?: number;
+ parentCommentId?: number | null;
+ postId?: number;
+ replies?: CommentResponse[];
+ userId?: number;
+ [property: string]: any;
}
const PostDetail: React.FC = () => {
- const [searchParams] = useSearchParams();
- const postId = searchParams.get('postId');
- const { refresh: getPostDetailRefresh } = useApi(() => request.get(getPostDetail + `/${postId}`), false);
- const { refresh: getPostCommentsRefresh } = useApi(() => request.get(getPostComments + `/${postId}`), false);
- const [post, setPost] = useState<PostResponse | null>(null);
- const [comments, setComments] = useState<CommentResponse[]>([]);
- const [newComment, setNewComment] = useState<string>('');
- const [loading, setLoading] = useState<boolean>(true);
+ const [searchParams] = useSearchParams();
+ const postId = searchParams.get('postId');
+ const { refresh: getPostDetailRefresh } = useApi(() => request.get(getPostDetail + `/${postId}`), false);
+ const { refresh: getPostCommentsRefresh } = useApi(() => request.get(getPostComments + `/${postId}`), false);
+ const [post, setPost] = useState<PostResponse | null>(null);
+ const [comments, setComments] = useState<CommentResponse[]>([]);
+ const [newComment, setNewComment] = useState<string>('');
+ const [loading, setLoading] = useState<boolean>(true);
+ const [liked, setLiked] = useState(false);
- useEffect(() => {
- console.log('postId', postId);
- if (!postId) return;
- const fetchData = async () => {
- setLoading(true);
- const res = await getPostDetailRefresh();
- if (res == null || (res as any).error) {
- setLoading(false);
- return;
- }
- setPost(res as PostResponse);
- await getPostCommentsRefresh();
- setComments(res as CommentResponse[]);
- setLoading(false);
- };
- fetchData();
- }, [postId]);
+ useEffect(() => {
+ if (!postId) return;
+ const fetchData = async () => {
+ setLoading(true);
+ const postRes = await getPostDetailRefresh();
+ if (!postRes || (postRes as any).error) {
+ setLoading(false);
+ return;
+ }
+ setPost(postRes as PostResponse);
- if (loading) return <div className={styles.center}><Spin /></div>;
- if (!post) return <div className={styles.center}><Empty description="未找到帖子" /></div>;
+ const commentsRes = await getPostCommentsRefresh();
+ setComments(commentsRes as CommentResponse[]);
+ setLoading(false);
+ };
+ fetchData();
+ }, [postId]);
- return (
- <div className={styles.container}>
- <div className={styles.nav}>
- <Navbar current={post.postType} />
- </div>
- <div className={styles.content}>
- <div className={styles.postDetail}>
-
- </div >
- <Card title={post.postTitle} className={styles.card}>
- <Paragraph>{post.postContent}</Paragraph>
- <div className={styles.actions}>
- <Button type="primary" onClick={() => setNewComment('')}>评论</Button>
- </div>
- </Card>
+ if (loading) return <div className={styles.center}><Spin /></div>;
+ if (!post) return <div className={styles.center}><Empty description="未找到帖子" /></div>;
- <List
- className={styles.commentList}
- header={<Title level={4}>评论区</Title>}
- dataSource={comments}
- renderItem={(item) => (
- <List.Item key={item.commentId}>
- <List.Item.Meta
- title={<Text strong>{item.userId}</Text>}
- description={<Text>{item.content}</Text>}
- />
- </List.Item>
- )}
- />
+ return (
+ <div className={styles.container}>
+ {/* 固定导航栏 */}
+ <div className={styles.nav}>
+ <Navbar current={post.postType} />
+ </div>
+ {/* 内容区域 */}
+ <div className={styles.contentArea}>
+ <Card
+ title={<Title level={3} style={{ margin: 0 }}>{post.postTitle || "帖子标题"}</Title>}
+ className={styles.card}
+ bordered={false}
+ style={{ marginBottom: 24, boxShadow: '0 4px 24px rgba(0,0,0,0.08)' }}
+ extra={
+ <div style={{ display: 'flex', gap: 16 }}>
+ <Button
+ type="primary"
+ icon={<DownloadOutlined />}
+ onClick={() => {
+ // 下载逻辑
+ window.open(`/api/download/post/${post.postId}`, '_blank');
+ }}
+ >
+ 下载
+ </Button>
+ <Button
+ type="primary"
+ icon={liked ? <LikeFilled /> : <LikeOutlined />}
+ style={liked ? { background: '#ccc', borderColor: '#ccc', color: '#888', cursor: 'not-allowed' } : {}}
+ disabled={liked}
+ onClick={() => setLiked(true)}
+ >
+ {liked ? '已点赞' : '点赞'}
+ </Button>
+ </div>
+ }
+ >
+ <div className={styles.metaRow}>
+ <Text type="secondary">作者ID: {post.userId}</Text>
+ <Text type="secondary">发布时间: {post.createdAt ? new Date(post.createdAt).toLocaleString() : "未知"}</Text>
+ <Text type="secondary">浏览量: {post.viewCount}</Text>
+ <Text type="secondary">类型: {post.postType}</Text>
+ <Text type="secondary">热度: {post.hotScore}</Text>
+ <Text type="secondary">最后计算: {post.lastCalculated ? new Date(post.lastCalculated).toLocaleString() : "无"}</Text>
+ </div>
+ {post.isLocked && (
+ <div className={styles.locked}>
+ <Text type="danger">本帖已锁定</Text>
+ {post.lockedReason && <Text type="secondary">(原因:{post.lockedReason})</Text>}
+ {post.lockedAt && <Text style={{ marginLeft: 8 }}>锁定时间: {post.lockedAt}</Text>}
+ {post.lockedBy !== 0 && <Text style={{ marginLeft: 8 }}>锁定人ID: {post.lockedBy}</Text>}
+ </div>
+ )}
+ <Divider style={{ margin: '16px 0' }} />
+ <Paragraph className={styles.contentText}>{post.postContent || "暂无内容"}</Paragraph>
+ </Card>
- <TextArea
- rows={4}
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- placeholder="写下你的评论..."
- />
- </div>
- </div>
- );
+ {/* 发布评论区域 */}
+ <Card className={styles.addCommentCard} style={{ marginBottom: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.06)' }}>
+ <Title level={5} style={{ marginBottom: 12 }}>发布评论</Title>
+ <TextArea
+ rows={4}
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="写下你的评论..."
+ className={styles.textarea}
+ />
+ <Button
+ type="primary"
+ style={{ marginTop: 12, float: 'right' }}
+ onClick={() => setNewComment('')}
+ disabled={!newComment.trim()}
+ >
+ 评论
+ </Button>
+ <div style={{ clear: 'both' }} />
+ </Card>
+
+ <Card
+ className={styles.commentListCard}
+ title={<Title level={4} style={{ margin: 0 }}>评论区</Title>}
+ bodyStyle={{ padding: 0 }}
+ >
+ <List
+ className={styles.commentList}
+ dataSource={comments}
+ locale={{ emptyText: <Empty description="暂无评论" /> }}
+ renderItem={(item) => (
+ <List.Item className={styles.commentItem} key={item.commentId}>
+ <List.Item.Meta
+ title={<Text strong>用户ID: {item.userId}</Text>}
+ description={
+ <>
+ <Text>{item.content}</Text>
+ <div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
+ {item.createdAt && new Date(item.createdAt).toLocaleString()}
+ </div>
+ </>
+ }
+ />
+ {/* 可递归渲染子评论 */}
+ {item.replies && item.replies.length > 0 && (
+ <List
+ className={styles.replyList}
+ dataSource={item.replies}
+ renderItem={reply => (
+ <List.Item className={styles.replyItem} key={reply.commentId}>
+ <List.Item.Meta
+ title={<Text strong>用户ID: {reply.userId}</Text>}
+ description={
+ <>
+ <Text>{reply.content}</Text>
+ <div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>
+ {reply.createdAt && new Date(reply.createdAt).toLocaleString()}
+ </div>
+ </>
+ }
+ />
+ </List.Item>
+ )}
+ />
+ )}
+ </List.Item>
+ )}
+ />
+ </Card>
+ </div>
+ </div>
+ );
};
export default PostDetail;
\ No newline at end of file
diff --git a/src/views/search/search.module.css b/src/views/search/search.module.css
new file mode 100644
index 0000000..2e1f40a
--- /dev/null
+++ b/src/views/search/search.module.css
@@ -0,0 +1,132 @@
+:root {
+ --primary-color: #3498db;
+ --primary-hover: #2980b9;
+ --secondary-color: #f1c40f;
+ --dark-color: #2c3e50;
+ --light-color: #ecf0f1;
+ --text-color: #333;
+ --text-secondary: #7f8c8d;
+ --border-color: #ddd;
+ --bg-color: #2b2b2b;
+ --card-bg: #1e1e1e;
+}
+
+.container {
+ min-height: 100vh;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ padding: 20px;
+}
+
+.secondaryHeader {
+ background-color: var(--card-bg);
+ padding: 15px 20px;
+ margin-bottom: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-wrap: wrap; /* 可换成 nowrap + overflow-x: auto 实现强制一行 + 横向滚动 */
+ gap: 15px;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.selectBox {
+ background-color: var(--light-color);
+ color: var(--text-color);
+ border: 1px solid var(--border-color);
+ border-radius: 5px;
+ padding: 8px 10px;
+ font-size: 14px;
+}
+
+.selectBox:focus {
+ outline: none;
+ border-color: var(--primary-color);
+}
+
+.tagFilters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ background-color: var(--light-color);
+ color: var(--text-color);
+ border: 1px solid var(--border-color);
+ border-radius: 5px;
+ padding: 8px 10px;
+ font-size: 14px;
+}
+
+.tagFilters label {
+ margin: 0;
+ color: var(--text-color);
+}
+
+.filterButton {
+ padding: 8px 16px;
+ background-color: var(--primary-color);
+ color: white;
+ border: none;
+ border-radius: 5px;
+ font-size: 14px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.filterButton:hover {
+ background-color: var(--primary-hover);
+}
+
+.results {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.postItem {
+ background-color: var(--card-bg);
+ padding: 20px;
+ border-radius: 8px;
+ border-left: 4px solid var(--primary-color);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
+}
+
+.postItem h3 {
+ margin: 0 0 10px 0;
+ color: var(--text-color);
+}
+
+.postItem p {
+ margin: 4px 0;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.secondaryHeader {
+ background-color: var(--card-bg);
+ padding: 15px 20px;
+ margin-bottom: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.leftSection {
+ display: flex;
+ gap: 10px;
+}
+
+.centerSection {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+}
+
+.rightSection {
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/src/views/search/search.tsx b/src/views/search/search.tsx
new file mode 100644
index 0000000..cd9db91
--- /dev/null
+++ b/src/views/search/search.tsx
@@ -0,0 +1,131 @@
+// search.tsx
+import React, { useState, useEffect } from 'react';
+import styles from './search.module.css';
+import { useLocation } from "react-router-dom";
+
+interface PostItem {
+ postId: number;
+ userId: number;
+ postTitle: string;
+ postContent: string;
+ createdAt: number;
+ postType: string;
+ viewCount: number;
+ hotScore: number;
+ lastCalculated: number;
+ tags?: number[];
+}
+
+const tagMap: Record<number, string> = {
+ 1: "搞笑",
+ 2: "悬疑",
+ 3: "教育",
+ 4: "动作",
+ 5: "剧情"
+};
+
+const SearchPage: React.FC = () => {
+ const [posts, setPosts] = useState<PostItem[]>([]);
+ const [filteredPosts, setFilteredPosts] = useState<PostItem[]>([]);
+ const [selectedPostType, setSelectedPostType] = useState<string>('all');
+ const [selectedRating, setSelectedRating] = useState<number | null>(null);
+ const [selectedTags, setSelectedTags] = useState<number[]>([]);
+
+ const location = useLocation();
+ const params = new URLSearchParams(location.search);
+ const keyword = params.get("keyword");
+
+ useEffect(() => {
+ fetch('/api/posts')
+ .then((res) => res.json())
+ .then((data) => {
+ setPosts(data);
+ setFilteredPosts(data);
+ });
+ }, []);
+
+ const applyFilters = () => {
+ let filtered = posts;
+
+ if (selectedPostType !== 'all') {
+ filtered = filtered.filter((post) => post.postType === selectedPostType);
+ }
+
+ if (selectedRating !== null) {
+ filtered = filtered.filter((post) => post.hotScore >= selectedRating);
+ }
+
+ if (selectedTags.length > 0) {
+ filtered = filtered.filter((post) =>
+ post.tags?.some((tag) => selectedTags.includes(tag))
+ );
+ }
+
+ setFilteredPosts(filtered);
+ };
+
+ return (
+ <div className={styles.secondaryHeader}>
+ <div className={styles.leftSection}>
+ <select
+ value={selectedPostType}
+ onChange={(e) => setSelectedPostType(e.target.value)}
+ className={styles.selectBox}
+ >
+ <option value="all">所有分区</option>
+ <option value="影视">影视</option>
+ <option value="音乐">音乐</option>
+ <option value="游戏">游戏</option>
+ <option value="软件">软件</option>
+ </select>
+
+ <select
+ value={selectedRating || ''}
+ onChange={(e) =>
+ setSelectedRating(e.target.value ? Number(e.target.value) : null)
+ }
+ className={styles.selectBox}
+ >
+ <option value="">所有评分</option>
+ <option value="1">1星及以上</option>
+ <option value="2">2星及以上</option>
+ <option value="3">3星及以上</option>
+ <option value="4">4星及以上</option>
+ <option value="5">5星</option>
+ </select>
+ </div>
+
+ <div className={styles.centerSection}>
+ <div className={styles.tagFilters}>
+ {Object.entries(tagMap).map(([tagId, tagName]) => (
+ <label key={tagId}>
+ <input
+ type="checkbox"
+ value={tagId}
+ checked={selectedTags.includes(Number(tagId))}
+ onChange={(e) => {
+ const value = Number(e.target.value);
+ setSelectedTags((prev) =>
+ prev.includes(value)
+ ? prev.filter((t) => t !== value)
+ : [...prev, value]
+ );
+ }}
+ />
+ {tagName}
+ </label>
+ ))}
+ </div>
+ </div>
+
+ <div className={styles.rightSection}>
+ <button className={styles.filterButton} onClick={applyFilters}>
+ 筛选
+ </button>
+ </div>
+ </div>
+
+ );
+};
+
+export default SearchPage;
diff --git a/src/views/upload/upload.module.css b/src/views/upload/upload.module.css
new file mode 100644
index 0000000..5a9952a
--- /dev/null
+++ b/src/views/upload/upload.module.css
@@ -0,0 +1,150 @@
+.container {
+ background-color: var(--card-bg);
+ padding: 32px;
+ border-radius: 12px;
+ width: 100%;
+ height: 100%;
+ margin: auto;
+ border: 1px solid var(--border-color);
+ color: var(--text-color);
+}
+
+.formGroup {
+ margin-bottom: 20px;
+}
+
+.input,
+.select,
+.textarea {
+ width: 100%;
+ padding: 8px 12px;
+ margin-top: 4px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background-color: var(--bg-color);
+ color: var(--text-color);
+}
+
+.upload {
+ margin-top: 8px;
+}
+
+.textarea {
+ height: 100px;
+ resize: none;
+}
+
+.charCount {
+ text-align: right;
+ font-size: 12px;
+ color: var(--text-color);
+}
+
+.requirement {
+ font-size: 14px;
+ color: var(--primary-color);
+ margin-bottom: 12px;
+}
+
+.checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.submitBtn {
+ background-color: var(--primary-color);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background-color 0.3s ease;
+}
+
+.submitBtn:hover {
+ background-color: var(--primary-hover);
+}
+
+
+.wrapper {
+ display: flex;
+ justify-content: center;
+ margin-top: 50px;
+}
+
+.form {
+ width: 400px;
+ background: #ffffff;
+ border: 1px solid #ddd;
+ border-radius: 12px;
+ padding: 24px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+
+.title {
+ font-size: 22px;
+ margin-bottom: 20px;
+ text-align: center;
+}
+
+.input,
+.textarea {
+ width: 100%;
+ padding: 10px;
+ margin-bottom: 16px;
+ border-radius: 6px;
+ border: 1px solid #ccc;
+ font-size: 14px;
+}
+
+.textarea {
+ resize: vertical;
+ height: 100px;
+}
+
+.uploadArea {
+ padding: 12px;
+ border: 2px dashed #999;
+ border-radius: 8px;
+ text-align: center;
+ cursor: pointer;
+ background: #f9f9f9;
+ margin-bottom: 16px;
+ transition: all 0.2s;
+}
+
+.uploadArea:hover {
+ background: #f0f0f0;
+ border-color: #666;
+}
+
+.fileName {
+ margin-top: 8px;
+ font-size: 14px;
+ color: #333;
+}
+
+.error {
+ color: red;
+ margin-bottom: 10px;
+ font-size: 14px;
+ text-align: center;
+}
+
+.uploadButton {
+ width: 100%;
+ padding: 10px;
+ background-color: #409eff;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.uploadButton:hover {
+ background-color: #317ee7;
+}
diff --git a/src/views/upload/upload.tsx b/src/views/upload/upload.tsx
new file mode 100644
index 0000000..4a60330
--- /dev/null
+++ b/src/views/upload/upload.tsx
@@ -0,0 +1,123 @@
+import instance from '@/utils/axios';
+import React, { useState } from 'react';
+import styles from './upload.module.css';
+import { Upload } from '@/api/upload';
+import { useNavigate } from 'react-router-dom'; // 用于跳转
+
+const PostDetails = () => {
+ const [postTitle, setPostTitle] = useState('');
+ const [postType, setPostType] = useState('');
+ const [postContent, setPostContent] = useState('');
+ const [isChecked, setIsChecked] = useState(false);
+
+ const navigate = useNavigate();
+
+ const handleSubmit = async () => {
+ if (!postTitle.trim() || !postType || !postContent.trim()) {
+ alert('请填写完整内容(资源名、类型、内容介绍)');
+ return;
+ }
+
+ if (!isChecked) {
+ alert('请先确认您已知晓以上内容');
+ return;
+ }
+
+ const payload = {
+ post: {
+ postId: 0,
+ userId: 0,
+ postTitle,
+ postContent,
+ createdAt: Date.now(),
+ postType,
+ viewCount: 0,
+ hotScore: 5,
+ lastCalculated: Date.now()
+ },
+ tagIds: [0]
+ };
+
+ try {
+ const res = await instance.post(Upload, payload);
+
+ console.log('mock返回内容:', res.code);
+
+ // 判断返回内容是否成功(根据你 mock 接口返回的 code 字段)
+ if (res.code !== 0) throw new Error('发布失败');
+
+ alert('发布成功!');
+ navigate(-1); // 返回上一页(homepage)
+ } catch (error) {
+ alert('发布失败,请稍后重试');
+ console.error(error);
+ }
+ };
+
+ return (
+ <div className={styles.container}>
+ <div className={styles.formGroup}>
+ <label>资源名:</label>
+ <input
+ type="text"
+ value={postTitle}
+ placeholder="请输入文本"
+ onChange={(e) => setPostTitle(e.target.value)}
+ className={styles.input}
+ />
+ </div>
+
+ <div className={styles.formGroup}>
+ <label>类型选择:</label>
+ <select
+ value={postType}
+ onChange={(e) => setPostType(e.target.value)}
+ className={styles.select}
+ >
+ <option value="">下拉选择</option>
+ <option value="type1">类型一</option>
+ <option value="type2">类型二</option>
+ </select>
+ </div>
+
+ {/* 暂时移除上传文件表单 */}
+ {/* <div className={styles.formGroup}>
+ <label>上传资源:</label>
+ <input
+ type="file"
+ onChange={(e) => setFile(e.target.files?.[0] || null)}
+ className={styles.upload}
+ />
+ </div> */}
+
+ <div className={styles.formGroup}>
+ <label>内容介绍:</label>
+ <textarea
+ placeholder="请输入内容介绍"
+ value={postContent}
+ onChange={(e) => setPostContent(e.target.value)}
+ maxLength={200}
+ className={styles.textarea}
+ />
+ <div className={styles.charCount}>{postContent.length}/200</div>
+ </div>
+
+ <div className={styles.requirement}>【发布内容要求】</div>
+
+ <div className={styles.checkbox}>
+ <input
+ type="checkbox"
+ checked={isChecked}
+ onChange={() => setIsChecked(!isChecked)}
+ />
+ <span>我已知晓以上内容</span>
+ </div>
+
+ <button onClick={handleSubmit} className={styles.submitBtn}>
+ 我已知晓
+ </button>
+ </div>
+ );
+};
+
+export default PostDetails;