Files
shaguabijia-admin-web/src/app/(main)/feedbacks/FeedbackHandleDrawer.tsx
T
zhuzihao 281cfdb0e4 feat: 反馈加反馈类型/运营回复 + 提现加提现类型筛选 (#34)
用户反馈后台 + 提现审核后台的运营字段与筛选。

- 反馈页:新增「反馈类型」列(比价反馈/普通反馈 + 场景)、顶部「反馈类型」筛选;审核抽屉采纳/拒绝
  都能填「给用户的回复」(用户端可见),并回显反馈类型/场景/运营回复。
- 提现页:新增「提现类型」列(福利页提现/邀请提现)、顶部「提现类型」筛选,详情抽屉展示提现类型。
- types.ts:Feedback 加 source/scene/admin_reply,WithdrawOrder 加 source。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #34
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 19:50:09 +08:00

381 lines
14 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 { 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>;
};
// 反馈类型:comparison=比价反馈 / profile(及旧数据)=普通反馈
const SOURCE_META: Record<string, { label: string; color: string }> = {
comparison: { label: '比价反馈', color: 'geekblue' },
profile: { label: '普通反馈', color: 'default' },
};
const sourceTag = (s: string) => {
const m = SOURCE_META[s] ?? { label: '普通反馈', 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,
reply: v.reply?.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,
reply: v.reply?.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>
<div style={{ marginTop: 6 }}>
{sourceTag(feedback.source)}
{feedback.scene ? (
<Text type="secondary" style={{ marginLeft: 6 }}>
:{feedback.scene}
</Text>
) : null}
</div>
<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>
<div style={{ marginTop: 6 }}>
():
{feedback.admin_reply || <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="reply"
label="给用户的回复(选填,用户端可见)"
rules={[{ max: 256, message: '最多 256 字' }]}
>
<Input.TextArea
rows={2}
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.Item
name="reply"
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>
<Space size={4}>
{sourceTag(h.source)}
{statusTag(h.status)}
</Space>
</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>
)}
{h.admin_reply ? (
<div style={{ fontSize: 12, marginTop: 2 }}>:{h.admin_reply}</div>
) : null}
</div>
))}
</Space>
)}
</div>
</Space>
)}
</Drawer>
);
}