Files
shaguabijia-admin-web/src/app/(main)/feedbacks/FeedbackHandleDrawer.tsx
T
zhuzihao 65d968585e feat(feedback): 反馈工单接入审核采纳/拒绝 + 历史反馈 (#16)
对接后端 approve/reject 接口,admin 反馈工单从单一「处理」改为审核双动作:
- FeedbackHandleDrawer: Segmented 采纳/拒绝 —— 采纳(发金币 1~10000 + 采纳备注)、
  拒绝(填用户端可见原因 + 内部备注);抽屉内联展示该用户历史反馈辅助审核
- 列表页: 状态筛选改 审核中/已采纳/未采纳、新增「审核结果」列、操作改 审核/查看,移除旧批量标记
- types.ts: Feedback 字段对齐后端(reject_reason/reward_coins/review_note/reviewed_by_admin_id/reviewed_at)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #16
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-22 23:20:24 +08:00

326 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { formatUtcTime } 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} · {formatUtcTime(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"> · {formatUtcTime(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} · {formatUtcTime(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>
);
}