feat: add script version history (#3085)

* feat: add script version history

* fix: validate canonical save-as paths before backup

* fix: stack script history footer on small screens
This commit is contained in:
whyour
2026-10-06 22:00:11 +08:00
committed by GitHub
parent 2cab112391
commit fa8910b3d6
22 changed files with 2487 additions and 80 deletions
+29 -1
View File
@@ -665,5 +665,33 @@
"重试": "Retry",
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH API Key from https://wpush.cn/settings, see https://wpush.cn/docs",
"推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "Channel: wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot, default wechat",
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs"
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "Optional Topic code for broadcast; see https://wpush.cn/docs",
"历史版本": "Version history",
"首次修改前": "Before the first edit",
"手动保存": "Manual save",
"从历史版本恢复": "Restored version",
"面板外修改后": "After an external edit",
"恢复此版本": "Restore this version",
"当前已保存的内容会保留在历史版本中。": "The currently saved content will be kept in version history.",
"请先保存或取消当前修改,再恢复历史版本": "Save or discard your unsaved changes before restoring a version",
"已恢复,恢复前的内容已保留": "Restored. The previous content is kept in version history",
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "Keep up to {count} previous versions. Older history is removed when storage is full.",
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "Only panel saves are recorded. Subscription updates may still overwrite restored files.",
"暂无历史版本,下次保存修改时会自动保留当前内容": "No versions yet. Your current content will be kept when you next save changes.",
"选择历史版本": "Choose a version",
"历史版本加载失败,请刷新重试": "Unable to load this version. Refresh and try again.",
"当前已保存文件": "Currently saved file",
"与当前文件一致": "Identical to the current file",
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "Version history supports UTF-8 text scripts up to 1 MiB",
"历史版本读取失败,请检查历史文件": "Unable to read version history. Check the history file.",
"历史版本不存在或已清理": "This version does not exist or has been removed",
"文件已变化,请刷新历史版本后重试": "The file has changed. Refresh version history and try again.",
"历史版本容量不足,无法安全保存": "Not enough version history capacity to save safely",
"脚本不存在": "The script does not exist",
"不记录历史直接保存?": "Save without version history?",
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "The current file or new content is outside the history limits. The content being replaced will not be kept.",
"仍然保存": "Save anyway",
"已保存,本次未记录历史版本": "Saved without a history snapshot",
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
"保存失败": "Unable to save"
}
+29 -1
View File
@@ -665,5 +665,33 @@
"重试": "重试",
"WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs": "WPUSH的API Key,在 https://wpush.cn/settings 获取,参考 https://wpush.cn/docs",
"推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat": "推送渠道,支持 wechat/app/sms/mail/webhook/dingtalk/feishu/wechat_work/clawbot/qqbot,默认 wechat",
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs"
"可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs": "可选,Topic 广播编码;填写后按 Topic 推送,参考 https://wpush.cn/docs",
"历史版本": "历史版本",
"首次修改前": "首次修改前",
"手动保存": "手动保存",
"从历史版本恢复": "从历史版本恢复",
"面板外修改后": "面板外修改后",
"恢复此版本": "恢复此版本",
"当前已保存的内容会保留在历史版本中。": "当前已保存的内容会保留在历史版本中。",
"请先保存或取消当前修改,再恢复历史版本": "请先保存或取消当前修改,再恢复历史版本",
"已恢复,恢复前的内容已保留": "已恢复,恢复前的内容已保留",
"最多保留 {count} 个历史版本,容量不足时自动清理旧历史。": "最多保留 {count} 个历史版本,容量不足时自动清理旧历史。",
"仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。": "仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。",
"暂无历史版本,下次保存修改时会自动保留当前内容": "暂无历史版本,下次保存修改时会自动保留当前内容",
"选择历史版本": "选择历史版本",
"历史版本加载失败,请刷新重试": "历史版本加载失败,请刷新重试",
"当前已保存文件": "当前已保存文件",
"与当前文件一致": "与当前文件一致",
"历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本": "历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本",
"历史版本读取失败,请检查历史文件": "历史版本读取失败,请检查历史文件",
"历史版本不存在或已清理": "历史版本不存在或已清理",
"文件已变化,请刷新历史版本后重试": "文件已变化,请刷新历史版本后重试",
"历史版本容量不足,无法安全保存": "历史版本容量不足,无法安全保存",
"脚本不存在": "脚本不存在",
"不记录历史直接保存?": "不记录历史直接保存?",
"当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。": "当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。",
"仍然保存": "仍然保存",
"已保存,本次未记录历史版本": "已保存,本次未记录历史版本",
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败"
}
@@ -0,0 +1,48 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
:global(.ant-typography) {
min-width: 0;
overflow-wrap: anywhere;
}
:global(.ant-btn) {
flex-shrink: 0;
}
}
.labels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
font-weight: 500;
}
.diff {
flex: 1;
min-height: 260px;
overflow: auto;
border: 1px solid var(--ant-border-color-split, #d9d9d9);
}
@media (max-width: 600px) {
.footer {
flex-direction: column;
align-items: stretch;
gap: 8px;
:global(.ant-btn) {
width: 100%;
height: auto;
min-height: 32px;
white-space: normal;
overflow-wrap: anywhere;
}
}
.labels {
gap: 8px;
font-size: 12px;
}
.diff {
flex: none;
min-height: 200px;
border: 0;
}
}
+333
View File
@@ -0,0 +1,333 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Button,
Drawer,
Empty,
Modal,
Select,
Space,
Spin,
Typography,
message,
} from 'antd';
import { DiffEditor } from '@monaco-editor/react';
import ReactDiffViewer from 'react-diff-viewer';
import intl from 'react-intl-universal';
import config from '@/utils/config';
import { request } from '@/utils/http';
import styles from './historyDrawer.module.less';
interface Version {
id: string;
createdAt: string;
source: 'baseline' | 'save' | 'restore' | 'external';
identical: boolean;
}
interface Detail {
version: Version & { content: string };
current: string;
currentHash: string;
}
interface Props {
filename: string;
directory: string;
isPhone: boolean;
theme: string;
language: string;
hasUnsavedChanges: () => boolean;
onClose: () => void;
onRestored: (content: string) => void;
}
const sourceLabel = (source: Version['source']) =>
intl.get(
{
baseline: '首次修改前',
save: '手动保存',
restore: '从历史版本恢复',
external: '面板外修改后',
}[source],
);
const versionLabel = (v: Version) =>
`${new Date(v.createdAt).toLocaleString()} · ${sourceLabel(v.source)}`;
export default function HistoryDrawer(props: Props) {
const {
filename,
directory,
isPhone,
theme,
language,
onClose,
onRestored,
hasUnsavedChanges,
} = props;
const [versions, setVersions] = useState<Version[]>([]);
const [selected, setSelected] = useState<string>();
const [detail, setDetail] = useState<Detail>();
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [restoring, setRestoring] = useState(false);
const [error, setError] = useState('');
const [detailError, setDetailError] = useState(false);
const [limit, setLimit] = useState(20);
const active = useRef(true);
const detailRequest = useRef(0);
const params = { filename, path: directory };
const load = async () => {
setLoading(true);
setError('');
setDetail(undefined);
setSelected(undefined);
detailRequest.current++;
try {
const result = await request.get(`${config.apiPrefix}scripts/history`, {
params,
});
if (result.code !== 200) throw new Error(result.message);
if (!active.current) return;
setVersions(result.data.versions);
setLimit(result.data.limit);
setSelected(result.data.versions[0]?.id);
} catch (e: any) {
if (active.current)
setError(e?.response?.data?.message || intl.get('加载失败'));
} finally {
if (active.current) setLoading(false);
}
};
const loadDetail = async (id: string) => {
const sequence = ++detailRequest.current;
setDetail(undefined);
setDetailLoading(true);
setDetailError(false);
try {
const result = await request.get(
`${config.apiPrefix}scripts/history/detail`,
{ params: { ...params, id } },
);
if (result.code !== 200) throw new Error(result.message);
if (active.current && sequence === detailRequest.current)
setDetail(result.data);
} catch {
if (active.current && sequence === detailRequest.current)
setDetailError(true);
} finally {
if (active.current && sequence === detailRequest.current)
setDetailLoading(false);
}
};
useEffect(() => {
active.current = true;
load();
return () => {
active.current = false;
detailRequest.current++;
};
}, []);
useEffect(() => {
if (selected) loadDetail(selected);
}, [selected]);
const restore = () => {
if (!detail) return;
if (hasUnsavedChanges()) {
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
return;
}
Modal.confirm({
title: intl.get('恢复此版本'),
content: (
<>
<p>{versionLabel(detail.version)}</p>
<p>{intl.get('当前已保存的内容会保留在历史版本中。')}</p>
</>
),
okText: intl.get('恢复此版本'),
onOk: async () => {
if (hasUnsavedChanges()) {
message.warning(intl.get('请先保存或取消当前修改,再恢复历史版本'));
throw new Error('Unsaved changes');
}
setRestoring(true);
try {
const result = await request.put(
`${config.apiPrefix}scripts/history/restore`,
{
...params,
id: detail.version.id,
expectedHash: detail.currentHash,
},
);
if (result.code !== 200) throw new Error(result.message);
if (!active.current) return;
onRestored(result.data.content);
if (result.data.cleanupPending) {
message.warning(
intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'),
);
} else {
message.success(intl.get('已恢复,恢复前的内容已保留'));
}
await load();
} catch (e: any) {
if (e?.response?.status === 409) {
setDetail(undefined);
setDetailError(true);
}
throw e;
} finally {
if (active.current) setRestoring(false);
}
},
});
};
const identical = detail?.version.content === detail?.current;
return (
<Drawer
title={
<Space direction="vertical" size={0}>
<span>{intl.get('历史版本')}</span>
<Typography.Text
type="secondary"
style={{ fontSize: 12, wordBreak: 'break-all' }}
>
{directory ? `${directory}/` : ''}
{filename}
</Typography.Text>
</Space>
}
open
width={isPhone ? '100%' : 'min(1100px, 90vw)'}
onClose={restoring ? undefined : onClose}
closable={!restoring}
maskClosable={!restoring}
keyboard={!restoring}
bodyStyle={{
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: isPhone ? 16 : 24,
}}
footer={
<div className={styles.footer}>
<Typography.Text type="secondary">
{intl.get(
'最多保留 {count} 个历史版本,容量不足时自动清理旧历史。',
{ count: limit },
)}
</Typography.Text>
<Button
type="primary"
onClick={restore}
loading={restoring}
disabled={!detail || detailLoading || identical}
>
{intl.get('恢复此版本')}
</Button>
</div>
}
>
<Alert
type="info"
showIcon
message={intl.get(
'仅记录通过面板保存的修改;订阅更新仍可能覆盖恢复后的文件。',
)}
/>
{loading ? (
<Spin />
) : error ? (
<Alert
type="error"
message={error}
action={<Button onClick={load}>{intl.get('重试')}</Button>}
/>
) : versions.length === 0 ? (
<Empty
description={intl.get(
'暂无历史版本,下次保存修改时会自动保留当前内容',
)}
/>
) : (
<>
<Select
aria-label={intl.get('选择历史版本')}
value={selected}
onChange={setSelected}
disabled={restoring}
options={versions.map((v) => ({
value: v.id,
label: versionLabel(v),
}))}
/>
{detailLoading ? (
<Spin />
) : detailError ? (
<Alert
type="error"
message={intl.get('历史版本加载失败,请刷新重试')}
action={<Button onClick={load}>{intl.get('重试')}</Button>}
/>
) : (
detail && (
<>
<div className={styles.labels}>
<span>
{isPhone ? '− ' : ''}
{intl.get('历史版本')}
</span>
<span>
{isPhone ? '+ ' : ''}
{intl.get('当前已保存文件')}
</span>
</div>
{identical && (
<Alert type="success" message={intl.get('与当前文件一致')} />
)}
<div className={styles.diff}>
{isPhone ? (
<ReactDiffViewer
oldValue={detail.version.content}
newValue={detail.current}
splitView={false}
disableWordDiff
useDarkTheme={theme.includes('dark')}
styles={{
contentText: {
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
},
}}
/>
) : (
<DiffEditor
language={language}
original={detail.version.content}
modified={detail.current}
theme={theme}
options={{
readOnly: true,
originalEditable: false,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderSideBySide: true,
automaticLayout: true,
}}
/>
)}
</div>
</>
)
)}
</>
)}
</Drawer>
);
}
+94 -47
View File
@@ -10,6 +10,7 @@ import {
DeleteOutlined,
EditOutlined,
EllipsisOutlined,
HistoryOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
@@ -40,6 +41,8 @@ import { useHotkeys } from 'react-hotkeys-hook';
import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane';
import EditModal from './editModal';
import HistoryDrawer from './historyDrawer';
import { saveWithHistory } from './saveWithHistory';
import EditScriptNameModal from './editNameModal';
import styles from './index.module.less';
import RenameModal from './renameModal';
@@ -65,6 +68,26 @@ const Script = () => {
const [currentNode, setCurrentNode] = useState<any>();
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [showMonaco, setShowMonaco] = useState(true);
const [historyVisible, setHistoryVisible] = useState(false);
const [saving, setSaving] = useState(false);
const savingRef = useRef(false);
const savedContentRef = useRef('');
const currentKeyRef = useRef(currentNode?.key);
currentKeyRef.current = currentNode?.key;
useEffect(() => {
setHistoryVisible(false);
}, [currentNode?.key]);
const hasUnsavedChanges = () => {
const content =
!isPhone && editorRef.current ? editorRef.current.getValue() : value;
return (
isEditing &&
content.replace(/\r\n/g, '\n') !==
savedContentRef.current.replace(/\r\n/g, '\n')
);
};
const handleIsEditing = (filename: string, value: boolean) => {
setIsEditing(value && canPreviewInMonaco(filename));
@@ -89,10 +112,11 @@ const Script = () => {
.get(
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
node.title,
)}&path=${node.parent || ''}`,
)}&path=${encodeURIComponent(node.parent || '')}`,
)
.then(({ code, data }) => {
if (code === 200) {
if (code === 200 && currentKeyRef.current === node.key) {
savedContentRef.current = data;
setValue(data);
if (options.callback) {
options.callback();
@@ -179,7 +203,7 @@ const Script = () => {
const currentContent = editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
const originalContent = value.replace(/\r\n/g, '\n');
const originalContent = savedContentRef.current.replace(/\r\n/g, '\n');
if (currentContent !== originalContent && isEditing) {
Modal.confirm({
@@ -247,42 +271,36 @@ const Script = () => {
getDetail(currentNode);
};
const saveFile = () => {
Modal.confirm({
title: intl.get('确认保存'),
content: (
<>
{intl.get('确认保存文件')}
<Text style={{ wordBreak: 'break-all' }} type="warning">
{' '}
{currentNode.title}
</Text>
{intl.get(',保存后不可恢复')}
</>
),
onOk() {
const content = editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
return new Promise((resolve, reject) => {
request
.put(`${config.apiPrefix}scripts`, {
filename: currentNode.title,
path: currentNode.parent || '',
content,
})
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('保存成功'));
setValue(content);
handleIsEditing(currentNode.title, false);
}
resolve(null);
})
.catch((e) => reject(e));
});
},
});
const saveFile = async () => {
if (savingRef.current || !currentNode) return;
const node = currentNode;
const content =
!isPhone && editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
savingRef.current = true;
setSaving(true);
try {
const result = await saveWithHistory(
'put',
`${config.apiPrefix}scripts`,
{
filename: node.title,
path: node.parent || '',
content,
},
);
if (result?.code === 200 && currentKeyRef.current === node.key) {
savedContentRef.current = content;
setValue(content);
handleIsEditing(node.title, false);
}
} catch {
// The shared HTTP handler presents the server's error; keep the draft.
} finally {
savingRef.current = false;
setSaving(false);
}
};
const deleteFile = () => {
@@ -548,8 +566,20 @@ const Script = () => {
</>
}
loading={loading}
extra={
isPhone
extra={[
...(currentNode?.type === 'file' && showMonaco
? [
<Button
key="history"
icon={<HistoryOutlined />}
disabled={saving}
onClick={() => setHistoryVisible(true)}
>
{!isPhone && intl.get('历史版本')}
</Button>,
]
: []),
...(isPhone
? [
<TreeSelect
treeExpandAction="click"
@@ -562,7 +592,7 @@ const Script = () => {
treeNodeFilterProp="title"
showSearch
allowClear
onSelect={onSelect}
onSelect={(key, node) => onTreeSelect([key], { node })}
/>,
<Dropdown menu={menu} trigger={['click']}>
<Button type="primary" icon={<EllipsisOutlined />} />
@@ -570,7 +600,7 @@ const Script = () => {
]
: isEditing
? [
<Button type="primary" onClick={saveFile}>
<Button type="primary" onClick={saveFile} loading={saving}>
{intl.get('保存')}
</Button>,
<Button type="primary" onClick={cancelEdit}>
@@ -625,12 +655,29 @@ const Script = () => {
>
{intl.get('调试')}
</Button>,
]
}
]),
]}
header={{
style: headerStyle,
}}
>
{historyVisible && currentNode && (
<HistoryDrawer
key={currentNode.key}
filename={currentNode.title}
directory={currentNode.parent || ''}
isPhone={isPhone}
theme={theme}
language={mode}
hasUnsavedChanges={hasUnsavedChanges}
onClose={() => setHistoryVisible(false)}
onRestored={(content) => {
savedContentRef.current = content;
setValue(content);
setIsEditing(false);
}}
/>
)}
<div className={`${styles['log-container']} log-container`}>
{!isPhone && (
/*// @ts-ignore*/
@@ -682,7 +729,7 @@ const Script = () => {
value={value}
theme={theme}
options={{
readOnly: !isEditing,
readOnly: !isEditing || saving,
fontSize: 12,
lineNumbersMinChars: 3,
glyphMargin: false,
@@ -704,7 +751,7 @@ const Script = () => {
mode ? [langs[mode as keyof typeof langs]()] : undefined
}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing}
readOnly={!isEditing || saving}
onChange={(value) => {
setValue(value);
}}
+16 -12
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import { Modal, message, Input, Form } from 'antd';
import { request } from '@/utils/http';
import config from '@/utils/config';
import { saveWithHistory } from './saveWithHistory';
const SaveModal = ({
file,
@@ -16,18 +17,21 @@ const SaveModal = ({
const handleOk = async (values: any) => {
setLoading(true);
const payload = { ...values, originFilename: file.title, content: file.content };
request
.post(`${config.apiPrefix}scripts`, payload)
.then(({ code, data }) => {
if (code === 200) {
message.success(intl.get('保存文件成功'));
handleCancel(data);
}
})
.finally(() => {
setLoading(false);
});
const payload = {
...values,
originFilename: file.title,
content: file.content,
};
try {
const result = await saveWithHistory(
'post',
`${config.apiPrefix}scripts`,
payload,
);
if (result?.code === 200) handleCancel(result.data);
} finally {
setLoading(false);
}
};
return (
+74
View File
@@ -0,0 +1,74 @@
import { Modal, message } from 'antd';
import intl from 'react-intl-universal';
import { IResponseData, request } from '@/utils/http';
/** Shared by the editor and debugger, so neither can silently skip history. */
export async function saveWithHistory(
method: 'put' | 'post',
url: string,
payload: Record<string, unknown>,
): Promise<IResponseData | undefined> {
const send = async (options: Record<string, unknown> = {}) => {
const result = await request[method](
url,
{ ...payload, ...options },
{
onError: (response) => Promise.reject({ response }),
},
);
if (result.code !== 200)
throw new Error(result.message || intl.get('保存失败'));
return result;
};
const report = (error: any) =>
message.error(
error?.response?.data?.message || error?.message || intl.get('保存失败'),
);
let result: IResponseData | undefined;
try {
result = await send();
} catch (error: any) {
const response = error?.response;
if (response?.status !== 413 || !response.data?.historyUnavailable) {
report(error);
return undefined;
}
result = await new Promise<IResponseData | undefined>((resolve) => {
const confirmation = Modal.confirm({
title: intl.get('不记录历史直接保存?'),
content: intl.get(
'当前文件或新内容超出历史记录范围,本次覆盖前的内容不会保留。',
),
okText: intl.get('仍然保存'),
maskClosable: false,
onCancel: () => resolve(undefined),
onOk: async () => {
confirmation.update({
cancelButtonProps: { disabled: true },
keyboard: false,
});
try {
const saved = await send({
skipHistory: true,
expectedHash: response.data.currentHash,
});
resolve(saved);
} catch (failure) {
report(failure);
// Close the confirmation and release the editor. A conflict needs a
// fresh read, not another retry with the stale confirmation hash.
resolve(undefined);
}
},
});
});
}
if (result?.data?.historyRecorded === false) {
message.warning(intl.get('已保存,本次未记录历史版本'));
} else if (result?.data?.cleanupPending) {
message.warning(intl.get('已保存,但旧历史清理失败,请检查存储权限和空间'));
} else if (result) {
message.success(intl.get('保存成功'));
}
return result;
}