Files
SnapGo/frontend/src/App.vue
T

586 lines
14 KiB
Vue

<script setup lang="ts">
/*
* App shell — switches between two distinct UI modes that share the same
* Wails window:
*
* • "settings" : full settings UI (native title bar + sidebar + form).
* • "overlay" : Snipaste-style region picker that fills the whole
* primary display.
*
* The Go side resizes/positions/flag-flips the window for each mode and
* emits a `capture:overlay` event with the screenshot payload so the
* frontend knows when (and what) to render.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import SettingsView from './views/SettingsView.vue'
import Toast from './components/Toast.vue'
import CaptureOverlay from './views/CaptureOverlay.vue'
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'
import {
RetryRegisterHotkey,
ConfirmRegion,
CopyRegionImage,
SaveRegionImage,
SaveRegionToRemote,
UploadRegionToFTP,
SummarizeRegion,
ExtractTextRegion,
CancelRegion,
GetConfig,
} from '../wailsjs/go/main/App'
type HotkeyStatus =
| { state: 'unknown' }
| { state: 'ok'; spec: string }
| { state: 'error'; reason: string }
const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings')
type ThemeMode = 'auto' | 'light' | 'dark'
const themeMode = ref<ThemeMode>('auto')
const appThemeClass = computed(() => `theme-${themeMode.value}`)
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
async function loadThemePreference() {
try {
const cfg = await GetConfig()
themeMode.value = normalizeTheme((cfg as any)?.theme)
} catch {
themeMode.value = 'auto'
}
}
// `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' | 'ftp' | 'ssh' | 'llm' | 'ocr'
const activeTab = ref<SettingsTab>('general')
// Sidebar entries are declarative so adding a destination type later is
// a one-line change. The label values match the user-facing tab names.
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: '文字提取' },
]
interface OverlayPayload {
cssWidth: number
cssHeight: number
scale: number
}
const overlayPayload = ref<OverlayPayload | null>(null)
interface OverlayAnnotation {
tool: string
color: string
points: Array<{ x: number; y: number }>
text?: string
strokeWidth?: number
fontSize?: number
}
interface OverlayResult {
rect: {
x: number
y: number
w: number
h: number
}
annotations: OverlayAnnotation[]
}
const capturing = ref(false)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
let toastTimer: number | undefined
const operationStatus = ref<{
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
} | null>(null)
let operationTimer: number | undefined
function showToast(kind: 'success' | 'error', text: string) {
toast.value = { kind, text }
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => {
toast.value = null
}, 3000)
}
function updateOperationStatus(payload: {
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
}) {
operationStatus.value = payload
window.clearTimeout(operationTimer)
if (payload.state !== 'running') {
operationTimer = window.setTimeout(() => {
operationStatus.value = null
}, payload.state === 'success' ? 1600 : 3200)
}
}
async function retryHotkey() {
try {
await RetryRegisterHotkey()
} catch {
/* Surfaced via hotkey:error */
}
}
async function onOverlayConfirm(rect: OverlayResult) {
// Optimistically swap back so the window does not visually lag the
// Go-side hide. If upload fails, the toast surfaces the reason.
mode.value = 'settings'
overlayPayload.value = null
try {
await ConfirmRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCopy(rect: OverlayResult) {
mode.value = 'settings'
overlayPayload.value = null
try {
await CopyRegionImage(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlaySave(rect: OverlayResult) {
mode.value = 'settings'
overlayPayload.value = null
try {
await SaveRegionImage(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlaySaveRemote(rect: OverlayResult) {
mode.value = 'settings'
overlayPayload.value = null
try {
await SaveRegionToRemote(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
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
try {
await SummarizeRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayOCR(rect: OverlayResult) {
mode.value = 'settings'
overlayPayload.value = null
try {
await ExtractTextRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() {
mode.value = 'settings'
overlayPayload.value = null
try {
await CancelRegion()
} catch {
/* Best-effort */
}
}
onMounted(() => {
void loadThemePreference()
EventsOn('capture:start', () => {
capturing.value = true
})
EventsOn('capture:end', () => {
capturing.value = false
})
EventsOn('capture:cancelled', () => {
capturing.value = false
})
EventsOn('capture:overlay', (payload: OverlayPayload) => {
overlayPayload.value = payload
mode.value = 'overlay'
capturing.value = false
})
EventsOn('upload:success', (url: string) => {
showToast(
'success',
url === 'summary copied to clipboard'
? 'Summary copied to clipboard'
: url === 'ocr text copied to clipboard'
? 'OCR text copied to clipboard'
: `Copied: ${url}`
)
})
EventsOn('upload:failure', (reason: string) => {
showToast('error', reason)
})
EventsOn('hotkey:ready', (spec: string) => {
hotkeyStatus.value = { state: 'ok', spec }
})
EventsOn('hotkey:error', (reason: string) => {
hotkeyStatus.value = { state: 'error', reason }
})
EventsOn('operation:status', updateOperationStatus)
})
onUnmounted(() => {
EventsOff('capture:start')
EventsOff('capture:end')
EventsOff('capture:cancelled')
EventsOff('capture:overlay')
EventsOff('upload:success')
EventsOff('upload:failure')
EventsOff('hotkey:ready')
EventsOff('hotkey:error')
EventsOff('operation:status')
})
</script>
<template>
<!-- Overlay mode: full-screen Snipaste-style picker. -->
<CaptureOverlay
v-if="mode === 'overlay' && overlayPayload"
:width="overlayPayload.cssWidth"
:height="overlayPayload.cssHeight"
@confirm="onOverlayConfirm"
@copy="onOverlayCopy"
@save="onOverlaySave"
@save-remote="onOverlaySaveRemote"
@upload-ftp="onOverlayUploadFTP"
@summarize="onOverlaySummarize"
@ocr="onOverlayOCR"
@cancel="onOverlayCancel"
/>
<!-- Settings mode: standard desktop window; macOS title bar is native. -->
<div v-else class="app-root" :class="appThemeClass">
<div v-if="hotkeyStatus.state === 'error'" class="permission-banner">
<div>
<strong>Global hotkey is not active.</strong>
Reason: {{ hotkeyStatus.reason }}
<br />
On macOS open
<em>System Settings Privacy &amp; Security Accessibility</em>,
enable <strong>SnapGo</strong>, then click "Retry".
</div>
<button class="btn" @click="retryHotkey">Retry</button>
</div>
<main class="main">
<aside class="sidebar">
<div
v-for="item in sidebarItems"
:key="item.id"
class="sidebar-item"
:class="{ active: activeTab === item.id }"
@click="activeTab = item.id"
>
<span class="dot" /> {{ item.label }}
</div>
</aside>
<section class="content">
<SettingsView
:tab="activeTab"
:theme="themeMode"
@theme-change="themeMode = normalizeTheme($event)"
/>
</section>
</main>
<Toast v-if="toast" :kind="toast.kind" :text="toast.text" />
<div
v-if="operationStatus"
class="operation-hud"
:class="operationStatus.state"
>
<span v-if="operationStatus.state === 'running'" class="spinner" />
<span v-else class="status-mark">
{{ operationStatus.state === 'success' ? 'OK' : '!' }}
</span>
<div>
<strong>{{ operationStatus.phase }}</strong>
<p>{{ operationStatus.message }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.app-root {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: #f6f7fa;
color: #111827;
font-size: 13px;
}
.app-root.theme-light {
color-scheme: light;
}
.app-root.theme-dark {
color-scheme: dark;
}
.app-root.theme-auto {
color-scheme: light dark;
}
.status-pill {
font-size: 11px;
padding: 3px 9px;
border-radius: 999px;
font-weight: 500;
}
.status-pill.ok {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.status-pill.err {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
cursor: help;
}
.status-pill.muted {
color: #6b7280;
background: rgba(107, 114, 128, 0.14);
}
.permission-banner {
display: flex;
gap: 12px;
align-items: flex-start;
margin: 12px 16px 0;
padding: 10px 14px;
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 8px;
color: #7c2d12;
font-size: 12px;
line-height: 1.55;
}
.permission-banner em {
font-style: normal;
background: rgba(124, 45, 18, 0.08);
padding: 0 4px;
border-radius: 3px;
}
.permission-banner .btn {
flex: 0 0 auto;
border: 1px solid #fdba74;
background: #fff;
color: #7c2d12;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.permission-banner .btn:hover {
background: #fed7aa;
}
.main {
flex: 1;
display: grid;
grid-template-columns: 180px 1fr;
min-height: 0;
}
.sidebar {
border-right: 1px solid #e5e7eb;
padding: 14px 8px;
background: rgba(255, 255, 255, 0.4);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 6px;
font-size: 13px;
color: #374151;
cursor: pointer;
}
.sidebar-item:hover:not(.active) {
background: rgba(0, 0, 0, 0.04);
}
.sidebar-item.active {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
font-weight: 500;
}
.sidebar-item .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.content {
overflow-y: auto;
min-width: 0;
}
.operation-hud {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
display: flex;
align-items: center;
gap: 12px;
min-width: 260px;
max-width: 360px;
padding: 12px 14px;
color: #f9fafb;
background: rgba(17, 24, 39, 0.94);
border-radius: 8px;
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.28);
}
.operation-hud strong {
display: block;
margin-bottom: 2px;
font-size: 13px;
}
.operation-hud p {
margin: 0;
color: #d1d5db;
font-size: 12px;
line-height: 1.35;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.75s linear infinite;
flex: 0 0 auto;
}
.status-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.operation-hud.success .status-mark {
color: #052e16;
background: #86efac;
}
.operation-hud.error .status-mark {
color: #450a0a;
background: #fca5a5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.app-root.theme-dark {
background: #1c1d22;
color: #e5e7eb;
}
.app-root.theme-dark .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.app-root.theme-dark .titlebar-title {
color: #f3f4f6;
}
.app-root.theme-dark .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.app-root.theme-dark .sidebar-item {
color: #d1d5db;
}
.app-root.theme-dark .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.app-root.theme-dark .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.app-root.theme-dark .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
@media (prefers-color-scheme: dark) {
.app-root.theme-auto {
background: #1c1d22;
color: #e5e7eb;
}
.app-root.theme-auto .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.app-root.theme-auto .titlebar-title {
color: #f3f4f6;
}
.app-root.theme-auto .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.app-root.theme-auto .sidebar-item {
color: #d1d5db;
}
.app-root.theme-auto .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.app-root.theme-auto .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.app-root.theme-auto .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
}
</style>