Compare commits

...

5 Commits

Author SHA1 Message Date
whyour 2370923a75 更新版本 v2.18.3 2025-03-19 23:47:53 +08:00
whyour 256e4e7a83 修复任务打开脚本链接 2025-03-19 23:47:51 +08:00
whyour c0ec063333 修复删除脚本参数 2025-03-18 23:17:03 +08:00
whyour 576408de01 脚本管理增加强制打开文件操作 2025-03-16 17:57:13 +08:00
whyour cf94ecfb11 脚本管理和日志管理支持下载 2025-03-13 00:22:24 +08:00
21 changed files with 580 additions and 265 deletions
+67 -24
View File
@@ -1,10 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { celebrate, Joi } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { getFileContentByName, readDirs, removeAnsi, rmPath } from '../config/util';
import { join, resolve } from 'path';
import { celebrate, Joi } from 'celebrate';
import {
getFileContentByName,
readDirs,
removeAnsi,
rmPath,
} from '../config/util';
import LogService from '../services/log';
const route = Router();
const blacklist = ['.tmp'];
@@ -29,17 +34,16 @@ export default (app: Router) => {
'/detail',
async (req: Request, res: Response, next: NextFunction) => {
try {
const finalPath = resolve(
config.logPath,
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.query.file as string) || '',
);
if (
blacklist.includes(req.query.path as string) ||
!finalPath.startsWith(config.logPath)
) {
return res.send({ code: 403, message: '暂无权限' });
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: removeAnsi(content) });
@@ -53,16 +57,16 @@ export default (app: Router) => {
'/:file',
async (req: Request, res: Response, next: NextFunction) => {
try {
const finalPath = resolve(
config.logPath,
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(
(req.query.path as string) || '',
(req.params.file as string) || '',
(req.query.file as string) || '',
);
if (
blacklist.includes(req.path) ||
!finalPath.startsWith(config.logPath)
) {
return res.send({ code: 403, message: '暂无权限' });
if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({
code: 403,
message: '暂无权限',
});
}
const content = await getFileContentByName(finalPath);
res.send({ code: 200, data: content });
@@ -83,17 +87,56 @@ export default (app: Router) => {
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path, type } = req.body as {
let { filename, path } = req.body as {
filename: string;
path: string;
type: string;
};
const filePath = join(config.logPath, path, filename);
await rmPath(filePath);
const logService = Container.get(LogService);
const finalPath = logService.checkFilePath(filename, path);
if (!finalPath || blacklist.includes(path)) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await rmPath(finalPath);
res.send({ code: 200 });
} catch (e) {
return next(e);
}
},
);
route.post(
'/download',
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const logService = Container.get(LogService);
const filePath = logService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
return res.download(filePath, filename, (err) => {
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
}
},
);
};
+35 -22
View File
@@ -1,4 +1,4 @@
import { fileExist, readDirs, readDir, rmPath } from '../config/util';
import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util';
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { Logger } from 'winston';
@@ -27,7 +27,7 @@ export default (app: Router) => {
route.get('/', async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let result = [];
let result: IFile[] = [];
const blacklist = [
'node_modules',
'.git',
@@ -102,7 +102,6 @@ export default (app: Router) => {
'/',
upload.single('file'),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, content, originFilename, directory } =
req.body as {
@@ -124,8 +123,8 @@ export default (app: Router) => {
}
if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({
code: 430,
message: '文件路径禁止访问',
code: 403,
message: '暂无权限',
});
}
@@ -175,14 +174,20 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, content, path } = req.body as {
filename: string;
content: string;
path: string;
};
const filePath = join(config.scriptPath, path, filename);
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
@@ -201,14 +206,19 @@ export default (app: Router) => {
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename, path, type } = req.body as {
let { filename, path } = req.body as {
filename: string;
path: string;
type: string;
};
const filePath = join(config.scriptPath, path, filename);
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
await rmPath(filePath);
res.send({ code: 200 });
} catch (e) {
@@ -222,24 +232,27 @@ export default (app: Router) => {
celebrate({
body: Joi.object({
filename: Joi.string().required(),
path: Joi.string().allow(''),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
let { filename } = req.body as {
let { filename, path } = req.body as {
filename: string;
path: string;
};
const filePath = join(config.scriptPath, filename);
// const stats = fs.statSync(filePath);
// res.set({
// 'Content-Type': 'application/octet-stream', //告诉浏览器这是一个二进制文件
// 'Content-Disposition': 'attachment; filename=' + filename, //告诉浏览器这是一个需要下载的文件
// 'Content-Length': stats.size //文件大小
// });
// fs.createReadStream(filePath).pipe(res);
const scriptService = Container.get(ScriptService);
const filePath = scriptService.checkFilePath(path, filename);
if (!filePath) {
return res.send({
code: 403,
message: '暂无权限',
});
}
return res.download(filePath, filename, (err) => {
return next(err);
if (err) {
return next(err);
}
});
} catch (e) {
return next(e);
+1 -1
View File
@@ -237,7 +237,7 @@ enum FileType {
'file',
}
interface IFile {
export interface IFile {
title: string;
key: string;
type: 'directory' | 'file';
+14
View File
@@ -0,0 +1,14 @@
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@Service()
export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.logPath, filePath, fileName);
return finalPath.startsWith(config.logPath) ? finalPath : '';
}
}
+7 -2
View File
@@ -64,10 +64,15 @@ export default class ScriptService {
return { code: 200 };
}
public async getFile(filePath: string, fileName: string) {
public checkFilePath(filePath: string, fileName: string) {
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
return finalPath.startsWith(config.scriptPath) ? finalPath : '';
}
if (!finalPath.startsWith(config.scriptPath)) {
public async getFile(filePath: string, fileName: string) {
const finalPath = this.checkFilePath(filePath, fileName);
if (!finalPath) {
return '';
}
-14
View File
@@ -14,9 +14,6 @@ class GrpcClient {
},
grpcOptions: {
'grpc.enable_http_proxy': 0,
'grpc.keepalive_time_ms': 120000,
'grpc.keepalive_timeout_ms': 20000,
'grpc.max_receive_message_length': 100 * 1024 * 1024,
},
defaultTimeout: 30000,
};
@@ -59,23 +56,12 @@ class GrpcClient {
grpc.credentials.createInsecure(),
grpcOptions,
);
this.#checkConnection();
} catch (error) {
console.error('Failed to initialize gRPC client:', error);
process.exit(1);
}
}
#checkConnection() {
this.#client.waitForReady(Date.now() + 5000, (error) => {
if (error) {
console.error('gRPC client connection failed:', error);
process.exit(1);
}
});
}
#promisifyMethod(methodName) {
const capitalizedMethod =
methodName.charAt(0).toUpperCase() + methodName.slice(1);
+6 -2
View File
@@ -186,7 +186,7 @@
"秒后重试": "Retry after seconds",
"在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "Open the two-factor authentication application on your device to view your authentication code and verify your identity.",
"请选择脚本文件": "Please select a script file",
"当前文件不支持预览": "The current file does not support preview",
"当前文件不支持预览": "Current file type is not supported for preview",
"清空日志": "Clear Logs",
"设置": "Settings",
"退出": "Exit",
@@ -501,5 +501,9 @@
"常规定时": "Normal Timing",
"手动运行": "Manual Run",
"开机运行": "Boot Run",
"时区": "Timezone"
"时区": "Timezone",
"强制打开": "Force Open",
"强制打开可能会导致编辑器显示异常": "Force opening may cause display issues in the editor",
"确认离开": "Confirm Leave",
"当前文件未保存,确认离开吗": "Current file is not saved, are you sure to leave?"
}
+5 -1
View File
@@ -501,6 +501,10 @@
"常规定时": "常规定时",
"手动运行": "手动运行",
"开机运行": "开机运行",
"时区": "时区"
"时区": "时区",
"强制打开": "强制打开",
"强制打开可能会导致编辑器显示异常": "强制打开可能会导致编辑器显示异常",
"确认离开": "确认离开",
"当前文件未保存,确认离开吗": "当前文件未保存,确认离开吗"
}
-15
View File
@@ -202,9 +202,6 @@ const CronDetailModal = ({
.catch((e) => reject(e));
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -232,9 +229,6 @@ const CronDetailModal = ({
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -259,9 +253,6 @@ const CronDetailModal = ({
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -298,9 +289,6 @@ const CronDetailModal = ({
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -337,9 +325,6 @@ const CronDetailModal = ({
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
+2 -23
View File
@@ -20,7 +20,7 @@ import {
PushpinOutlined,
SettingOutlined,
StopOutlined,
UnorderedListOutlined
UnorderedListOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import { history, useOutletContext } from '@umijs/max';
@@ -36,7 +36,7 @@ import {
TablePaginationConfig,
Tabs,
Tag,
Typography
Typography,
} from 'antd';
import { ColumnProps } from 'antd/lib/table';
import { FilterValue, SorterResult } from 'antd/lib/table/interface';
@@ -453,9 +453,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -488,9 +485,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -524,9 +518,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -569,9 +560,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -614,9 +602,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -738,9 +723,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -766,9 +748,6 @@ const Crontab = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
+1 -4
View File
@@ -85,7 +85,7 @@ const ViewManageModal = ({
title: intl.get('名称'),
dataIndex: 'name',
key: 'name',
render: (v) => (v === '全部任务' ? intl.get('全部任务') : v)
render: (v) => (v === '全部任务' ? intl.get('全部任务') : v),
},
{
title: intl.get('类型'),
@@ -162,9 +162,6 @@ const ViewManageModal = ({
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
-15
View File
@@ -315,9 +315,6 @@ const Dependence = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -342,9 +339,6 @@ const Dependence = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -367,9 +361,6 @@ const Dependence = () => {
getDependencies();
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -419,9 +410,6 @@ const Dependence = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -439,9 +427,6 @@ const Dependence = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
-12
View File
@@ -292,9 +292,6 @@ const Env = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -337,9 +334,6 @@ const Env = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -456,9 +450,6 @@ const Env = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -484,9 +475,6 @@ const Env = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
+43 -22
View File
@@ -1,31 +1,32 @@
import intl from 'react-intl-universal';
import { useState, useEffect, useCallback, Key, useRef } from 'react';
import useFilterTreeData from '@/hooks/useFilterTreeData';
import { SharedContext } from '@/layouts';
import { depthFirstSearch } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { CloudDownloadOutlined, DeleteOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import CodeMirror from '@uiw/react-codemirror';
import { useOutletContext } from '@umijs/max';
import {
TreeSelect,
Tree,
Input,
Empty,
Button,
Empty,
Input,
message,
Modal,
Tooltip,
Tree,
TreeSelect,
Typography,
} from 'antd';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { request } from '@/utils/http';
import styles from './index.module.less';
import CodeMirror from '@uiw/react-codemirror';
import SplitPane from 'react-split-pane';
import { useOutletContext } from '@umijs/max';
import { SharedContext } from '@/layouts';
import { DeleteOutlined } from '@ant-design/icons';
import { depthFirstSearch } from '@/utils';
import { saveAs } from 'file-saver';
import debounce from 'lodash/debounce';
import uniq from 'lodash/uniq';
import useFilterTreeData from '@/hooks/useFilterTreeData';
import prettyBytes from 'pretty-bytes';
import { Key, useCallback, useEffect, useRef, useState } from 'react';
import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane';
import styles from './index.module.less';
const { Text } = Typography;
@@ -67,6 +68,21 @@ const Log = () => {
});
};
const downloadLog = () => {
request
.post<Blob>(
`${config.apiPrefix}logs/download`,
{
filename: currentNode.title,
path: currentNode.parent || '',
},
{ responseType: 'blob' },
)
.then((res) => {
saveAs(res, currentNode.title);
});
};
const onSelect = (value: any, node: any) => {
if (node.key === select || !value) {
return;
@@ -159,9 +175,6 @@ const Log = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -225,10 +238,18 @@ const Log = () => {
/>,
]
: [
<Tooltip title={intl.get('下载')}>
<Button
disabled={!currentNode || currentNode.type === 'directory'}
type="primary"
onClick={downloadLog}
icon={<CloudDownloadOutlined />}
/>
</Tooltip>,
<Tooltip title={intl.get('删除')}>
<Button
type="primary"
disabled={!select}
disabled={!currentNode}
onClick={deleteFile}
icon={<DeleteOutlined />}
/>
@@ -0,0 +1,67 @@
.container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--background-color);
padding: 16px;
}
.content {
text-align: center;
background: var(--card-background);
padding: 24px;
border-radius: 12px;
max-width: 390px;
width: 100%;
transition: all 0.3s ease;
}
.iconWrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--background-color);
margin-bottom: 16px;
}
.icon {
font-size: 32px;
color: var(--text-color-secondary);
}
.message {
font-size: 16px;
color: var(--text-color);
margin-bottom: 16px;
font-weight: 500;
line-height: 1.5;
}
.actionArea {
width: 100%;
}
.button {
min-width: 140px;
height: 36px;
font-size: 14px;
}
.warning {
font-size: 13px;
color: var(--text-color-secondary);
line-height: 1.5;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.warningIcon {
font-size: 14px;
color: #faad14;
}
@@ -0,0 +1,41 @@
import React from 'react';
import { Button, Space } from 'antd';
import { FileUnknownOutlined, WarningOutlined } from '@ant-design/icons';
import intl from 'react-intl-universal';
import styles from './index.module.less';
interface UnsupportedFilePreviewProps {
onForceOpen: () => void;
}
const UnsupportedFilePreview: React.FC<UnsupportedFilePreviewProps> = ({
onForceOpen,
}) => {
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.iconWrapper}>
<FileUnknownOutlined className={styles.icon} />
</div>
<div className={styles.message}>
{intl.get('当前文件不支持预览')}
</div>
<Space direction="vertical" size={8} className={styles.actionArea}>
<Button
type="primary"
onClick={onForceOpen}
className={styles.button}
>
{intl.get('强制打开')}
</Button>
<div className={styles.warning}>
<WarningOutlined className={styles.warningIcon} />
{intl.get('强制打开可能会导致编辑器显示异常')}
</div>
</Space>
</div>
</div>
);
};
export default UnsupportedFilePreview;
+124 -81
View File
@@ -1,52 +1,49 @@
import intl from 'react-intl-universal';
import { useState, useEffect, useCallback, Key, useRef } from 'react';
import {
TreeSelect,
Tree,
Input,
Button,
Modal,
message,
Typography,
Tooltip,
Dropdown,
Menu,
Empty,
MenuProps,
} from 'antd';
import IconFont from '@/components/iconfont';
import useFilterTreeData from '@/hooks/useFilterTreeData';
import { SharedContext } from '@/layouts';
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
import config from '@/utils/config';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { request } from '@/utils/http';
import styles from './index.module.less';
import EditModal from './editModal';
import CodeMirror from '@uiw/react-codemirror';
import SplitPane from 'react-split-pane';
import { canPreviewInMonaco } from '@/utils/monaco';
import {
CloudDownloadOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
EllipsisOutlined,
FormOutlined,
PlusOutlined,
PlusSquareOutlined,
SearchOutlined,
UserOutlined,
} from '@ant-design/icons';
import EditScriptNameModal from './editNameModal';
import debounce from 'lodash/debounce';
import { history, useOutletContext, useLocation } from '@umijs/max';
import { parse } from 'query-string';
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
import { SharedContext } from '@/layouts';
import useFilterTreeData from '@/hooks/useFilterTreeData';
import uniq from 'lodash/uniq';
import IconFont from '@/components/iconfont';
import RenameModal from './renameModal';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { langs } from '@uiw/codemirror-extensions-langs';
import { useHotkeys } from 'react-hotkeys-hook';
import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
import {
Button,
Dropdown,
Empty,
Input,
MenuProps,
message,
Modal,
Tooltip,
Tree,
TreeSelect,
Typography,
} from 'antd';
import { saveAs } from 'file-saver';
import debounce from 'lodash/debounce';
import uniq from 'lodash/uniq';
import prettyBytes from 'pretty-bytes';
import { canPreviewInMonaco } from '@/utils/monaco';
import { parse } from 'query-string';
import { Key, useCallback, useEffect, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import intl from 'react-intl-universal';
import SplitPane from 'react-split-pane';
import EditModal from './editModal';
import EditScriptNameModal from './editNameModal';
import styles from './index.module.less';
import RenameModal from './renameModal';
import UnsupportedFilePreview from './components/UnsupportedFilePreview';
const { Text } = Typography;
const Script = () => {
@@ -67,6 +64,7 @@ const Script = () => {
useState(false);
const [currentNode, setCurrentNode] = useState<any>();
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [showMonaco, setShowMonaco] = useState(true);
const handleIsEditing = (filename: string, value: boolean) => {
setIsEditing(value && canPreviewInMonaco(filename));
@@ -86,7 +84,7 @@ const Script = () => {
.finally(() => needLoading && setLoading(false));
};
const getDetail = (node: any) => {
const getDetail = (node: any, options: any = {}) => {
request
.get(
`${config.apiPrefix}scripts/detail?file=${encodeURIComponent(
@@ -96,10 +94,28 @@ const Script = () => {
.then(({ code, data }) => {
if (code === 200) {
setValue(data);
if (options.callback) {
options.callback();
}
}
});
};
const downloadScript = () => {
request
.post<Blob>(
`${config.apiPrefix}scripts/download`,
{
filename: currentNode.title,
path: currentNode.parent || '',
},
{ responseType: 'blob' },
)
.then((res) => {
saveAs(res, currentNode.title);
});
};
const initGetScript = (_data: any) => {
const { p, s } = parse(history.location.search);
if (s) {
@@ -130,18 +146,27 @@ const Script = () => {
if (node.type === 'directory') {
setValue(intl.get('请选择脚本文件'));
setShowMonaco(true);
return;
}
if (!canPreviewInMonaco(node.title)) {
setValue(intl.get('当前文件不支持预览'));
setShowMonaco(false);
return;
}
setShowMonaco(true);
const newMode = getEditorMode(value);
setMode(isPhone && newMode === 'typescript' ? 'javascript' : newMode);
setValue(intl.get('加载中...'));
getDetail(node);
getDetail(node, {
callback: () => {
if (isEditing) {
setIsEditing(true);
}
},
});
};
const onTreeSelect = useCallback(
@@ -150,20 +175,20 @@ const Script = () => {
if (node.key === select && isEditing) {
return;
}
const content = editorRef.current
const currentContent = editorRef.current
? editorRef.current.getValue().replace(/\r\n/g, '\n')
: value;
if (content !== value) {
const originalContent = value.replace(/\r\n/g, '\n');
if (currentContent !== originalContent && isEditing) {
Modal.confirm({
title: `确认离开`,
content: <>{intl.get('当前修改未保存,确离开吗')}</>,
title: intl.get('确认离开'),
content: <>{intl.get('当前文件未保存,确离开吗')}</>,
onOk() {
onSelect(keys[0], e.node);
handleIsEditing(e.node.title, false);
},
onCancel() {
console.log('Cancel');
},
});
} else {
handleIsEditing(e.node.title, false);
@@ -257,9 +282,6 @@ const Script = () => {
.catch((e) => reject(e));
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -309,9 +331,6 @@ const Script = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -482,21 +501,19 @@ const Script = () => {
label: intl.get('编辑'),
key: 'edit',
icon: <EditOutlined />,
disabled:
!select ||
(currentNode && !canPreviewInMonaco(currentNode?.title)),
disabled: !currentNode,
},
{
label: intl.get('重命名'),
key: 'rename',
icon: <IconFont type="ql-icon-rename" />,
disabled: !select,
disabled: !currentNode,
},
{
label: intl.get('删除'),
key: 'delete',
icon: <DeleteOutlined />,
disabled: !select,
disabled: !currentNode,
},
],
onClick: ({ key, domEvent }) => {
@@ -505,6 +522,20 @@ const Script = () => {
},
};
const handleForceOpen = () => {
if (!currentNode) return;
setMode('plaintext');
setValue(intl.get('加载中...'));
setShowMonaco(true);
getDetail(currentNode, {
callback: () => {
setIsEditing(true);
},
});
};
return (
<PageContainer
className="ql-container-wrapper log-wrapper"
@@ -566,10 +597,7 @@ const Script = () => {
</Tooltip>,
<Tooltip title={intl.get('编辑')}>
<Button
disabled={
!select ||
(currentNode && !canPreviewInMonaco(currentNode?.title))
}
disabled={!currentNode}
type="primary"
onClick={editFile}
icon={<EditOutlined />}
@@ -577,16 +605,24 @@ const Script = () => {
</Tooltip>,
<Tooltip title={intl.get('重命名')}>
<Button
disabled={!select}
disabled={!currentNode}
type="primary"
onClick={renameFile}
icon={<IconFont type="ql-icon-rename" />}
/>
</Tooltip>,
<Tooltip title={intl.get('下载')}>
<Button
disabled={!currentNode || currentNode.type === 'directory'}
type="primary"
onClick={downloadScript}
icon={<CloudDownloadOutlined />}
/>
</Tooltip>,
<Tooltip title={intl.get('删除')}>
<Button
type="primary"
disabled={!select}
disabled={!currentNode}
onClick={deleteFile}
icon={<DeleteOutlined />}
/>
@@ -650,21 +686,28 @@ const Script = () => {
</div>
)}
</div>
<Editor
language={mode}
value={value}
theme={theme}
options={{
readOnly: !isEditing,
fontSize: 12,
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
}}
/>
{showMonaco ? (
<Editor
language={mode}
value={value}
theme={theme}
options={{
readOnly: !isEditing,
fontSize: 12,
lineNumbersMinChars: 3,
glyphMargin: false,
accessibilitySupport: 'off',
}}
onMount={(editor) => {
editorRef.current = editor;
}}
/>
) : (
<UnsupportedFilePreview
filename={currentNode?.title || ''}
onForceOpen={handleForceOpen}
/>
)}
</SplitPane>
)}
{isPhone && (
-6
View File
@@ -181,9 +181,6 @@ const Setting = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -213,9 +210,6 @@ const Setting = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
-12
View File
@@ -266,9 +266,6 @@ const Subscription = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -302,9 +299,6 @@ const Subscription = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -370,9 +364,6 @@ const Subscription = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
@@ -415,9 +406,6 @@ const Subscription = () => {
}
});
},
onCancel() {
console.log('Cancel');
},
});
};
+162 -1
View File
@@ -1,7 +1,168 @@
import * as monaco from 'monaco-editor';
interface FileTypeConfig {
extensions?: string[]; // 文件扩展名
filenames?: string[]; // 完整文件名
patterns?: RegExp[]; // 文件名正则匹配
startsWith?: string[]; // 文件名前缀匹配
endsWith?: string[]; // 文件名后缀匹配
}
// 文件类型分类配置(只包含特殊文件类型)
const fileTypeConfigs: Record<string, FileTypeConfig> = {
// 前端特殊文件
frontend: {
extensions: [
'.json5', // JSON5
'.vue', // Vue
'.svelte', // Svelte
'.astro', // Astro
'.wxss', // 微信小程序样式
'.pcss', // PostCSS
'.acss', // 支付宝小程序样式
],
patterns: [
/\.env\.(local|development|production|test)$/,
/\.module\.(css|less|scss|sass)$/,
/\.d\.ts$/,
/\.config\.(js|ts|json)$/,
],
},
// 小程序相关
miniprogram: {
extensions: [
'.wxml', // 微信小程序
'.wxs', // 微信小程序
'.axml', // 支付宝小程序
'.sjs', // 支付宝小程序
'.swan', // 百度小程序
'.ttml', // 字节跳动小程序
'.ttss', // 字节跳动小程序
'.wxl', // 微信小程序语言包
'.qml', // QQ小程序
'.qss', // QQ小程序
'.ksml', // 快手小程序
'.kss', // 快手小程序
],
},
// 开发工具相关
devtools: {
extensions: [
'.prisma', // Prisma
'.mdx', // MDX
'.swagger', // Swagger
'.openapi', // OpenAPI
],
},
// 锁文件
lock: {
filenames: [
'yarn.lock',
'pnpm-lock.yaml',
'package-lock.json',
'composer.lock',
'Gemfile.lock',
'poetry.lock',
'Cargo.lock',
],
},
// 无后缀配置文件
noExtension: {
filenames: [
'.dockerignore',
'.gitignore',
'.npmignore',
'.browserslistrc',
'.czrc',
'.huskyrc',
'.lintstagedrc',
'.nvmrc',
'.gcloudignore',
'.htaccess',
],
patterns: [
/^\.env\./,
],
},
// CI/CD 配置
cicd: {
patterns: [
/^\.github\/workflows\/.*\.yml$/,
/^\.gitlab\/.*\.yml$/,
/^\.circleci\/.*\.yml$/,
],
},
};
/**
* 检查文件是否可以在 Monaco 编辑器中预览
* @param fileName 文件名
* @returns boolean
*/
export function canPreviewInMonaco(fileName: string): boolean {
if (!fileName) return false;
// 获取 Monaco 支持的语言
const supportedLanguages = monaco.languages.getLanguages();
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
return supportedLanguages.some((lang) => lang.extensions?.includes(ext));
const lowercaseFileName = fileName.toLowerCase();
// 检查 Monaco 原生支持
if (supportedLanguages.some((lang) =>
lang.extensions?.includes(ext) ||
(lang.filenames?.includes(lowercaseFileName))
)) {
return true;
}
// 检查额外支持的文件类型
return Object.values(fileTypeConfigs).some(config => {
return (
(config.extensions?.includes(ext)) ||
(config.filenames?.includes(lowercaseFileName)) ||
(config.patterns?.some(pattern => pattern.test(lowercaseFileName))) ||
(config.startsWith?.some(prefix => lowercaseFileName.startsWith(prefix))) ||
(config.endsWith?.some(suffix => lowercaseFileName.endsWith(suffix)))
);
});
}
/**
* 获取文件类型分类
* @param fileName 文件名
* @returns string 文件类型分类名称
*/
export function getFileCategory(fileName: string): string {
if (!fileName) return 'unknown';
const lowercaseFileName = fileName.toLowerCase();
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
for (const [category, config] of Object.entries(fileTypeConfigs)) {
if (
(config.extensions?.includes(ext)) ||
(config.filenames?.includes(lowercaseFileName)) ||
(config.patterns?.some(pattern => pattern.test(lowercaseFileName))) ||
(config.startsWith?.some(prefix => lowercaseFileName.startsWith(prefix))) ||
(config.endsWith?.some(suffix => lowercaseFileName.endsWith(suffix)))
) {
return category;
}
}
// 检查 Monaco 原生支持
const supportedLanguages = monaco.languages.getLanguages();
if (supportedLanguages.some((lang) =>
lang.extensions?.includes(ext) ||
(lang.filenames?.includes(lowercaseFileName))
)) {
return 'monaco-native';
}
return 'unknown';
}
+5 -8
View File
@@ -1,10 +1,7 @@
version: 2.18.2
version: 2.18.3
changeLogLink: https://t.me/jiao_long/427
publishTime: 2025-02-28 00:00
publishTime: 2025-03-15 08:00
changeLog: |
1. 定时任务支持 开机运行@boot 和 手动运行@once 任务
2. 脚本管理增加可预览检查,避免无法预览文件被重复保存
3. 系统设置增加时区设置
4. 修复登录失败没有提示
5. 增加重置密码命令 ql resetpwd
6. 修复群晖通知参数,任务视图不属于筛选
1. 脚本管理和日志管理支持下载脚本和日志
2. 修复 gRPC client 连接超时
3. 脚本管理增加强制打开文件