风采审核功能开发
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled

This commit is contained in:
冯辉
2026-07-07 19:48:37 +08:00
parent 0ad9de3046
commit 8236050546
5 changed files with 502 additions and 54 deletions

View File

@@ -199,6 +199,11 @@ export default [
path: '/management/interview-list', path: '/management/interview-list',
component: './Management/InterviewList', component: './Management/InterviewList',
}, },
{
name: '风采审核',
path: '/management/showcase-review',
component: './Management/Showcase',
},
], ],
}, },
{ {

View File

@@ -9,6 +9,8 @@ import {
message, message,
Typography, Typography,
Modal, Modal,
Tag,
Tooltip,
} from 'antd'; } from 'antd';
import { import {
UploadOutlined, UploadOutlined,
@@ -29,6 +31,9 @@ interface ShowcaseItem {
type: string; type: string;
url: string; url: string;
createTime: string; createTime: string;
id: number;
reviewStatus: string; // '0' | '1' | '2'
reviewRemark?: string;
} }
/** 从 getInfoCache 缓存获取 userId */ /** 从 getInfoCache 缓存获取 userId */
@@ -75,11 +80,16 @@ const serverFileToItem = (row: {
id: number; id: number;
fileUrl: string; fileUrl: string;
createTime?: string; createTime?: string;
reviewStatus?: string;
reviewRemark?: string;
}): ShowcaseItem => ({ }): ShowcaseItem => ({
uid: `server-${row.id}`, uid: `server-${row.id}`,
type: guessType(row.fileUrl), type: guessType(row.fileUrl),
url: getFileDisplayUrl(row.fileUrl), url: getFileDisplayUrl(row.fileUrl),
createTime: formatTime(row.createTime), createTime: formatTime(row.createTime),
id: row.id,
reviewStatus: row.reviewStatus || '0',
reviewRemark: row.reviewRemark,
}); });
const isVideo = (type: string): boolean => type.startsWith('video/'); const isVideo = (type: string): boolean => type.startsWith('video/');
@@ -214,34 +224,60 @@ const ShowcasePage: React.FC = () => {
<Text type="secondary"> {items.length} </Text> <Text type="secondary"> {items.length} </Text>
</div> </div>
<div className="gallery-grid"> <div className="gallery-grid">
{items.map((item) => ( {items.map((item) => {
<Card const isPending = item.reviewStatus === '0';
key={item.uid} const isRejected = item.reviewStatus === '2';
className="gallery-card" return (
hoverable <Card
cover={ key={item.uid}
isVideo(item.type) ? ( className={`gallery-card${isPending || isRejected ? ' gallery-card--dimmed' : ''}`}
<div className="video-cover" onClick={() => setPreviewItem(item)}> hoverable={!isPending}
<video src={item.url} className="gallery-video" /> cover={
<div className="video-play-mask"> isVideo(item.type) ? (
<VideoCameraOutlined /> <div
className="video-cover"
onClick={() => !isPending && setPreviewItem(item)}
style={isPending || isRejected ? { pointerEvents: 'none', opacity: 0.5 } : undefined}
>
<video src={item.url} className="gallery-video" />
<div className="video-play-mask">
<VideoCameraOutlined />
</div>
</div> </div>
</div> ) : (
) : ( <div className="image-cover" style={isPending || isRejected ? { opacity: 0.5 } : undefined}>
<div className="image-cover"> <Image
<Image src={item.url}
src={item.url} alt="风采图片"
alt="风采图片" className="gallery-image"
className="gallery-image" preview={isPending || isRejected ? false : { mask: <CameraOutlined /> }}
preview={{ mask: <CameraOutlined /> }} />
/> </div>
</div> )
) }
} >
> <Card.Meta
<Card.Meta description={<Text type="secondary">{item.createTime}</Text>} /> description={
</Card> <div>
))} <Text type="secondary">{item.createTime}</Text>
{isPending && (
<div style={{ marginTop: 8 }}>
<Tag color="orange"></Tag>
</div>
)}
{isRejected && (
<div style={{ marginTop: 8 }}>
<Tooltip title={`驳回原因:${item.reviewRemark || '无'}`}>
<Tag color="error"></Tag>
</Tooltip>
</div>
)}
</div>
}
/>
</Card>
);
})}
</div> </div>
</div> </div>
)} )}

View File

@@ -9,6 +9,8 @@ import {
message, message,
Typography, Typography,
Modal, Modal,
Tag,
Tooltip,
} from 'antd'; } from 'antd';
import { import {
UploadOutlined, UploadOutlined,
@@ -29,6 +31,9 @@ interface ShowcaseItem {
type: string; type: string;
url: string; url: string;
createTime: string; createTime: string;
id: number;
reviewStatus: string; // '0' | '1' | '2'
reviewRemark?: string;
} }
/** 从 getInfoCache 缓存获取 userId */ /** 从 getInfoCache 缓存获取 userId */
@@ -75,11 +80,16 @@ const serverFileToItem = (row: {
id: number; id: number;
fileUrl: string; fileUrl: string;
createTime?: string; createTime?: string;
reviewStatus?: string;
reviewRemark?: string;
}): ShowcaseItem => ({ }): ShowcaseItem => ({
uid: `server-${row.id}`, uid: `server-${row.id}`,
type: guessType(row.fileUrl), type: guessType(row.fileUrl),
url: getFileDisplayUrl(row.fileUrl), url: getFileDisplayUrl(row.fileUrl),
createTime: formatTime(row.createTime), createTime: formatTime(row.createTime),
id: row.id,
reviewStatus: row.reviewStatus || '0',
reviewRemark: row.reviewRemark,
}); });
const isVideo = (type: string): boolean => type.startsWith('video/'); const isVideo = (type: string): boolean => type.startsWith('video/');
@@ -209,34 +219,60 @@ const EnterpriseShowcase: React.FC = () => {
<Text type="secondary"> {items.length} </Text> <Text type="secondary"> {items.length} </Text>
</div> </div>
<div className="es-gallery-grid"> <div className="es-gallery-grid">
{items.map((item) => ( {items.map((item) => {
<Card const isPending = item.reviewStatus === '0';
key={item.uid} const isRejected = item.reviewStatus === '2';
className="es-gallery-card" return (
hoverable <Card
cover={ key={item.uid}
isVideo(item.type) ? ( className={`es-gallery-card${isPending || isRejected ? ' es-gallery-card--dimmed' : ''}`}
<div className="es-video-cover" onClick={() => setPreviewItem(item)}> hoverable={!isPending}
<video src={item.url} className="es-gallery-video" /> cover={
<div className="es-video-play-mask"> isVideo(item.type) ? (
<VideoCameraOutlined /> <div
className="es-video-cover"
onClick={() => !isPending && setPreviewItem(item)}
style={isPending || isRejected ? { pointerEvents: 'none', opacity: 0.5 } : undefined}
>
<video src={item.url} className="es-gallery-video" />
<div className="es-video-play-mask">
<VideoCameraOutlined />
</div>
</div> </div>
</div> ) : (
) : ( <div className="es-image-cover" style={isPending || isRejected ? { opacity: 0.5 } : undefined}>
<div className="es-image-cover"> <Image
<Image src={item.url}
src={item.url} alt="企业风采图片"
alt="企业风采图片" className="es-gallery-image"
className="es-gallery-image" preview={isPending || isRejected ? false : { mask: <CameraOutlined /> }}
preview={{ mask: <CameraOutlined /> }} />
/> </div>
</div> )
) }
} >
> <Card.Meta
<Card.Meta description={<Text type="secondary">{item.createTime}</Text>} /> description={
</Card> <div>
))} <Text type="secondary">{item.createTime}</Text>
{isPending && (
<div style={{ marginTop: 8 }}>
<Tag color="orange"></Tag>
</div>
)}
{isRejected && (
<div style={{ marginTop: 8 }}>
<Tooltip title={`驳回原因:${item.reviewRemark || '无'}`}>
<Tag color="error"></Tag>
</Tooltip>
</div>
)}
</div>
}
/>
</Card>
);
})}
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,325 @@
import React, { useRef, useState } from 'react';
import { useAccess } from '@umijs/max';
import { Button, Image, message, Modal, Tag, Tooltip } from 'antd';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { EyeOutlined } from '@ant-design/icons';
import {
getShowcaseReviewList,
reviewShowcase,
type ShowcaseReviewItem,
} from '@/services/Management/showcaseReview';
import { getFileDisplayUrl } from '@/utils/fileUrl';
/** 根据文件名后缀推断类型 */
const guessType = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const videoExts = ['mp4', 'mov', 'avi', 'wmv', 'flv', 'mkv', 'webm', 'm4v'];
if (videoExts.includes(ext)) return 'video';
return 'image';
};
const isVideo = (fileUrl: string): boolean => guessType(fileUrl) === 'video';
const statusValueEnum = {
'0': { text: '待审核', status: 'Processing' },
'1': { text: '已通过', status: 'Success' },
'2': { text: '已驳回', status: 'Error' },
};
const ShowcaseReview: React.FC = () => {
const access = useAccess();
const actionRef = useRef<ActionType>();
const [approvalVisible, setApprovalVisible] = useState(false);
const [currentRow, setCurrentRow] = useState<ShowcaseReviewItem | null>(null);
const [approvalStatus, setApprovalStatus] = useState<'1' | '2'>('1');
const [approvalRemark, setApprovalRemark] = useState('');
const [submitting, setSubmitting] = useState(false);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const handleReview = (record: ShowcaseReviewItem) => {
setCurrentRow(record);
setApprovalStatus('1');
setApprovalRemark('');
setApprovalVisible(true);
};
const handleSubmit = async () => {
if (!currentRow) return;
if (approvalStatus === '2' && !approvalRemark.trim()) {
message.warning('请填写驳回原因');
return;
}
try {
setSubmitting(true);
const resp = await reviewShowcase({
id: currentRow.id,
reviewStatus: approvalStatus,
reviewRemark: approvalStatus === '2' ? approvalRemark.trim() : undefined,
});
if (resp?.code === 200) {
message.success('审核成功');
setApprovalVisible(false);
setCurrentRow(null);
setApprovalRemark('');
actionRef.current?.reload();
} else {
message.error(resp?.msg || '审核失败');
}
} catch (error) {
message.error('审核失败,请重试');
} finally {
setSubmitting(false);
}
};
const columns: ProColumns<ShowcaseReviewItem>[] = [
{
title: 'ID',
dataIndex: 'id',
width: 80,
search: false,
},
{
title: '预览',
dataIndex: 'fileUrl',
width: 100,
search: false,
render: (_, record) => {
const url = getFileDisplayUrl(record.fileUrl);
if (isVideo(record.fileUrl)) {
return (
<div
style={{
width: 80,
height: 60,
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
borderRadius: 4,
}}
onClick={() => {
setPreviewUrl(url);
setPreviewVisible(true);
}}
>
<video src={url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<EyeOutlined style={{ position: 'absolute', color: '#fff', fontSize: 20 }} />
</div>
);
}
return (
<Image
src={url}
width={80}
height={60}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={{ mask: <EyeOutlined /> }}
/>
);
},
},
{
title: '类型',
dataIndex: 'fileUrl',
width: 80,
search: false,
render: (_, record) => (
<Tag>{isVideo(record.fileUrl) ? '视频' : '图片'}</Tag>
),
},
{
title: '上传用户ID',
dataIndex: 'bussinessid',
width: 120,
fieldProps: { placeholder: '输入用户ID' },
},
{
title: '审核状态',
dataIndex: 'reviewStatus',
width: 100,
valueType: 'select',
valueEnum: statusValueEnum,
render: (_, record) => {
const status = record.reviewStatus || '0';
const map: Record<string, { color: string; text: string }> = {
'0': { color: 'orange', text: '待审核' },
'1': { color: 'green', text: '已通过' },
'2': { color: 'red', text: '已驳回' },
};
const info = map[status] || map['0'];
return <Tag color={info.color}>{info.text}</Tag>;
},
},
{
title: '审核备注',
dataIndex: 'reviewRemark',
width: 200,
search: false,
ellipsis: true,
render: (_, record) => {
if (!record.reviewRemark) return '-';
return (
<Tooltip title={record.reviewRemark}>
<span>{record.reviewRemark}</span>
</Tooltip>
);
},
},
{
title: '审核时间',
dataIndex: 'reviewTime',
width: 160,
search: false,
render: (_, record) => record.reviewTime || '-',
},
{
title: '上传时间',
dataIndex: 'createTime',
width: 160,
search: false,
},
{
title: '操作',
dataIndex: 'option',
valueType: 'option',
width: 100,
render: (_, record) => {
const isPending = (record.reviewStatus || '0') === '0';
if (!isPending) return '-';
return (
<Button
type="link"
size="small"
hidden={!access.hasPerms('cms:showcase:review')}
onClick={() => handleReview(record)}
>
</Button>
);
},
},
];
return (
<>
<ProTable<ShowcaseReviewItem>
headerTitle="风采审核管理"
actionRef={actionRef}
rowKey="id"
search={{ labelWidth: 100 }}
request={async (params) => {
const { current, pageSize, bussinessid, reviewStatus } = params;
const res = await getShowcaseReviewList({
current,
pageSize,
bussinessid: bussinessid ? Number(bussinessid) : undefined,
reviewStatus,
});
return {
data: res?.rows || [],
total: res?.total || 0,
success: res?.code === 200,
};
}}
columns={columns}
scroll={{ x: 'max-content' }}
/>
{/* 审核弹窗 */}
<Modal
title="风采审核"
open={approvalVisible}
width={520}
onCancel={() => {
setApprovalVisible(false);
setApprovalRemark('');
setCurrentRow(null);
}}
footer={null}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<span style={{ marginRight: 12 }}></span>
<label style={{ marginRight: 16 }}>
<input
type="radio"
name="approvalStatus"
checked={approvalStatus === '1'}
onChange={() => setApprovalStatus('1')}
/>
<span style={{ marginLeft: 6 }}></span>
</label>
<label>
<input
type="radio"
name="approvalStatus"
checked={approvalStatus === '2'}
onChange={() => setApprovalStatus('2')}
/>
<span style={{ marginLeft: 6 }}></span>
</label>
</div>
{approvalStatus === '2' && (
<div>
<div style={{ marginBottom: 6 }}></div>
<textarea
style={{ width: '100%', minHeight: 100, padding: '8px 12px', borderRadius: 6, border: '1px solid #d9d9d9' }}
placeholder="请输入驳回原因"
value={approvalRemark}
onChange={(e) => setApprovalRemark(e.target.value)}
/>
</div>
)}
<div style={{ textAlign: 'right', marginTop: 8 }}>
<Button
onClick={() => {
setApprovalVisible(false);
setApprovalRemark('');
}}
style={{ marginRight: 8 }}
>
</Button>
<Button
type="primary"
danger={approvalStatus === '2'}
loading={submitting}
onClick={handleSubmit}
>
</Button>
</div>
</div>
</Modal>
{/* 视频预览弹窗 */}
<Modal
title="视频预览"
open={previewVisible}
footer={null}
onCancel={() => setPreviewVisible(false)}
width={800}
destroyOnClose
>
{previewUrl && (
<video
src={previewUrl}
controls
autoPlay
style={{ width: '100%', display: 'block' }}
/>
)}
</Modal>
</>
);
};
export default ShowcaseReview;

View File

@@ -0,0 +1,46 @@
import { request } from '@umijs/max';
/** 风采审核列表项 */
export interface ShowcaseReviewItem {
id: number;
fileUrl: string;
bussinessid: number;
reviewStatus: string; // '0'=待审核, '1'=审核通过, '2'=审核未通过
reviewRemark?: string;
reviewBy?: string;
reviewTime?: string;
createTime: string;
updateTime?: string;
}
/** 风采审核列表查询参数 */
export interface ShowcaseReviewListParams {
current?: number;
pageSize?: number;
bussinessid?: number;
reviewStatus?: string;
}
/** 风采审核操作参数 */
export interface ShowcaseApprovalParams {
id: number;
reviewStatus: '1' | '2'; // 1=通过, 2=驳回
reviewRemark?: string;
}
/** 获取风采审核列表 GET /api/cms/file/review/list */
export async function getShowcaseReviewList(params: ShowcaseReviewListParams) {
return request<{ code: number; total: number; rows: ShowcaseReviewItem[] }>(
'/api/cms/file/review/list',
{ method: 'GET', params },
);
}
/** 审核风采(通过/驳回) PUT /api/cms/file/review/{id} */
export async function reviewShowcase(params: ShowcaseApprovalParams) {
const { id, ...data } = params;
return request<{ code: number; msg: string }>(`/api/cms/file/review/${id}`, {
method: 'PUT',
data,
});
}