From 17ddf7ff1e0ec2d592378a640cd7582d96f015f4 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:15:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E9=A2=86=E5=88=B8=E5=8D=95=E5=88=B8=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E7=8E=87=E5=B1=95=E7=A4=BA=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 将含义模糊的“百分比”列改名为“单券成功率”。 - 同时显示成功数量、有效尝试数量和百分比,例如 `7/8(87.5%)`。 - 保留点击数字查看逐张券结果明细的能力。 ## 验证结果 - Next.js 生产构建通过。 --------- Co-authored-by: linkeyu <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/68 Co-authored-by: linkeyu Co-committed-by: linkeyu --- src/app/(main)/coupon-data/page.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/coupon-data/page.tsx b/src/app/(main)/coupon-data/page.tsx index f20fbb6..8f4ed8f 100644 --- a/src/app/(main)/coupon-data/page.tsx +++ b/src/app/(main)/coupon-data/page.tsx @@ -149,6 +149,9 @@ function PointScorePopover({ row }: { row: CouponDataRow }) { return -; } const score = `${row.point_success_count}/${row.point_total_count}`; + const scoreWithRate = `${score}(${( + row.point_success_count / row.point_total_count * 100 + ).toFixed(1)}%)`; const scoreColor = row.point_success_count === row.point_total_count ? STATUS_TAG.completed.color @@ -220,7 +223,7 @@ function PointScorePopover({ row }: { row: CouponDataRow }) { )} > ); @@ -602,9 +605,9 @@ export default function CouponDataPage() { render: (v: number | null) => fmtSec(v), }, { - title: '百分比', + title: '单券成功率', key: 'point_success_rate', - width: 110, + width: 150, render: (_: unknown, r: CouponDataRow) => , }, { From bce720df9e026c2e3cd338df0c060600a156027e Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:16:44 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=87=91=E5=B8=81=E7=BB=8F=E6=B5=8E=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - “看视频金币”提示增加历史签到膨胀奖励。 - “常规任务金币”提示移除历史签到膨胀奖励。 - 页面数值继续使用后端统一分类结果。 ## 本地验证 - Next.js 生产构建通过。 - `git diff --check` 通过。 --------- Co-authored-by: linkeyu <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/70 Co-authored-by: linkeyu Co-committed-by: linkeyu --- src/app/(main)/dashboard/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx index 4830037..fc3ec05 100644 --- a/src/app/(main)/dashboard/page.tsx +++ b/src/app/(main)/dashboard/page.tsx @@ -1065,14 +1065,14 @@ export default function DashboardPage() { value={fmtInt(periodData?.coins.reward_video_coin_total)} delta={rewardVideoCoinRatio.value} deltaTone={rewardVideoCoinRatio.tone} - hint="独立统计看视频发放,不再重复计入领券奖励。" + hint="普通看视频与历史签到膨胀的实发金币之和,不再重复计入领券奖励或常规任务。" /> Date: Fri, 24 Jul 2026 12:04:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=90=8E=E7=AB=AF=E6=89=B9=E9=87=8F=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依赖 app-server PR #164。 ## 改动 - 低价审核、用户反馈仅勾选待审核记录 - 批量操作改为单次请求后端 `/bulk/approve` 或 `/bulk/reject` - 按后端逐条结果展示成功/失败;失败项保留勾选,方便重试 - 采纳/拒绝所需的统一金币、原因、内部备注和用户回复保持原有校验与二次确认 ## 验证 - `npm run build` - `git diff --check` --------- Co-authored-by: linkeyu <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/67 Co-authored-by: linkeyu Co-committed-by: linkeyu --- src/app/(main)/feedbacks/page.tsx | 203 +++++++++++++++++++++++++- src/app/(main)/price-reports/page.tsx | 148 +++++++++++++++++-- src/lib/bulkAction.ts | 33 +++++ 3 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 src/lib/bulkAction.ts diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index f94ba01..b37008d 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -4,11 +4,15 @@ 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, @@ -17,10 +21,12 @@ import { Typography, } from 'antd'; import type { Dayjs } from 'dayjs'; -import { api } from '@/lib/api'; +import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatWallTime } from '@/lib/format'; import { refreshReviewBadge } from '@/lib/reviewBadge'; +import { BULK_REVIEW_MAX, bulkFailureReasonText, 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'; @@ -28,6 +34,7 @@ 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 || ''; @@ -155,6 +162,8 @@ const RECORD_COLUMNS: ColumnsType = [ ]; export default function FeedbacksPage() { + const { message } = App.useApp(); + const [bulkForm] = Form.useForm(); // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); const [source, setSource] = useState(); @@ -171,6 +180,19 @@ export default function FeedbacksPage() { usePagedList('/admin/api/feedbacks', filters); const canReview = canDo(['operator']); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + 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(null); const loadSummary = async () => { @@ -236,6 +258,61 @@ export default function FeedbacksPage() { const sortOrderOf = (field: SortField) => sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; + const openBulkModal = (action: 'approve' | 'reject') => { + if (!selectedFeedbacks.length) return; + if (selectedFeedbacks.length > BULK_REVIEW_MAX) { + message.warning(`单次最多审核 ${BULK_REVIEW_MAX} 条反馈`); + 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( + `/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} 条;失败项已保留勾选${bulkFailureReasonText(data)}`, + ); + } else { + message.success(`${label}完成:成功 ${data.success} 条`); + } + } catch (e) { + message.error(errMsg(e)); + } finally { + setBulkSubmitting(false); + } + }; + const columns: ColumnsType = [ { title: '用户ID', @@ -411,16 +488,58 @@ export default function FeedbacksPage() { + {canReview && ( + + 已选 {selectedFeedbacks.length} 条待审核反馈 + + + {!!selectedFeedbacks.length && ( + + )} + + )} + { + const ids = keys as number[]; + if (ids.length > BULK_REVIEW_MAX) { + message.warning(`单次最多选择 ${BULK_REVIEW_MAX} 条待审核反馈`); + } + setSelectedRowKeys(ids.slice(0, BULK_REVIEW_MAX)); + }, + getCheckboxProps: (record) => ({ + disabled: !isPending(record.status), + name: `选择反馈 #${record.id}`, + }), + } + : undefined + } loading={loading} pagination={{ current: page, pageSize, total, showSizeChanger: true, + pageSizeOptions: [10, 20, 50], showTotal: (t) => `共 ${t} 条反馈`, onChange: onPageChange, }} @@ -428,6 +547,88 @@ export default function FeedbacksPage() { scroll={{ x: 1720 }} /> + { + if (bulkSubmitting) return; + setBulkAction(null); + bulkForm.resetFields(); + }} + destroyOnHidden + > + + 下列设置会统一应用到已选的 {selectedFeedbacks.length} + 条反馈。批量操作前请确认每条内容适用相同处理结果。 + +
+ {bulkAction === 'approve' ? ( + + + + ) : ( + + + + )} + + + + + + + +
+ (null); const [rejecting, setRejecting] = useState(null); + const [bulkRejecting, setBulkRejecting] = useState([]); const [rejectReason, setRejectReason] = useState(''); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 提交时间列服务端排序(默认按提交时间倒序,最新在前) const [sortBy, setSortBy] = useState('created_at'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); @@ -165,6 +170,16 @@ export default function PriceReportsPage() { }; const canReview = canDo(['operator']); + const selectedReports = items.filter( + (item) => selectedRowKeys.includes(item.id) && item.status === 'pending', + ); + + useEffect(() => { + const visiblePendingIds = new Set( + items.filter((item) => item.status === 'pending').map((item) => item.id), + ); + setSelectedRowKeys((keys) => keys.filter((id) => visiblePendingIds.has(id))); + }, [items]); const loadSummary = async () => { try { @@ -183,6 +198,49 @@ export default function PriceReportsPage() { loadSummary(); }; + const reportBulkResult = (label: string, result: BulkReviewResult) => { + const failedIds = failedReviewIds(result); + setSelectedRowKeys(failedIds); + refreshReviewBadge('/price-reports'); + loadSummary(); + if (failedIds.length) reload(); + else onPageChange(1, pageSize); + if (failedIds.length) { + message.warning( + `${label}完成:成功 ${result.success} 条,失败 ${failedIds.length} 条;失败项已保留勾选${bulkFailureReasonText(result)}`, + ); + } else { + message.success(`${label}完成:成功 ${result.success} 条`); + } + }; + + const bulkApprove = () => { + const targets = selectedReports; + if (!targets.length) return; + if (targets.length > BULK_REVIEW_MAX) { + message.warning(`单次最多审核 ${BULK_REVIEW_MAX} 条记录`); + return; + } + modal.confirm({ + title: `确认批量通过 ${targets.length} 条低价上报?`, + icon: , + content: `通过后将向每位用户发放 1000 金币,共发放 ${targets.length * 1000} 金币。请确认已逐条核实截图。`, + okText: '批量通过并发金币', + onOk: async () => { + try { + const { data } = await api.post( + '/admin/api/price-reports/bulk/approve', + { ids: targets.map((item) => item.id) }, + ); + reportBulkResult('批量通过', data); + } catch (e) { + message.error(errMsg(e)); + throw e; + } + }, + }); + }; + const approve = (r: PriceReport) => { modal.confirm({ title: '确认通过该上报?', @@ -214,21 +272,38 @@ export default function PriceReportsPage() { }; const confirmReject = async () => { - if (!rejecting) return; + const targets = bulkRejecting.length ? bulkRejecting : rejecting ? [rejecting] : []; + if (!targets.length) return; + if (targets.length > BULK_REVIEW_MAX) { + message.warning(`单次最多审核 ${BULK_REVIEW_MAX} 条记录`); + return; + } const reason = rejectReason.trim(); if (!reason) { message.warning('请填写拒绝理由'); return; } + setRejectSubmitting(true); try { - await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason }); - refreshReviewBadge('/price-reports'); - message.success('已拒绝'); + if (bulkRejecting.length) { + const { data } = await api.post( + '/admin/api/price-reports/bulk/reject', + { ids: targets.map((item) => item.id), reason }, + ); + reportBulkResult('批量拒绝', data); + } else { + await api.post(`/admin/api/price-reports/${targets[0].id}/reject`, { reason }); + refreshReviewBadge('/price-reports'); + message.success('已拒绝'); + refreshAfterChange(); + } setRejecting(null); + setBulkRejecting([]); setRejectReason(''); - refreshAfterChange(); } catch (e) { message.error(errMsg(e)); + } finally { + setRejectSubmitting(false); } }; @@ -393,10 +468,56 @@ export default function PriceReportsPage() { onChange={setActiveStatus} items={STATUS_TABS.map((t) => ({ key: t.key, label: t.label }))} /> + {canReview && ( + + 已选 {selectedReports.length} 条待审核记录 + + + {!!selectedReports.length && ( + + )} + + )}
{ + const ids = keys as number[]; + if (ids.length > BULK_REVIEW_MAX) { + message.warning(`单次最多选择 ${BULK_REVIEW_MAX} 条待审核记录`); + } + setSelectedRowKeys(ids.slice(0, BULK_REVIEW_MAX)); + }, + getCheckboxProps: (record) => ({ + disabled: record.status !== 'pending', + name: `选择低价上报 #${record.id}`, + }), + } + : undefined + } loading={loading} onChange={onTableChange} pagination={{ @@ -404,6 +525,7 @@ export default function PriceReportsPage() { pageSize, total, showSizeChanger: true, + pageSizeOptions: [10, 20, 50], showTotal: (t) => `共 ${t} 条`, onChange: onPageChange, }} @@ -412,21 +534,25 @@ export default function PriceReportsPage() { }} onOk={confirmReject} onCancel={() => { setRejecting(null); + setBulkRejecting([]); setRejectReason(''); }} > - {rejecting && ( + {(rejecting || bulkRejecting.length > 0) && ( - - 用户 #{rejecting.user_id} · {rejecting.store_name || '-'} - + {bulkRejecting.length ? ( + 以下原因会统一应用到已选的 {bulkRejecting.length} 条记录。 + ) : rejecting ? ( + 用户 #{rejecting.user_id} · {rejecting.store_name || '-'} + ) : null} {REJECT_TEMPLATES.map((tpl) => ( + + {cfg.video_url && ( + + )} + + MP4(H.264),≤100MB + + + + + {!canEdit && 仅 operator / super_admin 可修改} + + + )} + + ); +} diff --git a/src/app/(main)/config/page.tsx b/src/app/(main)/config/page.tsx index 3f48f83..c7a0344 100644 --- a/src/app/(main)/config/page.tsx +++ b/src/app/(main)/config/page.tsx @@ -6,6 +6,7 @@ import { api, errMsg } from '@/lib/api'; import HomeMarqueeSeeds from './HomeMarqueeSeeds'; import HomeStatsConfig from './HomeStatsConfig'; import FeedbackQrConfig from './FeedbackQrConfig'; +import GuideVideoConfig from './GuideVideoConfig'; interface ConfigItem { key: string; @@ -164,6 +165,7 @@ export default function ConfigPage() { }, { key: 'welfare', label: '福利页', children: welfareConfig }, { key: 'feedback-qr', label: '反馈二维码', children: }, + { key: 'guide-video', label: '领券引导视频', children: }, ]} /> diff --git a/src/lib/types.ts b/src/lib/types.ts index 9274e27..d3118a6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -317,6 +317,18 @@ export interface FeedbackQrConfig { updated_at: string | null; } +/** 领券等候浮层的「新手引导视频」配置(前 3 次用它替代广告,每次固定 120 金币)。 + * 后台只开放 enabled + 换片;下面几个字段接口仍返回,但页面不展示、也不回传。 */ +export interface GuideVideoConfig { + enabled: boolean; + video_url: string | null; // /media/guide_video/xxx.mp4;null = 未配片(浮层照旧只放广告) + max_plays: number; // 每个账号前 N 次浮层放引导视频(服务端默认 3,后台不开放调整) + reward_coin: number; // 每次固定金币(服务端默认 120,播完 / 中途关闭都发) + updated_at: string | null; + total_plays: number; // 只读统计:全站已播次数(后台不再展示) + granted_plays: number; // 只读统计:其中已发币次数(后台不再展示) +} + export interface AuditLog { id: number; admin_id: number;