feat: i18n/主题、多窗口、Tauri 后端接入与终端联通、UI 重构

- 引入国际化与中英文本体系,统一多语言文案。

- 增加主题系统(dark/light/system)与全局样式能力。

- 大幅升级 Dashboard、聊天区和整体响应式布局,修复多处 UI 显示问题。

- 接入 Tauri Rust 后端基础结构(src-tauri、Cargo 配置、能力声明)。

- 打通终端桥接与用户终端联通链路,完善终端相关前后端交互。

- 增加多窗口能力与窗口管理支持。
This commit is contained in:
skyseek committed 2026-02-26 09:32:18 +08:00
1 parent b738d1f459
commit fe289af4aa
19 files changed
+4320 -219

No files matched your search

+1 -39
View File
@@ -4,7 +4,7 @@
<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>Nexus Dashboard</title>
<title>golutra</title>
<script>
(() => {
const storageKey = 'nexus-theme';
@@ -19,44 +19,6 @@
root.classList.toggle('dark', resolvedTheme === 'dark');
})();
</script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
<!-- Material Symbols -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['"Be Vietnam Pro"', 'sans-serif'],
},
colors: {
background: 'rgb(var(--color-background) / <alpha-value>)',
surface: 'rgb(var(--color-surface) / <alpha-value>)',
panel: 'rgb(var(--color-panel) / <alpha-value>)',
'panel-strong': 'rgb(var(--color-panel-strong) / <alpha-value>)',
'panel-soft': 'rgb(var(--color-panel-soft) / <alpha-value>)',
primary: 'rgb(var(--color-primary) / <alpha-value>)',
'primary-hover': 'rgb(var(--color-primary-hover) / <alpha-value>)',
secondary: 'rgb(var(--color-secondary) / <alpha-value>)',
text: 'rgb(var(--color-text) / <alpha-value>)',
muted: 'rgb(var(--color-text-muted) / <alpha-value>)',
border: 'rgb(var(--color-border) / <alpha-value>)',
},
boxShadow: {
'glow': '0 0 20px -5px rgb(var(--color-primary) / 0.45)',
'glass': '0 8px 32px 0 rgb(0 0 0 / 0.32)',
}
}
}
}
</script>
<style>
/* Custom Scrollbar */
::-webkit-scrollbar {
+14 -5
View File
@@ -15,23 +15,32 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"pinia": "^3.0.4",
"vue": "^3.5.12",
"vue-i18n": "^11.2.8"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@eslint/js": "^9.39.2",
"@types/node": "^22.14.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.7.0",
"globals": "^17.0.0",
"postcss": "^8.5.6",
"prettier": "^3.3.3",
"vue-eslint-parser": "^10.2.0",
"@vitejs/plugin-vue": "^5.2.4",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vitest": "^2.1.9"
"vitest": "^2.1.9",
"vue-eslint-parser": "^10.2.0"
}
}
+885 -60
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -21,5 +21,9 @@ tauri-build = { version = "2.5.3", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
sha2 = "0.10"
fs2 = "0.4"
portable-pty = "0.9.0"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2.6.0"
+1703
View File
File diff suppressed because it is too large. Load diff
+213 -15
View File
@@ -1,31 +1,229 @@
<template>
<div v-if="activeTab === 'workspaces'" class="flex h-screen w-full bg-background app-shell relative overflow-hidden">
<WorkspaceSelection @select-workspace="activeTab = 'chat'" />
</div>
<div
v-else
class="flex h-screen w-full bg-background app-shell font-sans relative overflow-hidden"
class="window-frame"
:class="{ 'window-frame--max': isMaximized, 'window-frame--inactive': !isFocused }"
>
<SidebarNav :active-tab="activeTab" @change="activeTab = $event" />
<main class="flex-1 h-full overflow-hidden relative flex flex-col pb-16 md:pb-0">
<SkillStore v-if="activeTab === 'store'" />
<PluginMarketplace v-else-if="activeTab === 'plugins'" />
<Settings v-else-if="activeTab === 'settings'" @logout="activeTab = 'workspaces'" />
<ChatInterface v-else />
</main>
<header
class="titlebar"
:class="{ 'titlebar--mac': isMacOS }"
data-tauri-drag-region
@dblclick="handleToggleMaximize"
>
<div class="titlebar__left" data-tauri-drag-region>
<span class="titlebar__title">{{ windowTitle }}</span>
</div>
<div v-if="showWindowControls" class="titlebar__controls" data-tauri-drag-region="false" @dblclick.stop>
<button
type="button"
class="titlebar__btn"
:aria-label="t('app.windowControls.minimize')"
:title="t('app.windowControls.minimize')"
data-tauri-drag-region="false"
@click="handleMinimize"
>
<svg viewBox="0 0 10 10" aria-hidden="true">
<line x1="1" y1="5" x2="9" y2="5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<button
type="button"
class="titlebar__btn"
:aria-label="t('app.windowControls.maximize')"
:title="t('app.windowControls.maximize')"
data-tauri-drag-region="false"
@click="handleToggleMaximize"
>
<svg viewBox="0 0 10 10" aria-hidden="true">
<rect x="2" y="2" width="6" height="6" fill="none" stroke="currentColor" stroke-width="1.2" rx="0.6" />
</svg>
</button>
<button
type="button"
class="titlebar__btn titlebar__btn--close"
:aria-label="t('app.windowControls.close')"
:title="t('app.windowControls.close')"
data-tauri-drag-region="false"
@click="handleClose"
>
<svg viewBox="0 0 10 10" aria-hidden="true">
<line x1="2" y1="2" x2="8" y2="8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<line x1="8" y1="2" x2="2" y2="8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
</div>
</header>
<div class="window-body">
<div v-if="isTerminalView" class="flex h-full w-full bg-background app-shell relative overflow-hidden">
<TerminalWorkspace />
</div>
<div
v-else-if="showWorkspaceSelection"
class="flex h-full w-full bg-background app-shell relative overflow-y-auto overflow-x-hidden"
>
<WorkspaceSelection />
</div>
<div
v-else-if="!appReady"
class="flex h-full w-full bg-background app-shell relative overflow-hidden items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-white/70 text-sm">
<div class="h-10 w-10 rounded-full border border-white/20 border-t-transparent animate-spin"></div>
<span>Loading workspace...</span>
</div>
</div>
<div
v-else
class="flex h-full w-full bg-background app-shell font-sans relative overflow-hidden"
>
<SidebarNav :active-tab="activeTab" @change="setActiveTab($event)" />
<main class="flex-1 h-full overflow-hidden relative flex flex-col pb-16 md:pb-0">
<SkillStore v-if="activeTab === 'store'" />
<PluginMarketplace v-else-if="activeTab === 'plugins'" />
<Settings
v-else-if="activeTab === 'settings'"
@logout="setActiveTab('workspaces')"
/>
<ChatInterface v-else />
</main>
</div>
</div>
<ToastStack />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import SidebarNav from '@/shared/components/SidebarNav.vue';
import ToastStack from '@/shared/components/ToastStack.vue';
import SkillStore from '@/features/SkillStore.vue';
import PluginMarketplace from '@/features/PluginMarketplace.vue';
import TerminalWorkspace from '@/features/terminal/TerminalWorkspace.vue';
import Settings from '@/features/Settings.vue';
import WorkspaceSelection from '@/features/WorkspaceSelection.vue';
import ChatInterface from '@/features/chat/ChatInterface.vue';
import { useWorkspaceStore } from '@/features/workspace/workspaceStore';
import { useGlobalStore } from '@/features/global/globalStore';
import { useNavigationStore } from '@/stores/navigationStore';
import { useWorkspaceBootstrap } from './useWorkspaceBootstrap';
import { isTauri } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
type TabId = 'workspaces' | 'chat' | 'store' | 'plugins' | 'settings';
const navigationStore = useNavigationStore();
const { activeTab } = storeToRefs(navigationStore);
const { setActiveTab } = navigationStore;
const workspaceStore = useWorkspaceStore();
const { currentWorkspace } = storeToRefs(workspaceStore);
const showWorkspaceSelection = computed(() => activeTab.value === 'workspaces' || !currentWorkspace.value);
const { appReady } = useWorkspaceBootstrap();
const globalStore = useGlobalStore();
void globalStore.hydrate();
const { t } = useI18n();
const resolvedView =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('view') ??
(window as typeof window & { __NEXUS_VIEW__?: string }).__NEXUS_VIEW__
: null;
const isTerminalView = resolvedView === 'terminal';
const isTauriEnv = isTauri();
const APP_NAME = 'golutra';
const isMacOS = computed(() => typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform));
const showWindowControls = computed(() => isTauriEnv && !isMacOS.value);
const contextTitle = computed(() => {
if (isTerminalView) {
const name = currentWorkspace.value?.name?.trim();
return name ? `${name} Terminal` : 'Terminal';
}
if (showWorkspaceSelection.value) return 'Workspaces';
if (!appReady.value) return 'Loading';
const workspaceName = currentWorkspace.value?.name?.trim();
return workspaceName || 'Home';
});
const windowTitle = computed(() => `${contextTitle.value} - ${APP_NAME}`);
const appWindow = isTauriEnv ? getCurrentWindow() : null;
const isMaximized = ref(false);
const isFocused = ref(true);
let unlistenResize: (() => void) | null = null;
let unlistenFocus: (() => void) | null = null;
const activeTab = ref<TabId>('workspaces');
const refreshMaximized = async () => {
if (!appWindow) return;
try {
isMaximized.value = await appWindow.isMaximized();
} catch {
isMaximized.value = false;
}
};
const handleMinimize = () => {
if (!appWindow) return;
void appWindow.minimize();
};
const handleToggleMaximize = () => {
if (!appWindow) return;
void appWindow.toggleMaximize();
};
const handleClose = () => {
if (!appWindow) return;
void appWindow.close();
};
watch(
() => currentWorkspace.value,
(workspace) => {
if (!workspace) {
setActiveTab('workspaces');
return;
}
if (activeTab.value === 'workspaces') {
setActiveTab('chat');
}
},
{ immediate: true }
);
watch(
windowTitle,
(title) => {
if (!appWindow) return;
appWindow.setTitle(title).catch(() => {});
},
{ immediate: true }
);
onMounted(() => {
if (!appWindow) return;
void refreshMaximized();
appWindow
.onResized(() => {
void refreshMaximized();
})
.then((unlisten) => {
unlistenResize = unlisten;
})
.catch(() => {});
appWindow
.onFocusChanged((event) => {
isFocused.value = event.payload;
})
.then((unlisten) => {
unlistenFocus = unlisten;
})
.catch(() => {});
});
onBeforeUnmount(() => {
if (unlistenResize) {
unlistenResize();
unlistenResize = null;
}
if (unlistenFocus) {
unlistenFocus();
unlistenFocus = null;
}
});
</script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+273
View File
@@ -0,0 +1,273 @@
<template>
<div ref="rootRef" class="h-full w-full bg-[#0b0f14]">
<div ref="terminalRef" class="h-full w-full"></div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import '@xterm/xterm/css/xterm.css';
import { getSessionHistory, resizeSession, subscribeExit, subscribeOutput, writeSession } from './terminalBridge';
const props = defineProps<{ sessionId: string; active: boolean }>();
const sessionId = props.sessionId;
const terminalRef = ref<HTMLDivElement | null>(null);
const rootRef = ref<HTMLDivElement | null>(null);
const fitAddon = new FitAddon();
let webglAddon: WebglAddon | null = null;
let terminal: Terminal | null = null;
let resizeObserver: ResizeObserver | null = null;
let unsubscribeOutput: (() => void) | null = null;
let unsubscribeExit: (() => void) | null = null;
let mouseUpHandler: ((event: MouseEvent) => void) | null = null;
let refreshRaf: number | null = null;
let refreshRafTail: number | null = null;
let historyReady = false;
const isMac = navigator.platform.toLowerCase().includes('mac');
let lastCopiedSelection = '';
const copySelection = (options?: { force?: boolean; clear?: boolean }) => {
if (!terminal || !terminal.hasSelection()) {
return;
}
const selection = terminal.getSelection();
if (!selection || selection === lastCopiedSelection) {
if (options?.force && selection) {
void navigator.clipboard.writeText(selection).catch(() => {});
if (options?.clear) {
terminal.clearSelection();
terminal.focus();
lastCopiedSelection = '';
}
}
return;
}
lastCopiedSelection = selection;
void navigator.clipboard.writeText(selection).catch(() => {});
if (options?.clear) {
terminal.clearSelection();
terminal.focus();
lastCopiedSelection = '';
}
};
const attachClipboardHandlers = (root: HTMLElement) => {
if (!terminal) {
return;
}
terminal.attachCustomKeyEventHandler((event) => {
const ctrlKey = isMac ? event.metaKey : event.ctrlKey;
const key = event.key.toLowerCase();
if (ctrlKey && key === 'c') {
if (terminal.hasSelection()) {
copySelection({ force: true, clear: true });
return false;
}
return true;
}
return true;
});
mouseUpHandler = (event) => {
if (event.button === 0) {
copySelection();
}
};
root.addEventListener('mouseup', mouseUpHandler);
};
const fitTerminal = () => {
if (!terminal) {
return;
}
fitAddon.fit();
if (terminal.cols > 0 && terminal.rows > 0) {
void resizeSession(sessionId, terminal.cols, terminal.rows).catch(() => {});
}
};
const scheduleWebglRefresh = () => {
if (!terminal || !webglAddon) {
return;
}
if (refreshRaf !== null || refreshRafTail !== null) {
return;
}
refreshRaf = window.requestAnimationFrame(() => {
refreshRaf = null;
refreshRafTail = window.requestAnimationFrame(() => {
refreshRafTail = null;
if (!terminal || !webglAddon) {
return;
}
if (terminal.rows <= 0 || terminal.cols <= 0) {
return;
}
try {
terminal.clearTextureAtlas();
} catch {}
terminal.refresh(0, terminal.rows - 1);
});
});
};
const attachOutput = () => {
if (!terminal || unsubscribeOutput) {
return;
}
unsubscribeOutput = subscribeOutput(sessionId, (data) => {
terminal?.write(data);
});
unsubscribeExit = subscribeExit(sessionId, (payload) => {
const reason = payload.signal ? `signal ${payload.signal}` : `code ${payload.code ?? 'unknown'}`;
terminal?.writeln(`\r\n[process exited: ${reason}]`);
});
};
const detachOutput = () => {
unsubscribeOutput?.();
unsubscribeOutput = null;
unsubscribeExit?.();
unsubscribeExit = null;
};
const applyActiveState = (isActive: boolean) => {
if (!terminal) {
return;
}
terminal.options.disableStdin = !isActive;
if (!historyReady) {
if (!isActive) {
detachOutput();
resizeObserver?.disconnect();
terminal.blur();
}
return;
}
if (isActive) {
attachOutput();
if (rootRef.value && resizeObserver) {
resizeObserver.observe(rootRef.value);
}
void nextTick(() => {
fitTerminal();
scheduleWebglRefresh();
terminal?.focus();
});
} else {
detachOutput();
resizeObserver?.disconnect();
terminal.blur();
}
};
onMounted(async () => {
if (!terminalRef.value || !rootRef.value) {
return;
}
terminal = new Terminal({
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace",
fontSize: 13,
scrollback: 5000,
theme: {
background: '#0b0f14',
foreground: '#e5e7eb',
cursor: '#38bdf8'
}
});
terminal.loadAddon(fitAddon);
terminal.open(terminalRef.value);
await nextTick();
if ('fonts' in document) {
try {
await document.fonts.ready;
} catch {}
}
fitTerminal();
try {
webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon?.dispose();
webglAddon = null;
});
terminal.loadAddon(webglAddon);
window.setTimeout(() => {
if (!terminal) {
return;
}
try {
terminal.clearTextureAtlas();
} catch {}
fitTerminal();
}, 50);
} catch {
webglAddon?.dispose();
webglAddon = null;
}
attachClipboardHandlers(rootRef.value);
terminal.onData((data) => {
if (!props.active) {
return;
}
void writeSession(sessionId, data).catch((error) => {
terminal?.writeln(`\r\n[terminal error] ${String(error)}`);
});
});
resizeObserver = new ResizeObserver(() => {
if (props.active) {
fitTerminal();
}
});
try {
const history = await getSessionHistory(sessionId);
if (history) {
terminal?.write(history);
}
} catch (error) {
console.error('Failed to load terminal history.', error);
}
historyReady = true;
applyActiveState(props.active);
});
watch(
() => props.active,
(isActive) => {
applyActiveState(isActive);
},
{ flush: 'post' }
);
onBeforeUnmount(() => {
detachOutput();
if (rootRef.value && mouseUpHandler) {
rootRef.value.removeEventListener('mouseup', mouseUpHandler);
}
if (refreshRaf !== null) {
window.cancelAnimationFrame(refreshRaf);
refreshRaf = null;
}
if (refreshRafTail !== null) {
window.cancelAnimationFrame(refreshRafTail);
refreshRafTail = null;
}
resizeObserver?.disconnect();
webglAddon?.dispose();
webglAddon = null;
terminal?.dispose();
terminal = null;
});
</script>
+265
View File
@@ -0,0 +1,265 @@
<template>
<section class="flex h-full w-full flex-col overflow-hidden">
<header class="flex items-center justify-between px-6 py-4 border-b border-white/5 bg-panel/60 backdrop-blur">
<div>
<h1 class="text-xl font-semibold text-white">{{ t('terminal.title') }}</h1>
<p class="text-xs text-white/40">{{ t('terminal.subtitle') }}</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-semibold uppercase tracking-wide text-white/80 border border-white/10 bg-white/5 hover:bg-white/10 hover:text-white transition"
@click="handleNewTab"
>
<span class="material-symbols-outlined text-[18px]">add</span>
{{ t('terminal.newTab') }}
</button>
</div>
</header>
<div class="flex items-center gap-2 px-6 py-3 border-b border-white/5 bg-surface/30 overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
draggable="true"
@dragstart="onDragStart(tab.id, $event)"
@dragover="onDragOver(tab.id, $event)"
@drop="onDrop(tab.id)"
@dragend="onDragEnd"
@click="setActive(tab.id)"
:class="[
'group flex items-center gap-2 px-3 py-1.5 rounded-lg border transition whitespace-nowrap',
tab.id === activeId
? 'bg-white/10 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/60 hover:text-white hover:border-white/20',
dragOverId === tab.id && dragId !== tab.id ? 'ring-1 ring-primary/60' : ''
]"
>
<span class="material-symbols-outlined text-[16px]">terminal</span>
<span class="text-xs font-semibold">{{ tab.title }}</span>
<span v-if="tab.hasActivity" class="ml-1 w-2 h-2 rounded-full bg-primary shadow-glow"></span>
<span
class="material-symbols-outlined text-[14px] text-white/40 hover:text-white"
@click.stop="closeTab(tab.id)"
>
close
</span>
</button>
<span v-if="tabs.length === 0" class="text-xs text-white/40">
{{ t('terminal.emptyTabs') }}
</span>
</div>
<div class="flex-1 min-h-0 relative">
<div v-if="tabs.length === 0" class="h-full flex flex-col items-center justify-center text-center text-white/50">
<div class="w-14 h-14 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
<span class="material-symbols-outlined text-[28px]">terminal</span>
</div>
<p class="text-sm font-semibold text-white/70">{{ t('terminal.emptyTitle') }}</p>
<p class="text-xs text-white/40 mt-1">{{ t('terminal.emptySubtitle') }}</p>
<button
type="button"
class="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold uppercase tracking-wide text-white bg-primary hover:bg-primary-hover shadow-glow transition"
@click="handleNewTab"
>
<span class="material-symbols-outlined text-[18px]">add</span>
{{ t('terminal.newTab') }}
</button>
</div>
<div v-else class="h-full w-full">
<TerminalPane
v-for="tab in tabs"
:key="tab.id"
:session-id="tab.id"
:active="tab.id === activeId"
v-show="tab.id === activeId"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { emit, listen } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import TerminalPane from './TerminalPane.vue';
import { onActivity } from './terminalBridge';
import { useTerminalStore } from './terminalStore';
import {
TERMINAL_OPEN_TAB_EVENT,
TERMINAL_WINDOW_READY_EVENT,
TERMINAL_WINDOW_READY_REQUEST_EVENT,
type TerminalOpenTabPayload
} from './terminalEvents';
import { useToastStore } from '@/stores/toastStore';
import { useProjectStore } from '@/features/workspace/projectStore';
const { t } = useI18n();
const toastStore = useToastStore();
const { pushToast } = toastStore;
const terminalStore = useTerminalStore();
const { tabs, activeId } = storeToRefs(terminalStore);
const { createTab, setActive, closeTab, moveTab, markActivity, clearActivity, openTab } = terminalStore;
const projectStore = useProjectStore();
const { members } = storeToRefs(projectStore);
const dragId = ref<string | null>(null);
const dragOverId = ref<string | null>(null);
const isCreating = ref(false);
const resolveTabTitle = (memberId: string | undefined, payloadTitle: string) => {
if (memberId) {
const memberName = members.value.find((member) => member.id === memberId)?.name?.trim();
if (memberName) {
return memberName;
}
}
const fallbackTitle = payloadTitle.trim();
return fallbackTitle || payloadTitle;
};
const handleNewTab = async () => {
if (isCreating.value) {
return;
}
isCreating.value = true;
try {
await createTab();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('terminal buffer limit reached')) {
pushToast(t('terminal.resourceLimit'), { tone: 'error' });
} else {
console.error('Failed to start terminal.', error);
}
} finally {
isCreating.value = false;
}
};
const onDragStart = (id: string, event: DragEvent) => {
dragId.value = id;
dragOverId.value = null;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', id);
}
};
const onDragOver = (id: string, event: DragEvent) => {
if (!dragId.value || dragId.value === id) {
return;
}
event.preventDefault();
dragOverId.value = id;
};
const onDrop = (id: string) => {
if (!dragId.value || dragId.value === id) {
dragOverId.value = null;
dragId.value = null;
return;
}
moveTab(dragId.value, id);
dragOverId.value = null;
dragId.value = null;
};
const onDragEnd = () => {
dragOverId.value = null;
dragId.value = null;
};
const stopActivity = onActivity((sessionId) => {
markActivity(sessionId);
});
let stopOpenTab: (() => void) | null = null;
let stopReadyRequest: (() => void) | null = null;
let autoTabTimer: number | null = null;
const emitWindowReady = () => {
try {
const windowLabel = getCurrentWebviewWindow().label;
void emit(TERMINAL_WINDOW_READY_EVENT, { windowLabel });
} catch {
// No-op when running outside a Tauri window.
}
};
watch(activeId, (next) => {
if (next) {
clearActivity(next);
}
});
watch(
members,
(nextMembers) => {
const memberMap = new Map(nextMembers.map((member) => [member.id, member.name]));
for (const tab of tabs.value) {
if (!tab.memberId) {
continue;
}
const memberName = memberMap.get(tab.memberId)?.trim();
if (memberName && tab.title !== memberName) {
tab.title = memberName;
}
}
},
{ deep: true, immediate: true }
);
onMounted(async () => {
const init = async () => {
try {
stopOpenTab = await listen<TerminalOpenTabPayload>(TERMINAL_OPEN_TAB_EVENT, (event) => {
const { sessionId, title, memberId, keepAlive } = event.payload;
const resolvedTitle = resolveTabTitle(memberId, title);
openTab(sessionId, { title: resolvedTitle, memberId, keepAlive });
if (autoTabTimer !== null) {
window.clearTimeout(autoTabTimer);
autoTabTimer = null;
}
});
} catch (error) {
console.error('Failed to listen for terminal tabs.', error);
}
try {
stopReadyRequest = await listen(TERMINAL_WINDOW_READY_REQUEST_EVENT, () => {
emitWindowReady();
});
} catch (error) {
console.error('Failed to listen for terminal ready requests.', error);
}
emitWindowReady();
if (tabs.value.length === 0) {
autoTabTimer = window.setTimeout(() => {
if (tabs.value.length === 0) {
void handleNewTab();
}
}, 600);
}
};
await init();
});
onBeforeUnmount(() => {
stopActivity();
stopOpenTab?.();
stopOpenTab = null;
stopReadyRequest?.();
stopReadyRequest = null;
if (autoTabTimer !== null) {
window.clearTimeout(autoTabTimer);
autoTabTimer = null;
}
});
</script>
+169
View File
@@ -0,0 +1,169 @@
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
type OutputPayload = { sessionId: string; data: string };
type ExitPayload = { sessionId: string; code?: number | null; signal?: string | null };
type StatusPayload = { sessionId: string; status: string; memberId?: string; workspaceId?: string };
type OutputListener = (data: string) => void;
type ExitListener = (payload: ExitPayload) => void;
type ActivityListener = (sessionId: string) => void;
type StatusListener = (payload: StatusPayload) => void;
const outputListeners = new Map<string, Set<OutputListener>>();
const exitListeners = new Map<string, Set<ExitListener>>();
const activityListeners = new Set<ActivityListener>();
const statusListeners = new Set<StatusListener>();
const buffers = new Map<string, string[]>();
const BUFFER_LIMIT = 2000;
let initialized = false;
const pushBuffer = (sessionId: string, data: string) => {
const queue = buffers.get(sessionId) ?? [];
queue.push(data);
if (queue.length > BUFFER_LIMIT) {
queue.splice(0, queue.length - BUFFER_LIMIT);
}
buffers.set(sessionId, queue);
};
const flushBuffer = (sessionId: string, handler: OutputListener) => {
const queue = buffers.get(sessionId);
if (!queue || queue.length === 0) {
return;
}
handler(queue.join(''));
buffers.delete(sessionId);
};
const ensureListeners = async () => {
if (initialized) {
return;
}
initialized = true;
await listen<OutputPayload>('terminal-output', (event) => {
const { sessionId, data } = event.payload;
const listeners = outputListeners.get(sessionId);
if (listeners && listeners.size > 0) {
for (const handler of listeners) {
handler(data);
}
} else {
pushBuffer(sessionId, data);
}
for (const handler of activityListeners) {
handler(sessionId);
}
});
await listen<ExitPayload>('terminal-exit', (event) => {
const { sessionId } = event.payload;
const listeners = exitListeners.get(sessionId);
if (!listeners || listeners.size === 0) {
return;
}
for (const handler of listeners) {
handler(event.payload);
}
});
await listen<StatusPayload>('terminal-status-change', (event) => {
for (const handler of statusListeners) {
handler(event.payload);
}
});
};
export const createSession = async (options?: {
cols?: number;
rows?: number;
cwd?: string;
memberId?: string;
workspaceId?: string;
keepAlive?: boolean;
sessionId?: string;
initialData?: string;
}) => {
await ensureListeners();
return invoke<string>('terminal_create', {
cols: options?.cols,
rows: options?.rows,
cwd: options?.cwd,
memberId: options?.memberId,
workspaceId: options?.workspaceId,
keepAlive: options?.keepAlive,
sessionId: options?.sessionId,
initialData: options?.initialData
});
};
export const writeSession = async (sessionId: string, data: string) => {
await ensureListeners();
return invoke('terminal_write', { sessionId, data });
};
export const resizeSession = async (sessionId: string, cols: number, rows: number) => {
await ensureListeners();
return invoke('terminal_resize', { sessionId, cols, rows });
};
export const closeSession = async (sessionId: string, options?: { preserve?: boolean }) => {
await ensureListeners();
buffers.delete(sessionId);
outputListeners.delete(sessionId);
exitListeners.delete(sessionId);
return invoke('terminal_close', { sessionId, preserve: options?.preserve });
};
export const getSessionHistory = async (sessionId: string) => {
await ensureListeners();
buffers.delete(sessionId);
return invoke<string>('get_session_history', { sessionId });
};
export const subscribeOutput = (sessionId: string, handler: OutputListener) => {
void ensureListeners();
const listeners = outputListeners.get(sessionId) ?? new Set<OutputListener>();
listeners.add(handler);
outputListeners.set(sessionId, listeners);
flushBuffer(sessionId, handler);
return () => {
const current = outputListeners.get(sessionId);
if (!current) {
return;
}
current.delete(handler);
if (current.size === 0) {
outputListeners.delete(sessionId);
}
};
};
export const subscribeExit = (sessionId: string, handler: ExitListener) => {
void ensureListeners();
const listeners = exitListeners.get(sessionId) ?? new Set<ExitListener>();
listeners.add(handler);
exitListeners.set(sessionId, listeners);
return () => {
const current = exitListeners.get(sessionId);
if (!current) {
return;
}
current.delete(handler);
if (current.size === 0) {
exitListeners.delete(sessionId);
}
};
};
export const onActivity = (handler: ActivityListener) => {
activityListeners.add(handler);
return () => activityListeners.delete(handler);
};
export const onStatusChange = (handler: StatusListener) => {
void ensureListeners();
statusListeners.add(handler);
return () => statusListeners.delete(handler);
};
@@ -0,0 +1,240 @@
import { ref } from 'vue';
import { defineStore, storeToRefs } from 'pinia';
import { emitTo, listen } from '@tauri-apps/api/event';
import type { Member, MemberStatus } from '@/features/chat/types';
import { useProjectStore } from '@/features/workspace/projectStore';
import { useWorkspaceStore } from '@/features/workspace/workspaceStore';
import { openTerminalWindow } from './openTerminalWindow';
import { closeSession, createSession, onStatusChange } from './terminalBridge';
import {
TERMINAL_OPEN_TAB_EVENT,
TERMINAL_WINDOW_READY_EVENT,
TERMINAL_WINDOW_READY_REQUEST_EVENT,
type TerminalOpenTabPayload,
type TerminalWindowReadyPayload
} from './terminalEvents';
type MemberTerminalSession = {
memberId: string;
sessionId: string;
command: string;
title: string;
workspaceId: string;
status: MemberStatus;
};
const buildCommandInput = (command: string) => `${command}\r`;
const resolveTitle = (value?: string, fallback?: string) => {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
const fallbackTrimmed = fallback?.trim();
return fallbackTrimmed ?? '';
};
const readyWindowLabels = new Set<string>();
const pendingTabs = new Map<string, TerminalOpenTabPayload[]>();
let readyListenerInitialized = false;
let statusListenerInitialized = false;
const queuePendingTab = (windowLabel: string, payload: TerminalOpenTabPayload) => {
const list = pendingTabs.get(windowLabel) ?? [];
list.push(payload);
pendingTabs.set(windowLabel, list);
};
const flushPendingTabs = async (windowLabel: string) => {
const list = pendingTabs.get(windowLabel);
if (!list || list.length === 0) {
return;
}
pendingTabs.delete(windowLabel);
for (const payload of list) {
await emitTo(windowLabel, TERMINAL_OPEN_TAB_EVENT, payload);
}
};
export const useTerminalMemberStore = defineStore('terminal-member', () => {
const workspaceStore = useWorkspaceStore();
const { currentWorkspace } = storeToRefs(workspaceStore);
const projectStore = useProjectStore();
const { updateMember } = projectStore;
const memberSessions = ref<Record<string, MemberTerminalSession>>({});
const buildMemberKey = (memberId: string, workspaceId?: string) =>
workspaceId ? `${workspaceId}:${memberId}` : memberId;
const ensureReadyListener = () => {
if (readyListenerInitialized) {
return;
}
readyListenerInitialized = true;
void listen<TerminalWindowReadyPayload>(TERMINAL_WINDOW_READY_EVENT, (event) => {
const label = event.payload.windowLabel;
if (!label) {
return;
}
readyWindowLabels.add(label);
void flushPendingTabs(label);
});
};
const ensureStatusListener = () => {
if (statusListenerInitialized) {
return;
}
statusListenerInitialized = true;
onStatusChange((payload) => {
const workspaceId = currentWorkspace.value?.id;
if (!payload.memberId || !payload.workspaceId || payload.workspaceId !== workspaceId) {
return;
}
const status = payload.status as MemberStatus;
if (!['online', 'working', 'dnd', 'offline'].includes(status)) {
return;
}
const entry = Object.values(memberSessions.value).find((item) => item.sessionId === payload.sessionId);
if (entry) {
entry.status = status;
}
void updateMember(payload.memberId, { status });
});
};
const getSession = (memberId: string, workspaceId?: string) => {
const entry = memberSessions.value[buildMemberKey(memberId, workspaceId)];
if (!entry) {
return null;
}
if (workspaceId && entry.workspaceId !== workspaceId) {
return null;
}
return entry;
};
const openMemberTab = async (entry: MemberTerminalSession, titleOverride?: string) => {
const windowLabel = await ensureTerminalWindow();
if (!windowLabel) {
return;
}
const payload: TerminalOpenTabPayload = {
sessionId: entry.sessionId,
title: resolveTitle(titleOverride, entry.title),
memberId: entry.memberId,
keepAlive: true
};
ensureReadyListener();
if (!readyWindowLabels.has(windowLabel)) {
queuePendingTab(windowLabel, payload);
}
await emitTo(windowLabel, TERMINAL_OPEN_TAB_EVENT, payload).catch(() => {});
};
const ensureTerminalWindow = async () => {
ensureReadyListener();
const workspace = currentWorkspace.value;
if (!workspace) {
return null;
}
const result = await openTerminalWindow({ workspaceId: workspace.id, workspaceName: workspace.name });
if (!result) {
return null;
}
if (!result.reused) {
readyWindowLabels.delete(result.label);
}
if (!readyWindowLabels.has(result.label)) {
void emitTo(result.label, TERMINAL_WINDOW_READY_REQUEST_EVENT, {}).catch(() => {});
}
return result.label;
};
const startMemberSession = async (member: Member, options?: { openTab?: boolean }) => {
const workspace = currentWorkspace.value;
if (!workspace) {
return null;
}
const command = member.terminalCommand?.trim();
if (!command) {
return null;
}
const resolvedTitle = resolveTitle(member.name, member.id);
const requestedSessionId = member.name.trim() || undefined;
ensureStatusListener();
const sessionId = await createSession({
cwd: workspace.path,
memberId: member.id,
workspaceId: workspace.id,
keepAlive: true,
sessionId: requestedSessionId,
initialData: buildCommandInput(command)
});
const entry: MemberTerminalSession = {
memberId: member.id,
sessionId,
command,
title: resolvedTitle,
workspaceId: workspace.id,
status: 'online'
};
memberSessions.value[buildMemberKey(member.id, workspace.id)] = entry;
if (options?.openTab ?? true) {
await openMemberTab(entry, resolvedTitle);
}
return entry;
};
const ensureMemberSession = async (member: Member, options?: { openTab?: boolean }) => {
ensureStatusListener();
const workspaceId = currentWorkspace.value?.id;
const existing = getSession(member.id, workspaceId);
if (existing && existing.status !== 'offline') {
if (options?.openTab ?? true) {
await openMemberTab(existing, member.name);
}
return existing;
}
if (existing) {
try {
await closeSession(existing.sessionId, { preserve: false });
} catch {
// Ignore cleanup errors when restarting a session.
}
delete memberSessions.value[buildMemberKey(member.id, workspaceId)];
}
return startMemberSession(member, options);
};
const openMemberTerminal = async (member: Member) => {
ensureStatusListener();
const workspaceId = currentWorkspace.value?.id;
const entry = getSession(member.id, workspaceId);
if (entry) {
await openMemberTab(entry, member.name);
return entry;
}
return startMemberSession(member, { openTab: true });
};
const stopMemberSession = async (memberId: string, options?: { preserve?: boolean }) => {
const workspaceId = currentWorkspace.value?.id;
const entry = getSession(memberId, workspaceId);
if (!entry) {
return;
}
await closeSession(entry.sessionId, { preserve: options?.preserve ?? true });
if (options?.preserve ?? true) {
entry.status = 'offline';
return;
}
delete memberSessions.value[buildMemberKey(memberId, workspaceId)];
};
return {
ensureMemberSession,
openMemberTerminal,
stopMemberSession,
getSession
};
});
+96 -48
View File
@@ -1,21 +1,40 @@
export default {
app: {
name: 'Nexus Dashboard'
name: 'golutra',
windowControls: {
minimize: 'Minimize',
maximize: 'Maximize',
close: 'Close'
}
},
nav: {
chat: 'Chat & Modals',
terminal: 'Terminal',
workspaces: 'Workspaces',
store: 'Skill Store',
plugins: 'Plugins',
settings: 'Settings'
},
terminal: {
title: 'Terminal',
subtitle: 'Run multiple shells in one workspace.',
newTab: 'New Tab',
emptyTabs: 'No terminals yet.',
emptyTitle: 'No active terminals',
emptySubtitle: 'Create a new terminal tab to get started.',
unavailableTitle: 'Terminal unavailable',
unavailableSubtitle: 'Open this view inside the Tauri desktop app to use terminals.',
errorTitle: 'Terminal failed to start',
errorSubtitle: 'Check the desktop runtime and try again.',
resourceLimit: 'System resources are low. Close some background terminals and try again.'
},
common: {
userAvatarAlt: 'User',
remove: 'Remove'
},
chat: {
channelName: 'design-critique',
channelDisplay: '#design-critique',
channelName: 'general',
channelDisplay: '#general',
channelDescription: '',
directMessageDescription: 'Direct message with {name}',
header: {
@@ -35,7 +54,7 @@ export default {
}
},
sidebar: {
workspaceName: 'Frontend Squad',
workspaceName: 'Workspace',
channels: 'Channels',
directMessages: 'Direct Messages',
channelList: {
@@ -97,7 +116,7 @@ export default {
},
actions: {
sendMessage: 'Send Message',
mention: '@ Mention',
mention: 'Mention',
rename: 'Rename'
},
manage: {
@@ -151,7 +170,8 @@ export default {
title: 'Invite as Assistant',
subtitle: 'Select an AI model to join the workspace',
instances: 'Number of Instances',
unlimitedAccess: 'Unlimited Access',
instanceLimit: 'Max {count} instances',
unlimitedAccess: 'Unlimited Mode',
unlimitedAccessDesc: 'Bypass usage limits',
sandboxed: 'Sandboxed environment',
send: 'Send Invitation',
@@ -191,7 +211,7 @@ export default {
subtitle: 'Configure active skills for {channel}',
tabs: {
current: 'Current Skills',
library: 'My Skills Library'
library: 'My Skills'
}
},
current: {
@@ -288,6 +308,8 @@ export default {
searchPlaceholder: 'Search plugins, integrations, and themes...',
browseStore: 'Browse Store',
myPlugins: 'My Plugins',
importTitle: 'Import Plugin',
importSubtitle: 'From URL or Local File',
categories: {
all: 'All Plugins',
productivity: 'Productivity',
@@ -338,7 +360,7 @@ export default {
searchPlaceholder: 'Search skill folders, templates, and toolkits...',
tabs: {
store: 'Store',
installed: 'Installed'
installed: 'My Skills'
},
filters: {
all: 'All Skills',
@@ -351,7 +373,7 @@ export default {
syncPlaceholder: 'Paste sync URL...',
syncNow: 'Sync now',
installFolder: 'Install Folder',
installed: 'Installed',
installed: 'My Skills',
skills: {
automation: {
title: 'Automation Skill',
@@ -380,67 +402,92 @@ export default {
}
},
workspace: {
createTitle: 'Create New Team',
createSubtitle: 'Initialize a fresh collaborative environment',
openTitle: 'Open Folder',
openSubtitle: 'Pick a folder to start or resume a workspace',
recentTitle: 'Recent Workspaces',
more: 'More',
searchPlaceholder: 'Search teams...',
menu: {
settings: 'Workspace Settings',
invite: 'Invite Members',
copyId: 'Copy ID',
leave: 'Leave Workspace'
},
activeLabel: 'ACTIVE',
times: {
twoMinutes: '2m ago',
fourHours: '4h ago',
oneDay: '1d ago'
},
list: {
frontend: {
title: 'Frontend Squad',
desc: 'Main monorepo for the consumer-facing React application.'
},
mobile: {
title: 'Mobile API',
desc: 'GraphQL gateway and microservices for iOS/Android apps.'
},
infrastructure: {
title: 'Infrastructure',
desc: 'Terraform scripts and Kubernetes configuration files.'
}
},
recent: {
designSystems: 'Design Systems',
marketingTeam: 'Marketing Team',
backendCore: 'Backend Core'
}
searchPlaceholder: 'Search folders...',
emptyTitle: 'No recent workspaces',
emptySubtitle: 'Open a folder to start your first workspace.',
openAction: 'Open',
noResults: 'No matching workspaces',
openErrorTitle: 'Unable to open workspace',
readOnlyTitle: 'Workspace is read-only',
readOnlySubtitle: 'This folder is not writable. Project metadata will not be saved.',
registryMismatchTitle: 'Workspace location changed',
registryMismatchMessage: 'The workspace location has changed.\nPrevious path: {oldPath}\nCurrent path: {newPath}\nDid you move the original project or copy a new duplicate?',
registryMismatchMoved: 'Moved',
registryMismatchCopied: 'Copied'
},
settings: {
title: 'Settings',
preferences: 'Preferences',
preferencesSubtitle: 'Customize your account, notifications, and workspace preferences.',
accountSubtitle: 'Manage your profile, presence, and contact details.',
avatarTitle: 'Avatar',
avatarSubtitle: 'Pick a style or upload your own.',
avatarUpload: 'Upload Image',
avatarReset: 'Use Style',
avatarHint: 'PNG/JPG/WEBP up to 2MB.',
avatarUploads: 'Uploads',
avatarErrors: {
invalidType: 'Please upload an image file.',
tooLarge: 'Image is larger than 2MB.',
storageFailed: 'Unable to save avatar.'
},
avatarOptions: {
orbit: 'Orbit',
ember: 'Ember',
mint: 'Mint',
canyon: 'Canyon',
storm: 'Storm'
},
displayName: 'Display Name',
displayNamePlaceholder: 'Enter your display name',
emailAddress: 'Email Address',
emailPlaceholder: 'name@example.com',
emailPlaceholder: 'name{at}example.com',
changeEmail: 'Change Email',
jobTitle: 'Job Title',
jobTitlePlaceholder: 'e.g. Product Designer',
timeZone: 'Time Zone',
timeZones: {
utc: 'UTC',
pacificMidway: 'Pacific/Midway',
pacificHonolulu: 'Pacific/Honolulu',
americaAnchorage: 'America/Anchorage',
americaLosAngeles: 'America/Los_Angeles',
americaDenver: 'America/Denver',
americaChicago: 'America/Chicago',
americaNewYork: 'America/New_York',
americaHalifax: 'America/Halifax',
americaSaoPaulo: 'America/Sao_Paulo',
atlanticAzores: 'Atlantic/Azores',
europeLondon: 'Europe/London',
europeParis: 'Europe/Paris',
europeHelsinki: 'Europe/Helsinki',
europeMoscow: 'Europe/Moscow',
asiaDubai: 'Asia/Dubai',
asiaKarachi: 'Asia/Karachi',
asiaDhaka: 'Asia/Dhaka',
asiaBangkok: 'Asia/Bangkok',
asiaShanghai: 'Asia/Shanghai',
asiaTokyo: 'Asia/Tokyo',
australiaSydney: 'Australia/Sydney',
pacificNoumea: 'Pacific/Noumea',
pacificAuckland: 'Pacific/Auckland'
},
status: 'Status',
statusMessage: 'Status Message',
statusMessagePlaceholder: 'Share what you are working on',
statusOptions: {
online: 'Online',
away: 'Away',
dnd: 'Do Not Disturb'
working: 'Working',
dnd: 'Do Not Disturb',
offline: 'Offline'
},
language: 'Language',
languageDefault: 'Default',
changesApply: 'Changes apply after restart.',
spellCheck: 'Spell Check',
defaultMember: 'Default Member',
selectMember: 'Select Member',
refreshList: 'Refresh List',
@@ -454,12 +501,13 @@ export default {
members: 'Members',
notifications: 'Notifications',
keybinds: 'Keybinds',
createTeam: 'Create Team',
leaveTeam: 'Leave Team',
logOut: 'Log Out',
memberName: 'Member Name',
memberNamePlaceholder: 'Enter member label',
commandInput: 'Command Line Input',
commandPlaceholder: 'e.g. /usr/local/bin/my-cli',
commandPlaceholder: 'CLI startup command, e.g. gemini',
confirm: 'Confirm',
notificationsSubtitle: 'Choose when and how you want to be notified.',
notificationOptions: {
+96 -48
View File
@@ -1,21 +1,40 @@
export default {
app: {
name: 'Nexus Dashboard'
name: 'golutra',
windowControls: {
minimize: '最小化',
maximize: '最大化',
close: '关闭'
}
},
nav: {
chat: '聊天与弹窗',
terminal: '终端',
workspaces: '工作区',
store: '技能商店',
plugins: '插件',
settings: '设置'
},
terminal: {
title: '终端',
subtitle: '在一个工作区内运行多个终端会话。',
newTab: '新建标签',
emptyTabs: '暂无终端。',
emptyTitle: '没有活动终端',
emptySubtitle: '新建一个终端标签开始。',
unavailableTitle: '终端不可用',
unavailableSubtitle: '请在 Tauri 桌面应用中打开该页面。',
errorTitle: '终端启动失败',
errorSubtitle: '请检查桌面运行时后重试。',
resourceLimit: '系统资源不足,无法启动新终端,请先关闭部分后台任务。'
},
common: {
userAvatarAlt: '用户',
remove: '删除'
},
chat: {
channelName: 'design-critique',
channelDisplay: '#design-critique',
channelName: 'general',
channelDisplay: '#general',
channelDescription: '',
directMessageDescription: '与 {name} 的私信',
header: {
@@ -35,7 +54,7 @@ export default {
}
},
sidebar: {
workspaceName: '前端小队',
workspaceName: '工作区',
channels: '频道',
directMessages: '私信',
channelList: {
@@ -97,7 +116,7 @@ export default {
},
actions: {
sendMessage: '发送消息',
mention: '@',
mention: '提及',
rename: '更改名称'
},
manage: {
@@ -151,7 +170,8 @@ export default {
title: '以助手身份邀请',
subtitle: '选择要加入工作区的 AI 模型',
instances: '实例数量',
unlimitedAccess: '无限访问',
instanceLimit: '最多 {count} 个实例',
unlimitedAccess: '无限制模式',
unlimitedAccessDesc: '绕过使用限制',
sandboxed: '沙盒环境',
send: '发送邀请',
@@ -191,7 +211,7 @@ export default {
subtitle: '为 {channel} 配置已启用技能',
tabs: {
current: '当前技能',
library: '我的技能库'
library: '我的技能'
}
},
current: {
@@ -288,6 +308,8 @@ export default {
searchPlaceholder: '搜索插件、集成和主题...',
browseStore: '浏览商店',
myPlugins: '我的插件',
importTitle: '导入插件',
importSubtitle: '从 URL 或本地文件',
categories: {
all: '全部插件',
productivity: '效率',
@@ -338,7 +360,7 @@ export default {
searchPlaceholder: '搜索技能文件夹、模板和工具包...',
tabs: {
store: '商店',
installed: '已安装'
installed: '我的技能'
},
filters: {
all: '全部技能',
@@ -351,7 +373,7 @@ export default {
syncPlaceholder: '粘贴同步 URL...',
syncNow: '立即同步',
installFolder: '安装文件夹',
installed: '已安装',
installed: '我的技能',
skills: {
automation: {
title: '自动化技能',
@@ -380,67 +402,92 @@ export default {
}
},
workspace: {
createTitle: '创建新团队',
createSubtitle: '初始化全新的协作环境',
openTitle: '打开文件夹',
openSubtitle: '选择一个文件夹开始或恢复工作区',
recentTitle: '最近的工作区',
more: '更多',
searchPlaceholder: '搜索团队...',
menu: {
settings: '工作区设置',
invite: '邀请成员',
copyId: '复制 ID',
leave: '退出工作区'
},
activeLabel: '活跃',
times: {
twoMinutes: '2 分钟前',
fourHours: '4 小时前',
oneDay: '1 天前'
},
list: {
frontend: {
title: '前端小队',
desc: '面向用户的 React 应用主仓库。'
},
mobile: {
title: '移动端 API',
desc: '面向 iOS/Android 的 GraphQL 网关与微服务。'
},
infrastructure: {
title: '基础设施',
desc: 'Terraform 脚本与 Kubernetes 配置文件。'
}
},
recent: {
designSystems: '设计系统',
marketingTeam: '市场团队',
backendCore: '后端核心'
}
searchPlaceholder: '搜索文件夹...',
emptyTitle: '暂无最近工作区',
emptySubtitle: '打开文件夹以创建你的第一个工作区。',
openAction: '打开',
noResults: '未找到匹配的工作区',
openErrorTitle: '无法打开工作区',
readOnlyTitle: '工作区只读',
readOnlySubtitle: '当前文件夹不可写,项目数据不会保存。',
registryMismatchTitle: '工作区位置变化',
registryMismatchMessage: '检测到项目位置发生变化。\n旧路径: {oldPath}\n当前路径: {newPath}\n你是移动了原项目,还是复制了一个新副本?',
registryMismatchMoved: '移动了',
registryMismatchCopied: '复制副本'
},
settings: {
title: '设置',
preferences: '偏好设置',
preferencesSubtitle: '自定义账号、通知与工作区偏好。',
accountSubtitle: '管理你的资料、状态与联系方式。',
avatarTitle: '头像',
avatarSubtitle: '选择样式或上传图片。',
avatarUpload: '上传图片',
avatarReset: '恢复样式',
avatarHint: '支持 PNG/JPG/WEBP,最大 2MB。',
avatarUploads: '已上传',
avatarErrors: {
invalidType: '请上传图片文件。',
tooLarge: '图片超过 2MB。',
storageFailed: '头像存储失败。'
},
avatarOptions: {
orbit: '轨道',
ember: '余烬',
mint: '薄荷',
canyon: '峡谷',
storm: '风暴'
},
displayName: '显示名称',
displayNamePlaceholder: '输入显示名称',
emailAddress: '邮箱地址',
emailPlaceholder: 'name@example.com',
emailPlaceholder: 'name{at}example.com',
changeEmail: '更改邮箱',
jobTitle: '职位',
jobTitlePlaceholder: '例如 产品设计师',
timeZone: '时区',
timeZones: {
utc: 'UTC协调世界时',
pacificMidway: '太平洋/中途岛',
pacificHonolulu: '太平洋/檀香山',
americaAnchorage: '美洲/安克雷奇',
americaLosAngeles: '美洲/洛杉矶',
americaDenver: '美洲/丹佛',
americaChicago: '美洲/芝加哥',
americaNewYork: '美洲/纽约',
americaHalifax: '美洲/哈利法克斯',
americaSaoPaulo: '美洲/圣保罗',
atlanticAzores: '大西洋/亚速尔',
europeLondon: '欧洲/伦敦',
europeParis: '欧洲/巴黎',
europeHelsinki: '欧洲/赫尔辛基',
europeMoscow: '欧洲/莫斯科',
asiaDubai: '亚洲/迪拜',
asiaKarachi: '亚洲/卡拉奇',
asiaDhaka: '亚洲/达卡',
asiaBangkok: '亚洲/曼谷',
asiaShanghai: '亚洲/上海',
asiaTokyo: '亚洲/东京',
australiaSydney: '澳大利亚/悉尼',
pacificNoumea: '太平洋/努美阿',
pacificAuckland: '太平洋/奥克兰'
},
status: '状态',
statusMessage: '状态信息',
statusMessagePlaceholder: '分享你正在做的事情',
statusOptions: {
online: '在线',
away: '离开',
dnd: '请勿打扰'
working: '工作中',
dnd: '请勿打扰',
offline: '离线'
},
language: '语言',
languageDefault: '默认',
changesApply: '重启后生效。',
spellCheck: '拼写检查',
defaultMember: '默认成员',
selectMember: '选择成员',
refreshList: '刷新列表',
@@ -454,12 +501,13 @@ export default {
members: '成员',
notifications: '通知',
keybinds: '快捷键',
createTeam: '新建团队',
leaveTeam: '退出团队',
logOut: '退出账号',
memberName: '成员名称',
memberNamePlaceholder: '输入成员标签',
commandInput: '命令行输入',
commandPlaceholder: '例如 /usr/local/bin/my-cli',
commandPlaceholder: '终端启动命令 例如:gemini',
confirm: '确认',
notificationsSubtitle: '选择你接收通知的方式与时机。',
notificationOptions: {
+361 -4
View File
@@ -1,3 +1,265 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Be Vietnam Pro";
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("../assets/fonts/BeVietnamPro-300.woff2") format("woff2");
}
@font-face {
font-family: "Be Vietnam Pro";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../assets/fonts/BeVietnamPro-400.woff2") format("woff2");
}
@font-face {
font-family: "Be Vietnam Pro";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../assets/fonts/BeVietnamPro-500.woff2") format("woff2");
}
@font-face {
font-family: "Be Vietnam Pro";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("../assets/fonts/BeVietnamPro-600.woff2") format("woff2");
}
@font-face {
font-family: "Be Vietnam Pro";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("../assets/fonts/BeVietnamPro-700.woff2") format("woff2");
}
@font-face {
font-family: "Material Symbols Outlined";
font-style: normal;
font-weight: 100 700;
font-display: swap;
src: url("../assets/fonts/MaterialSymbolsOutlined.woff2") format("woff2");
}
.material-symbols-outlined {
font-family: "Material Symbols Outlined";
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: "liga";
-webkit-font-smoothing: antialiased;
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
}
body {
-webkit-user-select: none;
user-select: none;
cursor: default;
}
html,
body,
#app {
height: 100%;
background: transparent;
}
html {
overflow: hidden;
overscroll-behavior: none;
}
#app {
width: 100%;
}
.xterm {
overflow: hidden;
}
.xterm .xterm-scrollable-element {
overflow-x: hidden;
}
.xterm-viewport {
overflow-x: hidden !important;
}
.xterm .xterm-helpers {
left: 0;
right: 0;
width: 100%;
}
.xterm .xterm-helper-textarea {
position: absolute !important;
width: 1px !important;
height: 1px !important;
max-width: 1px !important;
max-height: 1px !important;
overflow: hidden !important;
padding: 0 !important;
border: 0 !important;
margin: 0 !important;
opacity: 0 !important;
z-index: -1;
}
.xterm .composition-view {
max-width: 100% !important;
box-sizing: border-box !important;
overflow: hidden;
text-overflow: clip;
}
.xterm .xterm-screen {
overflow: hidden;
}
.window-frame {
display: flex;
flex-direction: column;
height: 100%;
border-radius: 12px;
overflow: hidden;
overflow: clip;
border: 1px solid transparent;
background-color: rgb(var(--color-background));
background-image: var(--app-gradient), var(--app-gradient-spot);
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--window-glow);
}
.window-frame--max {
border-radius: 0;
border-color: transparent;
box-shadow: none;
}
.window-frame--inactive {
box-shadow: var(--window-glow-inactive);
}
.window-body {
flex: 1;
min-height: 0;
}
.titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 10px 0 14px;
background: transparent;
backdrop-filter: blur(16px);
}
.titlebar--mac {
padding-left: 82px;
}
.titlebar__left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.titlebar__title {
font-size: 12px;
font-weight: 600;
color: rgb(var(--color-text) / 0.7);
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.titlebar__controls {
display: flex;
align-items: center;
gap: 2px;
}
.titlebar__btn {
width: 46px;
height: 30px;
border-radius: 6px;
border: 0;
background: transparent;
color: rgb(var(--color-text) / 0.6);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.titlebar__btn svg {
width: 10px;
height: 10px;
}
.titlebar__btn:hover {
background: rgb(var(--color-overlay) / 0.12);
color: rgb(var(--color-text) / 0.9);
}
.titlebar__btn:active {
background: rgb(var(--color-overlay) / 0.2);
}
.titlebar__btn--close:hover {
background: rgb(239 68 68 / 0.85);
color: #fff;
}
.window-frame--inactive .titlebar {
}
.window-frame--inactive .titlebar__title {
color: rgb(var(--color-text) / 0.45);
}
.window-frame--inactive .titlebar__btn {
color: rgb(var(--color-text) / 0.45);
}
.selectable,
.allow-select,
.prose {
-webkit-user-select: text;
user-select: text;
cursor: text;
}
input,
textarea,
[contenteditable='true'] {
-webkit-user-select: text;
user-select: text;
cursor: text;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -47,6 +309,8 @@
--shadow-modal: 0 25px 50px -12px rgb(0 0 0 / 0.5);
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.18), rgb(var(--color-background)) 55%, rgb(5 5 5) 100%);
--app-gradient-spot: radial-gradient(900px circle at 20% 20%, rgb(var(--color-primary) / 0.12), transparent 60%);
--window-glow: 0 0 0 1px rgb(var(--color-primary) / 0.28), 0 0 18px rgb(var(--color-primary) / 0.18);
--window-glow-inactive: 0 0 0 1px rgb(var(--color-border) / 0.08);
color-scheme: dark;
}
@@ -68,6 +332,8 @@
--shadow-modal: 0 25px 50px -12px rgb(15 23 42 / 0.16);
--app-gradient: radial-gradient(1200px circle at top right, rgb(var(--color-primary) / 0.12), rgb(var(--color-background)) 45%, rgb(var(--color-panel)) 100%);
--app-gradient-spot: radial-gradient(800px circle at 20% 15%, rgb(194 231 255 / 0.45), transparent 60%);
--window-glow: 0 0 0 1px rgb(var(--color-primary) / 0.22), 0 0 14px rgb(var(--color-primary) / 0.12);
--window-glow-inactive: 0 0 0 1px rgb(var(--color-border) / 0.18);
color-scheme: light;
}
@@ -99,10 +365,7 @@
}
.app-shell {
background-color: rgb(var(--color-background));
background-image: var(--app-gradient), var(--app-gradient-spot);
background-repeat: no-repeat;
background-size: cover;
background: transparent;
}
:root[data-resolved-theme='light'] .message-bubble--me {
@@ -171,6 +434,10 @@
background-color: rgb(var(--color-overlay) / 0.2) !important;
}
:root[data-resolved-theme='light'] .bg-white\/15 {
background-color: rgb(var(--color-overlay) / 0.15) !important;
}
:root[data-resolved-theme='light'] .bg-white\/10 {
background-color: rgb(var(--color-overlay) / 0.1) !important;
}
@@ -195,6 +462,14 @@
background-color: rgb(var(--color-overlay) / 0.05) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.12\] {
background-color: rgb(var(--color-overlay) / 0.12) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.16\] {
background-color: rgb(var(--color-overlay) / 0.16) !important;
}
:root[data-resolved-theme='light'] .bg-white\/\[0\.06\] {
background-color: rgb(var(--color-overlay) / 0.06) !important;
}
@@ -203,6 +478,14 @@
background-color: rgb(var(--color-overlay) / 0.07) !important;
}
:root[data-resolved-theme='light'] .hover\:bg-white\/15:hover {
background-color: rgb(var(--color-overlay) / 0.15) !important;
}
:root[data-resolved-theme='light'] .hover\:bg-white\/\[0\.16\]:hover {
background-color: rgb(var(--color-overlay) / 0.16) !important;
}
:root[data-resolved-theme='light'] .border-white\/20 {
border-color: rgb(var(--color-border) / 0.45) !important;
}
@@ -226,3 +509,77 @@
:root[data-resolved-theme='light'] .ring-white\/5 {
--tw-ring-color: rgb(var(--color-border) / 0.2) !important;
}
.settings-select {
appearance: base-select;
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgb(var(--color-border) / 0.1);
background: linear-gradient(135deg, rgb(var(--color-background)) 0%, rgb(var(--color-panel)) 100%);
color: rgb(var(--color-text));
color-scheme: dark;
font-size: 0.875rem;
line-height: 1.25rem;
padding: 0.625rem 2.5rem 0.625rem 1rem;
}
.settings-select:focus-visible {
outline: none;
border-color: rgb(var(--color-primary) / 0.5);
box-shadow: 0 0 0 1px rgb(var(--color-primary) / 0.5);
}
.settings-select:open {
border-color: rgb(var(--color-primary) / 0.4);
box-shadow: 0 0 0 1px rgb(var(--color-primary) / 0.35);
}
.settings-select::picker(select) {
background: linear-gradient(135deg, rgb(var(--color-background)) 0%, rgb(var(--color-panel-strong)) 100%);
border: 1px solid rgb(var(--color-border) / 0.1);
border-radius: 0.75rem;
padding: 0.25rem;
box-shadow: var(--shadow-modal);
color: rgb(var(--color-text));
}
.settings-select option {
padding: 0.5rem 0.75rem;
color: rgb(var(--color-text));
background: rgb(var(--color-background));
}
.settings-select option:hover,
.settings-select option:focus-visible {
background: rgb(var(--color-text-muted) / 0.55);
color: rgb(var(--color-background));
}
.settings-select option:checked {
background: rgb(255 255 255 / 0.35);
color: rgb(var(--color-background));
font-weight: 600;
}
.settings-select option::checkmark {
color: rgb(var(--color-background));
}
.settings-select::picker-icon {
color: rgb(var(--color-text) / 0.6);
}
:root[data-resolved-theme='light'] .settings-select {
color-scheme: light;
}
:root[data-resolved-theme='light'] .settings-select option:hover,
:root[data-resolved-theme='light'] .settings-select option:focus-visible,
:root[data-resolved-theme='light'] .settings-select option:checked {
background: rgb(var(--color-panel-strong));
color: rgb(var(--color-text));
}
:root[data-resolved-theme='light'] .settings-select option::checkmark {
color: rgb(var(--color-text));
}