From 56a01f61e77b6061826e14d0dbd8bc285997043e Mon Sep 17 00:00:00 2001 From: Joe Bao Date: Fri, 19 Jun 2026 12:49:31 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=BA=E6=96=87=E4=BB=B6=E5=92=8C=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContextMenu.tsx | 127 +++++++++++++++++++++---------- src/components/TabBar.tsx | 63 +++++++++++----- src/components/TaskList.tsx | 132 ++++++++++++++++++++++++++------- src/i18n/locales/en-US.ts | 8 ++ src/i18n/locales/ja-JP.ts | 8 ++ src/i18n/locales/ko-KR.ts | 8 ++ src/i18n/locales/zh-CN.ts | 8 ++ src/i18n/locales/zh-TW.ts | 8 ++ src/utils/tabExportImport.ts | 132 +++++++++++++++++++++++++++++++-- 9 files changed, 400 insertions(+), 94 deletions(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index cd35894a..8e6e7e1d 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -92,9 +92,21 @@ export function getIcon(name: string): LucideIcon | undefined { } // 单个菜单项组件 -function MenuItemComponent({ item, onClose }: { item: MenuItem; onClose: () => void }) { +function MenuItemComponent({ + item, + onClose, + submenuDirection, + depth = 0, +}: { + item: MenuItem; + onClose: () => void; + submenuDirection: 'left' | 'right'; + depth?: number; +}) { + const hasChildren = !!item.children?.length; + const handleClick = () => { - if (item.disabled || item.children) return; + if (item.disabled || hasChildren) return; item.onClick?.(); onClose(); }; @@ -106,44 +118,72 @@ function MenuItemComponent({ item, onClose }: { item: MenuItem; onClose: () => v const Icon = item.icon; return ( - + + {hasChildren && !item.disabled && ( +
0 && 'z-[10000]', + )} + > + {item.children!.map((child, index) => ( + + ))} +
)} - - {/* 图标 */} - {Icon && } - - {/* 标签 */} - {item.label} - - {/* 快捷键 */} - {item.shortcut && {item.shortcut}} - - {/* 子菜单箭头 */} - {item.children && } - + ); } // 右键菜单组件 export function ContextMenu({ items, position, onClose }: ContextMenuProps) { const menuRef = useRef(null); + const [submenuDirection, setSubmenuDirection] = useState<'left' | 'right'>('right'); // 点击外部关闭菜单 useEffect(() => { @@ -196,6 +236,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) { // 确保不小于 0 x = Math.max(8, x); y = Math.max(8, y); + setSubmenuDirection(x + rect.width + 188 > viewportWidth ? 'left' : 'right'); menu.style.left = `${x}px`; menu.style.top = `${y}px`; @@ -218,6 +259,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) { key={item.divider ? `divider-${index}` : item.id} item={item} onClose={onClose} + submenuDirection={submenuDirection} /> ))} , @@ -229,24 +271,31 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) { export function useContextMenu() { const [state, setState] = useContextMenuState(); - const show = useCallback( - (e: React.MouseEvent, items: MenuItem[]) => { - e.preventDefault(); - e.stopPropagation(); + const showAt = useCallback( + (position: MenuPosition, items: MenuItem[]) => { setState({ isOpen: true, - position: { x: e.clientX, y: e.clientY }, + position, items, }); }, [setState], ); + const show = useCallback( + (e: React.MouseEvent, items: MenuItem[]) => { + e.preventDefault(); + e.stopPropagation(); + showAt({ x: e.clientX, y: e.clientY }, items); + }, + [showAt], + ); + const hide = useCallback(() => { setState((prev) => ({ ...prev, isOpen: false })); }, [setState]); - return { state, show, hide }; + return { state, show, showAt, hide }; } // 使用 React state 管理菜单状态 diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index b8f13091..583f7e18 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -21,12 +21,13 @@ import { Bell, History, Share2, + FileText, } from 'lucide-react'; import { useAppStore } from '@/stores/appStore'; import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu'; import { ConfirmDialog } from './ConfirmDialog'; import { getInterfaceLangKey } from '@/i18n'; -import { exportWithToast } from '@/utils/tabExportImport'; +import { exportFileWithToast, exportWithToast } from '@/utils/tabExportImport'; import clsx from 'clsx'; const LazyUpdatePanel = lazy(async () => { @@ -58,7 +59,6 @@ export function TabBar() { instances, activeInstanceId, createInstance, - removeInstance, setActiveInstance, renameInstance, reorderInstances, @@ -87,7 +87,7 @@ export function TabBar() { const showUpdatePanel = showUpdateDialog; const setShowUpdatePanel = setShowUpdateDialog; - const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); + const { state: menuState, showAt: showMenuAt, hide: hideMenu } = useContextMenu(); // 当最近关闭列表为空时,自动关闭面板 useEffect(() => { @@ -161,10 +161,18 @@ export function TabBar() { // 右键菜单处理 const handleTabContextMenu = useCallback( - (e: React.MouseEvent, instanceId: string, instanceName: string) => { + async (e: React.MouseEvent, instanceId: string, instanceName: string) => { + e.preventDefault(); + e.stopPropagation(); + const position = { x: e.clientX, y: e.clientY }; const instanceIndex = instances.findIndex((i) => i.id === instanceId); const isFirst = instanceIndex === 0; const isLast = instanceIndex === instances.length - 1; + const inst = instances.find((i) => i.id === instanceId); + const projectName = projectInterface?.name; + const exportHint = + inst && projectName ? t('preset.exportShareHint', { projectName, tabName: inst.name }) : ''; + const exportFooter = projectName ? t('preset.exportShareFooter', { projectName }) : ''; const menuItems: MenuItem[] = [ { @@ -183,19 +191,35 @@ export function TabBar() { id: 'export', label: t('contextMenu.exportConfig'), icon: Share2, - onClick: () => { - const inst = instances.find((i) => i.id === instanceId); - const projectName = projectInterface?.name; - if (inst && projectName) { - exportWithToast( - inst, - projectName, - t('preset.exportShareHint', { projectName, tabName: inst.name }), - t('preset.exportShareFooter', { projectName }), - { success: t('preset.exportSuccess'), failed: t('preset.exportFailed') }, - ); - } - }, + disabled: !inst || !projectName, + children: [ + { + id: 'export-clipboard', + label: t('contextMenu.exportToClipboard'), + icon: Copy, + onClick: () => { + if (inst && projectName) { + exportWithToast(inst, projectName, exportHint, exportFooter, { + success: t('preset.exportSuccess'), + failed: t('preset.exportFailed'), + }); + } + }, + }, + { + id: 'export-file', + label: t('contextMenu.exportToTxt'), + icon: FileText, + onClick: () => { + if (inst && projectName) { + exportFileWithToast(inst, projectName, exportHint, exportFooter, { + success: t('preset.exportFileSuccess'), + failed: t('preset.exportFileFailed'), + }); + } + }, + }, + ], }, { id: 'rename', @@ -276,16 +300,15 @@ export function TabBar() { }, ]; - showMenu(e, menuItems); + showMenuAt(position, menuItems); }, [ instances, t, createInstance, duplicateInstance, - removeInstance, reorderInstances, - showMenu, + showMenuAt, projectInterface, confirmBeforeDelete, startTabCloseAnimation, diff --git a/src/components/TaskList.tsx b/src/components/TaskList.tsx index d871fbf1..8ba8a8c0 100644 --- a/src/components/TaskList.tsx +++ b/src/components/TaskList.tsx @@ -26,6 +26,8 @@ import { Loader2, Share2, ClipboardPaste, + Copy, + FileText, } from 'lucide-react'; import { useAppStore } from '@/stores/appStore'; import { TaskItem } from './TaskItem'; @@ -37,7 +39,9 @@ import { getInterfaceLangKey } from '@/i18n'; import { useResolvedContent } from '@/services/contentResolver'; import { exportWithToast, + exportFileWithToast, importTabConfigFromClipboard, + importTabConfigFromFile, getImportErrorType, } from '@/utils/tabExportImport'; import { generateId, initializeAllOptionValues, sanitizeOptionValues } from '@/stores/helpers'; @@ -93,7 +97,7 @@ function PresetCard({ preset, onApply }: { preset: PresetItem; onApply: () => vo } /** 导入按钮 */ -function ImportConfigButton({ instanceId }: { instanceId: string }) { +function useImportConfigActions(instanceId: string) { const { projectInterface, updateInstance, @@ -103,12 +107,18 @@ function ImportConfigButton({ instanceId }: { instanceId: string }) { } = useAppStore(); const { t } = useTranslation(); - const handleImport = async () => { + const handleImport = async (source: 'clipboard' | 'file') => { const projectName = projectInterface?.name; - if (!projectName) return; + if (!projectName || !projectInterface || !instanceId) return; try { - const { tabName, payload } = await importTabConfigFromClipboard(projectName); + const result = + source === 'file' + ? await importTabConfigFromFile(projectName) + : await importTabConfigFromClipboard(projectName); + if (!result) return; + + const { tabName, payload } = result; const importedTasks = payload.selectedTasks .map((task) => { @@ -169,14 +179,34 @@ function ImportConfigButton({ instanceId }: { instanceId: string }) { } }; + return { + importFromClipboard: () => handleImport('clipboard'), + importFromFile: () => handleImport('file'), + }; +} + +/** 瀵煎叆鎸夐挳 */ +function ImportConfigButton({ instanceId }: { instanceId: string }) { + const { t } = useTranslation(); + const { importFromClipboard, importFromFile } = useImportConfigActions(instanceId); + return ( - +
+ + +
); } @@ -242,7 +272,8 @@ export function TaskList() { const instance = getActiveInstance(); const isInstanceRunning = instance?.isRunning || false; - const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu(); + const { state: menuState, showAt: showMenuAt, hide: hideMenu } = useContextMenu(); + const { importFromClipboard, importFromFile } = useImportConfigActions(instance?.id ?? ''); // 滚动容器引用 const scrollContainerRef = useRef(null); @@ -304,14 +335,21 @@ export function TaskList() { // 任务列表区域右键菜单 const handleListContextMenu = useCallback( - (e: React.MouseEvent) => { + async (e: React.MouseEvent) => { e.preventDefault(); + e.stopPropagation(); if (!instance) return; + const position = { x: e.clientX, y: e.clientY }; const tasks = instance.selectedTasks; const hasEnabledTasks = tasks.some((t) => t.enabled); const hasExpandedTasks = tasks.some((t) => t.expanded); const hasTasks = tasks.length > 0; + const projectName = projectInterface?.name; + const exportHint = projectName + ? t('preset.exportShareHint', { projectName, tabName: instance.name }) + : ''; + const exportFooter = projectName ? t('preset.exportShareFooter', { projectName }) : ''; const menuItems: MenuItem[] = [ { @@ -340,27 +378,63 @@ export function TaskList() { ] : []), { id: 'divider-export', label: '', divider: true }, + { + id: 'import', + label: t('contextMenu.importConfig'), + icon: ClipboardPaste, + disabled: !projectName, + children: [ + { + id: 'import-clipboard', + label: t('contextMenu.importFromClipboard'), + icon: ClipboardPaste, + onClick: importFromClipboard, + }, + { + id: 'import-file', + label: t('contextMenu.importFromTxt'), + icon: FileText, + onClick: importFromFile, + }, + ], + }, { id: 'export', label: t('contextMenu.exportConfig'), icon: Share2, - disabled: !hasTasks, - onClick: () => { - const projectName = projectInterface?.name; - if (projectName) { - exportWithToast( - instance, - projectName, - t('preset.exportShareHint', { projectName, tabName: instance.name }), - t('preset.exportShareFooter', { projectName }), - { success: t('preset.exportSuccess'), failed: t('preset.exportFailed') }, - ); - } - }, + disabled: !hasTasks || !projectName, + children: [ + { + id: 'export-clipboard', + label: t('contextMenu.exportToClipboard'), + icon: Copy, + onClick: () => { + if (projectName) { + exportWithToast(instance, projectName, exportHint, exportFooter, { + success: t('preset.exportSuccess'), + failed: t('preset.exportFailed'), + }); + } + }, + }, + { + id: 'export-file', + label: t('contextMenu.exportToTxt'), + icon: FileText, + onClick: () => { + if (projectName) { + exportFileWithToast(instance, projectName, exportHint, exportFooter, { + success: t('preset.exportFileSuccess'), + failed: t('preset.exportFileFailed'), + }); + } + }, + }, + ], }, ]; - showMenu(e, menuItems); + showMenuAt(position, menuItems); }, [ t, @@ -369,8 +443,10 @@ export function TaskList() { setShowAddTaskPanel, selectAllTasks, collapseAllTasks, - showMenu, + showMenuAt, projectInterface, + importFromClipboard, + importFromFile, ], ); diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index d4f88073..97e32d23 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -342,6 +342,7 @@ export default { taskCount: 'tasks', skipToManual: 'Skip, add tasks manually', importConfig: 'Import config from clipboard', + importConfigFromFile: 'Import config from file', importSuccess: 'Config imported successfully', importFailed: 'Import failed: invalid format', importProjectMismatch: 'Import failed: project mismatch', @@ -349,6 +350,8 @@ export default { 'Import failed: this config was exported by a newer version of {{projectName}}, please update {{projectName}} and try again', exportSuccess: 'Config copied to clipboard', exportFailed: 'Export failed: unable to write to clipboard', + exportFileSuccess: 'Config exported as txt file', + exportFileFailed: 'Export failed: unable to write file', exportShareHint: 'Sharing my {{projectName}} config "{{tabName}}" with you~', exportShareFooter: '👆 Copy this message, open {{projectName}}, create a new tab, and tap "Import Config" to use it instantly', @@ -758,6 +761,11 @@ export default { closeAllTabs: 'Close All Tabs', closeTabsToRight: 'Close Tabs to the Right', exportConfig: 'Export Config', + exportToClipboard: 'Export to Clipboard', + exportToTxt: 'Export as txt File', + importConfig: 'Import Config', + importFromClipboard: 'Import from Clipboard', + importFromTxt: 'Import from txt File', // Pre-action context menu duplicateAction: 'Duplicate', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 8cd76746..fec1ded3 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -336,6 +336,7 @@ export default { taskCount: 'タスク', skipToManual: 'スキップして手動でタスクを追加', importConfig: 'クリップボードから設定をインポート', + importConfigFromFile: 'ファイルから設定をインポート', importSuccess: '設定のインポートに成功しました', importFailed: 'インポート失敗:無効な形式', importProjectMismatch: 'インポート失敗:プロジェクトが一致しません', @@ -343,6 +344,8 @@ export default { 'インポート失敗:この設定はより新しいバージョンの{{projectName}}でエクスポートされました。{{projectName}}を更新してから再試行してください', exportSuccess: '設定をクリップボードにコピーしました', exportFailed: 'エクスポート失敗:クリップボードに書き込めません', + exportFileSuccess: '設定を txt ファイルとしてエクスポートしました', + exportFileFailed: 'エクスポート失敗:ファイルに書き込めません', exportShareHint: '{{projectName}} の「{{tabName}}」設定をシェアするよ~', exportShareFooter: '👆 このメッセージをコピーして、{{projectName}} で新しいタブを開き「設定をインポート」を押すだけでOK', @@ -757,6 +760,11 @@ export default { closeAllTabs: 'すべてのタブを閉じる', closeTabsToRight: '右側のタブを閉じる', exportConfig: '設定をエクスポート', + exportToClipboard: 'クリップボードへエクスポート', + exportToTxt: 'txt ファイルとしてエクスポート', + importConfig: '設定をインポート', + importFromClipboard: 'クリップボードからインポート', + importFromTxt: 'txt ファイルからインポート', // 前処理プログラムのコンテキストメニュー duplicateAction: '複製', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index b021fcf5..aa1266b3 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -334,6 +334,7 @@ export default { taskCount: '개 작업', skipToManual: '건너뛰고 수동으로 작업 추가', importConfig: '클립보드에서 설정 가져오기', + importConfigFromFile: '파일에서 설정 가져오기', importSuccess: '설정 가져오기 성공', importFailed: '가져오기 실패: 잘못된 형식', importProjectMismatch: '가져오기 실패: 프로젝트 불일치', @@ -341,6 +342,8 @@ export default { '가져오기 실패: 이 설정은 더 새로운 버전의 {{projectName}}에서 내보낸 것입니다. {{projectName}}를 업데이트한 후 다시 시도해 주세요', exportSuccess: '설정이 클립보드에 복사되었습니다', exportFailed: '내보내기 실패: 클립보드에 쓸 수 없습니다', + exportFileSuccess: '설정을 txt 파일로 내보냈습니다', + exportFileFailed: '내보내기 실패: 파일에 쓸 수 없습니다', exportShareHint: '{{projectName}} 의 「{{tabName}}」 설정 공유해요~', exportShareFooter: '👆 이 메시지를 복사해서 {{projectName}} 에서 새 탭을 만들고 「설정 가져오기」를 누르면 바로 사용할 수 있어요', @@ -750,6 +753,11 @@ export default { closeAllTabs: '모든 탭 닫기', closeTabsToRight: '오른쪽 탭 닫기', exportConfig: '설정 내보내기', + exportToClipboard: '클립보드로 내보내기', + exportToTxt: 'txt 파일로 내보내기', + importConfig: '설정 가져오기', + importFromClipboard: '클립보드에서 가져오기', + importFromTxt: 'txt 파일에서 가져오기', // 전처리 프로그램 컨텍스트 메뉴 duplicateAction: '복제', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0359f807..8fe2a577 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -333,6 +333,7 @@ export default { taskCount: '个任务', skipToManual: '跳过,手动添加任务', importConfig: '从剪贴板导入配置', + importConfigFromFile: '从文件导入配置', importSuccess: '配置导入成功', importFailed: '导入失败:格式无效', importProjectMismatch: '导入失败:项目不匹配', @@ -340,6 +341,8 @@ export default { '导入失败:该配置由更高版本的 {{projectName}} 导出,请更新 {{projectName}} 后重试', exportSuccess: '配置已复制到剪贴板', exportFailed: '导出失败:无法写入剪贴板', + exportFileSuccess: '配置已导出为 txt 文件', + exportFileFailed: '导出失败:无法写入文件', exportShareHint: '「{{tabName}}」的 {{projectName}} 配置,发给你啦~', exportShareFooter: '👆 复制这段文字,在 {{projectName}} 里新建标签页,点「导入配置」就能直接用', }, @@ -752,6 +755,11 @@ export default { closeAllTabs: '关闭所有标签页', closeTabsToRight: '关闭右侧标签页', exportConfig: '导出配置', + exportToClipboard: '导出到剪贴板', + exportToTxt: '导出为 txt 文件', + importConfig: '导入配置', + importFromClipboard: '从剪贴板导入', + importFromTxt: '从 txt 文件导入', // 前置程序右键菜单 duplicateAction: '复制', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index f954a9ab..756cf730 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -329,6 +329,7 @@ export default { taskCount: '個任務', skipToManual: '跳過,手動新增任務', importConfig: '從剪貼簿匯入設定', + importConfigFromFile: '從檔案匯入設定', importSuccess: '設定匯入成功', importFailed: '匯入失敗:格式無效', importProjectMismatch: '匯入失敗:專案不匹配', @@ -336,6 +337,8 @@ export default { '匯入失敗:該設定由更高版本的 {{projectName}} 匯出,請更新 {{projectName}} 後重試', exportSuccess: '設定已複製到剪貼簿', exportFailed: '匯出失敗:無法寫入剪貼簿', + exportFileSuccess: '設定已匯出為 txt 檔案', + exportFileFailed: '匯出失敗:無法寫入檔案', exportShareHint: '「{{tabName}}」的 {{projectName}} 設定,分享給你囉~', exportShareFooter: '👆 複製這段文字,在 {{projectName}} 裡新建標籤頁,點「匯入設定」就能直接用', }, @@ -737,6 +740,11 @@ export default { closeAllTabs: '關閉所有標籤頁', closeTabsToRight: '關閉右側標籤頁', exportConfig: '匯出設定', + exportToClipboard: '匯出到剪貼簿', + exportToTxt: '匯出為 txt 檔案', + importConfig: '匯入設定', + importFromClipboard: '從剪貼簿匯入', + importFromTxt: '從 txt 檔案匯入', // 前置程式右鍵選單 duplicateAction: '複製', diff --git a/src/utils/tabExportImport.ts b/src/utils/tabExportImport.ts index a2426838..fbd3072e 100644 --- a/src/utils/tabExportImport.ts +++ b/src/utils/tabExportImport.ts @@ -1,5 +1,6 @@ import type { SavedTask } from '@/types/config'; import type { ActionConfig, Instance, OptionValue } from '@/types/interface'; +import { isTauri } from '@/utils/paths'; import { toast } from 'sonner'; const PROTOCOL_SEGMENT = 'tab-sharing'; @@ -234,12 +235,12 @@ function fromBase64Url(str: string): Uint8Array { * {projectName}://tab-sharing/v1/{tabName}/{base64url(deflate(JSON))} * {footer} ← 结尾签名(调用方传入,本地化) */ -export async function exportTabConfig( +export async function buildTabConfigExportText( instance: Instance, projectName: string, hint?: string, footer?: string, -): Promise { +): Promise { const payload: TabExportPayload = { controllerName: instance.controllerName, resourceName: instance.resourceName, @@ -261,21 +262,78 @@ export async function exportTabConfig( const dataLine = `${projectName}://${PROTOCOL_SEGMENT}/${CURRENT_VERSION}/${tabNameEncoded}/${base64}`; const hintLine = hint ?? `[MXU] ${projectName} · ${instance.name}`; - const lines = footer ? `${hintLine}\n${dataLine}\n${footer}` : `${hintLine}\n${dataLine}`; + return footer ? `${hintLine}\n${dataLine}\n${footer}` : `${hintLine}\n${dataLine}`; +} + +export async function exportTabConfig( + instance: Instance, + projectName: string, + hint?: string, + footer?: string, +): Promise { + const lines = await buildTabConfigExportText(instance, projectName, hint, footer); await navigator.clipboard.writeText(lines); } +function sanitizeFileName(name: string): string { + return ( + name + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') + .replace(/\s+/g, ' ') + .trim() || 'config' + ); +} + +function downloadTextFile(fileName: string, content: string): void { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +export async function exportTabConfigToFile( + instance: Instance, + projectName: string, + hint: string, + footer: string, +): Promise { + const content = await buildTabConfigExportText(instance, projectName, hint, footer); + const fileName = `${sanitizeFileName(projectName)}-${sanitizeFileName(instance.name)}.txt`; + + if (isTauri()) { + const { save } = await import('@tauri-apps/plugin-dialog'); + const selected = await save({ + defaultPath: fileName, + filters: [{ name: 'Text', extensions: ['txt'] }], + }); + if (!selected) return false; + + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + await writeTextFile(selected, content); + return true; + } + + downloadTextFile(fileName, content); + return true; +} + /** * 从剪贴板读取并解析 Tab 配置导入数据。 * 返回解析后的 tabName + payload,或抛出带有 ImportError 类型的错误。 */ -export async function importTabConfigFromClipboard(projectName: string): Promise { - const rawText = (await navigator.clipboard.readText()).trim(); +export async function importTabConfigFromText( + projectName: string, + rawText: string, +): Promise { + const raw = rawText.trim(); const escapedSegment = PROTOCOL_SEGMENT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const dataLineRegex = new RegExp(`(.+://${escapedSegment}/.+)`, 'm'); - const dataLineMatch = rawText.match(dataLineRegex); - const text = dataLineMatch ? dataLineMatch[1].trim() : rawText; + const dataLineMatch = raw.match(dataLineRegex); + const text = dataLineMatch ? dataLineMatch[1].trim() : raw; const protocolPrefix = `${projectName}://${PROTOCOL_SEGMENT}/`; if (!text.startsWith(protocolPrefix)) { @@ -318,6 +376,51 @@ export async function importTabConfigFromClipboard(projectName: string): Promise return { tabName, payload }; } +export async function importTabConfigFromClipboard(projectName: string): Promise { + const rawText = await navigator.clipboard.readText(); + return importTabConfigFromText(projectName, rawText); +} + +function readTextFileFromBrowser(): Promise { + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.txt,text/plain'; + input.onchange = () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + file.text().then(resolve, reject); + }; + input.click(); + }); +} + +export async function importTabConfigFromFile( + projectName: string, +): Promise { + let content: string | null = null; + + if (isTauri()) { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ + multiple: false, + filters: [{ name: 'Text', extensions: ['txt'] }], + }); + if (!selected || Array.isArray(selected)) return null; + + const { readTextFile } = await import('@tauri-apps/plugin-fs'); + content = await readTextFile(selected); + } else { + content = await readTextFileFromBrowser(); + } + + if (content === null) return null; + return importTabConfigFromText(projectName, content); +} + function createImportError(type: ImportError): Error { const err = new Error(type); (err as Error & { importErrorType: ImportError }).importErrorType = type; @@ -343,3 +446,18 @@ export function exportWithToast( () => toast.error(messages.failed), ); } + +export function exportFileWithToast( + instance: Instance, + projectName: string, + hint: string, + footer: string, + messages: { success: string; failed: string }, +): void { + exportTabConfigToFile(instance, projectName, hint, footer).then( + (saved) => { + if (saved) toast.success(messages.success); + }, + () => toast.error(messages.failed), + ); +}