blob: baec9a635f597a08a658226bf0885ff8244cceea [file] [log] [blame]
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { Image } from "primereact/image";
import { Button } from "primereact/button";
import { Avatar } from "primereact/avatar";
import { ButtonGroup } from "primereact/buttongroup";
import { InputText } from "primereact/inputtext";
import { Dialog } from 'primereact/dialog';
// 引入图标
import 'primeicons/primeicons.css';
// 引入火苗图标
import Fire from '@icon-park/react/lib/icons/Fire';
import SmilingFace from '@icon-park/react/lib/icons/SmilingFace';
// 消息提醒
import { Toast } from 'primereact/toast';
// 接口传输
import axios from "axios";
// 页面跳转
import { useParams } from 'next/navigation'
import { useRouter } from 'next/navigation';
import Link from 'next/link';
// 回复评论
import { OverlayPanel } from 'primereact/overlaypanel';
import { Sidebar } from 'primereact/sidebar';
// 分页
import { Paginator, PaginatorPageChangeEvent } from 'primereact/paginator';
import { useLocalStorage } from '../../../hook/useLocalStorage';
// 样式
import './resource-detail.scss';
interface User {
Id: number;
}
// 种子
interface Torrent {
torrentRecordId: number;
torrentUrl: string;
infoHash: string;
uploadTime: string;
uploaderUserId: number;
}
// 资源版本
interface ResourceVersion {
resourceVersionId: string; // 资源版本id
resourceVersionName: string; // 资源版本名称
compatibleVersions: string[]; // 兼容的游戏版本列表
torrentList: Torrent[]; // 种子列表
}
// 资源信息
interface Resource {
resourceId: number;
resourceName: string; // 资源标题
resourcePicture: string; // 资源照片网址
resourceSummary: string; // 资源简介(一句话)
resourceDetail: string; // 资源介绍
uploadTime: string; // 上传时间
lastUpdateTime: string; // 最近更新时间
price: number;
downloads: number; // 下载数
likes: number; // 点赞数
collections: number; // 收藏数
comments: number; // 评论数
classify: string; // 资源分类(材质包:resourcePack,模组:mod,整合包:modPack ,地图:map
hot: number; // 资源热度
gameplayList: string[]; // 资源标签
resourceVersionList: ResourceVersion[]; // 资源版本列表
isCollect: boolean; // 是否被收藏
isLike: boolean; // 是否被点赞
isPurchase: boolean; // 是否被购买
isUpload: boolean; // 是否是该用户上传的
uploaderId: number; // 资源上传者的id
}
// 评论信息
interface Comment {
commentId: number;
userId: number | null;
replyId: number;
resourceId: number;
reawardId: number;
content: string;
createAt: string;
}
// 评论列表
interface CommentList {
total: number; // 评论总数
records: Comment[]; // 当前页评论数组
}
// 用户信息
interface UserInfo {
userId: number;
username: string;
avatar: string;
signature: string;
}
// 新评论接口
interface NewComment {
userId: number;
threadId: number | null;
resourceId: number | null;
rewardId: number | null;
replyId: number | null;
content: string;
createAt: string;
}
// 资源作者
interface ResourceAuthor {
userId: number;
username: string;
avatar: string;
signature: string;
}
// 关注
interface Subscriber {
userId: number;
username: string;
}
// 关注列表
interface SubscriberList {
userList: Subscriber[];
}
export default function ResourceDetail() {
const user = useLocalStorage<User>('user');
const userId: number = user?.Id ?? -1;
// 获取URL参数
const params = useParams<{ resourceId: string }>();
const resourceId = Number(decodeURIComponent(params.resourceId)); // 防止中文路径乱码
// 页面跳转
const router = useRouter();
// 资源信息
const [resource, setResource] = useState<Resource | null>(null);
// 资源作者信息
const [resourceAuthor, setResourceAuthor] = useState<ResourceAuthor>();
// 资源作者id
const [resourceAuthorId, setResourceAuthorId] = useState<number>(0);
// 关注列表
const [subscriberList, setSubscriberList] = useState<SubscriberList>();
// 添加本地关注状态
const [isSubscribed, setIsSubscribed] = useState(false);
// 添加本地收藏状态
const [isCollected, setIsCollected] = useState(false);
const [collectionCount, setCollectionCount] = useState(0);
// 添加本地点赞状态
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
// 发帖人信息
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
// 评论人信息
const [commentUserInfos, setCommentUserInfos] = useState<Map<number, UserInfo>>(new Map());
//评论
const [comments, setComments] = useState<Comment[]>([]);
const [commentValue, setCommentValue] = useState<string>('');
const [totalComments, setTotalComments] = useState<number>(0);
// 回复
const [replyValue, setReplyValue] = useState<string>('');
const [visibleReply, setVisibleReply] = useState<boolean>(false);// 回复评论可视
// 评论选择框
const ops = useRef<OverlayPanel[]>([]);
// 购买弹窗状态
const [visible, setVisible] = useState<boolean>(false);
// 消息提醒
const toast = useRef<Toast>(null);
// 分页
const [first, setFirst] = useState<number>(0);
const [rows, setRows] = useState<number>(5);
const onPageChange = (event: PaginatorPageChangeEvent) => {
setFirst(event.first);
setRows(event.rows);
};
useEffect(() => {
fetchResourceInfo();
}, []);
// 获取资源信息
const fetchResourceInfo = async () => {
try {
// console.log(resourceId);
const response = await axios.get<Resource>(process.env.PUBLIC_URL + `/resource/info`, {
params: { resourceId: resourceId, userId }
});
console.log('获取资源信息:', response.data);
setResource(response.data);
// 初始化本地收藏状态
if (response.data) {
setIsCollected(response.data.isCollect);
setCollectionCount(response.data.collections);
}
setResourceAuthorId(response.data.uploaderId);
} catch (err) {
console.error('获取资源信息失败', err);
toast.current?.show({ severity: 'error', summary: 'error', detail: "获取资源信息失败" });
}
};
// 获取到资源作者id时,获取资源作者信息
useEffect(() => {
fetchResourceAuthor();
}, [resourceAuthorId]);
// 获取资源作者信息
const fetchResourceAuthor = async () => {
try {
// console.log(resourceId);
// console.log(resourceAuthorId);
const response = await axios.get<ResourceAuthor>(process.env.PUBLIC_URL + `/user/info`, {
params: { userId: resourceAuthorId }
});
console.log('获取资源作者信息:', response.data);
setResourceAuthor(response.data);
// setResourceAuthorId(response.data.userId);
} catch (err) {
console.error('获取资源作者信息失败', err);
toast.current?.show({ severity: 'error', summary: 'error', detail: "获取资源作者信息失败" });
}
};
useEffect(() => {
fetchSubscriber();
}, []);
// 获取正在浏览资源的用户的关注列表
const fetchSubscriber = async () => {
try {
const response = await axios.get<SubscriberList>(process.env.PUBLIC_URL + `/user/subscriber`, {
params: { userId }
});
console.log("关注列表:", response.data);
setSubscriberList(response.data);
} catch (err) {
console.error('获取浏览用户关注列表失败', err);
toast.current?.show({ severity: 'error', summary: 'error', detail: "获取浏览用户关注列表失败" });
}
}
useEffect(() => {
if (!resource?.uploaderId || !subscriberList?.userList) return;
const authorId = resource.uploaderId;
// 设置 isSubscribed 状态
const subscribed = subscriberList.userList.some(user => user.userId === authorId);
setIsSubscribed(subscribed);
}, [subscriberList, resource]);
// 若浏览用户与资源作者是同一人,则不显示关注按钮。若不是同一人,则显示按钮
const handleSubscribe = () => {
// 资源作者 ID
const authorId = resource?.uploaderId;
// 当前登录用户 ID
const currentUserId = userId;
// 资源作者与浏览用户是同一人,不显示按钮
if (!authorId || authorId == currentUserId) {
return null;
}
return isSubscribed ? (
// 如果已关注,显示“取消关注”按钮
<Button
label="取消关注"
onClick={async () => {
try {
const response = await axios.delete(
process.env.PUBLIC_URL + '/user/subscription',
{
params: { userId: currentUserId, followerId: authorId }
}
);
if (response.status === 200) {
setIsSubscribed(false); // 🔥立刻更新按钮状态
toast.current?.show({
severity: 'success',
summary: '取消成功',
detail: '已取消关注该用户',
});
fetchSubscriber(); // 重新拉取完整关注列表
}
} catch (error) {
console.error('取消关注失败:', error);
toast.current?.show({
severity: 'error',
summary: '错误',
detail: '取消关注失败',
});
}
}}
/>
) : (
// 若未关注,则显示关注按钮
<Button
label="关注"
onClick={async () => {
try {
const postData = {
userId: currentUserId,
followerId: authorId,
};
const response = await axios.post(
process.env.PUBLIC_URL + '/user/subscription',
postData
);
if (response.status === 200) {
setIsSubscribed(true); // 🔥立刻更新按钮状态
toast.current?.show({
severity: 'success',
summary: '关注成功',
detail: '已成功关注该用户',
});
fetchSubscriber(); // 刷新列表
}
} catch (error) {
console.error('关注失败:', error);
toast.current?.show({
severity: 'error',
summary: '错误',
detail: '关注失败',
});
}
}}
/>
);
}
// 判断该资源是否已被购买, 返回不同的购买按钮
const isPurchase = () => {
// 作者本人查看资源,不显示购买按钮
if (resource?.uploaderId == userId) {
return;
}
// 该资源已被购买
if (resource?.isPurchase) {
return (
<Button label="已购买" style={{
width: "120px", height: "44px",
borderRadius: "20px 0 0 20px",
}} disabled={true} />
)
} else {
// 该资源未被购买
return (
<Button
label="立即购买"
style={{
width: "120px", height: "44px",
borderRadius: "20px 0 0 20px",
}}
onClick={() => setVisible(true)}
/>
)
}
}
// 购买按钮接口
const handlePurchase = async () => {
try {
const postData = {
userId, // 记得用户登录状态获取
resourceId: resource?.resourceId
};
// 发送POST请求
const response = await axios.post(process.env.PUBLIC_URL + '/resource/purchase', postData);
if (response.status === 200) {
toast.current?.show({ severity: 'success', summary: 'Success', detail: '成功购买资源' });
// 购买成功
setVisible(false);
// 刷新购买按钮
isPurchase();
} else if (response.status === 412) {
toast.current?.show({ severity: 'error', summary: 'error', detail: '积分不足,购买失败' });
}
} catch (error) {
console.error('购买资源失败:', error);
toast.current?.show({ severity: 'error', summary: 'error', detail: '购买资源失败' });
}
};
// 处理收藏操作
const handleCollection = async () => {
const newCollectionState = !isCollected;
const newCollectionCount = newCollectionState ? collectionCount + 1 : collectionCount - 1;
// 立即更新本地状态
setIsCollected(newCollectionState);
setCollectionCount(newCollectionCount);
try {
if (newCollectionState) {
// 收藏操作
await axios.post(process.env.PUBLIC_URL + `/resource/collection`, {
resourceId: resourceId, userId
});
console.log('收藏资源');
} else {
// 取消收藏操作
await axios.delete(process.env.PUBLIC_URL + `/resource/collection`, {
params: { resourceId: resourceId, userId }
});
console.log('取消收藏资源');
}
} catch (err) {
console.error(newCollectionState ? '收藏资源失败' : '取消收藏失败', err);
toast.current?.show({
severity: 'error',
summary: 'error',
detail: newCollectionState ? "收藏资源失败" : "取消收藏失败"
});
// 如果请求失败,回滚状态
setIsCollected(!newCollectionState);
setCollectionCount(newCollectionState ? collectionCount - 1 : collectionCount + 1);
}
};
// 处理点赞行为
const handleLike = async () => {
const newLikeState = !isLiked;
const newLikeCount = newLikeState ? likeCount + 1 : likeCount - 1;
// 立即更新本地状态
setIsLiked(newLikeState);
setLikeCount(newLikeCount);
try {
if (newLikeState) {
// 点赞操作
await axios.post(process.env.PUBLIC_URL + `/resource/like`, {
resourceId: resourceId, userId
});
console.log('点赞资源');
} else {
// 取消点赞操作
await axios.delete(process.env.PUBLIC_URL + `/resource/like`, {
params: { resourceId: resourceId, userId }
});
console.log('取消点赞资源');
}
} catch (err) {
console.error(newLikeState ? '点赞资源失败' : '取消点赞失败', err);
toast.current?.show({
severity: 'error',
summary: 'error',
detail: newLikeState ? "点赞资源失败" : "取消点赞失败"
});
// 如果请求失败,回滚状态
setIsLiked(!newLikeState);
setLikeCount(newLikeState ? newLikeCount - 1 : newLikeCount + 1);
}
};
// 格式化数字显示 (3000 -> 3k)
const formatCount = (count?: number): string => {
if (count == null) return "0"; // 同时处理 undefined/null
const absCount = Math.abs(count); // 处理负数
const format = (num: number, suffix: string) => {
const fixed = num.toFixed(1);
return fixed.endsWith('.0')
? `${Math.floor(num)}${suffix}`
: `${fixed}${suffix}`;
};
if (absCount >= 1e6) return format(count / 1e6, "m");
if (absCount >= 1e3) return format(count / 1e3, "k");
return count.toString();
};
// 获取发帖人
useEffect(() => {
if (!resource) return;
// 发帖人
axios.get(process.env.PUBLIC_URL + `/user/info?userId=${resource?.uploaderId}`)
.then(res => setUserInfo(res.data))
.catch(console.error);
}, [resource]);
// 当 resourceId 或分页参数变化时重新拉评论
useEffect(() => {
if (!resourceId) return;
fetchComments();
}, [resourceId, first, rows]);
//通过评论ID获取评论人信息
const getReplyUserName = (replyId: number) => {
if (replyId == null || replyId == 0) return '';
const replyComment = comments.find(comment => comment.commentId === replyId);
if (!replyComment?.userId) return '匿名用户';
return "回复 " + commentUserInfos.get(replyComment.userId)?.username || '匿名用户';
};
// 获取该资源的评论
const fetchComments = async () => {
try {
const pageNumber = first / rows + 1;
console.log("当前页" + pageNumber + "size" + rows);
const response = await axios.get<CommentList>(
process.env.PUBLIC_URL + `/comment`, {
params: { id: resourceId, pageNumber, rows, type: 'resource' }
}
);
console.log('获取评论列表:', response.data.records);
setComments(response.data.records);
setTotalComments(response.data.total);
// 拉取评论对应用户信息
response.data.records.forEach(comment => {
if (comment.userId != null && !commentUserInfos.has(comment.userId)) {
axios.get<UserInfo>(
process.env.PUBLIC_URL + `/user/info`,
{ params: { userId: comment.userId } }
).then(res => {
setCommentUserInfos(prev => new Map(prev).set(comment.userId!, res.data));
});
}
});
} catch (err) {
console.error('获取评论失败', err);
toast.current?.show({ severity: 'error', summary: 'error', detail: '获取评论失败' });
}
};
// 回复评论接口
const publishReply = async (commentId: number) => {
if (!replyValue.trim() || !resource) return;
console.log('发布评论:', commentId);
// console.log(typeof resourceId);
try {
const newComment: NewComment = {
userId,
rewardId: null,
threadId: null,
resourceId: resource.resourceId,
replyId: commentId,
content: commentValue,
createAt: new Date().toISOString().slice(0, 10).replace('T', ' ')
};
const response = await axios.post(process.env.PUBLIC_URL + '/comment', newComment);
if (response.status === 200) {
toast.current?.show({ severity: 'success', summary: 'Success', detail: '回复成功' });
// 更新评论列表
fetchComments();
setVisibleReply(false)
// 清空输入框
setReplyValue('');
}
} catch (error) {
console.error('发布评论失败:', error);
toast.current?.show({ severity: 'error', summary: 'error', detail: '回复失败' });
}
};
const buyResource = async () => {
console.log("Buy Resource")
try {
const response = await axios.post(process.env.PUBLIC_URL + '/resource/purchase', {
userId,
resourceId
});
if (response.status === 200) {
toast.current?.show({ severity: 'success', summary: 'Success', detail: '购买成功' });
} else {
toast.current?.show({ severity: 'error', summary: 'error', detail: '购买失败' });
}
} catch (error) {
console.error('购买失败:', error);
toast.current?.show({ severity: 'error', summary: 'error', detail: '购买失败' });
}
}
// 发布评论接口
const publishComment = async () => {
if (!commentValue.trim() || !resource) return;
try {
const newComment: NewComment = {
userId,
rewardId: null,
threadId: null,
resourceId: resource.resourceId,
replyId: null, // 直接评论,不是回复
content: commentValue,
createAt: new Date().toISOString().slice(0, 10).replace('T', ' ')
};
const response = await axios.post(process.env.PUBLIC_URL + '/comment', newComment);
if (response.status === 200) {
toast.current?.show({ severity: 'success', summary: 'Success', detail: '评论成功' });
// 更新评论列表
fetchComments();
// 清空输入框
setCommentValue('');
}
} catch (error) {
console.error('发布评论失败:', error);
toast.current?.show({ severity: 'error', summary: 'error', detail: '发布评论失败' });
}
};
// 删除评论接口
const deleteComment = async (commentId: number) => {
if (!resourceId) return;
try {
// 调用 DELETE 接口,URL 中最后一段是要删除的 commentId
const response = await axios.delete(
process.env.PUBLIC_URL + `/comment?commentId=${commentId}`
);
if (response.status === 200) {
fetchComments();
toast.current?.show({ severity: 'success', summary: 'Success', detail: '删除评论成功' });
} else {
toast.current?.show({ severity: 'error', summary: 'error', detail: '删除评论失败' });
console.error('删除评论失败,状态码:', response.status);
}
} catch (error) {
console.error('删除评论接口报错:', error);
}
};
const ReplyHeader = (
<div className="flex align-items-center gap-1">
<h3>回复评论</h3>
</div>
);
if (!resourceId || !userInfo) return <div>Loading...</div>;
return (
<div className="resource-detail-container">
<Toast ref={toast}></Toast>
{/*资源标题*/}
<div className="resource-header">
{resource?.resourceName}
</div>
{/*资源详细信息*/}
<div className="resource-info">
<Image
src={(process.env.NEXT_PUBLIC_NGINX_URL! + resource?.resourcePicture)}
alt={resource?.resourceName}
width="540px"
height="300px"
/>
<div className="resource-info-detail">
{/*资源热度*/}
<div className="resource-hot">
<Fire theme="outline" size="50" fill="#f5a623" strokeWidth={3} />
<span className="resource-hot-data">{resource?.hot}</span>
</div>
{/*资源标签*/}
<div className="resource-label">
{resource?.gameplayList.map((tag, index) => (
<Button
key={index} label={tag}
className="resource-label-button"
onClick={() => {
router.push(`/resource/classification/`)
}}
/>
))}
</div>
{/*资源浏览量和下载量*/}
<div className="resource-data">
<div className="resource-data-container">
<i className="pi pi-download" />
<span className="resource-data-container-number">下载量:{formatCount(resource?.downloads)}</span>
</div>
</div>
{/*资源发布时间和更新时间*/}
<div className="resource-time">
<div className="resource-time-data">
发布时间:{resource?.uploadTime}
</div>
<div className="resource-time-data">
更新时间:{resource?.lastUpdateTime}
</div>
</div>
</div>
</div>
{/*资源总结*/}
<div className="resource-summary">
{resource?.resourceSummary}
</div>
{/*关注作者、点赞、抽藏、购买资源*/}
<div className="resource-operation">
<div className="resource-author">
<Avatar
image={`${process.env.NEXT_PUBLIC_NGINX_URL}/users/${resourceAuthor?.avatar}`}
shape="circle"
style={{ width: "60px", height: "60px" }}
/>
<span className="resource-author-name">{resourceAuthor?.username}</span>
{handleSubscribe()}
</div>
<div className="resource-operation-detail">
<div className="resource-operation-detail-data">
<i
className={isCollected ? "pi pi-star-fill" : "pi pi-star"}
onClick={handleCollection}
style={{
cursor: 'pointer',
fontSize: '30px',
color: isCollected ? 'rgba(82, 102, 101, 1)' : 'inherit',
transition: 'color 0.3s ease'
}}
/>
<span>{formatCount(collectionCount)}</span>
</div>
<div className="resource-operation-detail-data">
{isLiked ? <SmilingFace
theme="filled"
size="30"
fill="#526665"
strokeWidth={5}
onClick={handleLike}
style={{ cursor: 'pointer' }}
/>
: <SmilingFace
theme="outline"
size="30"
fill="#526665"
strokeWidth={5}
onClick={handleLike}
style={{ cursor: 'pointer' }}
/>
}
<span>{formatCount(likeCount)}</span>
</div>
<ButtonGroup >
<Button label={"$" + resource?.price} style={{
height: "44px", background: "rgba(82, 102, 101, 1)",
borderStyle: "solid", borderWidth: "1px", borderColor: "rgba(82, 102, 101, 1)",
borderRadius: "0 20px 20px 0", fontSize: "26px",
}} onClick={buyResource} />
</ButtonGroup>
</div>
</div>
{/*资源详情*/}
<div className="resource-detail">
<h1 className="resource-detail-title">资源详情</h1>
<div className="resource-detail-text">
{resource?.resourceDetail}
</div>
</div>
{/* 评论列表 */}
<div className="comments-section">
<div className="comments-header">
<h2>评论 ({totalComments})</h2>
<Link href="/community" className="no-underline">进入社区</Link>
</div>
<div className="comments-input">
<Avatar image={ "users/" + userInfo.avatar} size="large" shape="circle" />
<InputText value={commentValue} placeholder="发布你的评论" onChange={(e) => setCommentValue(e.target.value)} />
<Button label="发布评论" onClick={publishComment} disabled={!commentValue.trim()} />
</div>
<div className="comments-list">
{comments.map((comment, index) => (
<div key={comment.commentId} className="comment-item">
<div className="comment-user">
<Avatar
image={comment.userId ? "users/" + commentUserInfos.get(comment.userId)?.avatar : '/default-avatar.png'}
size="normal"
shape="circle"
/>
<div className="comment-meta">
<span className="username">
{comment.userId ? commentUserInfos.get(comment.userId)?.username : '匿名用户'}
</span>
<div className="comment-time">
<span className="floor">#{first + index + 1}楼</span>
<span className="time">{comment.createAt}</span>
</div>
</div>
<i className='pi pi-ellipsis-v' onClick={(e) => ops.current[index].toggle(e)} />
</div>
<div className="comment-content">
{<span className="reply-to">{getReplyUserName(comment.replyId)}</span>}
<p>{comment.content}</p>
</div>
<OverlayPanel // 回调 ref:把实例放到 ops.current 对应的位置
ref={el => {
if (el) ops.current[index] = el;
}}>
<Button label="回复" text size="small" onClick={() => setVisibleReply(true)} />
{comment.userId === userId &&
<Button
label="删除"
text
size="small"
onClick={() => { console.log('Deleting comment:', comment.commentId, 'by user:', comment.userId); deleteComment(comment.commentId) }}
/>
}
</OverlayPanel>
<Sidebar className='reply' header={ReplyHeader} visible={visibleReply} position="bottom" onHide={() => setVisibleReply(false)}>
<div className="reply-input">
<Avatar image={ "users/" + userInfo.avatar} size="large" shape="circle" />
<InputText value={replyValue} placeholder="发布你的评论" onChange={(e) => setReplyValue(e.target.value)} />
<Button label="发布评论" onClick={() => publishReply(comment.commentId)} disabled={!replyValue.trim()} />
</div>
</Sidebar>
</div>
))}
{totalComments > 5 && (<Paginator className="Paginator" first={first} rows={rows} totalRecords={totalComments} rowsPerPageOptions={[5, 10]} onPageChange={onPageChange} />)}
</div>
</div>
{/*用户购买资源弹窗*/}
<Dialog
header="购买资源"
visible={visible}
onHide={() => setVisible(false)}
className="purchase-dialog"
modal
footer={
<div className="dialog-footer">
<Button label="购买" icon="pi pi-check" onClick={handlePurchase} autoFocus />
<Button label="取消" icon="pi pi-times" onClick={() => setVisible(false)} className="p-button-text" />
</div>
}
>
<div className="form-text">
购买该资源需要{resource?.price}积分,是否购买?
</div>
</Dialog>
</div>
)
}