645 lines
21 KiB
TypeScript
645 lines
21 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { SorterResult } from 'antd/es/table/interface';
|
||
import {
|
||
App,
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Form,
|
||
Image,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd';
|
||
import type { Dayjs } from 'dayjs';
|
||
import { api } from '@/lib/api';
|
||
import { canDo } from '@/lib/auth';
|
||
import { formatWallTime } from '@/lib/format';
|
||
import { refreshReviewBadge } from '@/lib/reviewBadge';
|
||
import { failedReviewIds } from '@/lib/bulkAction';
|
||
import type { BulkReviewResult } from '@/lib/bulkAction';
|
||
import { usePagedList } from '@/lib/usePagedList';
|
||
import type { Feedback, FeedbackSummary } from '@/lib/types';
|
||
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
|
||
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
|
||
|
||
const { Text, Paragraph } = Typography;
|
||
const { RangePicker } = DatePicker;
|
||
const REWARD_MAX = 10000;
|
||
|
||
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
|
||
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
|
||
const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
|
||
|
||
type SortField = 'id' | 'created_at';
|
||
|
||
// 待审核(同事后端初始态 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 SOURCE_OPTIONS = [
|
||
{ value: 'comparison', label: '比价反馈' },
|
||
{ value: 'profile', label: '普通反馈' },
|
||
];
|
||
|
||
// 反馈类型标签 +(比价反馈时)问题场景副标题
|
||
const renderSource = (f: Feedback) => {
|
||
const m = SOURCE_META[f.source] ?? { label: '普通反馈', color: 'default' };
|
||
return (
|
||
<Space direction="vertical" size={0}>
|
||
<Tag color={m.color}>{m.label}</Tag>
|
||
{f.scene ? (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{f.scene}
|
||
</Text>
|
||
) : null}
|
||
</Space>
|
||
);
|
||
};
|
||
|
||
// 用户可见的运营回复留言(采纳/未采纳都可能有)
|
||
const replyLine = (f: Feedback) =>
|
||
f.admin_reply ? (
|
||
<Text style={{ fontSize: 12 }} ellipsis>
|
||
回复:{f.admin_reply}
|
||
</Text>
|
||
) : null;
|
||
|
||
// 审核结果展示(采纳=金币+批注 / 未采纳=原因 / 回复留言 / 待审核=-),主表与「该用户全部反馈」抽屉共用
|
||
const renderReview = (f: Feedback) => {
|
||
if (f.status === 'adopted') {
|
||
return (
|
||
<Space direction="vertical" size={0}>
|
||
<Tag color="gold">+{f.reward_coins ?? 0} 金币</Tag>
|
||
{f.review_note ? (
|
||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||
{f.review_note}
|
||
</Text>
|
||
) : null}
|
||
{replyLine(f)}
|
||
</Space>
|
||
);
|
||
}
|
||
if (f.status === 'rejected') {
|
||
return (
|
||
<Space direction="vertical" size={0}>
|
||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||
未采纳:{f.reject_reason || '-'}
|
||
</Text>
|
||
{replyLine(f)}
|
||
</Space>
|
||
);
|
||
}
|
||
return <Text type="secondary">-</Text>;
|
||
};
|
||
|
||
// 机型 / OS版本(提交反馈时的端环境;改版前历史反馈无 → -)
|
||
const renderDeviceOs = (f: Feedback) => {
|
||
const os = [f.android_version ? `Android ${f.android_version}` : null, f.rom_name]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
if (!f.device_model && !os) return <Text type="secondary">-</Text>;
|
||
return (
|
||
<Space direction="vertical" size={0}>
|
||
<Text>{f.device_model || '-'}</Text>
|
||
{os ? (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{os}
|
||
</Text>
|
||
) : null}
|
||
</Space>
|
||
);
|
||
};
|
||
|
||
// 点手机号弹出的「该用户全部反馈」抽屉列(精简版:不含操作/手机号,避免抽屉里再套抽屉)
|
||
const RECORD_COLUMNS: ColumnsType<Feedback> = [
|
||
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
||
{ title: '状态', dataIndex: 'status', width: 80, render: statusTag },
|
||
{ title: '反馈类型', key: 'source', width: 100, render: (_: unknown, f: Feedback) => renderSource(f) },
|
||
{
|
||
title: '内容',
|
||
dataIndex: 'content',
|
||
render: (v: string) =>
|
||
v ? (
|
||
<Paragraph
|
||
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
|
||
>
|
||
{v}
|
||
</Paragraph>
|
||
) : (
|
||
<Text type="secondary">-</Text>
|
||
),
|
||
},
|
||
{ title: '审核结果', key: 'review', width: 160, render: (_: unknown, f: Feedback) => renderReview(f) },
|
||
];
|
||
|
||
export default function FeedbacksPage() {
|
||
const { message } = App.useApp();
|
||
const [bulkForm] = Form.useForm();
|
||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||
const [status, setStatus] = useState<string | undefined>();
|
||
const [source, setSource] = useState<string | undefined>();
|
||
const [userId, setUserId] = useState('');
|
||
const [content, setContent] = useState('');
|
||
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||
// 排序(服务端):点列头即时生效,与「查询」按钮分开
|
||
const [sortBy, setSortBy] = useState<SortField>('id');
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||
|
||
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||
usePagedList<Feedback>('/admin/api/feedbacks', filters);
|
||
|
||
const canReview = canDo(['operator']);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||
const [bulkAction, setBulkAction] = useState<'approve' | 'reject' | null>(null);
|
||
const [bulkSubmitting, setBulkSubmitting] = useState(false);
|
||
const selectedFeedbacks = items.filter(
|
||
(item) => selectedRowKeys.includes(item.id) && isPending(item.status),
|
||
);
|
||
|
||
useEffect(() => {
|
||
const visiblePendingIds = new Set(
|
||
items.filter((item) => isPending(item.status)).map((item) => item.id),
|
||
);
|
||
setSelectedRowKeys((keys) => keys.filter((id) => visiblePendingIds.has(id)));
|
||
}, [items]);
|
||
|
||
const [summary, setSummary] = useState<FeedbackSummary | null>(null);
|
||
const loadSummary = async () => {
|
||
try {
|
||
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
|
||
setSummary(data);
|
||
} catch {
|
||
/* 统计失败不阻塞列表 */
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
loadSummary();
|
||
}, []);
|
||
|
||
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
|
||
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const openDrawer = (f: Feedback) => {
|
||
setDrawerFb(f);
|
||
setDrawerOpen(true);
|
||
};
|
||
|
||
// 点手机号:看该用户全部反馈
|
||
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
|
||
const openUserRecords = (f: Feedback) => setRecordsUser({ userId: f.user_id, phone: f.phone });
|
||
|
||
const search = () =>
|
||
setApplied({
|
||
status,
|
||
source,
|
||
user_id: userId.trim() ? Number(userId.trim()) : undefined,
|
||
content: content.trim() || undefined,
|
||
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
|
||
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
|
||
});
|
||
|
||
const resetFilters = () => {
|
||
setStatus(undefined);
|
||
setSource(undefined);
|
||
setUserId('');
|
||
setContent('');
|
||
setCreatedRange(null);
|
||
setSortBy('id');
|
||
setSortOrder('desc');
|
||
setApplied({});
|
||
};
|
||
|
||
const onTableChange = (
|
||
_pagination: unknown,
|
||
_filters: unknown,
|
||
sorter: SorterResult<Feedback> | SorterResult<Feedback>[],
|
||
) => {
|
||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||
if (s && s.order && s.field) {
|
||
setSortBy(s.field as SortField);
|
||
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
|
||
} else {
|
||
setSortBy('id');
|
||
setSortOrder('desc');
|
||
}
|
||
};
|
||
|
||
const sortOrderOf = (field: SortField) =>
|
||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||
|
||
const openBulkModal = (action: 'approve' | 'reject') => {
|
||
if (!selectedFeedbacks.length) return;
|
||
bulkForm.resetFields();
|
||
setBulkAction(action);
|
||
};
|
||
|
||
const submitBulkReview = async () => {
|
||
if (!bulkAction || !selectedFeedbacks.length) return;
|
||
const values = await bulkForm.validateFields();
|
||
const targets = selectedFeedbacks;
|
||
setBulkSubmitting(true);
|
||
try {
|
||
const { data } = await api.post<BulkReviewResult>(
|
||
`/admin/api/feedbacks/bulk/${bulkAction}`,
|
||
bulkAction === 'approve'
|
||
? {
|
||
ids: targets.map((item) => item.id),
|
||
reward_coins: values.reward_coins,
|
||
note: values.note?.trim() || null,
|
||
reply: values.reply?.trim() || null,
|
||
}
|
||
: {
|
||
ids: targets.map((item) => item.id),
|
||
reason: values.reason.trim(),
|
||
note: values.note?.trim() || null,
|
||
reply: values.reply?.trim() || null,
|
||
},
|
||
);
|
||
const failedIds = failedReviewIds(data);
|
||
setSelectedRowKeys(failedIds);
|
||
setBulkAction(null);
|
||
bulkForm.resetFields();
|
||
if (failedIds.length) reload();
|
||
else onPageChange(1, pageSize);
|
||
loadSummary();
|
||
refreshReviewBadge('/feedbacks');
|
||
const label = bulkAction === 'approve' ? '批量采纳' : '批量拒绝';
|
||
if (failedIds.length) {
|
||
message.warning(
|
||
`${label}完成:成功 ${data.success} 条,失败 ${failedIds.length} 条;失败项已保留勾选`,
|
||
);
|
||
} else {
|
||
message.success(`${label}完成:成功 ${data.success} 条`);
|
||
}
|
||
} finally {
|
||
setBulkSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const columns: ColumnsType<Feedback> = [
|
||
{
|
||
title: '用户ID',
|
||
dataIndex: 'user_id',
|
||
width: 80,
|
||
render: (v: number) => <Text strong>#{v}</Text>,
|
||
},
|
||
{
|
||
title: '手机号',
|
||
dataIndex: 'phone',
|
||
width: 130,
|
||
render: (_: unknown, f: Feedback) =>
|
||
f.phone ? (
|
||
<a onClick={() => openUserRecords(f)}>{f.phone}</a>
|
||
) : (
|
||
<Text type="secondary">无手机号</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '用户昵称',
|
||
dataIndex: 'nickname',
|
||
width: 120,
|
||
render: (v: string | null) => v || <Text type="secondary">无昵称</Text>,
|
||
},
|
||
{
|
||
title: '反馈类型',
|
||
key: 'source',
|
||
width: 110,
|
||
render: (_: unknown, f: Feedback) => renderSource(f),
|
||
},
|
||
{
|
||
title: '提交版本号',
|
||
dataIndex: 'app_version',
|
||
width: 100,
|
||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||
},
|
||
{
|
||
title: '机型 / OS版本',
|
||
key: 'device',
|
||
width: 150,
|
||
render: (_: unknown, f: Feedback) => renderDeviceOs(f),
|
||
},
|
||
{
|
||
title: '内容',
|
||
dataIndex: 'content',
|
||
width: 340,
|
||
render: (v: string) =>
|
||
v ? (
|
||
<Paragraph
|
||
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||
ellipsis={{ rows: 3, expandable: true, symbol: '展开', tooltip: true }}
|
||
>
|
||
{v}
|
||
</Paragraph>
|
||
) : (
|
||
<Text type="secondary">-</Text>
|
||
),
|
||
},
|
||
{
|
||
title: '截图',
|
||
dataIndex: 'images',
|
||
width: 160,
|
||
render: (imgs: string[] | null) =>
|
||
imgs && imgs.length ? (
|
||
<Image.PreviewGroup>
|
||
<Space size={4} wrap>
|
||
{imgs.map((src, i) => (
|
||
<Image
|
||
key={i}
|
||
src={mediaUrl(src)}
|
||
width={44}
|
||
height={44}
|
||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||
/>
|
||
))}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
) : (
|
||
<Text type="secondary">无</Text>
|
||
),
|
||
},
|
||
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
|
||
{
|
||
title: '审核结果',
|
||
key: 'review',
|
||
width: 170,
|
||
render: (_: unknown, f: Feedback) => renderReview(f),
|
||
},
|
||
{
|
||
title: '提交时间',
|
||
dataIndex: 'created_at',
|
||
width: 170,
|
||
sorter: true,
|
||
sortOrder: sortOrderOf('created_at'),
|
||
render: (v: string) => formatWallTime(v),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'op',
|
||
width: 90,
|
||
render: (_: unknown, f: Feedback) =>
|
||
canReview && isPending(f.status) ? (
|
||
<a onClick={() => openDrawer(f)}>审核</a>
|
||
) : (
|
||
<a onClick={() => openDrawer(f)}>查看</a>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<h2>用户反馈</h2>
|
||
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||
<Card size="small" style={{ minWidth: 120 }}>
|
||
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
|
||
</Card>
|
||
<Card size="small" style={{ minWidth: 120 }}>
|
||
<Statistic title="已采纳" value={summary?.adopted ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||
</Card>
|
||
<Card size="small" style={{ minWidth: 120 }}>
|
||
<Statistic title="未采纳" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
|
||
</Card>
|
||
<Card size="small" style={{ minWidth: 120 }}>
|
||
<Statistic title="合计" value={summary?.total ?? 0} />
|
||
</Card>
|
||
</div>
|
||
<Space style={{ marginBottom: 16 }} wrap>
|
||
<Select
|
||
placeholder="状态"
|
||
value={status}
|
||
onChange={setStatus}
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
options={[
|
||
{ value: 'pending', label: '审核中' },
|
||
{ value: 'adopted', label: '已采纳' },
|
||
{ value: 'rejected', label: '未采纳' },
|
||
]}
|
||
/>
|
||
<Select
|
||
placeholder="反馈类型"
|
||
value={source}
|
||
onChange={setSource}
|
||
allowClear
|
||
style={{ width: 130 }}
|
||
options={SOURCE_OPTIONS}
|
||
/>
|
||
<Input
|
||
placeholder="用户ID"
|
||
value={userId}
|
||
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
|
||
onPressEnter={search}
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
/>
|
||
<Input
|
||
placeholder="内容(模糊)"
|
||
value={content}
|
||
onChange={(e) => setContent(e.target.value)}
|
||
onPressEnter={search}
|
||
allowClear
|
||
style={{ width: 180 }}
|
||
/>
|
||
<RangePicker
|
||
placeholder={['提交起', '提交止']}
|
||
value={createdRange}
|
||
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
|
||
allowClear
|
||
/>
|
||
<Button type="primary" onClick={search}>
|
||
查询
|
||
</Button>
|
||
<Button onClick={resetFilters}>重置</Button>
|
||
</Space>
|
||
|
||
{canReview && (
|
||
<Space wrap style={{ display: 'flex', marginBottom: 12 }}>
|
||
<Text>已选 <Text strong>{selectedFeedbacks.length}</Text> 条待审核反馈</Text>
|
||
<Button
|
||
type="primary"
|
||
disabled={!selectedFeedbacks.length}
|
||
onClick={() => openBulkModal('approve')}
|
||
>
|
||
批量采纳
|
||
</Button>
|
||
<Button
|
||
danger
|
||
disabled={!selectedFeedbacks.length}
|
||
onClick={() => openBulkModal('reject')}
|
||
>
|
||
批量拒绝
|
||
</Button>
|
||
{!!selectedFeedbacks.length && (
|
||
<Button onClick={() => setSelectedRowKeys([])}>取消选择</Button>
|
||
)}
|
||
</Space>
|
||
)}
|
||
|
||
<Table
|
||
rowKey="id"
|
||
columns={columns}
|
||
dataSource={items}
|
||
rowSelection={
|
||
canReview
|
||
? {
|
||
selectedRowKeys,
|
||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||
getCheckboxProps: (record) => ({
|
||
disabled: !isPending(record.status),
|
||
name: `选择反馈 #${record.id}`,
|
||
}),
|
||
}
|
||
: undefined
|
||
}
|
||
loading={loading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条反馈`,
|
||
onChange: onPageChange,
|
||
}}
|
||
onChange={onTableChange}
|
||
scroll={{ x: 1720 }}
|
||
/>
|
||
|
||
<Modal
|
||
title={
|
||
bulkAction === 'approve'
|
||
? `批量采纳 ${selectedFeedbacks.length} 条反馈`
|
||
: `批量拒绝 ${selectedFeedbacks.length} 条反馈`
|
||
}
|
||
open={bulkAction != null}
|
||
okText={bulkAction === 'approve' ? '批量采纳并发金币' : '确认批量拒绝'}
|
||
okButtonProps={{ danger: bulkAction === 'reject' }}
|
||
confirmLoading={bulkSubmitting}
|
||
onOk={submitBulkReview}
|
||
onCancel={() => {
|
||
if (bulkSubmitting) return;
|
||
setBulkAction(null);
|
||
bulkForm.resetFields();
|
||
}}
|
||
destroyOnHidden
|
||
>
|
||
<Text type="secondary">
|
||
下列设置会统一应用到已选的 {selectedFeedbacks.length}
|
||
条反馈。批量操作前请确认每条内容适用相同处理结果。
|
||
</Text>
|
||
<Form form={bulkForm} layout="vertical" preserve={false} style={{ marginTop: 16 }}>
|
||
{bulkAction === '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
|
||
min={1}
|
||
max={REWARD_MAX}
|
||
style={{ width: '100%' }}
|
||
placeholder="每条反馈发放相同金币"
|
||
/>
|
||
</Form.Item>
|
||
) : (
|
||
<Form.Item
|
||
name="reason"
|
||
label="未采纳原因(必填,用户可见)"
|
||
rules={[
|
||
{ required: true, whitespace: true, message: '请填写未采纳原因' },
|
||
{ max: 256, message: '最多 256 字' },
|
||
]}
|
||
>
|
||
<Input.TextArea
|
||
rows={3}
|
||
maxLength={256}
|
||
showCount
|
||
placeholder="同一原因将发送给全部已选用户"
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item
|
||
name="note"
|
||
label={
|
||
bulkAction === 'approve'
|
||
? '采纳要点 / 审核备注(选填,内部)'
|
||
: '内部备注(选填)'
|
||
}
|
||
rules={[{ max: 256, message: '最多 256 字' }]}
|
||
>
|
||
<Input.TextArea rows={2} maxLength={256} showCount />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="reply"
|
||
label="给用户的回复(选填,用户可见)"
|
||
rules={[{ max: 256, message: '最多 256 字' }]}
|
||
>
|
||
<Input.TextArea
|
||
rows={2}
|
||
maxLength={256}
|
||
showCount
|
||
placeholder="同一回复将发送给全部已选用户"
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<FeedbackHandleDrawer
|
||
feedback={drawerFb}
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
onDone={() => {
|
||
reload();
|
||
loadSummary();
|
||
refreshReviewBadge('/feedbacks');
|
||
}}
|
||
/>
|
||
|
||
<UserRecordsDrawer<Feedback>
|
||
open={recordsUser != null}
|
||
onClose={() => setRecordsUser(null)}
|
||
userId={recordsUser?.userId ?? null}
|
||
phone={recordsUser?.phone ?? null}
|
||
endpoint="/admin/api/feedbacks"
|
||
columns={RECORD_COLUMNS}
|
||
recordLabel="反馈"
|
||
countLabel="提交总计"
|
||
rewardLabel="提交奖励总计"
|
||
rewardOf={(f) => (f.status === 'adopted' ? (f.reward_coins ?? 0) : 0)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|