All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
后端 - 接入阿里云 OSS(ali-oss SDK),bucket=lnh2etest,目录 /dos/feedback/YYYY-MM-DD/ - POST /api/feedback/upload:单图 multipart 上传,限制 5MB,仅 png/jpeg/webp/gif - bi_user_feedback 增加 screenshots(JSON)、reply_content/reply_user/reply_at、user_id 索引 老表通过 try-catch 自动 ALTER 兼容 - POST /api/feedback/submit:增加 screenshots[] 字段 - GET /api/feedback/mine:当前用户自己的反馈历史 - PATCH /api/feedback/:id:更新状态 + 回复 - GET /api/feedback/list:增加 status 过滤 前端 - FeedbackFab 改为悬浮按钮 + 弹出菜单:「提个建议」/「我的反馈」 - 弹窗 Step 2 增加截图区:点击选择 / 多张 / 直接 Ctrl+V 粘贴 缩略图预览 + 单张移除,最多 6 张,上传中转圈 - FeedbackHistoryDrawer 新组件:底部抽屉展示自己的反馈 含状态徽章(待处理/处理中/已完成/已忽略)、截图缩略图、产品同学回复区 - 新增隐藏后台管理页 /admin/feedback(或 #/admin/feedback) 状态分类计数 + 列表 + 详情弹窗(改状态 + 写回复,状态选项含徽章色) 待处理项有快捷按钮(标记处理中 / 忽略) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { X, MailOpen, Loader2, ArrowLeft } from 'lucide-react';
|
|
import { fetchJson } from '../auth/api-client';
|
|
|
|
interface FeedbackItem {
|
|
id: number;
|
|
type: 'dimension' | 'bug' | 'ux' | 'other';
|
|
module: string | null;
|
|
content: string;
|
|
contact: string | null;
|
|
screenshots: string[] | string | null;
|
|
status: 'open' | 'in_progress' | 'done' | 'rejected';
|
|
reply_content: string | null;
|
|
reply_user: string | null;
|
|
reply_at: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
const TYPE_LABEL: Record<string, string> = {
|
|
dimension: '💡 新维度',
|
|
bug: '🐛 Bug',
|
|
ux: '🎨 体验',
|
|
other: '📝 其他',
|
|
};
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
open: '待处理',
|
|
in_progress: '处理中',
|
|
done: '已完成',
|
|
rejected: '已忽略',
|
|
};
|
|
|
|
const STATUS_STYLE: Record<string, string> = {
|
|
open: 'bg-slate-100 text-slate-500',
|
|
in_progress: 'bg-amber-100 text-amber-600',
|
|
done: 'bg-emerald-100 text-emerald-600',
|
|
rejected: 'bg-rose-100 text-rose-500',
|
|
};
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onBack?: () => void;
|
|
}
|
|
|
|
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
|
if (!s) return [];
|
|
if (Array.isArray(s)) return s;
|
|
try { return JSON.parse(String(s)); } catch { return []; }
|
|
}
|
|
|
|
export default function FeedbackHistoryDrawer({ open, onClose, onBack }: Props) {
|
|
const [items, setItems] = useState<FeedbackItem[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setItems(null);
|
|
setError(null);
|
|
fetchJson<{ items: FeedbackItem[] }>('/api/feedback/mine')
|
|
.then(d => setItems(d.items))
|
|
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
|
}, [open]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[100] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ y: '100%', opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: '100%', opacity: 0 }}
|
|
transition={{ y: { type: 'spring', damping: 28, stiffness: 320 }, opacity: { duration: 0.18 } }}
|
|
className="bg-white w-full md:max-w-lg md:rounded-3xl rounded-t-3xl shadow-2xl max-h-[92vh] overflow-hidden flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex justify-center pt-2.5 pb-1">
|
|
<div className="w-10 h-1 rounded-full bg-slate-300" />
|
|
</div>
|
|
<div className="px-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
|
{onBack && (
|
|
<button onClick={onBack} className="p-1 -ml-1 text-slate-500 hover:text-slate-700">
|
|
<ArrowLeft size={18} />
|
|
</button>
|
|
)}
|
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
|
<MailOpen size={14} className="text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-sm font-black text-slate-800 leading-tight">我的反馈</div>
|
|
<div className="text-[10px] text-slate-400 font-bold">查看进展与回复</div>
|
|
</div>
|
|
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-700">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
{error ? (
|
|
<div className="bg-rose-50 text-rose-600 rounded-xl p-3 text-[12px] font-bold">{error}</div>
|
|
) : items === null ? (
|
|
<div className="py-10 text-center text-slate-400 text-[12px] font-bold flex items-center justify-center gap-1.5">
|
|
<Loader2 size={14} className="animate-spin" /> 加载中…
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="py-10 text-center text-slate-300 text-[12px] font-bold">还没有提过反馈</div>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
{items.map(it => {
|
|
const shots = parseScreenshots(it.screenshots);
|
|
return (
|
|
<div key={it.id} className="bg-slate-50 rounded-xl p-3 space-y-2">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="text-[10px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${STATUS_STYLE[it.status]}`}>
|
|
{STATUS_LABEL[it.status]}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400 ml-auto">
|
|
{(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
|
</span>
|
|
</div>
|
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
|
{it.content}
|
|
</div>
|
|
{shots.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{shots.map((url, i) => (
|
|
<a key={i} href={url} target="_blank" rel="noreferrer" className="block w-12 h-12 rounded overflow-hidden border border-slate-200">
|
|
<img src={url} alt="" className="w-full h-full object-cover" />
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
{it.reply_content && (
|
|
<div className="bg-blue-50 border border-blue-100 rounded-lg p-2.5 mt-1">
|
|
<div className="text-[10px] font-bold text-blue-500 mb-0.5">
|
|
{it.reply_user || '产品同学'} 回复
|
|
{it.reply_at && <span className="text-blue-300 ml-1">{(it.reply_at || '').replace('T', ' ').slice(0, 16)}</span>}
|
|
</div>
|
|
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">
|
|
{it.reply_content}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|