Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d60dd1251 |
@@ -28,16 +28,6 @@ const STUCK_LABEL: Record<string, string> = {
|
|||||||
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
|
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
|
||||||
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
|
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
|
||||||
|
|
||||||
// LLM 成本估算:(input + output) token / 1e6 × 每百万 token 单价(元)。单价由页面输入框配置(localStorage)。
|
|
||||||
const calcCost = (inTok: number | null, outTok: number | null, price: number | null): number | null => {
|
|
||||||
if (price == null || Number.isNaN(price)) return null;
|
|
||||||
if (inTok == null && outTok == null) return null;
|
|
||||||
return (((inTok ?? 0) + (outTok ?? 0)) / 1_000_000) * price;
|
|
||||||
};
|
|
||||||
const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
|
|
||||||
const fmtTok = (inTok: number | null, outTok: number | null) =>
|
|
||||||
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
|
|
||||||
|
|
||||||
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
||||||
function pretty(s: string | null): string {
|
function pretty(s: string | null): string {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -60,18 +50,6 @@ export default function ComparisonRecordsPage() {
|
|||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [status, setStatus] = useState<string | undefined>();
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||||
// LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
|
|
||||||
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
const v = localStorage.getItem('llm_price_per_mtok');
|
|
||||||
return v != null && v !== '' ? Number(v) : null;
|
|
||||||
});
|
|
||||||
const onPriceChange = (v: number | null) => {
|
|
||||||
setPricePerMTok(v);
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
if (v == null) localStorage.removeItem('llm_price_per_mtok');
|
|
||||||
else localStorage.setItem('llm_price_per_mtok', String(v));
|
|
||||||
};
|
|
||||||
|
|
||||||
const { items, total, page, pageSize, loading, onChange } =
|
const { items, total, page, pageSize, loading, onChange } =
|
||||||
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
||||||
@@ -142,18 +120,6 @@ export default function ComparisonRecordsPage() {
|
|||||||
width: 50,
|
width: 50,
|
||||||
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
|
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Token(入/出)',
|
|
||||||
key: 'tokens',
|
|
||||||
width: 110,
|
|
||||||
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '成本',
|
|
||||||
key: 'cost',
|
|
||||||
width: 90,
|
|
||||||
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '机型/ROM',
|
title: '机型/ROM',
|
||||||
key: 'device',
|
key: 'device',
|
||||||
@@ -206,15 +172,6 @@ export default function ComparisonRecordsPage() {
|
|||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={search}>查询</Button>
|
<Button type="primary" onClick={search}>查询</Button>
|
||||||
<Button onClick={reset}>重置</Button>
|
<Button onClick={reset}>重置</Button>
|
||||||
<InputNumber
|
|
||||||
placeholder="LLM 单价"
|
|
||||||
value={pricePerMTok}
|
|
||||||
onChange={onPriceChange}
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
style={{ width: 210 }}
|
|
||||||
addonAfter="元/百万token"
|
|
||||||
/>
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -230,7 +187,7 @@ export default function ComparisonRecordsPage() {
|
|||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
onChange,
|
onChange,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1520 }}
|
scroll={{ x: 1320 }}
|
||||||
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -247,17 +204,6 @@ export default function ComparisonRecordsPage() {
|
|||||||
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
|
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="步数">{detail.step_count ?? '-'}</Descriptions.Item>
|
<Descriptions.Item label="步数">{detail.step_count ?? '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="LLM 次数/重试">{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'}</Descriptions.Item>
|
<Descriptions.Item label="LLM 次数/重试">{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Token(入/出/合计)">
|
|
||||||
{detail.input_tokens == null && detail.output_tokens == null
|
|
||||||
? '-'
|
|
||||||
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="估算成本">
|
|
||||||
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
|
|
||||||
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
|
|
||||||
? <span style={{ color: '#999', fontSize: 12 }}>(顶部填 LLM 单价后显示)</span>
|
|
||||||
: null}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}({cents(detail.source_price_cents)})</Descriptions.Item>
|
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}({cents(detail.source_price_cents)})</Descriptions.Item>
|
||||||
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}({cents(detail.best_price_cents)})</Descriptions.Item>
|
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}({cents(detail.best_price_cents)})</Descriptions.Item>
|
||||||
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
|
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,325 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Key } from 'react';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { SorterResult } from 'antd/es/table/interface';
|
import type { SorterResult } from 'antd/es/table/interface';
|
||||||
import {
|
import {
|
||||||
|
App,
|
||||||
Button,
|
Button,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Image,
|
Image,
|
||||||
@@ -15,12 +17,12 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { canDo } from '@/lib/auth';
|
import { canDo } from '@/lib/auth';
|
||||||
import { formatUtcTime } from '@/lib/format';
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { usePagedList } from '@/lib/usePagedList';
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
import type { Feedback } from '@/lib/types';
|
import type { Feedback } from '@/lib/types';
|
||||||
import FeedbackQrConfig from './FeedbackQrConfig';
|
import FeedbackQrConfig from './FeedbackQrConfig';
|
||||||
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
|
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -31,17 +33,8 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
|
|||||||
|
|
||||||
type SortField = 'id' | 'created_at';
|
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' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FeedbacksPage() {
|
export default function FeedbacksPage() {
|
||||||
|
const { message, modal } = App.useApp();
|
||||||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||||
const [status, setStatus] = useState<string | undefined>();
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
const [userId, setUserId] = useState('');
|
const [userId, setUserId] = useState('');
|
||||||
@@ -53,18 +46,17 @@ export default function FeedbacksPage() {
|
|||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||||
|
const filterKey = JSON.stringify(filters);
|
||||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||||
usePagedList<Feedback>('/admin/api/feedbacks', filters);
|
usePagedList<Feedback>('/admin/api/feedbacks', filters);
|
||||||
|
|
||||||
const canReview = canDo(['operator']);
|
const canHandle = canDo(['operator']);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||||
|
|
||||||
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
|
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||||
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
|
useEffect(() => {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
setSelectedRowKeys([]);
|
||||||
const openDrawer = (f: Feedback) => {
|
}, [filterKey]);
|
||||||
setDrawerFb(f);
|
|
||||||
setDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const search = () =>
|
const search = () =>
|
||||||
setApplied({
|
setApplied({
|
||||||
@@ -103,13 +95,50 @@ export default function FeedbacksPage() {
|
|||||||
const sortOrderOf = (field: SortField) =>
|
const sortOrderOf = (field: SortField) =>
|
||||||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||||
|
|
||||||
|
const handle = async (f: Feedback) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/api/feedbacks/${f.id}/handle`);
|
||||||
|
message.success('已标记处理');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedNew = items.filter(
|
||||||
|
(f) => selectedRowKeys.includes(f.id) && f.status === 'new',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
|
||||||
|
const bulkHandle = () => {
|
||||||
|
if (!selectedNew.length) {
|
||||||
|
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.confirm({
|
||||||
|
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
|
||||||
|
content: '仅对选中的待处理(new)反馈生效',
|
||||||
|
onOk: async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)),
|
||||||
|
);
|
||||||
|
const ok = results.filter((r) => r.status === 'fulfilled').length;
|
||||||
|
const fail = results.length - ok;
|
||||||
|
if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`);
|
||||||
|
else message.success(`批量标记处理完成:${ok} 条`);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<Feedback> = [
|
const columns: ColumnsType<Feedback> = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
||||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||||
{
|
{
|
||||||
title: '内容',
|
title: '内容',
|
||||||
dataIndex: 'content',
|
dataIndex: 'content',
|
||||||
width: 340,
|
width: 360,
|
||||||
render: (v: string) =>
|
render: (v: string) =>
|
||||||
v ? (
|
v ? (
|
||||||
<Paragraph
|
<Paragraph
|
||||||
@@ -125,7 +154,7 @@ export default function FeedbacksPage() {
|
|||||||
{
|
{
|
||||||
title: '截图',
|
title: '截图',
|
||||||
dataIndex: 'images',
|
dataIndex: 'images',
|
||||||
width: 160,
|
width: 180,
|
||||||
render: (imgs: string[] | null) =>
|
render: (imgs: string[] | null) =>
|
||||||
imgs && imgs.length ? (
|
imgs && imgs.length ? (
|
||||||
<Image.PreviewGroup>
|
<Image.PreviewGroup>
|
||||||
@@ -145,46 +174,17 @@ export default function FeedbacksPage() {
|
|||||||
<Text type="secondary">无</Text>
|
<Text type="secondary">无</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{ title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (s: string) => {
|
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
||||||
const m = STATUS_META[s] ?? { label: s, color: 'default' };
|
|
||||||
return <Tag color={m.color}>{m.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '审核结果',
|
|
||||||
key: 'review',
|
|
||||||
width: 170,
|
|
||||||
render: (_: unknown, 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}
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (f.status === 'rejected') {
|
|
||||||
return (
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
|
||||||
未采纳:{f.reject_reason || '-'}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Text type="secondary">-</Text>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
width: 170,
|
width: 180,
|
||||||
sorter: true,
|
sorter: true,
|
||||||
sortOrder: sortOrderOf('created_at'),
|
sortOrder: sortOrderOf('created_at'),
|
||||||
render: (v: string) => formatUtcTime(v),
|
render: (v: string) => formatUtcTime(v),
|
||||||
@@ -192,12 +192,12 @@ export default function FeedbacksPage() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'op',
|
key: 'op',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (_: unknown, f: Feedback) =>
|
render: (_: unknown, f: Feedback) =>
|
||||||
canReview && isPending(f.status) ? (
|
canHandle && f.status === 'new' ? (
|
||||||
<a onClick={() => openDrawer(f)}>审核</a>
|
<a onClick={() => handle(f)}>标记处理</a>
|
||||||
) : (
|
) : (
|
||||||
<a onClick={() => openDrawer(f)}>查看</a>
|
<span style={{ color: '#ccc' }}>-</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -214,9 +214,8 @@ export default function FeedbacksPage() {
|
|||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'pending', label: '审核中' },
|
{ value: 'new', label: '待处理' },
|
||||||
{ value: 'adopted', label: '已采纳' },
|
{ value: 'handled', label: '已处理' },
|
||||||
{ value: 'rejected', label: '未采纳' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -247,8 +246,29 @@ export default function FeedbacksPage() {
|
|||||||
<Button onClick={resetFilters}>重置</Button>
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{canHandle && selectedNew.length > 0 && (
|
||||||
|
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
|
||||||
|
<span style={{ color: '#888' }}>已选 {selectedNew.length} 条待处理</span>
|
||||||
|
<Button type="primary" onClick={bulkHandle}>
|
||||||
|
批量标记处理
|
||||||
|
</Button>
|
||||||
|
<Button type="link" onClick={() => setSelectedRowKeys([])}>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
rowSelection={
|
||||||
|
canHandle
|
||||||
|
? {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -262,13 +282,6 @@ export default function FeedbacksPage() {
|
|||||||
}}
|
}}
|
||||||
onChange={onTableChange}
|
onChange={onTableChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FeedbackHandleDrawer
|
|
||||||
feedback={drawerFb}
|
|
||||||
open={drawerOpen}
|
|
||||||
onClose={() => setDrawerOpen(false)}
|
|
||||||
onDone={reload}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Radio,
|
Radio,
|
||||||
@@ -34,8 +35,6 @@ const SOURCE_COLOR: Record<string, string> = {
|
|||||||
signin: 'green',
|
signin: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 10; // 金币记录每页条数
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: number;
|
userId: number;
|
||||||
user: WithdrawUserSnapshot | null;
|
user: WithdrawUserSnapshot | null;
|
||||||
@@ -49,8 +48,7 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
|||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
||||||
const [records, setRecords] = useState<UserCoinRecord[]>([]);
|
const [records, setRecords] = useState<UserCoinRecord[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||||
|
|
||||||
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
|
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
|
||||||
@@ -78,15 +76,17 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
|||||||
}, [userId, paramsKey]);
|
}, [userId, paramsKey]);
|
||||||
|
|
||||||
const loadRecords = useCallback(
|
const loadRecords = useCallback(
|
||||||
async (p: number) => {
|
async (cursor: number | null) => {
|
||||||
setRecordsLoading(true);
|
setRecordsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const reqParams: Record<string, unknown> = { ...JSON.parse(paramsKey), limit: 20 };
|
||||||
|
if (cursor != null) reqParams.cursor = cursor;
|
||||||
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
||||||
`/admin/api/users/${userId}/coin-records`,
|
`/admin/api/users/${userId}/coin-records`,
|
||||||
{ params: { ...JSON.parse(paramsKey), limit: PAGE_SIZE, cursor: (p - 1) * PAGE_SIZE } },
|
{ params: reqParams },
|
||||||
);
|
);
|
||||||
setRecords(data.items);
|
setRecords((prev) => (cursor == null ? data.items : [...prev, ...data.items]));
|
||||||
setTotal(data.total ?? 0);
|
setNextCursor(data.next_cursor);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(errMsg(e));
|
message.error(errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,11 +96,9 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
|||||||
[userId, paramsKey],
|
[userId, paramsKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 用户或时间筛选变化:回到第 1 页重新拉取
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStats();
|
loadStats();
|
||||||
setPage(1);
|
loadRecords(null);
|
||||||
loadRecords(1);
|
|
||||||
}, [loadStats, loadRecords]);
|
}, [loadStats, loadRecords]);
|
||||||
|
|
||||||
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
|
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
|
||||||
@@ -205,18 +203,18 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={records}
|
dataSource={records}
|
||||||
loading={recordsLoading || statsLoading}
|
loading={recordsLoading || statsLoading}
|
||||||
pagination={{
|
pagination={false}
|
||||||
current: page,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
total,
|
|
||||||
showSizeChanger: false,
|
|
||||||
onChange: (p) => {
|
|
||||||
setPage(p);
|
|
||||||
loadRecords(p);
|
|
||||||
},
|
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => loadRecords(nextCursor)}
|
||||||
|
disabled={!nextCursor}
|
||||||
|
loading={recordsLoading}
|
||||||
|
>
|
||||||
|
{nextCursor ? '加载更多' : '没有更多了'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,10 +149,9 @@ function riskTags(detail: WithdrawDetail | null) {
|
|||||||
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
|
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
|
||||||
if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
|
if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
|
||||||
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
|
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
|
||||||
const rejectedN = detail.recent_withdraws.filter((o) => o.status === 'rejected').length;
|
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
|
||||||
const failedN = detail.recent_withdraws.filter((o) => o.status === 'failed').length;
|
tags.push('历史异常单');
|
||||||
if (rejectedN) tags.push(`提现拒绝${rejectedN}笔`);
|
}
|
||||||
if (failedN) tags.push(`提现失败${failedN}笔`);
|
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,7 +858,7 @@ export default function WithdrawsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="提现详情"
|
title="提现详情"
|
||||||
width="50%"
|
width={720}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -901,7 +900,7 @@ export default function WithdrawsPage() {
|
|||||||
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
|
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>风险提示</h3>
|
<h3>风险提示 {detail.risk_score ? <Tag color="red">{detail.risk_score}分</Tag> : null}</h3>
|
||||||
{risks.length ? (
|
{risks.length ? (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{risks.map((risk) => (
|
{risks.map((risk) => (
|
||||||
|
|||||||
+1
-9
@@ -206,13 +206,7 @@ export interface Feedback {
|
|||||||
content: string;
|
content: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
images: string[] | null;
|
images: string[] | null;
|
||||||
// pending(审核中) / adopted(已采纳) / rejected(未采纳);历史数据可能为 new
|
status: string; // new / handled
|
||||||
status: string;
|
|
||||||
reject_reason: string | null; // 未采纳原因(用户端可见)
|
|
||||||
reward_coins: number | null; // 采纳后发放金币
|
|
||||||
review_note: string | null; // 审核批注(采纳要点 / 内部备注)
|
|
||||||
reviewed_by_admin_id: number | null;
|
|
||||||
reviewed_at: string | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +255,6 @@ export interface ComparisonRecordListItem {
|
|||||||
step_count: number | null;
|
step_count: number | null;
|
||||||
llm_call_count: number | null;
|
llm_call_count: number | null;
|
||||||
retry_count: number | null;
|
retry_count: number | null;
|
||||||
input_tokens: number | null;
|
|
||||||
output_tokens: number | null;
|
|
||||||
device_model: string | null;
|
device_model: string | null;
|
||||||
rom_vendor: string | null;
|
rom_vendor: string | null;
|
||||||
rom_name: string | null;
|
rom_name: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user