Files
qinglong/src/pages/subscription/index.tsx
T
whyour ef348b445b fix: synchronize runtime locales and harden online updates
Translate UI, scheduler, validation, preload and maintenance messages dynamically, and propagate locale changes between workers. Harden update checks, downloads and terminal states, verify Debian pnpm, and ignore generated test artifacts.
2026-10-17 07:07:09 +08:00

600 lines
16 KiB
TypeScript

import intl from 'react-intl-universal';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Button,
message,
Modal,
Table,
Tag,
Space,
Dropdown,
Menu,
Typography,
Input,
Tooltip,
Checkbox,
} from 'antd';
import {
ClockCircleOutlined,
Loading3QuartersOutlined,
CloseCircleOutlined,
EllipsisOutlined,
CheckCircleOutlined,
EditOutlined,
StopOutlined,
DeleteOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import { request } from '@/utils/http';
import SubscriptionModal from './modal';
import { history, useOutletContext } from '@umijs/max';
import './index.less';
import SubscriptionLogModal from './logModal';
import { SharedContext } from '@/layouts';
import useTableScrollHeight from '@/hooks/useTableScrollHeight';
import WebSocketManager from '@/utils/websocket';
const { Text, Paragraph } = Typography;
const { Search } = Input;
export enum SubscriptionStatus {
'running',
'idle',
'disabled',
'queued',
}
export enum IntervalSchedule {
'days' = '天',
'hours' = '时',
'minutes' = '分',
'seconds' = '秒',
}
export enum SubscriptionType {
'private-repo' = '私有仓库',
'public-repo' = '公开仓库',
'file' = '单文件',
}
const Subscription = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const columns: any = [
{
title: intl.get('名称'),
dataIndex: 'name',
key: 'name',
width: 150,
sorter: {
compare: (a: any, b: any) => a.name.localeCompare(b.name),
multiple: 2,
},
},
{
title: intl.get('链接'),
dataIndex: 'url',
key: 'url',
sorter: {
compare: (a: any, b: any) => a.name.localeCompare(b.name),
multiple: 2,
},
render: (text: string, record: any) => {
return (
<Paragraph
style={{
wordBreak: 'break-all',
marginBottom: 0,
}}
ellipsis={{ tooltip: text, rows: 2 }}
>
{text}
</Paragraph>
);
},
},
{
title: intl.get('类型'),
dataIndex: 'type',
key: 'type',
width: 130,
render: (text: string, record: any) => {
return intl.get((SubscriptionType as any)[record.type]);
},
},
{
title: intl.get('分支'),
dataIndex: 'branch',
key: 'branch',
width: 130,
render: (text: string, record: any) => {
return record.branch || '-';
},
},
{
title: intl.get('定时规则'),
width: 180,
render: (text: string, record: any) => {
if (record.schedule_type === 'interval') {
const { type, value } = record.interval_schedule;
return intl.get('间隔定时规则', { value, unit: intl.get((IntervalSchedule as any)[type]) });
}
return record.schedule;
},
},
{
title: intl.get('状态'),
key: 'status',
dataIndex: 'status',
width: 110,
filters: [
{
text: intl.get('运行中'),
value: 0,
},
{
text: intl.get('空闲中'),
value: 1,
},
{
text: intl.get('已禁用'),
value: 2,
},
],
onFilter: (value: number, record: any) => {
if (record.is_disabled && record.status !== 0) {
return value === 2;
} else {
return record.status === value;
}
},
render: (text: string, record: any) => (
<>
{(!record.is_disabled ||
record.status !== SubscriptionStatus.idle) && (
<>
{record.status === SubscriptionStatus.idle && (
<Tag icon={<ClockCircleOutlined />} color="default">
{intl.get('空闲中')}
</Tag>
)}
{record.status === SubscriptionStatus.running && (
<Tag
icon={<Loading3QuartersOutlined spin />}
color="processing"
>
{intl.get('运行中')}
</Tag>
)}
</>
)}
{record.is_disabled === 1 &&
record.status === SubscriptionStatus.idle && (
<Tag icon={<CloseCircleOutlined />} color="error">
{intl.get('已禁用')}
</Tag>
)}
</>
),
},
{
title: intl.get('操作'),
key: 'action',
width: 140,
render: (text: string, record: any, index: number) => {
const isPc = !isPhone;
return (
<Space size="middle">
{record.status === SubscriptionStatus.idle && (
<a
onClick={(e) => {
e.stopPropagation();
runSubscription(record, index);
}}
>
{intl.get('运行')}
</a>
)}
{record.status !== SubscriptionStatus.idle && (
<a
onClick={(e) => {
e.stopPropagation();
stopSubsciption(record, index);
}}
>
{intl.get('停止')}
</a>
)}
<a
onClick={(e) => {
e.stopPropagation();
setLogSubscription({ ...record, timestamp: Date.now() });
}}
>
{intl.get('日志')}
</a>
<MoreBtn key="more" record={record} index={index} />
</Space>
);
},
},
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedSubscription, setEditedSubscription] = useState();
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [logSubscription, setLogSubscription] = useState<any>();
const tableRef = useRef<HTMLDivElement>(null);
const tableScrollHeight = useTableScrollHeight(tableRef);
const deleteCheckRef = useRef(false);
const runSubscription = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认运行'),
content: (
<>
{intl.get('确认运行定时任务')}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.put(`${config.apiPrefix}subscriptions/run`, [record.id])
.then(({ code, data }) => {
if (code === 200) {
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
if (i !== -1) {
result.splice(i, 1, {
...record,
status: SubscriptionStatus.running,
});
setValue(result);
}
}
});
},
});
};
const stopSubsciption = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认停止'),
content: (
<>
{intl.get('确认停止定时任务')}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.put(`${config.apiPrefix}subscriptions/stop`, [record.id])
.then(({ code, data }) => {
if (code === 200) {
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
if (i !== -1) {
result.splice(i, 1, {
...record,
pid: null,
status: SubscriptionStatus.idle,
});
setValue(result);
}
}
});
},
});
};
const getSubscriptions = () => {
setLoading(true);
request
.get(`${config.apiPrefix}subscriptions?searchValue=${searchText}`)
.then(({ code, data }) => {
if (code === 200) {
setValue(data);
setCurrentPage(1);
}
})
.finally(() => setLoading(false));
};
const addSubscription = () => {
setEditedSubscription(null as any);
setIsModalVisible(true);
};
const editSubscription = (record: any, index: number) => {
setEditedSubscription(record);
setIsModalVisible(true);
};
const onCheckChange = (e) => {
deleteCheckRef.current = e.target.checked;
};
const delSubscription = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认删除'),
content: (
<>
{intl.get('确认删除定时订阅')}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
{intl.get('吗')}
<div style={{ marginTop: 20 }}>
<Checkbox onChange={onCheckChange}>
{intl.get('同时删除关联任务和脚本')}
</Checkbox>
</div>
</>
),
onOk() {
request
.delete(`${config.apiPrefix}subscriptions`, {
data: [record.id],
params: { force: deleteCheckRef.current },
})
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('删除成功'));
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
if (i !== -1) {
result.splice(i, 1);
setValue(result);
}
}
});
},
});
};
const enabledOrDisabledSubscription = (record: any, index: number) => {
Modal.confirm({
title: intl.get('确认操作', {
operation:
record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'),
}),
content: (
<>
{intl.get('确认操作对象', { operation: record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时订阅') })}{' '}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{record.name}
</Text>{' '}
{intl.get('吗')}
</>
),
onOk() {
request
.put(
`${config.apiPrefix}subscriptions/${
record.is_disabled === 1 ? 'enable' : 'disable'
}`,
[record.id],
)
.then(({ code, data }) => {
if (code === 200) {
const newStatus = record.is_disabled === 1 ? 0 : 1;
const result = [...value];
const i = result.findIndex((x) => x.id === record.id);
if (i !== -1) {
result.splice(i, 1, {
...record,
is_disabled: newStatus,
});
setValue(result);
}
}
});
},
});
};
const MoreBtn: React.FC<{
record: any;
index: number;
}> = ({ record, index }) => (
<Dropdown
placement="bottomRight"
trigger={['click']}
menu={{
items: [
{ label: intl.get('编辑'), key: 'edit', icon: <EditOutlined /> },
{
label:
record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'),
key: 'enableOrDisable',
icon:
record.is_disabled === 1 ? (
<CheckCircleOutlined />
) : (
<StopOutlined />
),
},
{ label: intl.get('删除'), key: 'delete', icon: <DeleteOutlined /> },
],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
action(key, record, index);
},
}}
>
<a onClick={(e) => e.stopPropagation()}>
<EllipsisOutlined />
</a>
</Dropdown>
);
const action = (key: string | number, record: any, index: number) => {
switch (key) {
case 'edit':
editSubscription(record, index);
break;
case 'enableOrDisable':
enabledOrDisabledSubscription(record, index);
break;
case 'delete':
delSubscription(record, index);
break;
default:
break;
}
};
const handleCancel = (subscription?: any) => {
setIsModalVisible(false);
if (subscription) {
handleSubscriptions(subscription);
}
};
const onSearch = (value: string) => {
setSearchText(value.trim());
};
const handleSubscriptions = (subscription: any) => {
const index = value.findIndex((x) => x.id === subscription.id);
const result = [...value];
if (index === -1) {
result.unshift(subscription);
} else {
result.splice(index, 1, {
...subscription,
});
}
setValue(result);
};
const onPageChange = (page: number, pageSize: number | undefined) => {
setCurrentPage(page);
setPageSize(pageSize as number);
localStorage.setItem('pageSize', pageSize + '');
};
const getRowClassName = (record: any, index: number) => {
return record.isPinned
? 'pinned-subscription subscription'
: 'subscription';
};
const handleMessage = useCallback((payload: any) => {
const { message, references } = payload;
setValue((p) => {
const result = [...p];
for (let i = 0; i < references.length; i++) {
const index = p.findIndex((x) => x.id === references[i]);
if (index !== -1) {
result.splice(index, 1, {
...p[index],
status: SubscriptionStatus.idle,
});
}
}
return result;
});
}, []);
useEffect(() => {
const ws = WebSocketManager.getInstance();
ws.subscribe('runSubscriptionEnd', handleMessage);
return () => {
ws.unsubscribe('runSubscriptionEnd', handleMessage);
};
}, []);
useEffect(() => {
if (logSubscription) {
localStorage.setItem('logSubscription', logSubscription.id);
setIsLogModalVisible(true);
}
}, [logSubscription]);
useEffect(() => {
getSubscriptions();
}, [searchText]);
useEffect(() => {
setPageSize(parseInt(localStorage.getItem('pageSize') || '20'));
}, []);
return (
<PageContainer
className="ql-container-wrapper subscriptiontab-wrapper"
title={intl.get('订阅管理')}
extra={[
<Search
placeholder={intl.get('请输入名称或者关键词')}
style={{ width: 'auto' }}
enterButton
allowClear
loading={loading}
onSearch={onSearch}
/>,
<Button key="2" type="primary" onClick={() => addSubscription()}>
{intl.get('创建订阅')}
</Button>,
]}
header={{
style: headerStyle,
}}
>
<Table
ref={tableRef}
columns={columns}
pagination={{
current: currentPage,
onChange: onPageChange,
pageSize: pageSize,
showSizeChanger: true,
simple: isPhone,
defaultPageSize: 20,
showTotal: (total: number, range: number[]) =>
intl.get('分页总数', { start: range[0], end: range[1], total }),
pageSizeOptions: [20, 100, 500, 1000] as any,
}}
dataSource={value}
rowKey="id"
size="middle"
scroll={{ x: 1000, y: tableScrollHeight }}
loading={loading}
rowClassName={getRowClassName}
/>
{isModalVisible && (
<SubscriptionModal
handleCancel={handleCancel}
subscription={editedSubscription}
/>
)}
{isLogModalVisible && (
<SubscriptionLogModal
handleCancel={() => {
setIsLogModalVisible(false);
}}
subscription={logSubscription}
/>
)}
</PageContainer>
);
};
export default Subscription;