Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c699ad0b8e | |||
| c488513d24 | |||
| 67fc466778 | |||
| 65d968585e | |||
| bd0bacb836 | |||
| 12cc6f7aa6 |
+7
-5
@@ -1,7 +1,9 @@
|
||||
# 生产:前端与 admin API 同域(admin.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
|
||||
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。
|
||||
# 生产:前端与 admin API 同域(admin-web.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
|
||||
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。(已验证线上 /admin/api 走通、操作 200)
|
||||
NEXT_PUBLIC_API_BASE=
|
||||
|
||||
# 媒体(反馈二维码 / 上报截图)的公开地址。媒体由 App 后端的 /media 托管,默认不在 admin 域。
|
||||
# 设为可公开访问媒体的源(如 https://api.shaguabijia.com),或在 admin nginx 增加 /media 反代后留空走同域。
|
||||
NEXT_PUBLIC_MEDIA_BASE=
|
||||
# 媒体(反馈二维码 / 上报截图 / 头像)由 App 后端托管在 app-api.shaguabijia.com/media,【不在 admin 域】。
|
||||
# 必须指到 App 后端的公开域名,否则 admin 里所有 /media 图片都会 404(被拼到 admin 域而非 App 域)。
|
||||
# 都是 https → 无混合内容;<img> 跨域加载不受 CORS 限制,无需后端配 CORS。
|
||||
# ⚠️ NEXT_PUBLIC_* 是编译期常量:改这里后必须重新 `npm run build` 再部署,前端才会带上新值。
|
||||
NEXT_PUBLIC_MEDIA_BASE=https://app-api.shaguabijia.com
|
||||
|
||||
@@ -4,10 +4,31 @@ Next.js 15 + Ant Design 5,对接 `shaguabijia-app-server` 的 admin API(`app/adm
|
||||
后端是独立 FastAPI app(端口 8771,复用 app-server 的 models/repositories + 同一 PostgreSQL,
|
||||
鉴权用独立 JWT)。本仓只是前端。
|
||||
|
||||
## 功能(P0)
|
||||
## 功能模块
|
||||
|
||||
数据大盘 / 用户管理(列表+360详情+封禁+调金币) / 提现管理(列表+重试+对账) /
|
||||
反馈工单 / 管理员账号(super) / 审计日志。按角色显示操作:finance=钱、operator=用户+反馈、super=全部。
|
||||
侧边栏 `MENU`(`src/app/(main)/layout.tsx`)共 12 个模块,均已上线:
|
||||
|
||||
- **数据大盘**(`/dashboard`)— 核心经营指标概览。
|
||||
- **用户管理**(`/users`)— 列表(多条件筛选+服务端排序)+ 360 详情;封禁/解封、调金币、调现金;运营开关:开启新手引导(标记用户重看一次,看完自动恢复)、开/关调试链接(trace 排障)、以及上述操作的批量勾选执行。
|
||||
- **设备管理**(`/devices`)— 按硬件 ANDROID_ID 聚合走过新手引导的设备;可单设备或全量「重设新手引导」。
|
||||
- **提现管理**(`/withdraws`)— 提现单审核(通过即发起微信打款 / 拒绝退回余额)、查单刷新、批量通过/拒绝/刷新、批量对账,含微信配置健康检查 + 现金账本校验。
|
||||
- **上报审核**(`/price-reports`)— 用户上报「某平台更低价」+ 截图,人工核实;通过发放 1000 金币、拒绝需填理由(用户端轮询看到结果)。
|
||||
- **比价记录**(`/comparison-records`)— 比价过程明细(逐平台结局/卡在哪一步、LLM 调用明细、trace);支持按页面输入的 LLM 单价做 token / 成本估算(纯前端,见 `calcCost` / `pricePerMTok`)。
|
||||
- **反馈工单**(`/feedbacks`)— 用户反馈审核闭环:采纳发金币 / 拒绝填用户可见原因(`FeedbackHandleDrawer`),并含反馈二维码配置。
|
||||
- **广告管理**(`/ad-revenue`)— 穿山甲广告配置(应用ID/各场景广告位ID/验签密钥/开关)+ 收益报表(展示/预估收益 与 应发/实发金币逐组对账)。
|
||||
- **CPS 分发**(`/cps`)— 优惠券分发与对账(美团/淘宝/京东);单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。含群对账详情子页(`/cps/groups/[id]`):点击/复制趋势折线 + 按天明细 + 群内微信用户画像。
|
||||
- **系统配置**(`/config`)— 业务运行参数(金额/上限等)在线读写,改完即时生效、每次进审计。
|
||||
- **管理员**(`/admins`)— 管理员账号管理(仅 `super_admin` 可见)。
|
||||
- **审计日志**(`/audit-logs`)— 所有写操作的审计流水。
|
||||
|
||||
### 按角色显示操作
|
||||
|
||||
权限以 `src/lib/auth.ts` 的 `canDo()` 为准(`super_admin` 恒可全部),各 `page.tsx` 用 `canDo([...])` 控制按钮:
|
||||
|
||||
- **finance**(钱):提现审核、用户调金币/调现金、CPS 对账(刷新拉美团订单)。
|
||||
- **operator**(用户+内容):用户封禁/调试链接/新手引导、反馈审核、上报审核、设备重设引导、CPS 群/活动管理。
|
||||
- **super**(super_admin):以上全部 + 管理员账号管理。
|
||||
- 数据大盘 / 比价记录 / 广告管理 / 系统配置 / 审计日志:无 `canDo` 门,登录即可查看。
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -52,5 +73,6 @@ ssh server "nginx -t && systemctl reload nginx"
|
||||
- app-server 的 `.env` 里 `ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token)
|
||||
- nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位
|
||||
- `.env.production` 的 `NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api)
|
||||
- `.env.production` 的 `NEXT_PUBLIC_MEDIA_BASE` 必须指向 **App 后端** `https://app-api.shaguabijia.com`(/media 的真正的家),否则后台所有 /media 图片(上报/反馈截图、二维码、头像)被拼到 admin 域而 404。`NEXT_PUBLIC_*` 是编译期常量,改完必须重新 `npm run build` 才生效(详见 `src/lib/media.ts`)
|
||||
- 首个 super_admin 已建、能登录
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import type {
|
||||
AdRevenueDaily,
|
||||
AdRevenueImpression,
|
||||
AdRevenueRecord,
|
||||
AdRevenueReport,
|
||||
AdRevenueRow,
|
||||
@@ -94,25 +93,7 @@ const LT_FACTOR_ROWS = [
|
||||
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
|
||||
];
|
||||
|
||||
// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位)
|
||||
const IMPRESSION_COLUMNS: ColumnsType<AdRevenueImpression> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
||||
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 },
|
||||
{ title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) },
|
||||
{
|
||||
title: 'adn',
|
||||
dataIndex: 'adn',
|
||||
width: 90,
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '底层代码位ID',
|
||||
dataIndex: 'slot_id',
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
];
|
||||
|
||||
// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
|
||||
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
|
||||
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
||||
{
|
||||
@@ -413,24 +394,29 @@ export default function AdRevenuePage() {
|
||||
|
||||
const columns: ColumnsType<AdRevenueRow> = [
|
||||
...(queriedMultiDay
|
||||
? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||||
: []),
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
...(queriedGranularity === 'hour'
|
||||
? [
|
||||
{
|
||||
title: '小时',
|
||||
dataIndex: 'hour',
|
||||
width: 90,
|
||||
render: (h: number | null) =>
|
||||
h == null ? '-' : `${String(h).padStart(2, '0')}:00`,
|
||||
} as ColumnsType<AdRevenueRow>[number],
|
||||
]
|
||||
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||||
: []),
|
||||
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_phone',
|
||||
width: 150,
|
||||
render: (phone: string | null, r: AdRevenueRow) =>
|
||||
phone ? (
|
||||
<span>
|
||||
{phone}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
||||
#{r.user_id}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
<Typography.Text type="secondary">#{r.user_id}(无手机号)</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '广告类型',
|
||||
dataIndex: 'ad_type',
|
||||
width: 120,
|
||||
width: 110,
|
||||
render: (s: string) => {
|
||||
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
@@ -439,7 +425,7 @@ export default function AdRevenuePage() {
|
||||
{
|
||||
title: '来源应用',
|
||||
dataIndex: 'app_env',
|
||||
width: 150,
|
||||
width: 140,
|
||||
render: (v: string | null) => {
|
||||
if (!v) return <Typography.Text type="secondary">未知</Typography.Text>;
|
||||
const t = APP_TAG[v] ?? { color: 'default', label: v };
|
||||
@@ -449,38 +435,68 @@ export default function AdRevenuePage() {
|
||||
{
|
||||
title: '广告位ID',
|
||||
dataIndex: 'our_code_id',
|
||||
width: 120,
|
||||
width: 110,
|
||||
render: (v: string | null) =>
|
||||
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{ title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' },
|
||||
{
|
||||
title: 'eCPM(分)',
|
||||
dataIndex: 'ecpm',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '预估收益(元)',
|
||||
dataIndex: 'revenue_yuan',
|
||||
width: 120,
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (v: number) => v.toFixed(4),
|
||||
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
|
||||
},
|
||||
{
|
||||
title: '发奖状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string | null) => {
|
||||
if (!s) return <Tag>仅展示</Tag>;
|
||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '应发金币',
|
||||
dataIndex: 'expected_coin',
|
||||
width: 100,
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (v: number) => <b>{v}</b>,
|
||||
render: (v: number, r: AdRevenueRow) =>
|
||||
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '实发金币',
|
||||
dataIndex: 'actual_coin',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (v: number, r: AdRevenueRow) =>
|
||||
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{ title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' },
|
||||
{
|
||||
title: '一致',
|
||||
dataIndex: 'matched',
|
||||
width: 80,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (m: boolean) => (m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>),
|
||||
render: (m: boolean, r: AdRevenueRow) =>
|
||||
r.has_reward ? (
|
||||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '底层 ADN',
|
||||
dataIndex: 'adns',
|
||||
render: (v: string[]) =>
|
||||
v.length ? v.map((a) => <Tag key={a}>{a}</Tag>) : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
dataIndex: 'adn',
|
||||
width: 100,
|
||||
render: (v: string | null) =>
|
||||
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -591,7 +607,7 @@ export default function AdRevenuePage() {
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
style={{ width: 110 }}
|
||||
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 组` }))}
|
||||
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
@@ -618,7 +634,7 @@ export default function AdRevenuePage() {
|
||||
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="聚合组数" value={data.total} />
|
||||
<Statistic title="广告事件数" value={data.total} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||
@@ -659,18 +675,18 @@ export default function AdRevenuePage() {
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={`共 ${data.total} 组,其中 ${data.mismatch_count} 组应发≠实发(✗);应发−实发 合计 ${
|
||||
message={`共 ${data.total} 条广告事件,其中 ${data.mismatch_count} 条应发≠实发(✗);应发−实发 合计 ${
|
||||
derived && derived.coinGap >= 0 ? '+' : ''
|
||||
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="success" showIcon message={`共 ${data.total} 组,应发与实发全部一致。`} />
|
||||
<Alert type="success" showIcon message={`共 ${data.total} 条广告事件,应发与实发全部一致。`} />
|
||||
)}
|
||||
{data.truncated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`聚合明细已截断到 ${queriedLimit} 组,还有更多未显示——请按用户/类型/日期缩小范围,或调大「组数」。合计数字不受影响。`}
|
||||
message={`明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/类型/日期缩小范围,或调大「条数」。合计数字不受影响。`}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
@@ -732,53 +748,36 @@ export default function AdRevenuePage() {
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey={(r) => `${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`}
|
||||
rowKey="event_key"
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
|
||||
scroll={{ x: 1200 }}
|
||||
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
||||
expandable={{
|
||||
rowExpandable: (r) =>
|
||||
(r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0,
|
||||
expandedRowRender: (r) => {
|
||||
const imps = r.impression_records ?? [];
|
||||
const recs = r.records ?? [];
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text strong>展示明细</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">({imps.length} 条)</Typography.Text>
|
||||
<Table<AdRevenueImpression>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="id"
|
||||
columns={IMPRESSION_COLUMNS}
|
||||
dataSource={imps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>发奖明细</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">({recs.length} 条)</Typography.Text>
|
||||
<Table<AdRevenueRecord>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="record_id"
|
||||
columns={DETAIL_COLUMNS}
|
||||
dataSource={recs}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||
locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
||||
rowExpandable: (r) => r.reward_detail != null,
|
||||
expandedRowRender: (r) =>
|
||||
r.reward_detail ? (
|
||||
<div>
|
||||
<Typography.Text strong>金币复算明细</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">
|
||||
(本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin})
|
||||
</Typography.Text>
|
||||
<Table<AdRevenueRecord>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="record_id"
|
||||
columns={DETAIL_COLUMNS}
|
||||
dataSource={[r.reward_detail]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
|
||||
@@ -28,6 +28,16 @@ const STUCK_LABEL: Record<string, string> = {
|
||||
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
|
||||
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 就缩进展开,否则原样。
|
||||
function pretty(s: string | null): string {
|
||||
if (!s) return '';
|
||||
@@ -50,6 +60,18 @@ export default function ComparisonRecordsPage() {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
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 } =
|
||||
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
||||
@@ -120,6 +142,18 @@ export default function ComparisonRecordsPage() {
|
||||
width: 50,
|
||||
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',
|
||||
key: 'device',
|
||||
@@ -172,6 +206,15 @@ export default function ComparisonRecordsPage() {
|
||||
/>
|
||||
<Button type="primary" onClick={search}>查询</Button>
|
||||
<Button onClick={reset}>重置</Button>
|
||||
<InputNumber
|
||||
placeholder="LLM 单价"
|
||||
value={pricePerMTok}
|
||||
onChange={onPriceChange}
|
||||
min={0}
|
||||
step={0.1}
|
||||
style={{ width: 210 }}
|
||||
addonAfter="元/百万token"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
@@ -187,7 +230,7 @@ export default function ComparisonRecordsPage() {
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1320 }}
|
||||
scroll={{ x: 1520 }}
|
||||
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
|
||||
@@ -204,6 +247,17 @@ export default function ComparisonRecordsPage() {
|
||||
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</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="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.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>
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
'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,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
DatePicker,
|
||||
Image,
|
||||
@@ -17,12 +15,12 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { Feedback } from '@/lib/types';
|
||||
import FeedbackQrConfig from './FeedbackQrConfig';
|
||||
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
@@ -33,8 +31,17 @@ 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' },
|
||||
};
|
||||
|
||||
export default function FeedbacksPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [userId, setUserId] = useState('');
|
||||
@@ -46,17 +53,18 @@ export default function FeedbacksPage() {
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
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 } =
|
||||
usePagedList<Feedback>('/admin/api/feedbacks', filters);
|
||||
|
||||
const canHandle = canDo(['operator']);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const canReview = canDo(['operator']);
|
||||
|
||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [filterKey]);
|
||||
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
|
||||
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const openDrawer = (f: Feedback) => {
|
||||
setDrawerFb(f);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const search = () =>
|
||||
setApplied({
|
||||
@@ -95,50 +103,13 @@ export default function FeedbacksPage() {
|
||||
const sortOrderOf = (field: SortField) =>
|
||||
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> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
width: 360,
|
||||
width: 340,
|
||||
render: (v: string) =>
|
||||
v ? (
|
||||
<Paragraph
|
||||
@@ -154,7 +125,7 @@ export default function FeedbacksPage() {
|
||||
{
|
||||
title: '截图',
|
||||
dataIndex: 'images',
|
||||
width: 180,
|
||||
width: 160,
|
||||
render: (imgs: string[] | null) =>
|
||||
imgs && imgs.length ? (
|
||||
<Image.PreviewGroup>
|
||||
@@ -174,17 +145,46 @@ export default function FeedbacksPage() {
|
||||
<Text type="secondary">无</Text>
|
||||
),
|
||||
},
|
||||
{ title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
||||
render: (s: string) => {
|
||||
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: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
width: 170,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('created_at'),
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
@@ -192,12 +192,12 @@ export default function FeedbacksPage() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
width: 100,
|
||||
width: 90,
|
||||
render: (_: unknown, f: Feedback) =>
|
||||
canHandle && f.status === 'new' ? (
|
||||
<a onClick={() => handle(f)}>标记处理</a>
|
||||
canReview && isPending(f.status) ? (
|
||||
<a onClick={() => openDrawer(f)}>审核</a>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
<a onClick={() => openDrawer(f)}>查看</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -214,8 +214,9 @@ export default function FeedbacksPage() {
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'new', label: '待处理' },
|
||||
{ value: 'handled', label: '已处理' },
|
||||
{ value: 'pending', label: '审核中' },
|
||||
{ value: 'adopted', label: '已采纳' },
|
||||
{ value: 'rejected', label: '未采纳' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
@@ -246,29 +247,8 @@ export default function FeedbacksPage() {
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
</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
|
||||
rowKey="id"
|
||||
rowSelection={
|
||||
canHandle
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
@@ -282,6 +262,13 @@ export default function FeedbacksPage() {
|
||||
}}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
|
||||
<FeedbackHandleDrawer
|
||||
feedback={drawerFb}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onDone={reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Radio,
|
||||
@@ -35,6 +34,8 @@ const SOURCE_COLOR: Record<string, string> = {
|
||||
signin: 'green',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10; // 金币记录每页条数
|
||||
|
||||
interface Props {
|
||||
userId: number;
|
||||
user: WithdrawUserSnapshot | null;
|
||||
@@ -48,7 +49,8 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const [records, setRecords] = useState<UserCoinRecord[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
|
||||
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
|
||||
@@ -76,17 +78,15 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
||||
}, [userId, paramsKey]);
|
||||
|
||||
const loadRecords = useCallback(
|
||||
async (cursor: number | null) => {
|
||||
async (p: number) => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const reqParams: Record<string, unknown> = { ...JSON.parse(paramsKey), limit: 20 };
|
||||
if (cursor != null) reqParams.cursor = cursor;
|
||||
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
||||
`/admin/api/users/${userId}/coin-records`,
|
||||
{ params: reqParams },
|
||||
{ params: { ...JSON.parse(paramsKey), limit: PAGE_SIZE, cursor: (p - 1) * PAGE_SIZE } },
|
||||
);
|
||||
setRecords((prev) => (cursor == null ? data.items : [...prev, ...data.items]));
|
||||
setNextCursor(data.next_cursor);
|
||||
setRecords(data.items);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
@@ -96,9 +96,11 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
||||
[userId, paramsKey],
|
||||
);
|
||||
|
||||
// 用户或时间筛选变化:回到第 1 页重新拉取
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadRecords(null);
|
||||
setPage(1);
|
||||
loadRecords(1);
|
||||
}, [loadStats, loadRecords]);
|
||||
|
||||
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
|
||||
@@ -203,18 +205,18 @@ export default function UserRewardPanel({ userId, user }: Props) {
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={recordsLoading || statsLoading}
|
||||
pagination={false}
|
||||
pagination={{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,9 +149,10 @@ function riskTags(detail: WithdrawDetail | null) {
|
||||
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?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
|
||||
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
|
||||
tags.push('历史异常单');
|
||||
}
|
||||
const rejectedN = detail.recent_withdraws.filter((o) => o.status === 'rejected').length;
|
||||
const failedN = detail.recent_withdraws.filter((o) => o.status === 'failed').length;
|
||||
if (rejectedN) tags.push(`提现拒绝${rejectedN}笔`);
|
||||
if (failedN) tags.push(`提现失败${failedN}笔`);
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -858,7 +859,7 @@ export default function WithdrawsPage() {
|
||||
|
||||
<Drawer
|
||||
title="提现详情"
|
||||
width={720}
|
||||
width="50%"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
@@ -900,7 +901,7 @@ export default function WithdrawsPage() {
|
||||
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
|
||||
|
||||
<div>
|
||||
<h3>风险提示 {detail.risk_score ? <Tag color="red">{detail.risk_score}分</Tag> : null}</h3>
|
||||
<h3>风险提示</h3>
|
||||
{risks.length ? (
|
||||
<Space wrap>
|
||||
{risks.map((risk) => (
|
||||
|
||||
+6
-5
@@ -1,8 +1,9 @@
|
||||
// 媒体(上报截图 / 反馈截图 / 反馈二维码等)由 App 后端的 /media 静态服务托管,admin 域不一定同源:
|
||||
// - dev:NEXT_PUBLIC_MEDIA_BASE 指向 App 后端(http://localhost:8770)。
|
||||
// - 生产:留空 = 走同域相对路径,由 admin nginx 的 /media 反代到后端(见 deploy/nginx/*.conf),
|
||||
// 避免跨域 / 混合内容(https 页面引 http 图会被浏览器拦)。
|
||||
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
|
||||
// 媒体(上报截图 / 反馈截图 / 反馈二维码等)由 App 后端的 /media 静态服务托管,与 admin 不同域:
|
||||
// - dev :NEXT_PUBLIC_MEDIA_BASE = http://localhost:8770(本地 App 后端)。
|
||||
// - 生产:NEXT_PUBLIC_MEDIA_BASE = https://app-api.shaguabijia.com(线上 App 后端,/media 真正的家)。
|
||||
// 留空则退化为同域相对路径——而 admin 域(admin-web.shaguabijia.com)并不 serve /media,会 404,
|
||||
// 这正是线上后台图片打不开的根因。NEXT_PUBLIC_* 是编译期常量,改完需重新 build 才生效。
|
||||
const MEDIA_BASE = (process.env.NEXT_PUBLIC_MEDIA_BASE || '').replace(/\/+$/, ''); // 去末尾斜杠,防拼出 //media
|
||||
|
||||
/** 把后端返回的相对媒体路径(/media/...)拼成可加载的 URL;已是 http(s) 绝对地址的原样返回。 */
|
||||
export function mediaUrl(p: string): string {
|
||||
|
||||
+30
-13
@@ -206,7 +206,13 @@ export interface Feedback {
|
||||
content: string;
|
||||
contact: string;
|
||||
images: string[] | null;
|
||||
status: string; // new / handled
|
||||
// pending(审核中) / adopted(已采纳) / rejected(未采纳);历史数据可能为 new
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -255,6 +261,8 @@ export interface ComparisonRecordListItem {
|
||||
step_count: number | null;
|
||||
llm_call_count: number | null;
|
||||
retry_count: number | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
device_model: string | null;
|
||||
rom_vendor: string | null;
|
||||
rom_name: string | null;
|
||||
@@ -333,35 +341,44 @@ export interface AdRevenueDaily {
|
||||
actual_coin: number;
|
||||
}
|
||||
|
||||
// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合
|
||||
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
|
||||
export interface AdRevenueRow {
|
||||
report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD
|
||||
event_key: string; // 事件稳定唯一键(前端 rowKey)
|
||||
report_date: string; // 该事件所属日期 北京时间 YYYY-MM-DD
|
||||
user_id: number;
|
||||
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
|
||||
ad_type: string; // reward_video / feed / draw
|
||||
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
|
||||
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
|
||||
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
|
||||
impressions: number; // 展示条数(轮播每条各计一次)
|
||||
revenue_yuan: number; // 收益(元),预估口径
|
||||
expected_coin: number; // 应发金币(公式复算,与金币审计同源)
|
||||
actual_coin: number; // 实发金币(实际入账)
|
||||
matched: boolean; // 该组应发==实发
|
||||
adns: string[]; // 底层填充 ADN 子渠道集合
|
||||
impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻)
|
||||
records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻)
|
||||
created_at: string; // 事件时间(有展示=展示时间,纯发奖=发奖时间)
|
||||
// ── 展示侧 ──
|
||||
has_impression: boolean; // 是否有广告展示(信息流逐条展示=true,纯发奖行=false)
|
||||
impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用)
|
||||
ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值
|
||||
revenue_yuan: number; // 本次展示预估收益(元);纯发奖行=0
|
||||
adn: string | null; // 实际填充 ADN;纯发奖行为空
|
||||
slot_id: string | null; // 底层 mediation rit;纯发奖行为空
|
||||
// ── 发奖侧 ──
|
||||
has_reward: boolean; // 是否有发奖(激励视频合并行/信息流整场发奖行=true;纯展示=false)
|
||||
status: string | null; // 发奖状态 granted/closed_early/…;纯展示为空
|
||||
expected_coin: number; // 应发金币;纯展示=0
|
||||
actual_coin: number; // 实发金币;纯展示=0
|
||||
matched: boolean; // 本条应发==实发;纯展示恒 true(不计对账)
|
||||
reward_detail: AdRevenueRecord | null; // 发奖复算明细(点行展开下钻);纯展示为空
|
||||
}
|
||||
|
||||
export interface AdRevenueReport {
|
||||
date_from: string; // 起始日 北京时间 YYYY-MM-DD
|
||||
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
|
||||
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
||||
total: number; // 聚合组总数(全量,不受 limit 影响)
|
||||
total: number; // 广告事件总数(全量,不受 limit 影响)
|
||||
truncated: boolean;
|
||||
total_impressions: number;
|
||||
total_revenue_yuan: number;
|
||||
total_expected_coin: number;
|
||||
total_actual_coin: number;
|
||||
mismatch_count: number; // 应发≠实发的组数
|
||||
mismatch_count: number; // 应发≠实发的发奖条数
|
||||
items: AdRevenueRow[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user