587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
|
|
/**
|
|||
|
|
* Scheduled Jobs Management Page
|
|||
|
|
*/
|
|||
|
|
import { useState, useEffect } from 'react';
|
|||
|
|
import { Modal, Form, Input, Switch, Button, Space, Popconfirm, Tag, Tooltip, Badge, Tabs, Select, Row, Col, Card, Alert } from 'antd';
|
|||
|
|
import { PlayCircleOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
|||
|
|
import type { ColumnsType } from 'antd/es/table';
|
|||
|
|
import { DataTable } from '../../components/admin/DataTable';
|
|||
|
|
import { request } from '../../utils/request';
|
|||
|
|
import { useToast } from '../../contexts/ToastContext';
|
|||
|
|
|
|||
|
|
interface ScheduledJob {
|
|||
|
|
id: number;
|
|||
|
|
name: string;
|
|||
|
|
job_type: 'predefined' | 'custom_code';
|
|||
|
|
predefined_function?: string;
|
|||
|
|
function_params?: Record<string, any>;
|
|||
|
|
cron_expression: string;
|
|||
|
|
python_code?: string;
|
|||
|
|
is_active: boolean;
|
|||
|
|
description: string;
|
|||
|
|
last_run_at: string | null;
|
|||
|
|
last_run_status: 'success' | 'failed' | null;
|
|||
|
|
next_run_at: string | null;
|
|||
|
|
created_at: string;
|
|||
|
|
updated_at: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AvailableTask {
|
|||
|
|
name: string;
|
|||
|
|
description: string;
|
|||
|
|
category: string;
|
|||
|
|
parameters: Array<{
|
|||
|
|
name: string;
|
|||
|
|
type: string;
|
|||
|
|
description: string;
|
|||
|
|
required: boolean;
|
|||
|
|
default: any;
|
|||
|
|
}>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ScheduledJobs() {
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [data, setData] = useState<ScheduledJob[]>([]);
|
|||
|
|
const [filteredData, setFilteredData] = useState<ScheduledJob[]>([]);
|
|||
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|||
|
|
const [editingRecord, setEditingRecord] = useState<ScheduledJob | null>(null);
|
|||
|
|
const [activeTabKey, setActiveTabKey] = useState('basic');
|
|||
|
|
const [availableTasks, setAvailableTasks] = useState<AvailableTask[]>([]);
|
|||
|
|
const [selectedTask, setSelectedTask] = useState<AvailableTask | null>(null);
|
|||
|
|
const [form] = Form.useForm();
|
|||
|
|
const toast = useToast();
|
|||
|
|
|
|||
|
|
const jobType = Form.useWatch('job_type', form);
|
|||
|
|
const predefinedFunction = Form.useWatch('predefined_function', form);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadData();
|
|||
|
|
loadAvailableTasks();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
// When predefined function changes, update selected task
|
|||
|
|
if (predefinedFunction && availableTasks.length > 0) {
|
|||
|
|
const task = availableTasks.find(t => t.name === predefinedFunction);
|
|||
|
|
setSelectedTask(task || null);
|
|||
|
|
|
|||
|
|
// Set default parameter values only if not editing
|
|||
|
|
if (task && !editingRecord) {
|
|||
|
|
const defaultParams: Record<string, any> = {};
|
|||
|
|
task.parameters.forEach(param => {
|
|||
|
|
if (param.default !== null && param.default !== undefined) {
|
|||
|
|
defaultParams[param.name] = param.default;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
form.setFieldsValue({ function_params: defaultParams });
|
|||
|
|
} else if (task && editingRecord) {
|
|||
|
|
// When editing, just set the selected task, don't override params
|
|||
|
|
setSelectedTask(task);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
setSelectedTask(null);
|
|||
|
|
}
|
|||
|
|
}, [predefinedFunction, availableTasks]);
|
|||
|
|
|
|||
|
|
const loadData = async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const { data: result } = await request.get('/scheduled-jobs');
|
|||
|
|
setData(result || []);
|
|||
|
|
setFilteredData(result || []);
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.error('加载数据失败');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const loadAvailableTasks = async () => {
|
|||
|
|
try {
|
|||
|
|
const { data: result } = await request.get('/scheduled-jobs/available-tasks');
|
|||
|
|
setAvailableTasks(result || []);
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.error('加载可用任务列表失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSearch = (keyword: string) => {
|
|||
|
|
const lowerKeyword = keyword.toLowerCase();
|
|||
|
|
const filtered = data.filter(
|
|||
|
|
(item) =>
|
|||
|
|
item.name.toLowerCase().includes(lowerKeyword) ||
|
|||
|
|
item.description?.toLowerCase().includes(lowerKeyword)
|
|||
|
|
);
|
|||
|
|
setFilteredData(filtered);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleAdd = () => {
|
|||
|
|
setEditingRecord(null);
|
|||
|
|
setSelectedTask(null);
|
|||
|
|
form.resetFields();
|
|||
|
|
form.setFieldsValue({
|
|||
|
|
job_type: 'predefined',
|
|||
|
|
is_active: true,
|
|||
|
|
function_params: {}
|
|||
|
|
});
|
|||
|
|
setActiveTabKey('basic');
|
|||
|
|
setIsModalOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleEdit = (record: ScheduledJob) => {
|
|||
|
|
setEditingRecord(record);
|
|||
|
|
form.setFieldsValue({
|
|||
|
|
...record,
|
|||
|
|
function_params: record.function_params || {}
|
|||
|
|
});
|
|||
|
|
setActiveTabKey('basic');
|
|||
|
|
setIsModalOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (record: ScheduledJob) => {
|
|||
|
|
try {
|
|||
|
|
await request.delete(`/scheduled-jobs/${record.id}`);
|
|||
|
|
toast.success('删除成功');
|
|||
|
|
loadData();
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.error('删除失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleRunNow = async (record: ScheduledJob) => {
|
|||
|
|
try {
|
|||
|
|
await request.post(`/scheduled-jobs/${record.id}/run`);
|
|||
|
|
toast.success('定时任务已触发,请前往"系统任务"中查看进度');
|
|||
|
|
setTimeout(loadData, 1000);
|
|||
|
|
} catch (error) {
|
|||
|
|
toast.error('触发失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleModalOk = async () => {
|
|||
|
|
try {
|
|||
|
|
const values = await form.validateFields();
|
|||
|
|
|
|||
|
|
// Clean up data based on job_type
|
|||
|
|
if (values.job_type === 'predefined') {
|
|||
|
|
delete values.python_code;
|
|||
|
|
} else {
|
|||
|
|
delete values.predefined_function;
|
|||
|
|
delete values.function_params;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (editingRecord) {
|
|||
|
|
await request.put(`/scheduled-jobs/${editingRecord.id}`, values);
|
|||
|
|
toast.success('更新成功');
|
|||
|
|
} else {
|
|||
|
|
await request.post('/scheduled-jobs', values);
|
|||
|
|
toast.success('创建成功');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setIsModalOpen(false);
|
|||
|
|
loadData();
|
|||
|
|
} catch (error: any) {
|
|||
|
|
if (error.response?.data?.detail) {
|
|||
|
|
const detail = error.response.data.detail;
|
|||
|
|
if (typeof detail === 'object' && detail.message) {
|
|||
|
|
toast.error(detail.message);
|
|||
|
|
} else {
|
|||
|
|
toast.error(detail);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const columns: ColumnsType<ScheduledJob> = [
|
|||
|
|
{
|
|||
|
|
title: 'ID',
|
|||
|
|
dataIndex: 'id',
|
|||
|
|
width: 60,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '任务名称',
|
|||
|
|
dataIndex: 'name',
|
|||
|
|
width: 200,
|
|||
|
|
render: (text, record) => (
|
|||
|
|
<div>
|
|||
|
|
<div style={{ fontWeight: 500 }}>{text}</div>
|
|||
|
|
{record.description && (
|
|||
|
|
<div style={{ fontSize: 12, color: '#888' }}>{record.description}</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '类型',
|
|||
|
|
dataIndex: 'job_type',
|
|||
|
|
width: 120,
|
|||
|
|
render: (type) => (
|
|||
|
|
<Tag color={type === 'predefined' ? 'blue' : 'purple'}>
|
|||
|
|
{type === 'predefined' ? '内置任务' : '自定义代码'}
|
|||
|
|
</Tag>
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '任务函数',
|
|||
|
|
dataIndex: 'predefined_function',
|
|||
|
|
width: 200,
|
|||
|
|
render: (func, record) => {
|
|||
|
|
if (record.job_type === 'predefined') {
|
|||
|
|
return <Tag color="cyan">{func}</Tag>;
|
|||
|
|
}
|
|||
|
|
return <span style={{ color: '#ccc' }}>-</span>;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: 'Cron 表达式',
|
|||
|
|
dataIndex: 'cron_expression',
|
|||
|
|
width: 130,
|
|||
|
|
render: (text) => <Tag color="green">{text}</Tag>,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '状态',
|
|||
|
|
dataIndex: 'is_active',
|
|||
|
|
width: 80,
|
|||
|
|
render: (active) => (
|
|||
|
|
<Badge status={active ? 'success' : 'default'} text={active ? '启用' : '禁用'} />
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '上次执行',
|
|||
|
|
width: 200,
|
|||
|
|
render: (_, record) => (
|
|||
|
|
<div>
|
|||
|
|
{record.last_run_at ? (
|
|||
|
|
<>
|
|||
|
|
<div>{new Date(record.last_run_at).toLocaleString()}</div>
|
|||
|
|
<Tag color={record.last_run_status === 'success' ? 'green' : 'red'}>
|
|||
|
|
{record.last_run_status === 'success' ? '成功' : '失败'}
|
|||
|
|
</Tag>
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<span style={{ color: '#ccc' }}>从未执行</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '操作',
|
|||
|
|
key: 'action',
|
|||
|
|
width: 150,
|
|||
|
|
render: (_, record) => (
|
|||
|
|
<Space size="small">
|
|||
|
|
<Tooltip title="立即执行">
|
|||
|
|
<Button
|
|||
|
|
type="text"
|
|||
|
|
icon={<PlayCircleOutlined />}
|
|||
|
|
onClick={() => handleRunNow(record)}
|
|||
|
|
style={{ color: '#52c41a' }}
|
|||
|
|
/>
|
|||
|
|
</Tooltip>
|
|||
|
|
<Tooltip title="编辑">
|
|||
|
|
<Button
|
|||
|
|
type="text"
|
|||
|
|
icon={<EditOutlined />}
|
|||
|
|
onClick={() => handleEdit(record)}
|
|||
|
|
style={{ color: '#1890ff' }}
|
|||
|
|
/>
|
|||
|
|
</Tooltip>
|
|||
|
|
<Popconfirm
|
|||
|
|
title="确认删除该任务?"
|
|||
|
|
onConfirm={() => handleDelete(record)}
|
|||
|
|
okText="删除"
|
|||
|
|
cancelText="取消"
|
|||
|
|
>
|
|||
|
|
<Tooltip title="删除">
|
|||
|
|
<Button type="text" danger icon={<DeleteOutlined />} />
|
|||
|
|
</Tooltip>
|
|||
|
|
</Popconfirm>
|
|||
|
|
</Space>
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<>
|
|||
|
|
<DataTable
|
|||
|
|
title="定时任务管理"
|
|||
|
|
columns={columns}
|
|||
|
|
dataSource={filteredData}
|
|||
|
|
loading={loading}
|
|||
|
|
total={filteredData.length}
|
|||
|
|
onSearch={handleSearch}
|
|||
|
|
onAdd={handleAdd}
|
|||
|
|
onEdit={handleEdit}
|
|||
|
|
onDelete={handleDelete}
|
|||
|
|
rowKey="id"
|
|||
|
|
pageSize={10}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Modal
|
|||
|
|
title={editingRecord ? '编辑任务' : '新增任务'}
|
|||
|
|
open={isModalOpen}
|
|||
|
|
onOk={handleModalOk}
|
|||
|
|
onCancel={() => setIsModalOpen(false)}
|
|||
|
|
width={900}
|
|||
|
|
destroyOnClose
|
|||
|
|
>
|
|||
|
|
<Form
|
|||
|
|
form={form}
|
|||
|
|
layout="vertical"
|
|||
|
|
>
|
|||
|
|
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey}>
|
|||
|
|
{/* 基础配置 Tab */}
|
|||
|
|
<Tabs.TabPane tab="基础配置" key="basic">
|
|||
|
|
<Row gutter={16}>
|
|||
|
|
<Col span={12}>
|
|||
|
|
<Form.Item
|
|||
|
|
name="name"
|
|||
|
|
label="任务名称"
|
|||
|
|
rules={[{ required: true, message: '请输入任务名称' }]}
|
|||
|
|
>
|
|||
|
|
<Input placeholder="例如:每日数据同步" />
|
|||
|
|
</Form.Item>
|
|||
|
|
</Col>
|
|||
|
|
|
|||
|
|
<Col span={12}>
|
|||
|
|
<Form.Item
|
|||
|
|
name="job_type"
|
|||
|
|
label="任务类型"
|
|||
|
|
rules={[{ required: true, message: '请选择任务类型' }]}
|
|||
|
|
>
|
|||
|
|
<Select
|
|||
|
|
options={[
|
|||
|
|
{ label: '内置任务', value: 'predefined' },
|
|||
|
|
{ label: '自定义代码', value: 'custom_code' }
|
|||
|
|
]}
|
|||
|
|
onChange={() => {
|
|||
|
|
// Clear related fields when type changes
|
|||
|
|
form.setFieldsValue({
|
|||
|
|
predefined_function: undefined,
|
|||
|
|
function_params: {},
|
|||
|
|
python_code: undefined
|
|||
|
|
});
|
|||
|
|
setSelectedTask(null);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</Form.Item>
|
|||
|
|
</Col>
|
|||
|
|
</Row>
|
|||
|
|
|
|||
|
|
<Row gutter={16}>
|
|||
|
|
<Col span={12}>
|
|||
|
|
<Form.Item
|
|||
|
|
name="cron_expression"
|
|||
|
|
label={
|
|||
|
|
<Space>
|
|||
|
|
<span>Cron 表达式</span>
|
|||
|
|
<Tooltip title="格式:分 时 日 月 周 (例如: 0 0 * * * 表示每天零点)">
|
|||
|
|
<QuestionCircleOutlined style={{ color: '#888' }} />
|
|||
|
|
</Tooltip>
|
|||
|
|
</Space>
|
|||
|
|
}
|
|||
|
|
rules={[{ required: true, message: '请输入 Cron 表达式' }]}
|
|||
|
|
>
|
|||
|
|
<Input placeholder="0 0 * * *" style={{ fontFamily: 'monospace' }} />
|
|||
|
|
</Form.Item>
|
|||
|
|
</Col>
|
|||
|
|
|
|||
|
|
<Col span={12}>
|
|||
|
|
<Form.Item
|
|||
|
|
name="is_active"
|
|||
|
|
label="是否启用"
|
|||
|
|
valuePropName="checked"
|
|||
|
|
>
|
|||
|
|
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
|||
|
|
</Form.Item>
|
|||
|
|
</Col>
|
|||
|
|
</Row>
|
|||
|
|
|
|||
|
|
<Form.Item
|
|||
|
|
name="description"
|
|||
|
|
label="描述"
|
|||
|
|
>
|
|||
|
|
<Input.TextArea rows={3} placeholder="任务描述" />
|
|||
|
|
</Form.Item>
|
|||
|
|
|
|||
|
|
{/* 内置任务配置 */}
|
|||
|
|
{jobType === 'predefined' && (
|
|||
|
|
<>
|
|||
|
|
<Form.Item
|
|||
|
|
name="predefined_function"
|
|||
|
|
label="选择预定义任务"
|
|||
|
|
rules={[{ required: true, message: '请选择预定义任务' }]}
|
|||
|
|
>
|
|||
|
|
<Select
|
|||
|
|
placeholder="请选择任务"
|
|||
|
|
options={availableTasks.map(task => ({
|
|||
|
|
label: `${task.name} - ${task.description}`,
|
|||
|
|
value: task.name
|
|||
|
|
}))}
|
|||
|
|
/>
|
|||
|
|
</Form.Item>
|
|||
|
|
|
|||
|
|
{selectedTask && (
|
|||
|
|
<Card
|
|||
|
|
size="small"
|
|||
|
|
title={
|
|||
|
|
<Space>
|
|||
|
|
<InfoCircleOutlined />
|
|||
|
|
<span>任务参数配置</span>
|
|||
|
|
</Space>
|
|||
|
|
}
|
|||
|
|
style={{ marginBottom: 16 }}
|
|||
|
|
>
|
|||
|
|
<Alert
|
|||
|
|
message={selectedTask.description}
|
|||
|
|
type="info"
|
|||
|
|
showIcon
|
|||
|
|
style={{ marginBottom: 16 }}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{selectedTask.parameters.map(param => (
|
|||
|
|
<Form.Item
|
|||
|
|
key={param.name}
|
|||
|
|
name={['function_params', param.name]}
|
|||
|
|
label={
|
|||
|
|
<Space>
|
|||
|
|
<span>{param.name}</span>
|
|||
|
|
{!param.required && <Tag color="orange">可选</Tag>}
|
|||
|
|
</Space>
|
|||
|
|
}
|
|||
|
|
tooltip={param.description}
|
|||
|
|
rules={[
|
|||
|
|
{ required: param.required, message: `请输入${param.name}` }
|
|||
|
|
]}
|
|||
|
|
>
|
|||
|
|
{param.type === 'integer' ? (
|
|||
|
|
<Input type="number" placeholder={`默认: ${param.default}`} />
|
|||
|
|
) : param.type === 'boolean' ? (
|
|||
|
|
<Switch />
|
|||
|
|
) : param.type === 'array' ? (
|
|||
|
|
<Select mode="tags" placeholder="输入后回车添加" />
|
|||
|
|
) : (
|
|||
|
|
<Input placeholder={`默认: ${param.default || '无'}`} />
|
|||
|
|
)}
|
|||
|
|
</Form.Item>
|
|||
|
|
))}
|
|||
|
|
</Card>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</Tabs.TabPane>
|
|||
|
|
|
|||
|
|
{/* Python 代码 Tab - 仅在自定义代码模式下显示 */}
|
|||
|
|
{jobType === 'custom_code' && (
|
|||
|
|
<Tabs.TabPane tab="Python 代码" key="code">
|
|||
|
|
<Alert
|
|||
|
|
message="自定义代码执行环境"
|
|||
|
|
description={
|
|||
|
|
<div>
|
|||
|
|
<p>可用变量:</p>
|
|||
|
|
<ul style={{ marginBottom: 0 }}>
|
|||
|
|
<li><code>db</code>: AsyncSession - 数据库会话</li>
|
|||
|
|
<li><code>logger</code>: Logger - 日志记录器</li>
|
|||
|
|
<li><code>task_id</code>: int - 任务ID</li>
|
|||
|
|
<li><code>asyncio</code>: 异步IO模块</li>
|
|||
|
|
</ul>
|
|||
|
|
<p style={{ marginTop: 8, marginBottom: 0 }}>
|
|||
|
|
⚠️ 注意:代码在异步环境中执行,可以使用 <code>await</code>
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
}
|
|||
|
|
type="info"
|
|||
|
|
showIcon
|
|||
|
|
style={{ marginBottom: 16 }}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Form.Item
|
|||
|
|
name="python_code"
|
|||
|
|
rules={[{ required: jobType === 'custom_code', message: '请输入执行脚本' }]}
|
|||
|
|
>
|
|||
|
|
<CodeEditor
|
|||
|
|
placeholder={`# 动态任务脚本示例
|
|||
|
|
# 可用变量: db (AsyncSession), logger (Logger), task_id (int)
|
|||
|
|
# 必须是异步环境,可以使用 await
|
|||
|
|
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
logger.info(f"任务开始执行: {datetime.now()}")
|
|||
|
|
|
|||
|
|
# 你的业务逻辑...
|
|||
|
|
|
|||
|
|
return "执行成功"`}
|
|||
|
|
/>
|
|||
|
|
</Form.Item>
|
|||
|
|
</Tabs.TabPane>
|
|||
|
|
)}
|
|||
|
|
</Tabs>
|
|||
|
|
</Form>
|
|||
|
|
</Modal>
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Simple Code Editor with Line Numbers
|
|||
|
|
const CodeEditor = ({
|
|||
|
|
value = '',
|
|||
|
|
onChange,
|
|||
|
|
placeholder = ''
|
|||
|
|
}: {
|
|||
|
|
value?: string;
|
|||
|
|
onChange?: (e: any) => void;
|
|||
|
|
placeholder?: string;
|
|||
|
|
}) => {
|
|||
|
|
const displayValue = value || placeholder;
|
|||
|
|
const lineCount = displayValue.split('\n').length;
|
|||
|
|
const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1).join('\n');
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{
|
|||
|
|
display: 'flex',
|
|||
|
|
border: '1px solid #d9d9d9',
|
|||
|
|
borderRadius: 6,
|
|||
|
|
overflow: 'hidden',
|
|||
|
|
backgroundColor: '#fafafa'
|
|||
|
|
}}>
|
|||
|
|
<div
|
|||
|
|
style={{
|
|||
|
|
padding: '4px 8px',
|
|||
|
|
backgroundColor: '#f0f0f0',
|
|||
|
|
borderRight: '1px solid #d9d9d9',
|
|||
|
|
color: '#999',
|
|||
|
|
textAlign: 'right',
|
|||
|
|
fontFamily: 'monospace',
|
|||
|
|
lineHeight: '1.5',
|
|||
|
|
fontSize: '14px',
|
|||
|
|
userSelect: 'none',
|
|||
|
|
whiteSpace: 'pre',
|
|||
|
|
overflow: 'hidden'
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{lineNumbers}
|
|||
|
|
</div>
|
|||
|
|
<Input.TextArea
|
|||
|
|
value={value}
|
|||
|
|
onChange={onChange}
|
|||
|
|
placeholder={placeholder}
|
|||
|
|
style={{
|
|||
|
|
border: 'none',
|
|||
|
|
borderRadius: 0,
|
|||
|
|
resize: 'none',
|
|||
|
|
fontFamily: 'monospace',
|
|||
|
|
lineHeight: '1.5',
|
|||
|
|
fontSize: '14px',
|
|||
|
|
padding: '4px 8px',
|
|||
|
|
flex: 1,
|
|||
|
|
backgroundColor: '#fafafa',
|
|||
|
|
color: value ? '#333' : '#999'
|
|||
|
|
}}
|
|||
|
|
rows={20}
|
|||
|
|
spellCheck={false}
|
|||
|
|
wrap="off"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|