diff --git a/deploy/nginx/admin.shaguabijia.com.conf b/deploy/nginx/admin.shaguabijia.com.conf index f76eb32..0d66ae1 100644 --- a/deploy/nginx/admin.shaguabijia.com.conf +++ b/deploy/nginx/admin.shaguabijia.com.conf @@ -32,6 +32,20 @@ server { proxy_read_timeout 60s; } + # 新手引导视频上传:视频比图片大一个量级,单独放宽到 100MB(对齐后端 + # settings.GUIDE_VIDEO_MAX_BYTES)。不放宽全站上限,避免其它接口被大 body 打。 + # 上传大文件耗时长,读超时同步放宽到 300s。 + location = /admin/api/guide-video/video { + client_max_body_size 100m; + proxy_pass http://127.0.0.1:8771; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_request_buffering off; + } + # 用户上传媒体(上报截图 / 反馈截图 / 反馈二维码)由 App 后端(:8770)的 /media 托管。 # admin 页面要展示这些图,经此同域反代过去(免跨域 + 免 https 页面引 http 图被拦)。 # 前提:App 后端与本 admin 同机;若分机,把 127.0.0.1:8770 换成 App 后端可达地址。 diff --git a/src/app/(main)/config/GuideVideoConfig.tsx b/src/app/(main)/config/GuideVideoConfig.tsx new file mode 100644 index 0000000..dcf8b86 --- /dev/null +++ b/src/app/(main)/config/GuideVideoConfig.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button, Card, Space, Spin, Switch, Tag, Typography, Upload, message } from 'antd'; +import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { mediaUrl } from '@/lib/media'; +import type { GuideVideoConfig as GuideCfg } from '@/lib/types'; + +const { Text } = Typography; + +/** 后端 media.save_guide_video 只认 MP4 魔数;这里先在浏览器挡一道,省得白传 100MB。 */ +const MAX_BYTES = 100 * 1024 * 1024; + +/** + * 领券等候浮层的「新手引导视频」配置。 + * + * 用户点首页「一键自动领取」→ 出等候浮层,浮层下方那块位置**前 3 次**放这支引导视频 + * (而不是广告),每次固定发 120 金币;播完若浮层还开着,自动接着放广告(原逻辑)。 + * 「系统配置 → 领券引导视频」tab 的一个区块。 + * + * 后台只管两件事:**开关** 和 **换片**。次数(3)/ 金币(120)走服务端默认值,产品已拍板不再 + * 开放配置,所以这里不渲染输入框、PATCH 也不带这两个字段(后端仍保留字段与默认值)。 + */ +export default function GuideVideoConfig() { + const [cfg, setCfg] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + + // 本地编辑态,保存时一次性 PATCH + const [enabled, setEnabled] = useState(true); + + const canEdit = canDo(['operator']); + + const sync = (c: GuideCfg) => { + setCfg(c); + setEnabled(c.enabled); + }; + + const load = async () => { + setLoading(true); + try { + const { data } = await api.get('/admin/api/guide-video'); + sync(data); + } finally { + setLoading(false); + } + }; + useEffect(() => { + load(); + }, []); + + const save = async () => { + setSaving(true); + try { + const { data } = await api.patch('/admin/api/guide-video', { enabled }); + sync(data); + message.success('已保存,用户下一次进入领券浮层即生效'); + } catch (e) { + message.error(errMsg(e)); + } finally { + setSaving(false); + } + }; + + // antd Upload:beforeUpload 里自行 POST(multipart),返回 false 阻止其默认上传。 + const beforeUpload = (file: File) => { + // 有些系统给 .mp4 的 type 是空串,不能只看 type,再兜一层扩展名。 + const looksMp4 = file.type === 'video/mp4' || /\.mp4$/i.test(file.name); + if (!looksMp4) { + message.error('仅支持 MP4 视频(H.264 编码)'); + return Upload.LIST_IGNORE; + } + if (file.size > MAX_BYTES) { + message.error('视频不能超过 100MB'); + return Upload.LIST_IGNORE; + } + void uploadVideo(file); + return false; + }; + + const uploadVideo = async (file: File) => { + const form = new FormData(); + form.append('file', file); + setUploading(true); + try { + const { data } = await api.post('/admin/api/guide-video/video', form); + sync(data); + message.success('引导视频已更新,用户下一次进入领券浮层即生效'); + } catch (e) { + message.error(errMsg(e)); + } finally { + setUploading(false); + } + }; + + const removeVideo = async () => { + setUploading(true); + try { + const { data } = await api.delete('/admin/api/guide-video/video'); + sync(data); + message.success('已移除引导视频,领券浮层恢复为只放广告'); + } catch (e) { + message.error(errMsg(e)); + } finally { + setUploading(false); + } + }; + + return ( + + 更新于 {new Date(cfg.updated_at).toLocaleString('zh-CN')} + + ) : null + } + > +

+ 用户点「一键自动领取」后出现的等候浮层,下方那块广告位前 3 次改放这支引导视频。 + 视频不可快进;播完或中途关闭都算看完,各发一次 120 金币;播完若浮层还开着,会自动接着放广告。 + 次数按账号计(换设备不重置),视频一开播就算用掉一次。 + 次数与金币走固定值、后台不开放调整;这里只管开关换片,每次改动进审计日志。 +

+ {loading || !cfg ? ( + + ) : ( + + {/* 左:视频预览 */} +
+ + 当前引导视频 + +
+ {cfg.video_url ? ( + // eslint-disable-next-line jsx-a11y/media-has-caption +
+ {!enabled && cfg.video_url && ( +
+ 当前为「关闭」:浮层不放引导视频,直接放广告 +
+ )} +
+ + {/* 右:编辑控件 */} + + + 启用引导视频: + + {!enabled && 已关闭} + + + + + + + {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/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) => , }, { 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="普通看视频与历史签到膨胀的实发金币之和,不再重复计入领券奖励或常规任务。" /> = [ ]; 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) => (