feat(feedback): 截图上传 + 我的反馈历史 + 后台管理页
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
938
package-lock.json
generated
938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"ali-oss": "^6.23.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -28,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import MileageModule from './modules/mileage/MileageModule';
|
||||
import SchedulingModule from './modules/scheduling/SchedulingModule';
|
||||
import EnergyModule from './modules/energy/EnergyModule';
|
||||
import EleImportPage from './modules/ele/EleImportPage';
|
||||
import FeedbackAdminPage from './modules/admin/FeedbackAdminPage';
|
||||
import AuthProvider from './auth/AuthProvider';
|
||||
import { useAuth } from './auth/useAuth';
|
||||
import UnauthorizedPage from './auth/UnauthorizedPage';
|
||||
@@ -46,13 +47,16 @@ function AuthGate() {
|
||||
return <UnauthorizedPage message={error || undefined} />;
|
||||
}
|
||||
|
||||
// 隐藏后端管理页:通过 /ele/import 或 #/ele/import 直接访问,主导航不出现
|
||||
// 隐藏后端管理页:通过路径或 hash 直接访问,主导航不出现
|
||||
if (typeof window !== 'undefined') {
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
if (path === '/ele/import' || hash === '#/ele/import' || hash === '#ele/import') {
|
||||
return <EleImportPage />;
|
||||
}
|
||||
if (path === '/admin/feedback' || hash === '#/admin/feedback' || hash === '#admin/feedback') {
|
||||
return <FeedbackAdminPage />;
|
||||
}
|
||||
}
|
||||
|
||||
return <Shell modules={modules} />;
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles } from 'lucide-react';
|
||||
import { MessageCircleHeart, X, ChevronRight, ChevronLeft, Check, Sparkles, ImagePlus, Loader2, Inbox } from 'lucide-react';
|
||||
import { fetchJson } from '../auth/api-client';
|
||||
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
||||
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
const MAX_IMG_SIZE_MB = 5;
|
||||
|
||||
interface UploadedImg {
|
||||
url: string;
|
||||
thumbDataUrl: string;
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<string> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch('/api/feedback/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
||||
return json.url as string;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(String(r.result || ''));
|
||||
r.onerror = reject;
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
type FeedbackType = 'dimension' | 'bug' | 'ux' | 'other';
|
||||
|
||||
@@ -37,14 +69,42 @@ interface Props {
|
||||
|
||||
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
|
||||
const [type, setType] = useState<FeedbackType | null>(null);
|
||||
const [mod, setMod] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [shots, setShots] = useState<UploadedImg[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFiles = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||||
if (list.length === 0) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const f of list) {
|
||||
if (shots.length >= MAX_SCREENSHOTS) break;
|
||||
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
||||
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
||||
continue;
|
||||
}
|
||||
const thumbDataUrl = await readAsDataUrl(f);
|
||||
const url = await uploadImage(f);
|
||||
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open: detect current module
|
||||
useEffect(() => {
|
||||
@@ -66,6 +126,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
setType(null);
|
||||
setContent('');
|
||||
setContact('');
|
||||
setShots([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -87,6 +148,7 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
module: mod || null,
|
||||
content: content.trim(),
|
||||
contact: contact.trim() || null,
|
||||
screenshots: shots.map(s => s.url),
|
||||
userAgent: navigator.userAgent.slice(0, 500),
|
||||
}),
|
||||
});
|
||||
@@ -119,21 +181,58 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60]">
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-20 right-4 md:bottom-6 md:right-6 z-[60] w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="提建议"
|
||||
title="提建议 / 想看的数据"
|
||||
onClick={() => setMenuOpen(m => !m)}
|
||||
className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 text-white shadow-lg shadow-blue-200 flex items-center justify-center group"
|
||||
aria-label="反馈"
|
||||
title="提建议 / 我的反馈"
|
||||
>
|
||||
<MessageCircleHeart size={20} className="drop-shadow group-hover:scale-110 transition-transform" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-amber-300 ring-2 ring-white animate-pulse" />
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-14 right-0 bg-white rounded-2xl shadow-xl border border-slate-100 p-1.5 min-w-[148px] flex flex-col gap-0.5"
|
||||
>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-blue-50 hover:text-blue-600 text-left"
|
||||
>
|
||||
<Sparkles size={14} className="text-blue-500" /> 提个建议
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
||||
className="flex items-center gap-2 px-3 py-2 text-[12px] font-bold text-slate-700 rounded-lg hover:bg-emerald-50 hover:text-emerald-600 text-left"
|
||||
>
|
||||
<Inbox size={14} className="text-emerald-500" /> 我的反馈
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 点击外面关菜单 */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-1]"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
{/* Modal */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
@@ -223,12 +322,28 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
ref={taRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imgs: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||
const f = it.getAsFile();
|
||||
if (f) imgs.push(f);
|
||||
}
|
||||
}
|
||||
if (imgs.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(imgs);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder={
|
||||
type === 'dimension' ? '比如:希望按客户/区域/日期范围 等等切片看里程数据…'
|
||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…'
|
||||
: type === 'bug' ? '比如:氢能页面 04-28 嘉燃经开站显示 153.81,但…(可粘贴截图)'
|
||||
: type === 'ux' ? '比如:能不能把外部 tab 默认收起,加载快一点…'
|
||||
: '随便聊聊你的想法'
|
||||
}
|
||||
@@ -236,6 +351,46 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{content.length} / 1000</div>
|
||||
</div>
|
||||
|
||||
{/* 截图上传 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase">截图(可选)</p>
|
||||
<span className="text-[10px] text-slate-300 font-bold">{shots.length}/{MAX_SCREENSHOTS},可粘贴</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shots.map((s, i) => (
|
||||
<div key={i} className="relative w-16 h-16 rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img src={s.thumbDataUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => setShots(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="absolute top-0.5 right-0.5 w-4 h-4 rounded-full bg-slate-900/70 text-white flex items-center justify-center hover:bg-slate-900"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shots.length < MAX_SCREENSHOTS && (
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-16 h-16 rounded-lg border border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 flex flex-col items-center justify-center gap-0.5 text-slate-400"
|
||||
>
|
||||
{uploading ? <Loader2 size={16} className="animate-spin" /> : <ImagePlus size={16} />}
|
||||
<span className="text-[9px] font-bold">{uploading ? '上传中' : '加截图'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">所在板块</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
@@ -250,6 +405,8 @@ export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-rose-500 font-bold">{error}</div>}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
163
src/components/FeedbackHistoryDrawer.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
330
src/modules/admin/FeedbackAdminPage.tsx
Normal file
330
src/modules/admin/FeedbackAdminPage.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Inbox, RotateCcw, X, Send, CheckCircle2, AlertCircle, Image as ImageIcon, Loader2 } 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;
|
||||
user_id: string | null;
|
||||
user_name: 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_OPTIONS: { key: FeedbackItem['status']; label: string; cls: string }[] = [
|
||||
{ key: 'open', label: '待处理', cls: 'bg-slate-100 text-slate-500 border-slate-200' },
|
||||
{ key: 'in_progress', label: '处理中', cls: 'bg-amber-100 text-amber-600 border-amber-200' },
|
||||
{ key: 'done', label: '已完成', cls: 'bg-emerald-100 text-emerald-600 border-emerald-200' },
|
||||
{ key: 'rejected', label: '已忽略', cls: 'bg-rose-100 text-rose-500 border-rose-200' },
|
||||
];
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
assets: '资产管理',
|
||||
mileage: '里程管理',
|
||||
energy: '能源管理',
|
||||
scheduling: '智能调度',
|
||||
ele: '充电导入',
|
||||
};
|
||||
|
||||
function parseScreenshots(s: FeedbackItem['screenshots']): string[] {
|
||||
if (!s) return [];
|
||||
if (Array.isArray(s)) return s;
|
||||
try { return JSON.parse(String(s)); } catch { return []; }
|
||||
}
|
||||
|
||||
async function patchItem(id: number, data: { status?: string; reply?: string }): Promise<void> {
|
||||
const token = sessionStorage.getItem('bi_jwt');
|
||||
const res = await fetch(`/api/feedback/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json.message || `更新失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export default function FeedbackAdminPage() {
|
||||
const [items, setItems] = useState<FeedbackItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<'' | FeedbackItem['status']>('');
|
||||
const [active, setActive] = useState<FeedbackItem | null>(null);
|
||||
const [replyDraft, setReplyDraft] = useState('');
|
||||
const [replyStatus, setReplyStatus] = useState<FeedbackItem['status']>('done');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hint, setHint] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('limit', '200');
|
||||
const d = await fetchJson<{ items: FeedbackItem[] }>(`/api/feedback/list?${params.toString()}`);
|
||||
setItems(d.items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
const open = (it: FeedbackItem) => {
|
||||
setActive(it);
|
||||
setReplyDraft(it.reply_content || '');
|
||||
setReplyStatus(it.status === 'open' ? 'done' : it.status);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!active) return;
|
||||
setSaving(true);
|
||||
setHint(null);
|
||||
try {
|
||||
await patchItem(active.id, { status: replyStatus, reply: replyDraft });
|
||||
setHint('已保存');
|
||||
setActive(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setHint(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setHint(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const setStatusOnly = async (it: FeedbackItem, status: FeedbackItem['status']) => {
|
||||
try {
|
||||
await patchItem(it.id, { status });
|
||||
await reload();
|
||||
} catch (e) {
|
||||
setHint(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setHint(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const counters = items.reduce<Record<string, number>>((m, it) => {
|
||||
m[it.status] = (m[it.status] || 0) + 1;
|
||||
return m;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FB] p-4 md:p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 flex items-center justify-center">
|
||||
<Inbox size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-slate-900 leading-tight">用户反馈管理</h1>
|
||||
<p className="text-[11px] font-bold text-slate-400">查看、回复、跟进用户提交的建议</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={reload} className="p-2 text-slate-400 hover:text-blue-500" title="刷新">
|
||||
<RotateCcw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 状态过滤 */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-2 flex items-center gap-1 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === '' ? 'bg-blue-50 text-blue-600' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>全部 {items.length}</button>
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setStatusFilter(statusFilter === o.key ? '' : o.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold whitespace-nowrap ${statusFilter === o.key ? `${o.cls} border` : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
>
|
||||
{o.label} {counters[o.key] ?? 0}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-rose-600">
|
||||
<AlertCircle size={14} /> {error}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{hint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="bg-emerald-50 border border-emerald-100 rounded-xl p-3 flex items-center gap-2 text-[12px] font-bold text-emerald-600"
|
||||
>
|
||||
<CheckCircle2 size={14} /> {hint}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="space-y-2">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-10 text-center text-slate-300 text-[12px] font-bold">还没有反馈</div>
|
||||
) : items.map(it => {
|
||||
const shots = parseScreenshots(it.screenshots);
|
||||
const statusOpt = STATUS_OPTIONS.find(o => o.key === it.status);
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 cursor-pointer hover:border-blue-200 transition-colors"
|
||||
onClick={() => open(it)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
|
||||
<span className="text-[11px] font-bold text-slate-500">{TYPE_LABEL[it.type] || it.type}</span>
|
||||
{it.module && <span className="text-[10px] text-slate-400 font-bold">{MODULE_LABELS[it.module] || it.module}</span>}
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border ${statusOpt?.cls || 'bg-slate-50 text-slate-400 border-slate-200'}`}>
|
||||
{statusOpt?.label || it.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 ml-auto">
|
||||
{(it.user_name || it.user_id || '匿名')} · {(it.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed line-clamp-2 break-words">{it.content}</div>
|
||||
{(shots.length > 0 || it.contact) && (
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-slate-400 font-bold">
|
||||
{shots.length > 0 && <span className="flex items-center gap-0.5"><ImageIcon size={11} />{shots.length} 张</span>}
|
||||
{it.contact && <span>📞 {it.contact}</span>}
|
||||
</div>
|
||||
)}
|
||||
{it.reply_content && (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg px-2.5 py-1.5 mt-2 text-[11px] text-slate-600 line-clamp-1">
|
||||
回复: {it.reply_content}
|
||||
</div>
|
||||
)}
|
||||
{it.status === 'open' && (
|
||||
<div className="flex gap-1 mt-2 pt-2 border-t border-slate-50" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setStatusOnly(it, 'in_progress')} className="flex-1 px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-600 hover:bg-amber-100">标记处理中</button>
|
||||
<button onClick={() => setStatusOnly(it, 'rejected')} className="px-2 py-1 rounded text-[10px] font-bold bg-rose-50 text-rose-500 hover:bg-rose-100">忽略</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情 / 回复弹窗 */}
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[80] bg-slate-900/40 backdrop-blur-sm flex items-end md:items-center justify-center"
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<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-xl 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">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-black text-slate-800 leading-tight">反馈详情 #{active.id}</div>
|
||||
<div className="text-[10px] text-slate-400 font-bold">
|
||||
{active.user_name || active.user_id || '匿名'} · {(active.created_at || '').replace('T', ' ').slice(0, 16)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setActive(null)} 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-4 space-y-3">
|
||||
<div className="bg-slate-50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap text-[10px] font-bold">
|
||||
<span className="text-slate-500">{TYPE_LABEL[active.type] || active.type}</span>
|
||||
{active.module && <span className="text-slate-400">板块: {MODULE_LABELS[active.module] || active.module}</span>}
|
||||
{active.contact && <span className="text-slate-400">联系: {active.contact}</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words">{active.content}</div>
|
||||
{parseScreenshots(active.screenshots).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{parseScreenshots(active.screenshots).map((u, i) => (
|
||||
<a key={i} href={u} target="_blank" rel="noreferrer" className="block w-20 h-20 rounded-lg overflow-hidden border border-slate-200">
|
||||
<img src={u} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">状态</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{STATUS_OPTIONS.map(o => (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => setReplyStatus(o.key)}
|
||||
className={`px-3 py-1 rounded-full text-[11px] font-bold border ${replyStatus === o.key ? o.cls : 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase mb-1.5">回复内容</p>
|
||||
<textarea
|
||||
value={replyDraft}
|
||||
onChange={(e) => setReplyDraft(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
placeholder="给用户的回复(用户在「我的反馈」里能看到)"
|
||||
className="w-full bg-slate-50 border-none rounded-xl p-3 text-[12px] text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20 resize-none"
|
||||
/>
|
||||
<div className="text-right text-[10px] text-slate-300 font-bold mt-1">{replyDraft.length} / 2000</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t border-slate-100 flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setActive(null)}
|
||||
className="px-3 py-2 rounded-xl text-[12px] font-bold text-slate-500 hover:bg-slate-50"
|
||||
>取消</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-xl text-[12px] font-bold bg-blue-600 text-white shadow shadow-blue-100 disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none flex items-center gap-1"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={13} />}
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import { Hono } from 'hono';
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { uploadFeedbackImage } from './oss.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -12,14 +16,19 @@ CREATE TABLE IF NOT EXISTS bi_user_feedback (
|
||||
module VARCHAR(64) NULL,
|
||||
content TEXT NOT NULL,
|
||||
contact VARCHAR(200) NULL,
|
||||
screenshots JSON NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
user_name VARCHAR(128) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open',
|
||||
reply_content TEXT NULL,
|
||||
reply_user VARCHAR(128) NULL,
|
||||
reply_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
KEY idx_created_at (created_at),
|
||||
KEY idx_type (type),
|
||||
KEY idx_status (status)
|
||||
KEY idx_status (status),
|
||||
KEY idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`;
|
||||
|
||||
@@ -27,16 +36,28 @@ let ensured = false;
|
||||
async function ensureTable(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await pool.query(CREATE_TABLE_SQL);
|
||||
// 兼容旧表:补齐缺失列
|
||||
for (const alter of [
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`,
|
||||
`ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`,
|
||||
`ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`,
|
||||
]) {
|
||||
try { await pool.query(alter); } catch { /* 已存在则忽略 */ }
|
||||
}
|
||||
ensured = true;
|
||||
}
|
||||
|
||||
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']);
|
||||
|
||||
const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']);
|
||||
|
||||
app.post('/submit', async (c) => {
|
||||
await ensureTable();
|
||||
const body = await c.req.json().catch(() => ({})) as {
|
||||
type?: string; module?: string | null; content?: string;
|
||||
contact?: string | null; userAgent?: string;
|
||||
contact?: string | null; userAgent?: string; screenshots?: string[];
|
||||
};
|
||||
const type = (body.type || '').trim();
|
||||
const content = (body.content || '').trim();
|
||||
@@ -51,27 +72,107 @@ app.post('/submit', async (c) => {
|
||||
const moduleVal = (body.module || '').slice(0, 64) || null;
|
||||
const contact = (body.contact || '').slice(0, 200) || null;
|
||||
const userAgent = (body.userAgent || '').slice(0, 512) || null;
|
||||
const screenshots = Array.isArray(body.screenshots)
|
||||
? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6)
|
||||
: [];
|
||||
|
||||
const [r] = await pool.query<ResultSetHeader>(
|
||||
`INSERT INTO bi_user_feedback (type, module, content, contact, user_id, user_name, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
|
||||
[type, moduleVal, content, contact, user?.userId || null, user?.userName || null, userAgent],
|
||||
`INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
|
||||
[type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent],
|
||||
);
|
||||
return c.json({ ok: true, id: r.insertId });
|
||||
});
|
||||
|
||||
// GET /api/feedback/list — 简易列表(按时间倒序,最多 200 条)
|
||||
app.get('/list', async (c) => {
|
||||
// =========================================================
|
||||
// POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file)
|
||||
// =========================================================
|
||||
app.post('/upload', async (c) => {
|
||||
const form = await c.req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return c.json({ ok: false, message: '未上传文件' }, 400);
|
||||
}
|
||||
const mime = file.type || 'image/png';
|
||||
if (!ALLOWED_MIME.has(mime)) {
|
||||
return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400);
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400);
|
||||
}
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
try {
|
||||
const url = await uploadFeedbackImage(file.name || 'screenshot.png', buf, mime);
|
||||
return c.json({ ok: true, url });
|
||||
} catch (e) {
|
||||
console.error('feedback upload error:', e);
|
||||
return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/feedback/mine — 当前用户的反馈历史
|
||||
app.get('/mine', async (c) => {
|
||||
await ensureTable();
|
||||
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
if (!user?.userId) return c.json({ items: [] });
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, user_name, status, created_at
|
||||
`SELECT id, type, module, content, contact, screenshots, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[limit],
|
||||
LIMIT 100`,
|
||||
[user.userId],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// GET /api/feedback/list — 管理列表(含全部反馈)
|
||||
app.get('/list', async (c) => {
|
||||
await ensureTable();
|
||||
const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100));
|
||||
const status = c.req.query('status') || '';
|
||||
const where: string[] = ['1=1'];
|
||||
const params: (string | number)[] = [];
|
||||
if (VALID_STATUS.has(status)) {
|
||||
where.push('status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT id, type, module, content, contact, screenshots, user_id, user_name, status,
|
||||
reply_content, reply_user, reply_at, created_at
|
||||
FROM bi_user_feedback
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit],
|
||||
);
|
||||
return c.json({ items: rows });
|
||||
});
|
||||
|
||||
// PATCH /api/feedback/:id — 管理:更新状态与回复
|
||||
app.patch('/:id', async (c) => {
|
||||
await ensureTable();
|
||||
const id = Number(c.req.param('id'));
|
||||
if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400);
|
||||
const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string };
|
||||
const fields: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
if (body.status) {
|
||||
if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400);
|
||||
fields.push('status = ?');
|
||||
params.push(body.status);
|
||||
}
|
||||
if (typeof body.reply === 'string') {
|
||||
const reply = body.reply.trim().slice(0, 2000);
|
||||
fields.push('reply_content = ?', 'reply_user = ?', 'reply_at = NOW()');
|
||||
const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined;
|
||||
params.push(reply || null, user?.userName || user?.userId || null);
|
||||
}
|
||||
if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400);
|
||||
params.push(id);
|
||||
await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
38
src/server/routes/feedback/oss.ts
Normal file
38
src/server/routes/feedback/oss.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import OSS from 'ali-oss';
|
||||
|
||||
let client: OSS | null = null;
|
||||
|
||||
function getClient(): OSS {
|
||||
if (client) return client;
|
||||
const region = process.env.OSS_REGION || 'oss-cn-shanghai';
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID || '';
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET || '';
|
||||
const bucket = process.env.OSS_BUCKET || '';
|
||||
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||
throw new Error('OSS 未配置:OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
|
||||
}
|
||||
client = new OSS({ region, accessKeyId, accessKeySecret, bucket, secure: true });
|
||||
return client;
|
||||
}
|
||||
|
||||
function safeExt(filename: string, fallback = 'png'): string {
|
||||
const m = /\.([a-zA-Z0-9]{1,8})$/.exec(filename);
|
||||
return m ? m[1].toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function randId(len = 8): string {
|
||||
return Math.random().toString(36).slice(2, 2 + len);
|
||||
}
|
||||
|
||||
/** 上传 buffer 到 OSS,返回公开访问的 URL */
|
||||
export async function uploadFeedbackImage(filename: string, buf: Buffer, mimetype: string): Promise<string> {
|
||||
const c = getClient();
|
||||
const baseDir = (process.env.OSS_BASE_DIR || '/dos').replace(/^\/+|\/+$/g, '');
|
||||
const ymd = new Date().toISOString().slice(0, 10);
|
||||
const key = `${baseDir}/feedback/${ymd}/${Date.now().toString(36)}-${randId()}.${safeExt(filename, mimetype.split('/')[1] || 'png')}`;
|
||||
await c.put(key, buf, {
|
||||
headers: { 'Content-Type': mimetype, 'x-oss-object-acl': 'public-read' },
|
||||
});
|
||||
const host = (process.env.OSS_HOST || `https://${process.env.OSS_BUCKET}.${process.env.OSS_ENDPOINT}/`).replace(/\/+$/, '/');
|
||||
return host + key;
|
||||
}
|
||||
Reference in New Issue
Block a user