98cea13d51
- 重命名:菜单与页面标题「反馈工单→用户反馈」「上报审核→低价审核」 - 反馈页「加群二维码」卡挪到系统配置页,新增「反馈二维码」tab - 修复反馈四态统计恒为 0:对接后端新增的 /admin/api/feedbacks/summary - 两页表格增强:用户列拆为 用户ID/手机号/用户昵称;新增 提交版本号、机型/OS版本 列; 点手机号弹抽屉看该用户全部记录,抽屉顶部汇总 提交/上报总计 + 奖励总计 - 低价审核:提交时间列可排序、审核结果与操作拆两列、顶部统计改卡片式、 Trace 列对齐比价记录页(有 trace_url 即可点) - 修复反馈时间显示快 8 小时:反馈时间为北京 wall-clock,改用 formatWallTime Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #28 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
'use client';
|
||
|
||
// 反馈审核抽屉:展示本条反馈 + 该用户历史反馈(就近参考),待审核态可采纳(发金币)或拒绝(填用户可见原因)。
|
||
// 已审核态只读回显。对接同事 PR#66 接口:POST .../approve、POST .../reject(.../handle 已废弃)。
|
||
import { useEffect, useState } from 'react';
|
||
import {
|
||
App,
|
||
Button,
|
||
Drawer,
|
||
Form,
|
||
Image,
|
||
Input,
|
||
InputNumber,
|
||
Segmented,
|
||
Space,
|
||
Spin,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd';
|
||
import { api, errMsg } from '@/lib/api';
|
||
import { canDo } from '@/lib/auth';
|
||
import { formatWallTime } from '@/lib/format';
|
||
import { mediaUrl } from '@/lib/media';
|
||
import type { CursorPage, Feedback } from '@/lib/types';
|
||
|
||
const { Text, Paragraph } = Typography;
|
||
|
||
const REWARD_MAX = 10000; // 对齐后端 FEEDBACK_REWARD_MAX_COINS
|
||
|
||
interface Props {
|
||
feedback: Feedback | null;
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onDone: () => void; // 审核成功后刷新列表
|
||
}
|
||
|
||
// 待审核(同事后端初始态 pending;兼容历史 new 数据)
|
||
const isPending = (s: string) => s === 'pending' || s === 'new';
|
||
|
||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||
pending: { label: '审核中', color: 'orange' },
|
||
new: { label: '待审核', color: 'orange' },
|
||
adopted: { label: '已采纳', color: 'green' },
|
||
rejected: { label: '未采纳', color: 'red' },
|
||
};
|
||
|
||
const statusTag = (s: string) => {
|
||
const m = STATUS_META[s] ?? { label: s, color: 'default' };
|
||
return <Tag color={m.color}>{m.label}</Tag>;
|
||
};
|
||
|
||
function FeedbackImages({ images }: { images: string[] | null }) {
|
||
if (!images || !images.length) return <Text type="secondary">无截图</Text>;
|
||
return (
|
||
<Image.PreviewGroup>
|
||
<Space size={4} wrap>
|
||
{images.map((src, i) => (
|
||
<Image
|
||
key={i}
|
||
src={mediaUrl(src)}
|
||
width={48}
|
||
height={48}
|
||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||
/>
|
||
))}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
);
|
||
}
|
||
|
||
export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }: Props) {
|
||
const { message } = App.useApp();
|
||
const [form] = Form.useForm();
|
||
const [action, setAction] = useState<'approve' | 'reject'>('approve');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [history, setHistory] = useState<Feedback[]>([]);
|
||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||
|
||
const editable = feedback != null && isPending(feedback.status) && canDo(['operator']);
|
||
|
||
// 打开时:重置表单 + 拉该用户全部历史反馈(含本条,渲染时滤掉本条)
|
||
useEffect(() => {
|
||
if (!open || !feedback) return;
|
||
setAction('approve');
|
||
form.resetFields();
|
||
let alive = true;
|
||
setLoadingHistory(true);
|
||
api
|
||
.get<CursorPage<Feedback>>('/admin/api/feedbacks', {
|
||
params: { user_id: feedback.user_id, limit: 50, sort_by: 'id', sort_order: 'desc' },
|
||
})
|
||
.then((r) => {
|
||
if (alive) setHistory(r.data.items);
|
||
})
|
||
.catch(() => {
|
||
if (alive) setHistory([]);
|
||
})
|
||
.finally(() => {
|
||
if (alive) setLoadingHistory(false);
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open, feedback?.id]);
|
||
|
||
const submit = async () => {
|
||
if (!feedback) return;
|
||
const v = await form.validateFields();
|
||
setSubmitting(true);
|
||
try {
|
||
if (action === 'approve') {
|
||
await api.post(`/admin/api/feedbacks/${feedback.id}/approve`, {
|
||
reward_coins: v.reward_coins,
|
||
note: v.note?.trim() || null,
|
||
});
|
||
message.success(`已采纳,发放 ${v.reward_coins} 金币`);
|
||
} else {
|
||
await api.post(`/admin/api/feedbacks/${feedback.id}/reject`, {
|
||
reason: v.reason.trim(),
|
||
note: v.note?.trim() || null,
|
||
});
|
||
message.success('已拒绝');
|
||
}
|
||
onClose();
|
||
onDone();
|
||
} catch (e) {
|
||
message.error(errMsg(e));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const others = feedback ? history.filter((h) => h.id !== feedback.id) : [];
|
||
|
||
return (
|
||
<Drawer
|
||
title={feedback ? (editable ? `审核反馈 #${feedback.id}` : `反馈详情 #${feedback.id}`) : ''}
|
||
width={560}
|
||
open={open}
|
||
onClose={onClose}
|
||
destroyOnHidden
|
||
extra={
|
||
editable ? (
|
||
<Button type="primary" danger={action === 'reject'} loading={submitting} onClick={submit}>
|
||
{action === 'approve' ? '采纳并发金币' : '确认拒绝'}
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
{feedback && (
|
||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||
{/* 本条反馈 */}
|
||
<div>
|
||
<Text type="secondary">
|
||
用户 {feedback.user_id} · {formatWallTime(feedback.created_at)}
|
||
</Text>
|
||
<Paragraph style={{ whiteSpace: 'pre-wrap', marginTop: 8, marginBottom: 8 }}>
|
||
{feedback.content || '-'}
|
||
</Paragraph>
|
||
<FeedbackImages images={feedback.images} />
|
||
</div>
|
||
|
||
{/* 已审核(或无权限):只读回显 */}
|
||
{!editable && (
|
||
<div
|
||
style={{
|
||
background:
|
||
feedback.status === 'adopted'
|
||
? '#f6ffed'
|
||
: feedback.status === 'rejected'
|
||
? '#fff1f0'
|
||
: '#fafafa',
|
||
border: `1px solid ${
|
||
feedback.status === 'adopted'
|
||
? '#b7eb8f'
|
||
: feedback.status === 'rejected'
|
||
? '#ffccc7'
|
||
: '#f0f0f0'
|
||
}`,
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
}}
|
||
>
|
||
<div>
|
||
状态:{statusTag(feedback.status)}
|
||
{feedback.reviewed_at ? (
|
||
<Text type="secondary"> · {formatWallTime(feedback.reviewed_at)}</Text>
|
||
) : null}
|
||
</div>
|
||
{feedback.status === 'adopted' && (
|
||
<div style={{ marginTop: 6 }}>
|
||
奖励金币:<b>{feedback.reward_coins ?? 0}</b>
|
||
</div>
|
||
)}
|
||
{feedback.status === 'rejected' && (
|
||
<div style={{ marginTop: 6 }}>
|
||
未采纳原因(用户可见):
|
||
{feedback.reject_reason || <Text type="secondary">(无)</Text>}
|
||
</div>
|
||
)}
|
||
<div style={{ marginTop: 6 }}>
|
||
审核备注:{feedback.review_note || <Text type="secondary">(无)</Text>}
|
||
</div>
|
||
{isPending(feedback.status) && (
|
||
<div style={{ marginTop: 6 }}>
|
||
<Text type="secondary">仅 operator / super_admin 可审核</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 待审核:采纳 / 拒绝表单 */}
|
||
{editable && (
|
||
<div>
|
||
<Segmented
|
||
value={action}
|
||
onChange={(v) => setAction(v as 'approve' | 'reject')}
|
||
options={[
|
||
{ label: '采纳(发金币)', value: 'approve' },
|
||
{ label: '拒绝', value: 'reject' },
|
||
]}
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
{/* preserve=false:切换采纳/拒绝时卸载的字段从 store 移除,validateFields 不误校验另一侧 */}
|
||
<Form form={form} layout="vertical" preserve={false}>
|
||
{action === 'approve' ? (
|
||
<>
|
||
<Form.Item
|
||
name="reward_coins"
|
||
label={`奖励金币(必填,1 ~ ${REWARD_MAX})`}
|
||
rules={[
|
||
{ required: true, message: '请输入奖励金币' },
|
||
{ type: 'number', min: 1, max: REWARD_MAX, message: `1 ~ ${REWARD_MAX}` },
|
||
]}
|
||
>
|
||
<InputNumber
|
||
style={{ width: '100%' }}
|
||
min={1}
|
||
max={REWARD_MAX}
|
||
placeholder="采纳后发放给用户的金币"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="note"
|
||
label="采纳要点 / 审核备注(选填)"
|
||
rules={[{ max: 256, message: '最多 256 字' }]}
|
||
>
|
||
<Input.TextArea rows={3} maxLength={256} showCount placeholder="如:建议已采纳,将在下个版本上线" />
|
||
</Form.Item>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Form.Item
|
||
name="reason"
|
||
label="未采纳原因(必填,用户端可见)"
|
||
rules={[
|
||
{ required: true, message: '请填写未采纳原因' },
|
||
{ max: 256, message: '最多 256 字' },
|
||
]}
|
||
>
|
||
<Input.TextArea rows={3} maxLength={256} showCount placeholder="向用户说明为何未采纳" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="note"
|
||
label="内部备注(选填,用户不可见)"
|
||
rules={[{ max: 256, message: '最多 256 字' }]}
|
||
>
|
||
<Input.TextArea rows={2} maxLength={256} showCount placeholder="运营内部备注" />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form>
|
||
</div>
|
||
)}
|
||
|
||
{/* 该用户历史反馈 */}
|
||
<div>
|
||
<Text strong>该用户历史反馈{others.length ? `(${others.length})` : ''}</Text>
|
||
{loadingHistory ? (
|
||
<Spin style={{ display: 'block', margin: '16px 0' }} />
|
||
) : others.length === 0 ? (
|
||
<div style={{ marginTop: 8 }}>
|
||
<Text type="secondary">该用户暂无其它反馈</Text>
|
||
</div>
|
||
) : (
|
||
<Space direction="vertical" size={8} style={{ width: '100%', marginTop: 8 }}>
|
||
{others.map((h) => (
|
||
<div
|
||
key={h.id}
|
||
style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 10 }}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<Text type="secondary">
|
||
#{h.id} · {formatWallTime(h.created_at)}
|
||
</Text>
|
||
{statusTag(h.status)}
|
||
</div>
|
||
<Paragraph
|
||
style={{ whiteSpace: 'pre-wrap', margin: '6px 0 0' }}
|
||
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
|
||
>
|
||
{h.content || '-'}
|
||
</Paragraph>
|
||
{h.status === 'adopted' && (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
采纳 · 奖励 {h.reward_coins ?? 0} 金币
|
||
{h.review_note ? ` · ${h.review_note}` : ''}
|
||
</Text>
|
||
)}
|
||
{h.status === 'rejected' && (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
未采纳:{h.reject_reason || '-'}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
))}
|
||
</Space>
|
||
)}
|
||
</div>
|
||
</Space>
|
||
)}
|
||
</Drawer>
|
||
);
|
||
}
|