From b1270481aa40084996c23538bc6db9460eef5176 Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Sun, 14 Jun 2026 14:37:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin-web):=20=E5=8F=8D=E9=A6=88=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=E5=8A=A0=E6=88=AA=E5=9B=BE=E5=88=97=20+=20?= =?UTF-8?q?=E6=8E=92=E5=BA=8F/=E7=AD=9B=E9=80=89/=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 截图列:缩略图 + Image.PreviewGroup 预览,URL 走 NEXT_PUBLIC_MEDIA_BASE(本地 :8770) - 排序:ID/时间 列头服务端排序 - 筛选:状态 + 用户ID + 内容模糊 + 提交时间范围(草稿态 + 查询/重置) - 批量:勾选 new 态 → 批量标记处理(循环 /handle + Promise.allSettled) - 对齐用户页交互模式;依赖 app-server list_feedbacks 新增的 sort/filter 参数 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(main)/feedbacks/page.tsx | 214 ++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 14 deletions(-) diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index 9d1fc11..61091bb 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -1,23 +1,100 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { Key } from 'react'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Select, Space, Table, Tag, message } from 'antd'; +import type { SorterResult } from 'antd/es/table/interface'; +import { + Button, + DatePicker, + Image, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; +import { formatUtcTime } from '@/lib/format'; import { useCursorList } from '@/lib/useCursorList'; import type { Feedback } from '@/lib/types'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +const { Text } = Typography; +const { RangePicker } = DatePicker; + +// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。 +const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || ''; +const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p); + +type SortField = 'id' | 'created_at'; export default function FeedbacksPage() { + // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); - const [filters, setFilters] = useState>({}); + const [userId, setUserId] = useState(''); + const [content, setContent] = useState(''); + const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端):点列头即时生效,与「查询」按钮分开 + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const filterKey = JSON.stringify(filters); const { items, nextCursor, loading, loadMore, reload } = useCursorList( '/admin/api/feedbacks', filters, ); + const canHandle = canDo(['operator']); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + // 筛选/排序变化后清空已选(避免选中项跨筛选错位) + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey]); + + const search = () => + setApplied({ + status, + user_id: userId.trim() ? Number(userId.trim()) : undefined, + content: content.trim() || undefined, + created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined, + created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined, + }); + + const resetFilters = () => { + setStatus(undefined); + setUserId(''); + setContent(''); + setCreatedRange(null); + setSortBy('id'); + setSortOrder('desc'); + setApplied({}); + }; + + const onTableChange = ( + _pagination: unknown, + _filters: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + setSortBy('id'); + setSortOrder('desc'); + } + }; + + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; const handle = async (f: Feedback) => { try { @@ -29,18 +106,75 @@ export default function FeedbacksPage() { } }; + 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 = [ - { title: 'ID', dataIndex: 'id', width: 70 }, + { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, { title: '用户', dataIndex: 'user_id', width: 80 }, { title: '内容', dataIndex: 'content' }, - { title: '联系方式', dataIndex: 'contact', width: 140 }, + { + title: '截图', + dataIndex: 'images', + width: 180, + render: (imgs: string[] | null) => + imgs && imgs.length ? ( + + + {imgs.map((src, i) => ( + + ))} + + + ) : ( + + ), + }, + { title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s}, }, - { title: '时间', dataIndex: 'created_at', render: dt, width: 180 }, + { + title: '时间', + dataIndex: 'created_at', + width: 180, + sorter: true, + sortOrder: sortOrderOf('created_at'), + render: (v: string) => formatUtcTime(v), + }, { title: '操作', key: 'op', @@ -57,23 +191,75 @@ export default function FeedbacksPage() { return (

反馈工单

- + setUserId(e.target.value.replace(/\D/g, ''))} + onPressEnter={search} + allowClear + style={{ width: 120 }} + /> + setContent(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 180 }} + /> + setCreatedRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + + - + + {canHandle && selectedNew.length > 0 && ( + + 已选 {selectedNew.length} 条待处理 + + + + )} + +
({ disabled: f.status !== 'new' }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={false} + onChange={onTableChange} + />