Skip to content
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ frontend/node_modules/*
frontend/.next/*
frontend/next-env.d.ts
frontend/package-lock.json
frontend/.env
.env.*
!.env.example
*.tsbuildinfo

# os
.DS_Store
Expand Down
13 changes: 13 additions & 0 deletions frontend/components/common/receive/ReceiveContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {Badge} from '@/components/ui/badge';
import {TRUST_LEVEL_OPTIONS} from '@/components/common/project';
import {ArrowLeftIcon, Copy, Tag, Gift, Clock, AlertCircle, Package} from 'lucide-react';
import ContentRender from '@/components/common/markdown/ContentRender';
import {ReportButton} from '@/components/common/receive/ReportButton';
import services from '@/lib/services';
import {BasicUserInfo} from '@/lib/services/core';
import {GetProjectResponseData} from '@/lib/services/project';
Expand Down Expand Up @@ -331,6 +332,18 @@ export function ReceiveContent({data}: ReceiveContentProps) {
/>
</div>
</motion.div>

<motion.div variants={itemVariants}>
<hr className="border-t border-gray-200 dark:border-gray-700" />
<div className="pt-4">
<ReportButton
projectId={projectId}
user={user}
hasReported={false}
variant="text"
/>
</div>
</motion.div>
</motion.div>
);
}
160 changes: 160 additions & 0 deletions frontend/components/common/receive/ReportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use client';

import {useState} from 'react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {Textarea} from '@/components/ui/textarea';
import {Label} from '@/components/ui/label';
import {Flag} from 'lucide-react';
import services from '@/lib/services';
import {BasicUserInfo} from '@/lib/services/core';

/**
* 举报按钮组件 Props
*/
interface ReportButtonProps {
/** 项目ID */
projectId: string;
/** 当前用户信息 */
user: BasicUserInfo | null;
/** 是否已经举报过 */
hasReported?: boolean;
/** 按钮样式变体 */
variant?: 'default' | 'text';
}

/**
* 举报按钮组件
* 提供项目举报功能,通过弹窗让用户填写举报理由
*/
export function ReportButton({projectId, user, hasReported = false, variant = 'default'}: ReportButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [reason, setReason] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasReportedLocal, setHasReportedLocal] = useState(hasReported);

// 计算 trimmed reason,用于多个地方的验证
const trimmedReason = reason.trim();

/**
* 处理举报提交
*/
const handleSubmit = async () => {
if (!trimmedReason) {
toast.error('请填写举报理由');
return;
}

if (trimmedReason.length > 255) {
toast.error('举报理由不能超过255个字符');
return;
}

try {
setIsSubmitting(true);

const result = await services.project.reportProjectSafe(projectId, trimmedReason);

if (result.success) {
toast.success('举报提交成功,感谢您的反馈');
setHasReportedLocal(true);
setIsOpen(false);
setReason('');
} else {
// 检查是否是重复举报的错误
if (result.error && result.error.includes('已举报过当前项目')) {
setHasReportedLocal(true);
}
toast.error(result.error || '举报失败,请稍后重试');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '举报失败,请稍后重试';
// 检查是否是重复举报的错误
if (errorMessage.includes('已举报过当前项目')) {
setHasReportedLocal(true);
}
toast.error(errorMessage);
} finally {
setIsSubmitting(false);
}
};

/**
* 处理对话框关闭
*/
const handleClose = () => {
if (!isSubmitting) {
setIsOpen(false);
setReason('');
}
};

return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant={variant === 'text' ? 'ghost' : 'outline'}
size={variant === 'text' ? 'sm' : 'sm'}
disabled={!user || hasReportedLocal}
className={
variant === 'text' ?
'h-auto p-0 text-sm text-muted-foreground hover:text-foreground font-normal justify-start' :
'h-8 px-3 text-xs border-muted-foreground/30 text-muted-foreground hover:text-foreground hover:border-muted-foreground'
}
>
<Flag className={variant === 'text' ? 'w-4 h-4 mr-2' : 'w-3 h-3 mr-1'} />
{!user ? '请先登录' : hasReportedLocal ? '已举报' : '举报项目'}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>举报项目</DialogTitle>
<DialogDescription>
如果您发现此项目存在违规内容、恶意行为或其他问题,请填写举报理由。我们会认真处理您的反馈。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="reason">举报理由</Label>
<Textarea
id="reason"
placeholder="请详细描述您发现的问题..."
value={reason}
onChange={(e) => setReason(e.target.value)}
className="min-h-[100px]"
maxLength={255}
disabled={isSubmitting}
/>
<div className="text-xs text-muted-foreground text-right">
{reason.length}/255
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
取消
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !trimmedReason}
>
{isSubmitting ? '提交中...' : '提交举报'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
1 change: 1 addition & 0 deletions frontend/components/common/receive/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export {ReceiveMain} from './ReceiveMain';
export {ReceiveContent} from './ReceiveContent';
export {ReportButton} from './ReportButton';
39 changes: 39 additions & 0 deletions frontend/lib/services/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ProjectListResponse,
ApiRequestParams,
ReceiveProjectData,
ReportProjectResponse,
} from './types';
import apiClient from '../core/api-client';

Expand Down Expand Up @@ -195,6 +196,22 @@ export class ProjectService extends BaseService {
return response.data.data;
}

/**
* 举报项目
* @param projectId - 项目ID
* @param reason - 举报理由
* @returns 举报结果
*/
static async reportProject(projectId: string, reason: string): Promise<void> {
const response = await apiClient.post<ReportProjectResponse>(`${this.basePath}/${projectId}/report`, {
reason,
});

if (response.data.error_msg) {
throw new Error(response.data.error_msg);
}
}

/**
* 获取项目详情(带错误处理)
* @param projectId - 项目ID
Expand Down Expand Up @@ -412,4 +429,26 @@ export class ProjectService extends BaseService {
};
}
}

/**
* 举报项目(带错误处理)
* @param projectId - 项目ID
* @param reason - 举报理由
* @returns 举报结果,包含成功状态和错误信息
*/
static async reportProjectSafe(projectId: string, reason: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await this.reportProject(projectId, reason);
return {success: true};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '举报失败';
return {
success: false,
error: errorMessage,
};
}
}
}
13 changes: 13 additions & 0 deletions frontend/lib/services/project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,16 @@ export interface ProjectListData {
* 项目列表响应类型
*/
export type ProjectListResponse = BackendResponse<ProjectListData>;

/**
* 举报项目请求参数
*/
export interface ReportProjectRequest {
/** 举报理由 */
reason: string;
}

/**
* 举报项目响应类型
*/
export type ReportProjectResponse = BackendResponse<null>;