فهرست منبع

feat: 添加定时触发配置

jiaxing.liao 1 ماه پیش
والد
کامیت
57388a4204

+ 6 - 0
apps/web/components.d.ts

@@ -19,6 +19,7 @@ declare module 'vue' {
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCollapse: typeof import('element-plus/es')['ElCollapse']
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -42,6 +43,7 @@ declare module 'vue' {
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
     ElPopover: typeof import('element-plus/es')['ElPopover']
+    ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadio: typeof import('element-plus/es')['ElRadio']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
@@ -57,6 +59,7 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElUpload: typeof import('element-plus/es')['ElUpload']
     ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']
@@ -83,6 +86,7 @@ declare global {
   const ElButton: typeof import('element-plus/es')['ElButton']
   const ElCard: typeof import('element-plus/es')['ElCard']
   const ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+  const ElCheckTag: typeof import('element-plus/es')['ElCheckTag']
   const ElCol: typeof import('element-plus/es')['ElCol']
   const ElCollapse: typeof import('element-plus/es')['ElCollapse']
   const ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -106,6 +110,7 @@ declare global {
   const ElOption: typeof import('element-plus/es')['ElOption']
   const ElPagination: typeof import('element-plus/es')['ElPagination']
   const ElPopover: typeof import('element-plus/es')['ElPopover']
+  const ElProgress: typeof import('element-plus/es')['ElProgress']
   const ElRadio: typeof import('element-plus/es')['ElRadio']
   const ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
   const ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
@@ -121,6 +126,7 @@ declare global {
   const ElTabPane: typeof import('element-plus/es')['ElTabPane']
   const ElTabs: typeof import('element-plus/es')['ElTabs']
   const ElTag: typeof import('element-plus/es')['ElTag']
+  const ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
   const ElTooltip: typeof import('element-plus/es')['ElTooltip']
   const ElUpload: typeof import('element-plus/es')['ElUpload']
   const ExecutionChart: typeof import('./src/components/Chart/ExecutionChart.vue')['default']

+ 11 - 1
apps/web/index.html

@@ -4,7 +4,17 @@
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/vite.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>web</title>
+    <title>Shalu Agent</title>
+
+    <script src="/Content/Lib/jquery-1.11.3/jquery-1.11.3.js"></script>
+    <script src="/Content/Lib/jquery-1.10.2/jquery.cookie.js"></script>
+    <script src="/Content/Lib/axios/1.5.1/axios.min.js"></script>
+    <script src="/Content/Lib/jquery-1.11.3/file-md5.js"></script>
+    <script src="/Content/Lib/jsencrypt/3.3.2/jsencrypt.min.js"></script>
+    <script src="/Content/Lib/crypto-js/4.2.0/crypto-js.min.js"></script>
+
+    <script src="/Views/SharedInclude/Permissions.js"></script>
+    <script src="/Views/SharedInclude/BpmTools.js"></script>
   </head>
   <body>
     <div id="app"></div>

+ 66 - 15
apps/web/src/api/index.ts

@@ -1,31 +1,82 @@
 import request from '@repo/api-client'
+
+type UploadResponse = {
+	id: string
+	name: string
+	extensionName: string
+	size: number
+	path: string
+}
+
 /**
  * 上传文件
  * @param file
  * @returns
  */
-export const UploadFile = (data: FormData) => {
-	return request<{
-		code: number
-		result: {
-			id: string
-		}[]
-	}>('/api/File/UploadFiles', {
-		method: 'POST',
-		headers: {
-			'Content-Type': 'multipart/form-data'
-		},
-		data
+export const UploadFile = (
+	data: FormData,
+	callback?: (p: number) => void
+): Promise<UploadResponse> | undefined => {
+	// return request<{
+	// 	code: number
+	// 	result: {
+	// 		id: string
+	// 	}[]
+	// }>('/fileApi/File/UploadFiles', {
+	// 	method: 'POST',
+	// 	headers: {
+	// 		'Content-Type': 'multipart/form-data'
+	// 	},
+	// 	data
+	// })
+	const partSize = 2 * 1024 * 1024
+
+	// 进行文件分块传输
+	// @ts-ignore
+	if (window?.BpmTools) {
+		return new Promise((resolve, reject) => {
+			// @ts-ignore
+			window?.BpmTools?.$$doFilePartUpload({
+				file: data.get('files'),
+				partSize: partSize,
+				success: function (res: UploadResponse) {
+					console.log('success', res)
+					callback?.(100)
+					resolve(res)
+				},
+				progress: function (progress: number) {
+					console.log('progress', progress)
+					callback?.(progress)
+				},
+				error: function (err: Error) {
+					console.error(err)
+					reject(err)
+				}
+			})
+		})
+	}
+}
+
+/**
+ * 获取图片
+ * @param fileId 文件id
+ * @returns
+ */
+export const GetImage = (data: { fileId: string }) => {
+	return request('/File/GetImage', {
+		method: 'GET',
+		params: data,
+		responseType: 'blob'
 	})
 }
 
 /**
- * 获取文件
+ * 下载文件
  * @param fileId 文件id
  * @returns
  */
-export const GetFile = (data: { fileId: string }) => {
-	return request('/api/File/GetImage', {
+export const Download = (data: { fileId: string }) => {
+	return request('/File/Download', {
 		method: 'GET',
 		params: data,
 		responseType: 'blob'

+ 175 - 31
apps/web/src/features/fileUpload/FileUploadInput.vue

@@ -31,7 +31,33 @@
 			</button>
 		</div>
 
-		<div v-if="normalizedFiles.length" class="file-list">
+		<div v-if="pendingUploads.length || normalizedFiles.length" class="file-list">
+			<div
+				v-for="file in pendingUploads"
+				:key="`pending-${file.id}`"
+				class="file-item file-item--uploading"
+			>
+				<div class="file-item__left">
+					<div class="file-badge" :class="`file-badge--${getFileVisualType(file)}`">
+						{{ getFileBadgeText(file) }}
+					</div>
+					<div class="file-meta">
+						<div class="file-name">{{ file.name }}</div>
+						<div class="file-info">
+							<span>{{ getFileTypeText(file) }}</span>
+							<span v-if="formatFileSize(file.size)">| {{ formatFileSize(file.size) }}</span>
+							<span class="file-info__status">
+								{{ file.progress >= 100 ? 'Processing' : 'Uploading' }}
+							</span>
+						</div>
+						<div class="file-progress">
+							<el-progress :percentage="file.progress" :stroke-width="6" :show-text="false" />
+							<span class="file-progress__text">{{ file.progress }}%</span>
+						</div>
+					</div>
+				</div>
+			</div>
+
 			<div v-for="file in normalizedFiles" :key="file.id" class="file-item">
 				<div class="file-item__left">
 					<div class="file-badge" :class="`file-badge--${getFileVisualType(file)}`">
@@ -116,6 +142,11 @@ interface Emits {
 }
 
 type UploadRequestError = Parameters<UploadRequestOptions['onError']>[0]
+type UploadListFile = Pick<WorkflowUploadFile, 'id' | 'name' | 'extensionName' | 'size'>
+
+interface PendingUploadFile extends UploadListFile {
+	progress: number
+}
 
 const props = withDefaults(defineProps<Props>(), {
 	modelValue: null,
@@ -131,15 +162,14 @@ const linkDialogVisible = ref(false)
 const linkValue = ref('')
 const uploadingCount = ref(0)
 const localFiles = ref<WorkflowUploadFile[]>([])
+const pendingUploads = ref<PendingUploadFile[]>([])
+const activeUploadKeys = ref<string[]>([])
 
 const isUploading = computed(() => uploadingCount.value > 0)
 
 const createFileId = () =>
 	`file_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
 
-const isRecord = (value: unknown): value is Record<string, unknown> =>
-	typeof value === 'object' && value !== null
-
 // Persisted link values may only contain a path, so derive a readable name when possible.
 const getNameFromPath = (path?: string) => {
 	const value = `${path || ''}`.trim()
@@ -166,9 +196,7 @@ const normalizeUploadFile = (value: Partial<WorkflowUploadFile>) => {
 		name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: Number(value.size || 0),
-		path,
-		mimeType: value.mimeType,
-		source: value.source
+		path
 	} satisfies WorkflowUploadFile
 }
 
@@ -227,16 +255,81 @@ const ensureExtensionAllowed = (extension: string) => {
 	return allowedExtensions.value.includes(normalizedExtension)
 }
 
-const getFileBadgeText = (file: WorkflowUploadFile) => {
+const getFileBadgeText = (file: Partial<WorkflowUploadFile>) => {
 	const extension = file.extensionName?.trim()
 	return extension ? extension.slice(0, 4).toUpperCase() : 'FILE'
 }
 
-const getFileTypeText = (file: WorkflowUploadFile) => {
+const getFileTypeText = (file: Partial<WorkflowUploadFile>) => {
 	const extension = file.extensionName?.trim()
 	return extension ? extension.toUpperCase() : 'FILE'
 }
 
+const isLinkPath = (path?: string) => /^https?:\/\//i.test(`${path || ''}`.trim())
+
+const getWorkflowFileDuplicateKey = (file: Partial<WorkflowUploadFile>) => {
+	const path = `${file.path || ''}`.trim()
+	const source = isLinkPath(path) ? 'link' : 'local'
+
+	if (source === 'link') {
+		return `link::${path}`
+	}
+
+	const name = `${file.name || getNameFromPath(path) || ''}`.trim().toLowerCase()
+	const extension = normalizeFileExtension(
+		file.extensionName || getFileExtension(file.name || path)
+	)
+	const size = Number(file.size || 0)
+
+	return `local::${name}::${size}::${extension}`
+}
+
+const getRawFileDuplicateKey = (file: Pick<File, 'name' | 'size'>) => {
+	const name = `${file.name || ''}`.trim().toLowerCase()
+	const extension = normalizeFileExtension(getFileExtension(file.name))
+	const size = Number(file.size || 0)
+
+	return `local::${name}::${size}::${extension}`
+}
+
+const hasDuplicateFile = (duplicateKey: string) =>
+	normalizedFiles.value.some((item) => getWorkflowFileDuplicateKey(item) === duplicateKey) ||
+	activeUploadKeys.value.includes(duplicateKey)
+
+const createPendingUpload = (file: File): PendingUploadFile => {
+	const extension = getFileExtension(file.name)
+
+	return {
+		id: createFileId(),
+		name: file.name,
+		extensionName: extension.replace(/^\./, '').toUpperCase(),
+		size: file.size,
+		progress: 0
+	}
+}
+
+const normalizeProgress = (value: number) => {
+	if (!Number.isFinite(value)) {
+		return 0
+	}
+
+	const percent = value > 0 && value < 1 ? value * 100 : value
+	return Math.min(100, Math.max(0, Math.round(percent)))
+}
+
+const updatePendingUploadProgress = (id: string, progress: number) => {
+	const target = pendingUploads.value.find((item) => item.id === id)
+	if (!target) {
+		return
+	}
+
+	target.progress = normalizeProgress(progress)
+}
+
+const removePendingUpload = (id: string) => {
+	pendingUploads.value = pendingUploads.value.filter((item) => item.id !== id)
+}
+
 // The upload API currently returns a file id, so keep it in `path` as the persisted reference.
 const buildFileFromUploadedFile = (file: File, fileId: string): WorkflowUploadFile => {
 	const extension = getFileExtension(file.name)
@@ -246,9 +339,7 @@ const buildFileFromUploadedFile = (file: File, fileId: string): WorkflowUploadFi
 		name: file.name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: file.size,
-		path: fileId,
-		mimeType: file.type,
-		source: 'local'
+		path: fileId
 	}
 }
 
@@ -262,23 +353,10 @@ const buildFileFromLink = (value: string): WorkflowUploadFile => {
 		name,
 		extensionName: extension.replace(/^\./, '').toUpperCase(),
 		size: 0,
-		path: value,
-		source: 'link'
+		path: value
 	}
 }
 
-const extractUploadedFileId = (response: unknown) => {
-	if (!isRecord(response)) return ''
-
-	const result = response.result
-	if (!Array.isArray(result) || !result.length) {
-		return ''
-	}
-
-	const firstFile = result[0]
-	return isRecord(firstFile) ? `${firstFile.id || ''}`.trim() : ''
-}
-
 const updateFiles = (files: WorkflowUploadFile[]) => {
 	normalizedFiles.value = props.multiple ? files : files.slice(0, 1)
 }
@@ -293,30 +371,49 @@ const beforeUpload = (rawFile: File) => {
 		return false
 	}
 
+	if (props.multiple) {
+		const duplicateKey = getRawFileDuplicateKey(rawFile)
+		if (hasDuplicateFile(duplicateKey)) {
+			ElMessage.warning('文件已存在,请勿重复上传')
+			return false
+		}
+
+		activeUploadKeys.value = [...activeUploadKeys.value, duplicateKey]
+	}
+
 	return true
 }
 
 const handleUploadRequest = async (options: UploadRequestOptions) => {
 	uploadingCount.value += 1
+	const duplicateKey = getRawFileDuplicateKey(options.file)
+	const pendingUpload = createPendingUpload(options.file)
+	pendingUploads.value = [...pendingUploads.value, pendingUpload]
 
 	try {
 		const formData = new FormData()
 		formData.append('files', options.file)
-
-		const response = await uploadWorkflowFile(formData)
-		const fileId = extractUploadedFileId(response)
+		const res = await uploadWorkflowFile(formData, (progress) => {
+			const percent = normalizeProgress(progress)
+			updatePendingUploadProgress(pendingUpload.id, percent)
+			options.onProgress({ percent } as UploadProgressEvent)
+		})
+		const fileId = res?.id
 		if (!fileId) {
 			throw new Error('upload file id is missing')
 		}
 
+		updatePendingUploadProgress(pendingUpload.id, 100)
 		appendFiles([buildFileFromUploadedFile(options.file, fileId)])
-		options.onProgress({ percent: 100 } as UploadProgressEvent)
-		options.onSuccess(response)
+		removePendingUpload(pendingUpload.id)
+		options.onSuccess(res)
 	} catch (error) {
 		console.error('upload workflow file error', error)
 		ElMessage.error('文件上传失败')
 		options.onError(error as UploadRequestError)
 	} finally {
+		removePendingUpload(pendingUpload.id)
+		activeUploadKeys.value = activeUploadKeys.value.filter((item) => item !== duplicateKey)
 		uploadingCount.value = Math.max(0, uploadingCount.value - 1)
 	}
 }
@@ -335,6 +432,11 @@ const handleConfirmLink = () => {
 
 	try {
 		const file = buildFileFromLink(value)
+		if (props.multiple && hasDuplicateFile(getWorkflowFileDuplicateKey(file))) {
+			ElMessage.warning('链接文件已存在,请勿重复添加')
+			return
+		}
+
 		const extension = normalizeFileExtension(file.extensionName)
 
 		if (allowedExtensions.value.length && (!extension || !ensureExtensionAllowed(extension))) {
@@ -426,6 +528,15 @@ const removeFile = (id: string) => {
 	box-shadow: 0 4px 12px rgba(16, 24, 40, 0.05);
 }
 
+.file-item--uploading {
+	border-color: #b2ccff;
+	background: #f8fbff;
+}
+
+.file-item--uploading .file-item__left {
+	align-items: flex-start;
+}
+
 .file-item__left {
 	display: flex;
 	align-items: center;
@@ -473,6 +584,7 @@ const removeFile = (id: string) => {
 
 .file-meta {
 	min-width: 0;
+	flex: 1;
 }
 
 .file-name {
@@ -483,11 +595,43 @@ const removeFile = (id: string) => {
 }
 
 .file-info {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 8px;
 	margin-top: 4px;
 	font-size: 12px;
 	color: #667085;
 }
 
+.file-info__status {
+	color: #296dff;
+	font-weight: 600;
+}
+
+.file-progress {
+	display: flex;
+	align-items: center;
+	gap: 12px;
+	margin-top: 10px;
+}
+
+.file-progress :deep(.el-progress) {
+	flex: 1;
+}
+
+.file-progress :deep(.el-progress-bar__outer) {
+	background: #dbe7ff;
+}
+
+.file-progress__text {
+	min-width: 38px;
+	font-size: 12px;
+	font-weight: 600;
+	color: #296dff;
+	text-align: right;
+}
+
 .file-item__delete {
 	color: #98a2b3;
 }

+ 7 - 4
apps/web/src/features/fileUpload/shared.ts

@@ -6,8 +6,6 @@ export interface WorkflowUploadFile {
 	extensionName: string
 	size: number
 	path: string
-	mimeType?: string
-	source?: 'local' | 'link'
 }
 
 // 文件类型分组和后缀白名单集中维护,start 节点配置和上传组件共用这一份定义。
@@ -53,7 +51,10 @@ export const getFileExtension = (value?: string) => {
 }
 
 // “分类文件类型”与“自定义后缀”会在这里合并成最终白名单。
-export const getAllowedExtensions = (fileTypes: UploadFileType[] = [], fileExtensions: string[] = []) => {
+export const getAllowedExtensions = (
+	fileTypes: UploadFileType[] = [],
+	fileExtensions: string[] = []
+) => {
 	const normalizedTypes = Array.isArray(fileTypes) ? fileTypes : []
 	const normalizedExtensions = (Array.isArray(fileExtensions) ? fileExtensions : [])
 		.map(normalizeFileExtension)
@@ -93,7 +94,9 @@ export const formatFileSize = (size?: number) => {
 }
 
 export const getFileVisualType = (file: Partial<WorkflowUploadFile>) => {
-	const extension = normalizeFileExtension(file.extensionName || getFileExtension(file.name || file.path))
+	const extension = normalizeFileExtension(
+		file.extensionName || getFileExtension(file.name || file.path)
+	)
 
 	if (FILE_EXTENSION_GROUPS.image.includes(extension)) return 'image'
 	if (FILE_EXTENSION_GROUPS.audio.includes(extension)) return 'audio'

+ 50 - 13
apps/web/src/nodes/src/schedule/index.ts

@@ -1,28 +1,37 @@
-import { NodeConnectionTypes, type INodeType, type INodeDataBaseSchema } from '../../Interface'
+import { NodeConnectionTypes, type INodeDataBaseSchema, type INodeType } from '../../Interface'
+
+import Setter from './setter.vue'
+
+export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly'
+export type ScheduleMode = 'visual' | 'cron'
+export type ScheduleWeekday = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat'
+
+export interface ScheduleVisualConfig {
+	minute: number
+	time: string
+	weekdays: ScheduleWeekday[]
+	monthly_days: Array<number | 'last'>
+}
 
 export type ScheduleData = INodeDataBaseSchema & {
-	frequency: 'daily' // 频率,hourly/daily/weekly/monthly  mode仅限visual使用
-	mode: 'visual' // 调度模式 cron/visual,
-	cron_expression: '' // cron表达式
-	visual_config: {
-		minute: string // 时分值 仅限frequency为daily使用
-		time: string // 仅限frequency为daily使用
-		weekdays: ('sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat')[] // 每周几列表 仅限frequency为weekly使用
-		monthly_days: (number | 'last')[] // 每月日列表 last: 最后一天,仅限frequency为monthly使用
-	}
+	frequency: ScheduleFrequency
+	mode: ScheduleMode
+	timezone?: string
+	cron_expression: string
+	visual_config: ScheduleVisualConfig
 }
 
 export const triggerScheduleNode: INodeType = {
 	version: ['1'],
 	displayName: '定时触发',
 	name: 'trigger-schedule',
-	description: '基于时间的触发节点',
+	description: '基于时间配置触发工作流',
 	group: '开始',
 	icon: 'lucide:clock',
 	iconColor: '#875bf7',
+	Setter,
 	inputs: [],
 	outputs: [NodeConnectionTypes.main],
-	// 业务数据
 	schema: {
 		appAgentId: '',
 		parentId: '',
@@ -35,6 +44,34 @@ export const triggerScheduleNode: INodeType = {
 		selected: false,
 		nodeType: 'trigger-schedule',
 		zIndex: 1,
-		data: {}
+		data: {
+			title: '定时触发',
+			type: 'trigger-schedule',
+			isInIteration: false,
+			iteration_id: '',
+			isInLoop: false,
+			loop_id: '',
+			variables: [],
+			retry_config: {
+				max_retries: 3,
+				retry_enabled: false,
+				retry_interval: 100
+			},
+			error_strategy: 'none',
+			fail_branch_node_id: '',
+			default_value: [],
+			output_can_alter: false,
+			outputs: [],
+			frequency: 'daily',
+			mode: 'visual',
+			timezone: '',
+			cron_expression: '0 0 0 * * ? *',
+			visual_config: {
+				minute: 0,
+				time: '00:00',
+				weekdays: ['sun'],
+				monthly_days: [1]
+			}
+		}
 	}
 }

+ 707 - 0
apps/web/src/nodes/src/schedule/setter.vue

@@ -0,0 +1,707 @@
+<template>
+	<el-scrollbar class="w-full box-border p-12px">
+		<div class="schedule-setter">
+			<div class="w-full flex items-center justify-between beautify">
+				<label class="text-14px font-bold text-gray-700">{{ TEXT.title }}</label>
+				<el-button link @click="toggleMode">
+					<Icon :icon="formData.mode === 'visual' ? 'lucide:asterisk' : 'lucide:calendar-range'" />
+					<span>{{ formData.mode === 'visual' ? TEXT.modeCron : TEXT.modeVisual }}</span>
+				</el-button>
+			</div>
+
+			<template v-if="formData.mode === 'visual'">
+				<div class="config-grid">
+					<div class="field-block">
+						<div class="field-label">{{ TEXT.frequency }}</div>
+						<el-select v-model="formData.frequency" class="w-full">
+							<el-option
+								v-for="item in FREQUENCY_OPTIONS"
+								:key="item.value"
+								:label="item.label"
+								:value="item.value"
+							/>
+						</el-select>
+					</div>
+
+					<div class="field-block field-block--wide">
+						<div class="field-label">
+							{{ formData.frequency === 'hourly' ? TEXT.minute : TEXT.time }}
+						</div>
+
+						<div v-if="formData.frequency === 'hourly'" class="slider-panel">
+							<el-slider v-model="formData.visual_config.minute" :min="0" :max="59" :step="1" />
+							<span>{{ formData.visual_config.minute }}</span>
+						</div>
+
+						<el-time-picker
+							v-else
+							v-model="visualTimeModel"
+							format="HH:mm A"
+							value-format="HH:mm"
+							style="width: 100%"
+							:clearable="false"
+							:suffix="timezoneLabel"
+						>
+						</el-time-picker>
+					</div>
+				</div>
+
+				<div v-if="formData.frequency === 'weekly'" class="field-block">
+					<div class="field-label">{{ TEXT.weekday }}</div>
+					<div class="tag-grid tag-grid--week">
+						<el-check-tag
+							v-for="item in WEEKDAY_OPTIONS"
+							:key="item.value"
+							:checked="selectedWeekdays.has(item.value)"
+							class="schedule-tag"
+							@change="onWeekdayTagChange(item.value, $event)"
+						>
+							{{ item.label }}
+						</el-check-tag>
+					</div>
+				</div>
+
+				<div v-if="formData.frequency === 'monthly'" class="field-block">
+					<div class="field-label">{{ TEXT.monthDay }}</div>
+					<div class="tag-grid tag-grid--month">
+						<el-check-tag
+							v-for="day in MONTH_DAYS"
+							:key="day"
+							:checked="selectedMonthlyDays.has(day)"
+							class="schedule-tag"
+							@change="onMonthDayTagChange(day, $event)"
+						>
+							{{ day }}
+						</el-check-tag>
+						<el-check-tag
+							:checked="selectedMonthlyDays.has('last')"
+							class="schedule-tag schedule-tag--last"
+							@change="onMonthDayTagChange('last', $event)"
+						>
+							<span>{{ TEXT.lastDay }}</span>
+							<el-tooltip :content="TEXT.lastDayTip" placement="top">
+								<el-icon class="hint-icon"><QuestionFilled /></el-icon>
+							</el-tooltip>
+						</el-check-tag>
+					</div>
+				</div>
+
+				<el-divider />
+
+				<div class="field-block">
+					<div class="field-label">{{ TEXT.nextRuns }}</div>
+					<div class="preview-panel">
+						<div v-if="nextRuns.length" class="preview-list">
+							<div v-for="(item, index) in nextRuns" :key="item.timestamp" class="preview-item">
+								<span class="preview-index">{{ padIndex(index + 1) }}</span>
+								<span class="preview-text">{{ item.label }}</span>
+							</div>
+						</div>
+						<div v-else class="preview-empty">{{ TEXT.previewEmpty }}</div>
+					</div>
+				</div>
+			</template>
+
+			<template v-else>
+				<div class="field-block">
+					<div class="field-label">{{ TEXT.cronLabel }}</div>
+					<el-input
+						v-model="formData.cron_expression"
+						class="cron-input"
+						:placeholder="TEXT.cronPlaceholder"
+					/>
+					<div class="cron-hint" :class="{ 'cron-hint--error': !isCronExpressionValid }">
+						{{ cronHint }}
+					</div>
+				</div>
+			</template>
+		</div>
+	</el-scrollbar>
+</template>
+
+<script setup lang="ts">
+import { QuestionFilled } from '@element-plus/icons-vue'
+import { computed, watch } from 'vue'
+import { Icon } from '@repo/ui'
+
+import { useSetterModel } from '../_shared/useSetterModel'
+
+import type { ScheduleData, ScheduleFrequency, ScheduleWeekday } from './index'
+
+interface Emits {
+	(e: 'update', value: ScheduleData): void
+}
+
+type ScheduleMonthDay = number | 'last'
+type PreviewItem = {
+	timestamp: string
+	label: string
+}
+
+const TEXT = {
+	title: '定时触发',
+	modeCron: '使用 Cron 表达式',
+	modeVisual: '使用可视化配置',
+	frequency: '频率',
+	minute: '分钟',
+	time: '时间',
+	weekday: '星期',
+	monthDay: '天',
+	lastDay: '最后一天',
+	lastDayTip: '按每个月的自然月最后一天执行',
+	nextRuns: '接下来 5 次执行时间',
+	previewEmpty: '当前配置暂时无法推导执行时间',
+	cronLabel: 'Cron 表达式',
+	cronPlaceholder: '例如:0 0 0 * * ? *',
+	cronSupport: '支持 5 到 7 段 Cron 表达式',
+	cronUsing: '将按输入的 Cron 表达式触发',
+	cronInvalid: 'Cron 表达式格式不正确,请输入 5 到 7 段'
+} as const
+
+const FREQUENCY_OPTIONS: Array<{ label: string; value: ScheduleFrequency }> = [
+	{ label: '每小时', value: 'hourly' },
+	{ label: '每日', value: 'daily' },
+	{ label: '每周', value: 'weekly' },
+	{ label: '每月', value: 'monthly' }
+]
+
+const WEEKDAY_OPTIONS: Array<{ label: string; value: ScheduleWeekday; day: number }> = [
+	{ label: 'Sun', value: 'sun', day: 0 },
+	{ label: 'Mon', value: 'mon', day: 1 },
+	{ label: 'Tue', value: 'tue', day: 2 },
+	{ label: 'Wed', value: 'wed', day: 3 },
+	{ label: 'Thu', value: 'thu', day: 4 },
+	{ label: 'Fri', value: 'fri', day: 5 },
+	{ label: 'Sat', value: 'sat', day: 6 }
+]
+
+const WEEKDAY_TO_CRON: Record<ScheduleWeekday, string> = {
+	sun: 'SUN',
+	mon: 'MON',
+	tue: 'TUE',
+	wed: 'WED',
+	thu: 'THU',
+	fri: 'FRI',
+	sat: 'SAT'
+}
+
+const MONTH_DAYS = Array.from({ length: 31 }, (_, index) => index + 1)
+const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
+
+const props = defineProps<{
+	data: ScheduleData
+}>()
+
+const emit = defineEmits<Emits>()
+const formData = useSetterModel<ScheduleData>(props, emit)
+
+const normalizeTime = (value?: string) => {
+	return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value || '') ? value! : '00:00'
+}
+
+const clampMinute = (value: unknown) => {
+	const minute = Number(value)
+	if (Number.isNaN(minute)) {
+		return 0
+	}
+
+	return Math.min(59, Math.max(0, Math.trunc(minute)))
+}
+
+const sortWeekdays = (weekdays: ScheduleWeekday[]) => {
+	const orderMap = new Map(WEEKDAY_OPTIONS.map((item) => [item.value, item.day]))
+	return [...new Set(weekdays)].sort((left, right) => {
+		return (orderMap.get(left) || 0) - (orderMap.get(right) || 0)
+	})
+}
+
+const normalizeMonthlyDays = (values: Array<ScheduleMonthDay | string>): ScheduleMonthDay[] => {
+	const parsedValues = values
+		.map((item) => {
+			if (item === 'last') {
+				return 'last' as const
+			}
+
+			const day = Number(item)
+			if (Number.isNaN(day)) {
+				return null
+			}
+
+			return Math.min(31, Math.max(1, Math.trunc(day)))
+		})
+		.filter((item): item is ScheduleMonthDay => item !== null)
+
+	if (!parsedValues.length) {
+		return [1]
+	}
+
+	const uniqueDays = Array.from(new Set(parsedValues))
+
+	return uniqueDays.sort((left, right) => {
+		if (left === 'last') return 1
+		if (right === 'last') return -1
+		return left - right
+	})
+}
+
+const ensureScheduleState = () => {
+	if (!formData.value.title) {
+		formData.value.title = TEXT.title
+	}
+	if (!formData.value.type) {
+		formData.value.type = 'trigger-schedule'
+	}
+	if (!formData.value.variables) {
+		formData.value.variables = []
+	}
+	if (!formData.value.outputs) {
+		formData.value.outputs = []
+	}
+	if (!formData.value.default_value) {
+		formData.value.default_value = []
+	}
+	if (!formData.value.retry_config) {
+		formData.value.retry_config = {
+			max_retries: 3,
+			retry_enabled: false,
+			retry_interval: 100
+		}
+	}
+	if (!formData.value.error_strategy) {
+		formData.value.error_strategy = 'none'
+	}
+	if (!['visual', 'cron'].includes(formData.value.mode)) {
+		formData.value.mode = 'visual'
+	}
+	if (!['hourly', 'daily', 'weekly', 'monthly'].includes(formData.value.frequency)) {
+		formData.value.frequency = 'daily'
+	}
+	if (!formData.value.timezone) {
+		formData.value.timezone = browserTimeZone
+	}
+	if (!formData.value.visual_config || typeof formData.value.visual_config !== 'object') {
+		formData.value.visual_config = {
+			minute: 0,
+			time: '00:00',
+			weekdays: ['sun'],
+			monthly_days: [1]
+		}
+	}
+
+	formData.value.visual_config.minute = clampMinute(formData.value.visual_config.minute)
+	formData.value.visual_config.time = normalizeTime(formData.value.visual_config.time)
+	formData.value.visual_config.weekdays = sortWeekdays(
+		(formData.value.visual_config.weekdays || ['sun']) as ScheduleWeekday[]
+	)
+	formData.value.visual_config.monthly_days = normalizeMonthlyDays(
+		formData.value.visual_config.monthly_days || [1]
+	)
+}
+
+ensureScheduleState()
+
+const parseTime = (value: string) => {
+	const [hourText = '0', minuteText = '0'] = normalizeTime(value).split(':')
+	return {
+		hour: Math.min(23, Math.max(0, Number(hourText))),
+		minute: Math.min(59, Math.max(0, Number(minuteText)))
+	}
+}
+
+const buildQuartzCronExpression = (data: ScheduleData) => {
+	const minute = clampMinute(data.visual_config.minute)
+	const { hour, minute: timeMinute } = parseTime(data.visual_config.time)
+
+	switch (data.frequency) {
+		case 'hourly':
+			return `0 ${minute} * * * ? *`
+		case 'weekly':
+			return `0 ${timeMinute} ${hour} ? * ${sortWeekdays(data.visual_config.weekdays)
+				.map((item) => WEEKDAY_TO_CRON[item])
+				.join(',')} *`
+		case 'monthly':
+			return `0 ${timeMinute} ${hour} ${normalizeMonthlyDays(data.visual_config.monthly_days)
+				.map((item) => (item === 'last' ? 'L' : item))
+				.join(',')} * ? *`
+		case 'daily':
+		default:
+			return `0 ${timeMinute} ${hour} * * ? *`
+	}
+}
+
+const selectedWeekdays = computed<Set<ScheduleWeekday>>(() => {
+	return new Set(sortWeekdays(formData.value.visual_config.weekdays))
+})
+
+const selectedMonthlyDays = computed<Set<ScheduleMonthDay>>(() => {
+	return new Set(normalizeMonthlyDays(formData.value.visual_config.monthly_days))
+})
+
+const visualTimeModel = computed<string>({
+	get: () => normalizeTime(formData.value.visual_config.time),
+	set: (value: string) => {
+		formData.value.visual_config.time = normalizeTime(value)
+	}
+})
+
+const timezoneLabel = computed(() => {
+	const offset = -new Date().getTimezoneOffset()
+	const sign = offset >= 0 ? '+' : '-'
+	const absolute = Math.abs(offset)
+	const hours = Math.floor(absolute / 60)
+	const minutes = absolute % 60
+
+	if (minutes === 0) {
+		return `UTC${sign}${hours}`
+	}
+
+	return `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}`
+})
+
+const toggleMode = () => {
+	formData.value.cron_expression = buildQuartzCronExpression(formData.value)
+	formData.value.mode = formData.value.mode === 'visual' ? 'cron' : 'visual'
+}
+
+const handleWeekdayChange = (weekday: ScheduleWeekday, checked: boolean) => {
+	const weekdays = sortWeekdays(formData.value.visual_config.weekdays)
+	const nextWeekdays = checked
+		? [...weekdays, weekday]
+		: weekdays.filter((item) => item !== weekday)
+
+	formData.value.visual_config.weekdays = sortWeekdays(
+		nextWeekdays.length ? nextWeekdays : weekdays
+	)
+}
+
+const handleMonthDayChange = (day: ScheduleMonthDay, checked: boolean) => {
+	const monthlyDays = normalizeMonthlyDays(formData.value.visual_config.monthly_days)
+	const nextMonthlyDays = checked
+		? [...monthlyDays, day]
+		: monthlyDays.filter((item) => item !== day)
+
+	formData.value.visual_config.monthly_days = normalizeMonthlyDays(
+		nextMonthlyDays.length ? nextMonthlyDays : monthlyDays
+	)
+}
+
+const onWeekdayTagChange = (weekday: ScheduleWeekday, checked: unknown) => {
+	handleWeekdayChange(weekday, Boolean(checked))
+}
+
+const onMonthDayTagChange = (day: ScheduleMonthDay, checked: unknown) => {
+	handleMonthDayChange(day, Boolean(checked))
+}
+
+const createCandidate = (base: Date, hour: number, minute: number) => {
+	const candidate = new Date(base)
+	candidate.setHours(hour, minute, 0, 0)
+	return candidate
+}
+
+const getLastDayOfMonth = (date: Date) => {
+	return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
+}
+
+const generateHourlyRuns = (count: number) => {
+	const nextRuns: Date[] = []
+	const now = new Date()
+	const scheduleMinute = clampMinute(formData.value.visual_config.minute)
+	let cursor = new Date(now)
+	cursor.setSeconds(0, 0)
+	cursor.setMinutes(scheduleMinute, 0, 0)
+
+	if (cursor <= now) {
+		cursor.setHours(cursor.getHours() + 1)
+		cursor.setMinutes(scheduleMinute, 0, 0)
+	}
+
+	while (nextRuns.length < count) {
+		nextRuns.push(new Date(cursor))
+		cursor.setHours(cursor.getHours() + 1)
+	}
+
+	return nextRuns
+}
+
+const generateDailyRuns = (count: number) => {
+	const nextRuns: Date[] = []
+	const now = new Date()
+	const { hour, minute } = parseTime(formData.value.visual_config.time)
+	let cursor = createCandidate(now, hour, minute)
+
+	if (cursor <= now) {
+		cursor.setDate(cursor.getDate() + 1)
+	}
+
+	while (nextRuns.length < count) {
+		nextRuns.push(new Date(cursor))
+		cursor.setDate(cursor.getDate() + 1)
+	}
+
+	return nextRuns
+}
+
+const generateWeeklyRuns = (count: number) => {
+	const nextRuns: Date[] = []
+	const now = new Date()
+	const { hour, minute } = parseTime(formData.value.visual_config.time)
+	const weekdays = new Set(
+		WEEKDAY_OPTIONS.filter((item) =>
+			formData.value.visual_config.weekdays.includes(item.value)
+		).map((item) => item.day)
+	)
+	let cursor = new Date(now)
+	cursor.setHours(0, 0, 0, 0)
+
+	for (let dayOffset = 0; dayOffset < 370 && nextRuns.length < count; dayOffset += 1) {
+		const candidate = createCandidate(cursor, hour, minute)
+		if (weekdays.has(candidate.getDay()) && candidate > now) {
+			nextRuns.push(candidate)
+		}
+		cursor.setDate(cursor.getDate() + 1)
+	}
+
+	return nextRuns
+}
+
+const generateMonthlyRuns = (count: number) => {
+	const nextRuns: Date[] = []
+	const now = new Date()
+	const { hour, minute } = parseTime(formData.value.visual_config.time)
+	const monthlyDays = normalizeMonthlyDays(formData.value.visual_config.monthly_days)
+	let cursor = new Date(now)
+	cursor.setHours(0, 0, 0, 0)
+
+	for (let dayOffset = 0; dayOffset < 740 && nextRuns.length < count; dayOffset += 1) {
+		const candidate = createCandidate(cursor, hour, minute)
+		const currentDate = candidate.getDate()
+		const isLastDay = currentDate === getLastDayOfMonth(candidate)
+		const matched = monthlyDays.some((item) => (item === 'last' ? isLastDay : item === currentDate))
+
+		if (matched && candidate > now) {
+			nextRuns.push(candidate)
+		}
+
+		cursor.setDate(cursor.getDate() + 1)
+	}
+
+	return nextRuns
+}
+
+const formatPreviewDate = (date: Date) => {
+	const formatter = new Intl.DateTimeFormat('en-US', {
+		month: 'long',
+		day: 'numeric',
+		year: 'numeric',
+		hour: 'numeric',
+		minute: '2-digit',
+		hour12: true
+	})
+
+	return `${formatter.format(date)} (${timezoneLabel.value})`
+}
+
+const nextRuns = computed<PreviewItem[]>(() => {
+	let dates: Date[] = []
+	switch (formData.value.frequency) {
+		case 'hourly':
+			dates = generateHourlyRuns(5)
+			break
+		case 'weekly':
+			dates = generateWeeklyRuns(5)
+			break
+		case 'monthly':
+			dates = generateMonthlyRuns(5)
+			break
+		case 'daily':
+		default:
+			dates = generateDailyRuns(5)
+			break
+	}
+
+	return dates.map((date) => ({
+		timestamp: `${date.getTime()}`,
+		label: formatPreviewDate(date)
+	}))
+})
+
+const isCronExpressionValid = computed(() => {
+	const value = formData.value.cron_expression?.trim()
+	if (!value) {
+		return false
+	}
+
+	return /^(?:\S+\s+){4,6}\S+$/.test(value)
+})
+
+const cronHint = computed(() => {
+	if (!formData.value.cron_expression?.trim()) {
+		return TEXT.cronSupport
+	}
+
+	return isCronExpressionValid.value ? TEXT.cronUsing : TEXT.cronInvalid
+})
+
+const padIndex = (value: number) => String(value).padStart(2, '0')
+
+watch(
+	() => [
+		formData.value.mode,
+		formData.value.frequency,
+		formData.value.visual_config?.minute,
+		formData.value.visual_config?.time,
+		(formData.value.visual_config?.weekdays || []).join(','),
+		(formData.value.visual_config?.monthly_days || []).join(',')
+	],
+	() => {
+		if (formData.value.mode === 'visual' || !formData.value.cron_expression?.trim()) {
+			formData.value.cron_expression = buildQuartzCronExpression(formData.value)
+		}
+	},
+	{ immediate: true }
+)
+</script>
+
+<style scoped lang="less">
+.schedule-setter {
+	display: flex;
+	flex-direction: column;
+	gap: 18px;
+}
+
+.schedule-header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 12px;
+}
+
+.schedule-title {
+	font-size: 16px;
+	font-weight: 700;
+	color: #344054;
+}
+
+.mode-toggle {
+	display: inline-flex;
+	align-items: center;
+	gap: 8px;
+	padding: 0;
+	border: 0;
+	background: transparent;
+	font-size: 13px;
+	color: #344054;
+	cursor: pointer;
+}
+
+.config-grid {
+	display: grid;
+	grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
+	gap: 16px;
+}
+
+.field-block {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+}
+
+.field-block--wide {
+	min-width: 0;
+}
+
+.field-label {
+	font-size: 13px;
+	color: #667085;
+}
+
+.slider-panel,
+.time-panel,
+.preview-panel {
+	border-radius: 8px;
+	background: #f2f5f9;
+}
+
+.slider-panel {
+	padding: 0px 14px 0px 8px;
+	display: flex;
+	align-items: center;
+	gap: 12px;
+}
+
+.time-panel {
+	position: relative;
+}
+
+.tag-grid {
+	display: grid;
+	gap: 10px;
+}
+
+.tag-grid--week {
+	grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.tag-grid--month {
+	grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.schedule-tag {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	// min-height: 38px;
+	border-radius: 12px;
+	font-size: 14px;
+	color: #475467;
+	background: #f8fafc;
+	border: 1px solid #e5e7eb;
+}
+
+.schedule-tag--last {
+	grid-column: span 3;
+	gap: 6px;
+}
+
+.hint-icon {
+	color: #98a2b3;
+}
+
+.preview-panel {
+	padding: 14px 16px;
+}
+
+.preview-list {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.preview-item {
+	display: flex;
+	align-items: baseline;
+	gap: 10px;
+	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
+	font-size: 13px;
+	color: #475467;
+}
+
+.preview-index {
+	color: #98a2b3;
+}
+
+.preview-text {
+	word-break: break-word;
+}
+
+.preview-empty,
+.cron-hint {
+	font-size: 12px;
+	color: #667085;
+}
+
+.cron-hint--error {
+	color: #f04438;
+}
+</style>

+ 1 - 19
apps/web/src/nodes/src/start/index.ts

@@ -65,24 +65,6 @@ export const startNode: INodeType = {
 		selected: false,
 		nodeType: 'start',
 		zIndex: 1,
-		data: {
-			title: '用户输入',
-			type: 'start',
-			isInIteration: false,
-			iteration_id: '',
-			isInLoop: false,
-			loop_id: '',
-			variables: [],
-			retry_config: {
-				max_retries: 3,
-				retry_enabled: false,
-				retry_interval: 100
-			},
-			error_strategy: 'none',
-			fail_branch_node_id: '',
-			default_value: [],
-			output_can_alter: true,
-			outputs: []
-		}
+		data: {}
 	}
 }

+ 1 - 1
packages/api-client/request.ts

@@ -127,7 +127,7 @@ class HttpClient {
 			response: AxiosResponse<ResponseData<any>>
 		): AxiosResponse<ResponseData<any>> => {
 			// 会话丢失 重定向到/登录页
-			if (response.data?.code === 9999) {
+			if (response.data?.code === 9999 && !import.meta.env.DEV) {
 				window.location.href = '/'
 			}