feat: add FTP and SFTP upload support
This commit is contained in:
+14
-1
@@ -22,6 +22,7 @@ import {
|
||||
CopyRegionImage,
|
||||
SaveRegionImage,
|
||||
SaveRegionToRemote,
|
||||
UploadRegionToFTP,
|
||||
SummarizeRegion,
|
||||
ExtractTextRegion,
|
||||
CancelRegion,
|
||||
@@ -59,7 +60,7 @@ async function loadThemePreference() {
|
||||
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives
|
||||
// here) and the inner SettingsView (which renders the matching card) can
|
||||
// share a single source of truth without an event bus.
|
||||
type SettingsTab = 'general' | 's3' | 'ssh' | 'llm' | 'ocr'
|
||||
type SettingsTab = 'general' | 's3' | 'ftp' | 'ssh' | 'llm' | 'ocr'
|
||||
const activeTab = ref<SettingsTab>('general')
|
||||
|
||||
// Sidebar entries are declarative so adding a destination type later is
|
||||
@@ -67,6 +68,7 @@ const activeTab = ref<SettingsTab>('general')
|
||||
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
|
||||
{ id: 'general', label: '通用设置' },
|
||||
{ id: 's3', label: '对象存储' },
|
||||
{ id: 'ftp', label: 'FTP / SFTP' },
|
||||
{ id: 'ssh', label: '远程主机' },
|
||||
{ id: 'llm', label: '智能识图' },
|
||||
{ id: 'ocr', label: '文字提取' },
|
||||
@@ -184,6 +186,16 @@ async function onOverlaySaveRemote(rect: OverlayResult) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onOverlayUploadFTP(rect: OverlayResult) {
|
||||
mode.value = 'settings'
|
||||
overlayPayload.value = null
|
||||
try {
|
||||
await UploadRegionToFTP(rect as any)
|
||||
} catch {
|
||||
/* Surfaced via upload:failure */
|
||||
}
|
||||
}
|
||||
|
||||
async function onOverlaySummarize(rect: OverlayResult) {
|
||||
mode.value = 'settings'
|
||||
overlayPayload.value = null
|
||||
@@ -275,6 +287,7 @@ onUnmounted(() => {
|
||||
@copy="onOverlayCopy"
|
||||
@save="onOverlaySave"
|
||||
@save-remote="onOverlaySaveRemote"
|
||||
@upload-ftp="onOverlayUploadFTP"
|
||||
@summarize="onOverlaySummarize"
|
||||
@ocr="onOverlayOCR"
|
||||
@cancel="onOverlayCancel"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M128 128h768v256H128V128zm64 64v128h640V192H192zm64 48h64v32h-64v-32zM128 448h416v64H192v192h352v64H128V448zm560-32 160 160-45.248 45.248L720 538.496V832h-64V538.496l-82.752 82.752L528 576l160-160z" fill="currentColor"></path></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -8,6 +8,7 @@ import cancelIcon from '../assets/icons/cancel.svg?raw'
|
||||
import copyIcon from '../assets/icons/copy.svg?raw'
|
||||
import saveIcon from '../assets/icons/save-local.svg?raw'
|
||||
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
|
||||
import ftpIcon from '../assets/icons/ftp.svg?raw'
|
||||
import uploadIcon from '../assets/icons/upload.svg?raw'
|
||||
|
||||
interface Props {
|
||||
@@ -54,6 +55,10 @@ const emit = defineEmits<{
|
||||
e: 'save-remote',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
): void
|
||||
(
|
||||
e: 'upload-ftp',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
): void
|
||||
(
|
||||
e: 'summarize',
|
||||
payload: { rect: Rect; annotations: Annotation[] }
|
||||
@@ -142,7 +147,7 @@ const sizeLabel = computed(() => {
|
||||
})
|
||||
|
||||
const MARK_TOOLBAR_W = 178
|
||||
const ACTION_TOOLBAR_W = 252
|
||||
const ACTION_TOOLBAR_W = 288
|
||||
const TOOLBAR_GROUP_GAP = 8
|
||||
|
||||
const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text']
|
||||
@@ -571,6 +576,10 @@ function onSaveRemote() {
|
||||
emitAction('save-remote')
|
||||
}
|
||||
|
||||
function onUploadFTP() {
|
||||
emitAction('upload-ftp')
|
||||
}
|
||||
|
||||
function onSummarize() {
|
||||
emitAction('summarize')
|
||||
}
|
||||
@@ -579,7 +588,16 @@ function onOCR() {
|
||||
emitAction('ocr')
|
||||
}
|
||||
|
||||
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize' | 'ocr') {
|
||||
function emitAction(
|
||||
action:
|
||||
| 'confirm'
|
||||
| 'copy'
|
||||
| 'save'
|
||||
| 'save-remote'
|
||||
| 'upload-ftp'
|
||||
| 'summarize'
|
||||
| 'ocr'
|
||||
) {
|
||||
if (!rect.value) return
|
||||
commitTextDraft()
|
||||
const payload = {
|
||||
@@ -590,6 +608,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summa
|
||||
if (action === 'copy') emit('copy', payload)
|
||||
if (action === 'save') emit('save', payload)
|
||||
if (action === 'save-remote') emit('save-remote', payload)
|
||||
if (action === 'upload-ftp') emit('upload-ftp', payload)
|
||||
if (action === 'summarize') emit('summarize', payload)
|
||||
if (action === 'ocr') emit('ocr', payload)
|
||||
}
|
||||
@@ -995,11 +1014,18 @@ onUnmounted(() => {
|
||||
/>
|
||||
<button
|
||||
class="action-btn"
|
||||
data-tip="保存远端"
|
||||
aria-label="保存远端"
|
||||
data-tip="上传 SSH/SCP"
|
||||
aria-label="上传 SSH/SCP"
|
||||
@click="onSaveRemote"
|
||||
v-html="saveRemoteIcon"
|
||||
/>
|
||||
<button
|
||||
class="action-btn"
|
||||
data-tip="上传 FTP/SFTP"
|
||||
aria-label="上传 FTP/SFTP"
|
||||
@click="onUploadFTP"
|
||||
v-html="ftpIcon"
|
||||
/>
|
||||
<button
|
||||
class="action-btn"
|
||||
data-tip="提取文字"
|
||||
@@ -1030,8 +1056,8 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<button
|
||||
class="action-btn primary"
|
||||
data-tip="上传云端"
|
||||
aria-label="上传云端"
|
||||
data-tip="上传 S3"
|
||||
aria-label="上传 S3"
|
||||
@click="onConfirm"
|
||||
v-html="uploadIcon"
|
||||
/>
|
||||
@@ -1148,7 +1174,9 @@ onUnmounted(() => {
|
||||
width: 178px;
|
||||
}
|
||||
.action-toolbar {
|
||||
width: 252px;
|
||||
box-sizing: border-box;
|
||||
width: 288px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*
|
||||
* • "general" — the global hotkey / capture parameters.
|
||||
* • "s3" — S3-compatible object-storage credentials.
|
||||
* • "ftp" — FTP/SFTP file-transfer destination.
|
||||
* • "ssh" — SSH/SCP destination for the "save remote" button.
|
||||
* • "llm" — multimodal screenshot summary provider settings.
|
||||
* • "ocr" — cloud OCR provider settings for text extraction.
|
||||
@@ -18,14 +19,16 @@ import {
|
||||
GetConfig,
|
||||
SaveConfig,
|
||||
TestConnection,
|
||||
TestFTPConnection,
|
||||
TestSSHConnection,
|
||||
CaptureNow,
|
||||
} from '../../wailsjs/go/main/App'
|
||||
|
||||
// Tab discriminator shared with the App shell. Kept as a string union so
|
||||
// the parent can pass the value without importing a type from this view.
|
||||
type TabId = 'general' | 's3' | 'ssh' | 'llm' | 'ocr'
|
||||
type TabId = 'general' | 's3' | 'ftp' | 'ssh' | 'llm' | 'ocr'
|
||||
type ThemeMode = 'auto' | 'light' | 'dark'
|
||||
type FTPProtocol = 'ftp' | 'sftp'
|
||||
|
||||
const props = defineProps<{
|
||||
tab: TabId
|
||||
@@ -59,6 +62,19 @@ interface SSHConfig {
|
||||
connectTimeoutSecs: number
|
||||
}
|
||||
|
||||
interface FTPConfig {
|
||||
protocol: FTPProtocol
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
authMethod: 'password' | 'key'
|
||||
password: string
|
||||
pathPrefix: string
|
||||
strictHostKey: boolean
|
||||
knownHostsPath: string
|
||||
connectTimeoutSecs: number
|
||||
}
|
||||
|
||||
interface LLMProviderConfig {
|
||||
label: string
|
||||
baseUrl: string
|
||||
@@ -97,6 +113,7 @@ interface AppConfig {
|
||||
theme: ThemeMode
|
||||
s3: S3Config
|
||||
ssh: SSHConfig
|
||||
ftp: FTPConfig
|
||||
llm: LLMConfig
|
||||
ocr: OCRConfig
|
||||
}
|
||||
@@ -145,6 +162,18 @@ function defaultConfig(): AppConfig {
|
||||
publicUrlBase: '',
|
||||
usePathStyle: true,
|
||||
},
|
||||
ftp: {
|
||||
protocol: 'ftp',
|
||||
host: '',
|
||||
port: 21,
|
||||
user: '',
|
||||
authMethod: 'password',
|
||||
password: '',
|
||||
pathPrefix: 'snapgo/',
|
||||
strictHostKey: false,
|
||||
knownHostsPath: '',
|
||||
connectTimeoutSecs: 10,
|
||||
},
|
||||
ssh: {
|
||||
host: '',
|
||||
port: 22,
|
||||
@@ -223,6 +252,7 @@ const config = ref<AppConfig>(defaultConfig())
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testingFTP = ref(false)
|
||||
const testingSSH = ref(false)
|
||||
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||
|
||||
@@ -240,6 +270,39 @@ const sshPathDisplay = computed({
|
||||
},
|
||||
})
|
||||
|
||||
// FTP and SFTP both upload relative to the authenticated login directory.
|
||||
// The visible prefix communicates how that location will be copied: FTP uses
|
||||
// its virtual root, while SFTP uses the SSH user's home directory.
|
||||
const ftpPathDisplay = computed({
|
||||
get: () => config.value.ftp.pathPrefix,
|
||||
set: (raw: string) => {
|
||||
let cleaned = raw.trim()
|
||||
cleaned = cleaned.replace(/^~/, '')
|
||||
cleaned = cleaned.replace(/^\/+/, '')
|
||||
config.value.ftp.pathPrefix = cleaned
|
||||
},
|
||||
})
|
||||
|
||||
const ftpPathRootLabel = computed(() =>
|
||||
config.value.ftp.protocol === 'sftp' ? '~/' : '/'
|
||||
)
|
||||
|
||||
function changeFTPProtocol(event: Event) {
|
||||
const next = (event.target as HTMLSelectElement).value as FTPProtocol
|
||||
const previous = config.value.ftp.protocol
|
||||
const previousDefault = previous === 'sftp' ? 22 : 21
|
||||
if (config.value.ftp.port === previousDefault) {
|
||||
config.value.ftp.port = next === 'sftp' ? 22 : 21
|
||||
}
|
||||
config.value.ftp.protocol = next
|
||||
}
|
||||
|
||||
function changeFTPAuthMethod(event: Event) {
|
||||
config.value.ftp.authMethod = (event.target as HTMLSelectElement).value as
|
||||
| 'password'
|
||||
| 'key'
|
||||
}
|
||||
|
||||
const activeLLMProvider = computed(() => {
|
||||
const id = config.value.llm.activeProvider || 'openai'
|
||||
if (!config.value.llm.providers[id]) {
|
||||
@@ -280,6 +343,7 @@ async function load() {
|
||||
Object.assign(merged, cfg)
|
||||
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
|
||||
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
|
||||
merged.ftp = { ...merged.ftp, ...(cfg as any).ftp }
|
||||
merged.llm = { ...merged.llm, ...(cfg as any).llm }
|
||||
merged.llm.providers = {
|
||||
...defaultConfig().llm.providers,
|
||||
@@ -320,6 +384,21 @@ async function test() {
|
||||
}
|
||||
}
|
||||
|
||||
async function testFTP() {
|
||||
testingFTP.value = true
|
||||
try {
|
||||
await TestFTPConnection(config.value.ftp as any)
|
||||
flash(
|
||||
'ok',
|
||||
`${config.value.ftp.protocol.toUpperCase()} connection OK — remote directory is writable`
|
||||
)
|
||||
} catch (err: any) {
|
||||
flash('err', `FTP/SFTP test failed: ${err?.message ?? err}`)
|
||||
} finally {
|
||||
testingFTP.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testSSH() {
|
||||
testingSSH.value = true
|
||||
try {
|
||||
@@ -450,6 +529,121 @@ onMounted(load)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FTP/SFTP tab — file-transfer destination for its toolbar action. -->
|
||||
<section v-if="props.tab === 'ftp'" class="card">
|
||||
<h2>FTP / SFTP destination</h2>
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span>Protocol</span>
|
||||
<select :value="config.ftp.protocol" @change="changeFTPProtocol">
|
||||
<option value="ftp">FTP</option>
|
||||
<option value="sftp">SFTP (SSH File Transfer Protocol)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Port</span>
|
||||
<input
|
||||
v-model.number="config.ftp.port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
:placeholder="config.ftp.protocol === 'sftp' ? '22' : '21'"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Host (IP or domain) *</span>
|
||||
<input v-model.trim="config.ftp.host" placeholder="files.example.com" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>User *</span>
|
||||
<input v-model.trim="config.ftp.user" placeholder="snapgo" />
|
||||
</label>
|
||||
<label
|
||||
v-if="config.ftp.protocol === 'sftp'"
|
||||
key="sftp-auth-method"
|
||||
class="field"
|
||||
>
|
||||
<span>Auth method</span>
|
||||
<select
|
||||
:value="config.ftp.authMethod"
|
||||
@change="changeFTPAuthMethod"
|
||||
>
|
||||
<option value="password">Password</option>
|
||||
<option value="key">SSH-Key</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
v-if="config.ftp.protocol === 'ftp' || config.ftp.authMethod === 'password'"
|
||||
:key="`${config.ftp.protocol}-password`"
|
||||
class="field"
|
||||
>
|
||||
<span>Password</span>
|
||||
<input v-model="config.ftp.password" type="password" />
|
||||
</label>
|
||||
<label class="field full">
|
||||
<span>Remote directory (under login root)</span>
|
||||
<div class="prefixed-input">
|
||||
<span class="input-prefix">{{ ftpPathRootLabel }}</span>
|
||||
<input
|
||||
v-model="ftpPathDisplay"
|
||||
placeholder="snapgo/"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Connect timeout (sec)</span>
|
||||
<input
|
||||
v-model.number="config.ftp.connectTimeoutSecs"
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
placeholder="10"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="config.ftp.protocol === 'sftp'" class="field">
|
||||
<span>Known hosts path</span>
|
||||
<input
|
||||
v-model="config.ftp.knownHostsPath"
|
||||
placeholder="~/.ssh/known_hosts"
|
||||
:disabled="!config.ftp.strictHostKey"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="config.ftp.protocol === 'sftp'" class="field checkbox">
|
||||
<input type="checkbox" v-model="config.ftp.strictHostKey" />
|
||||
<span>Strict host key checking (recommended)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="config.ftp.protocol === 'ftp'" class="hint warn">
|
||||
FTP sends the username, password, and screenshot without encryption.
|
||||
Prefer SFTP on networks you do not fully trust. SFTP is not FTPS.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="config.ftp.authMethod === 'key'"
|
||||
class="hint"
|
||||
>
|
||||
SSH-Key mode uses your <code>ssh-agent</code> and
|
||||
<code>~/.ssh/id_*</code> keys.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!config.ftp.strictHostKey"
|
||||
class="hint warn"
|
||||
>
|
||||
Host key checking is disabled. Enable it for production servers to
|
||||
detect server impersonation.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" :disabled="testingFTP" @click="testFTP">
|
||||
{{ testingFTP ? 'Testing…' : 'Test connection' }}
|
||||
</button>
|
||||
<button class="btn primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SSH-Conf tab — destination for the save-remote action. -->
|
||||
<section v-if="props.tab === 'ssh'" class="card">
|
||||
<h2>SSH / SCP destination</h2>
|
||||
|
||||
Reference in New Issue
Block a user