whistle.mock-plugins
Version:
Whistle 插件,用于快速创建 API 模拟数据
646 lines (595 loc) • 20.5 kB
JavaScript
import React, { useState, useEffect } from 'react';
import { ReactSortable } from 'react-sortablejs';
import ReactDOM from 'react-dom';
import { Card, Button, Space, Badge, Row, Col, Input, Select, Tooltip, message, Tag, Divider } from 'antd';
import {
CodeOutlined,
EyeOutlined,
FormatPainterOutlined,
FileTextOutlined,
PlusCircleOutlined,
DeleteOutlined,
CheckCircleOutlined,
EditOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
MenuOutlined,
FilterOutlined,
PlusOutlined,
QuestionCircleOutlined
} from '@ant-design/icons';
import { generateResponseId } from './utils';
const { TextArea } = Input;
const { Option } = Select;
const MATCH_TYPE_LABELS = {
exact: '精确',
contains: '包含',
regex: '正则',
};
const ResponseContentEditor = ({
form,
responses,
activeResponseId,
onPreview,
enableSorting = false
}) => {
const { getFieldValue, setFieldsValue } = form;
const [isEditing, setIsEditing] = useState(false);
const [editingName, setEditingName] = useState('');
const [isTextareaFullscreen, setIsTextareaFullscreen] = useState(false);
const [showSorting, setShowSorting] = useState(false);
const [responseParamMatcherEnabled, setResponseParamMatcherEnabled] = useState(() => {
const v = localStorage.getItem('responseParamMatcherEnabled');
return v === null ? true : v !== 'false';
});
useEffect(() => {
const handleStorage = () => {
const v = localStorage.getItem('responseParamMatcherEnabled');
setResponseParamMatcherEnabled(v === null ? true : v !== 'false');
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
// 查找激活的响应
const activeResponseIndex = responses.findIndex(r => r.id === activeResponseId);
const activeResponse = activeResponseIndex >= 0 ? responses[activeResponseIndex] : null;
// 拖拽排序:基于 ReactSortable 管理一个本地列表镜像
const [sortableList, setSortableList] = useState(() => {
return Array.isArray(responses) ? responses.map(r => ({ ...r })) : [];
});
useEffect(() => {
setSortableList(Array.isArray(responses) ? responses.map(r => ({ ...r })) : []);
}, [responses]);
const handleAfterSort = (newList) => {
setFieldsValue({ responses: newList });
};
// 全屏切换函数
const toggleTextareaFullscreen = () => {
setIsTextareaFullscreen(!isTextareaFullscreen);
};
// ESC键退出全屏 - 移到全屏覆盖层内部处理
const handleFullscreenEscKey = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setIsTextareaFullscreen(false);
}
};
// 添加新响应
const handleAddResponse = () => {
const newResponseId = generateResponseId();
let currentResponses = getFieldValue('responses') || [];
if (!Array.isArray(currentResponses)) {
try {
if (typeof currentResponses === 'string') {
currentResponses = JSON.parse(currentResponses);
}
} catch (e) {
currentResponses = [];
}
}
if (!Array.isArray(currentResponses)) {
currentResponses = [];
}
const newResponse = {
id: newResponseId,
name: `响应 ${currentResponses.length + 1}`,
description: '',
content: '{\n "code": 0,\n "message": "success",\n "data": {}\n}',
paramMatchers: []
};
const updatedResponses = [...currentResponses, newResponse];
setFieldsValue({
responses: updatedResponses,
activeResponseId: newResponseId
});
};
// 删除响应
const handleDeleteResponse = (responseId) => {
let currentResponses = getFieldValue('responses') || [];
if (currentResponses.length <= 1) {
message.warning('至少需要保留一个响应');
return;
}
const updatedResponses = currentResponses.filter(resp => resp.id !== responseId);
let newActiveId = activeResponseId;
if (responseId === activeResponseId) {
newActiveId = updatedResponses[0]?.id;
}
setFieldsValue({
responses: updatedResponses,
activeResponseId: newActiveId
});
};
// 切换响应
const handleResponseSelect = (responseId) => {
setFieldsValue({ activeResponseId: responseId });
setIsEditing(false);
};
// 开始编辑响应名称
const handleStartEdit = () => {
if (activeResponse) {
setEditingName(activeResponse.name || '');
setIsEditing(true);
}
};
// 完成编辑响应名称
const handleFinishEdit = () => {
if (activeResponseId && editingName.trim()) {
const currentResponses = getFieldValue('responses') || [];
const updatedResponses = currentResponses.map(resp =>
resp.id === activeResponseId ? { ...resp, name: editingName.trim() } : resp
);
setFieldsValue({ responses: updatedResponses });
}
setIsEditing(false);
setEditingName('');
};
// 取消编辑
const handleCancelEdit = () => {
setIsEditing(false);
setEditingName('');
};
// 更新响应内容
const updateActiveResponseContent = (content) => {
if (activeResponseIndex >= 0) {
const currentResponses = getFieldValue('responses') || [];
const newResponses = [...currentResponses];
newResponses[activeResponseIndex] = {
...newResponses[activeResponseIndex],
content
};
setFieldsValue({ responses: newResponses });
}
};
// 更新当前响应的 paramMatchers
const updateActiveResponseParamMatchers = (paramMatchers) => {
if (activeResponseIndex >= 0) {
const currentResponses = getFieldValue('responses') || [];
const newResponses = [...currentResponses];
newResponses[activeResponseIndex] = {
...newResponses[activeResponseIndex],
paramMatchers
};
setFieldsValue({ responses: newResponses });
}
};
// 添加入参匹配规则
const handleAddParamMatcher = () => {
const currentMatchers = (activeResponse && activeResponse.paramMatchers) || [];
updateActiveResponseParamMatchers([
...currentMatchers,
{ paramPath: '', paramValue: '', matchType: 'exact' }
]);
};
// 删除入参匹配规则
const handleRemoveParamMatcher = (index) => {
const currentMatchers = (activeResponse && activeResponse.paramMatchers) || [];
const newMatchers = currentMatchers.filter((_, i) => i !== index);
updateActiveResponseParamMatchers(newMatchers);
};
// 更新入参匹配规则某个字段
const handleParamMatcherChange = (index, field, value) => {
const currentMatchers = (activeResponse && activeResponse.paramMatchers) || [];
const newMatchers = currentMatchers.map((m, i) =>
i === index ? { ...m, [field]: value } : m
);
updateActiveResponseParamMatchers(newMatchers);
};
// 格式化JSON内容
const formatJsonContent = () => {
if (!activeResponse || !activeResponse.content) {
message.warning('没有可格式化的JSON内容');
return;
}
try {
const parsedJson = JSON.parse(activeResponse.content);
const formattedJson = JSON.stringify(parsedJson, null, 2);
updateActiveResponseContent(formattedJson);
message.success('JSON已格式化');
} catch (error) {
message.error('无效的JSON格式,无法格式化');
}
};
// 预览响应内容
const handlePreview = () => {
if (!activeResponse) {
message.error('未找到有效的响应内容');
return;
}
if (onPreview) {
onPreview();
}
};
// 渲染全屏TextArea
const renderFullscreenTextarea = () => {
if (!isTextareaFullscreen || !activeResponse) return null;
return ReactDOM.createPortal(
<div
className="textarea-fullscreen-overlay"
onKeyDown={handleFullscreenEscKey}
tabIndex={-1}
>
<div className="textarea-fullscreen-header">
<Button
icon={<FullscreenExitOutlined />}
onClick={toggleTextareaFullscreen}
size="small"
type="text"
style={{ color: '#595959' }}
>
退出全屏 (ESC)
</Button>
</div>
<TextArea
className="textarea-fullscreen-editor"
autoFocus
placeholder="请输入响应内容..."
value={activeResponse.content || ''}
onChange={(e) => updateActiveResponseContent(e.target.value)}
/>
</div>,
document.body
);
};
// 渲染响应级入参匹配规则区域
const renderParamMatchers = () => {
if (!activeResponse) return null;
const matchers = activeResponse.paramMatchers || [];
return (
<div style={{ marginBottom: '12px' }}>
<Divider orientation="left" orientationMargin={0} style={{ margin: '0 0 10px 0', fontSize: '12px' }}>
<Space size={6}>
<FilterOutlined style={{ color: '#1677ff' }} />
<span style={{ fontWeight: 'bold', color: '#333' }}>入参匹配规则</span>
{matchers.length > 0 && (
<Tag color="blue" style={{ fontSize: '11px', padding: '0 4px' }}>{matchers.length}条</Tag>
)}
<Tooltip title="满足所有规则时返回此响应;无规则的响应作为默认兜底。响应按列表顺序依次检查,第一个匹配的响应被返回。">
<QuestionCircleOutlined style={{ color: '#bbb', cursor: 'help', fontSize: '12px' }} />
</Tooltip>
</Space>
</Divider>
<div>
{matchers.map((matcher, index) => (
<div key={index} style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
<Input
style={{ flex: 1 }}
placeholder="参数路径(如:userId 或 data.user.id)"
value={matcher.paramPath || ''}
onChange={(e) => handleParamMatcherChange(index, 'paramPath', e.target.value)}
size="small"
/>
<Input
style={{ flex: 1 }}
placeholder="期望值(如:123 或 admin)"
value={matcher.paramValue || ''}
onChange={(e) => handleParamMatcherChange(index, 'paramValue', e.target.value)}
size="small"
/>
<Select
style={{ width: 90 }}
value={matcher.matchType || 'exact'}
onChange={(val) => handleParamMatcherChange(index, 'matchType', val)}
size="small"
>
<Option value="exact">精确匹配</Option>
<Option value="contains">包含</Option>
<Option value="regex">正则</Option>
</Select>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
onClick={() => handleRemoveParamMatcher(index)}
/>
</div>
))}
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={handleAddParamMatcher}
style={{ width: '100%' }}
>
添加匹配规则
</Button>
{matchers.length === 0 && (
<div style={{ color: '#bbb', fontSize: '12px', marginTop: '6px', textAlign: 'center' }}>
无规则时此响应作为默认兜底响应
</div>
)}
</div>
</div>
);
};
// 如果没有响应数据,显示创建按钮
if (!Array.isArray(responses) || responses.length === 0) {
return (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<CodeOutlined />
<span>响应内容编辑</span>
</div>
}
style={{ marginBottom: 16 }}
bodyStyle={{ padding: '16px' }}
>
<div style={{
textAlign: 'center',
padding: '40px 20px',
borderRadius: '6px',
border: '1px dashed #d9d9d9'
}}>
<FileTextOutlined style={{ fontSize: '32px', color: '#bfbfbf', marginBottom: '12px' }} />
<div style={{ color: '#999', marginBottom: '16px' }}>暂无响应数据</div>
<Button
type="primary"
icon={<PlusCircleOutlined />}
onClick={handleAddResponse}
>
创建第一个响应
</Button>
</div>
</Card>
);
}
return (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<CodeOutlined />
<span>响应内容编辑</span>
<Badge
count={responses.length}
style={{ backgroundColor: '#52c41a' }}
/>
</div>
}
extra={
<Space size="small">
{enableSorting && (
<Button
type="default"
icon={<MenuOutlined />}
onClick={() => setShowSorting(!showSorting)}
size="small"
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
排序
</Button>
)}
<Button
type="default"
icon={<PlusCircleOutlined />}
onClick={handleAddResponse}
size="small"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
添加响应
</Button>
<Button
type="default"
icon={<EyeOutlined />}
onClick={handlePreview}
size="small"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
borderColor: '#52c41a',
color: '#52c41a'
}}
>
预览
</Button>
<Button
type="default"
icon={<FormatPainterOutlined />}
onClick={formatJsonContent}
size="small"
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
borderColor: '#fa8c16',
color: '#fa8c16'
}}
>
格式化JSON
</Button>
</Space>
}
style={{ marginBottom: 16 }}
bodyStyle={{ padding: '16px' }}
>
{enableSorting && showSorting && Array.isArray(sortableList) && sortableList.length > 1 && (
<div style={{ marginBottom: 12 }}>
<ReactSortable
list={sortableList}
setList={(newState) => {
setSortableList(newState);
handleAfterSort(newState);
}}
animation={150}
ghostClass="sortable-ghost"
style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}
>
{sortableList.map((item) => (
<div
key={item.id}
className={`sortable-chip${item.id === activeResponseId ? ' sortable-chip-active' : ''}`}
onClick={() => handleResponseSelect(item.id)}
title={item.name || '未命名响应'}
>
{item.name || '未命名响应'}
</div>
))}
</ReactSortable>
</div>
)}
{/* 响应选择和名称编辑合并区域 */}
<div style={{ marginBottom: '16px' }}>
<Row gutter={12} align="middle">
<Col span={20}>
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '12px',
color: '#666',
fontWeight: 'bold'
}}>
选择响应 / 编辑名称
</label>
</div>
<div style={{ position: 'relative' }}>
{isEditing ? (
<Input.Group compact>
<Input
style={{ width: 'calc(100% - 80px)' }}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onPressEnter={handleFinishEdit}
onBlur={handleFinishEdit}
placeholder="输入响应名称"
autoFocus
/>
<Button
style={{ width: '40px' }}
type="primary"
icon={<CheckCircleOutlined />}
onClick={handleFinishEdit}
/>
<Button
style={{ width: '40px' }}
icon={<DeleteOutlined />}
onClick={handleCancelEdit}
/>
</Input.Group>
) : (
<Input.Group compact>
<Select
style={{ width: 'calc(100% - 40px)' }}
placeholder="选择要编辑的响应"
value={activeResponseId}
onChange={handleResponseSelect}
>
{responses.map((resp, index) => (
<Option key={resp.id} value={resp.id}>
{resp.name || `响应 ${index + 1}`}
</Option>
))}
</Select>
<Tooltip title="编辑响应名称">
<Button
style={{ width: '40px' }}
icon={<EditOutlined />}
onClick={handleStartEdit}
disabled={!activeResponse}
/>
</Tooltip>
</Input.Group>
)}
</div>
</Col>
<Col span={4}>
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '12px',
color: '#666',
fontWeight: 'bold'
}}>
操作
</label>
</div>
<Tooltip title="删除当前响应">
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => activeResponseId && handleDeleteResponse(activeResponseId)}
disabled={responses.length <= 1 || !activeResponse}
style={{ width: '100%' }}
>
删除
</Button>
</Tooltip>
</Col>
</Row>
</div>
{/* 响应级入参匹配规则区域 */}
{activeResponse && responseParamMatcherEnabled && renderParamMatchers()}
{/* 响应内容编辑区域 */}
{activeResponse && (
<>
<div style={{ marginBottom: '8px' }}>
<label style={{
fontSize: '12px',
color: '#666',
fontWeight: 'bold'
}}>
响应内容
</label>
</div>
<div style={{ position: 'relative' }}>
<TextArea
rows={26}
placeholder="请输入响应内容..."
style={{
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
fontSize: '13px',
lineHeight: '1.5',
minHeight: '620px'
}}
value={activeResponse.content || ''}
onChange={(e) => updateActiveResponseContent(e.target.value)}
/>
<Button
className="textarea-fullscreen-btn"
icon={<FullscreenOutlined />}
onClick={toggleTextareaFullscreen}
size="small"
style={{
position: 'absolute',
top: '8px',
right: '12px',
background: 'rgba(255, 255, 255, 0.9)',
border: '1px solid #d9d9d9',
zIndex: 10
}}
title="全屏编辑"
/>
</div>
</>
)}
{/* 渲染全屏编辑器 */}
{renderFullscreenTextarea()}
</Card>
);
};
export default ResponseContentEditor;