feat: add i18n, platform channels page, and WeChat QR login
- Add vue-i18n with auto-detect browser language and manual toggle (EN/中文) - Move platform channels to separate page with credential management - Support Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, Weixin, WeCom - Add WeChat QR code login (opens in browser, polls status, auto-saves) - Write platform credentials to ~/.hermes/.env matching hermes gateway setup - Auto restart gateway after platform config changes - Add settings store with per-section save for all config categories - Persist session group collapse state across navigation - Fix pre-existing TypeScript build errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import PlatformSettings from '@/components/settings/PlatformSettings.vue'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.fetchSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="channels-view">
|
||||
<header class="channels-header">
|
||||
<h2 class="header-title">{{ t('sidebar.channels') }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="channels-content">
|
||||
<NSpin :show="settingsStore.loading">
|
||||
<PlatformSettings />
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.channels-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.channels-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.channels-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import JobsPanel from '@/components/jobs/JobsPanel.vue'
|
||||
import JobFormModal from '@/components/jobs/JobFormModal.vue'
|
||||
import { useJobsStore } from '@/stores/jobs'
|
||||
|
||||
const { t } = useI18n()
|
||||
const jobsStore = useJobsStore()
|
||||
const showModal = ref(false)
|
||||
const editingJob = ref<string | null>(null)
|
||||
@@ -37,12 +39,12 @@ async function handleSave() {
|
||||
<template>
|
||||
<div class="jobs-view">
|
||||
<header class="jobs-header">
|
||||
<h2 class="header-title">Scheduled Jobs</h2>
|
||||
<h2 class="header-title">{{ t('jobs.title') }}</h2>
|
||||
<NButton type="primary" @click="openCreateModal">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
Create Job
|
||||
{{ t('jobs.createJob') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NSelect, NButton, NSpin, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchLogFiles, fetchLogs, type LogEntry } from '@/api/logs'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const logFiles = ref<{ name: string; size: string; modified: string }[]>([])
|
||||
const selectedLog = ref('agent')
|
||||
@@ -16,13 +18,13 @@ const logOptions = computed(() =>
|
||||
logFiles.value.map(f => ({ label: `${f.name} (${f.size})`, value: f.name })),
|
||||
)
|
||||
|
||||
const levelOptions = [
|
||||
{ label: 'All', value: '' },
|
||||
const levelOptions = computed(() => [
|
||||
{ label: t('logs.all'), value: '' },
|
||||
{ label: 'ERROR', value: 'ERROR' },
|
||||
{ label: 'WARNING', value: 'WARNING' },
|
||||
{ label: 'INFO', value: 'INFO' },
|
||||
{ label: 'DEBUG', value: 'DEBUG' },
|
||||
]
|
||||
])
|
||||
|
||||
const lineOptions = [
|
||||
{ label: '50', value: 50 },
|
||||
@@ -85,7 +87,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="logs-view">
|
||||
<header class="logs-header">
|
||||
<h2 class="header-title">Logs</h2>
|
||||
<h2 class="header-title">{{ t('logs.title') }}</h2>
|
||||
<div class="header-actions">
|
||||
<NSelect
|
||||
v-model:value="selectedLog"
|
||||
@@ -111,16 +113,16 @@ onMounted(async () => {
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
placeholder="Search..."
|
||||
:placeholder="t('logs.searchPlaceholder')"
|
||||
/>
|
||||
<NButton size="small" :loading="loading" @click="loadLogs">Refresh</NButton>
|
||||
<NButton size="small" :loading="loading" @click="loadLogs">{{ t('logs.refresh') }}</NButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="logs-body">
|
||||
<NSpin :show="loading">
|
||||
<div v-if="filteredEntries.length === 0 && !loading" class="logs-empty">
|
||||
No log entries
|
||||
{{ t('logs.noEntries') }}
|
||||
</div>
|
||||
<div class="log-list">
|
||||
<div
|
||||
|
||||
+20
-18
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
|
||||
import { fetchMemory, saveMemory, type MemoryData } from '@/api/skills'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const data = ref<MemoryData | null>(null)
|
||||
@@ -19,7 +21,7 @@ async function loadMemory() {
|
||||
data.value = await fetchMemory()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load memory:', err)
|
||||
message.error('Failed to load memory')
|
||||
message.error(t('memory.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -43,9 +45,9 @@ async function handleSave() {
|
||||
await loadMemory()
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
message.success('Saved')
|
||||
message.success(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(`Save failed: ${err.message}`)
|
||||
message.error(`${t('common.saveFailed')}: ${err.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -71,7 +73,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<template>
|
||||
<div class="memory-view">
|
||||
<header class="memory-header">
|
||||
<h2 class="header-title">Memory</h2>
|
||||
<h2 class="header-title">{{ t('memory.title') }}</h2>
|
||||
<NButton size="small" quaternary @click="loadMemory">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -79,12 +81,12 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</template>
|
||||
Refresh
|
||||
{{ t('memory.refresh') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="memory-content">
|
||||
<div v-if="loading && !data" class="memory-loading">Loading...</div>
|
||||
<div v-if="loading && !data" class="memory-loading">{{ t('common.loading') }}</div>
|
||||
<div v-else class="memory-sections">
|
||||
<!-- My Notes -->
|
||||
<div class="memory-section">
|
||||
@@ -98,7 +100,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">My Notes</span>
|
||||
<span class="section-title">{{ t('memory.myNotes') }}</span>
|
||||
<span v-if="data?.memory_mtime" class="section-mtime">{{ formatTime(data.memory_mtime) }}</span>
|
||||
</div>
|
||||
<NButton v-if="editingSection !== 'memory'" size="tiny" quaternary @click="startEdit('memory')">
|
||||
@@ -108,14 +110,14 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
Edit
|
||||
{{ t('common.edit') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- View mode -->
|
||||
<div v-if="editingSection !== 'memory'" class="section-body">
|
||||
<MarkdownRenderer v-if="!memoryEmpty" :content="displayMemory" />
|
||||
<p v-else class="empty-text">No notes yet.</p>
|
||||
<p v-else class="empty-text">{{ t('memory.noNotes') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
@@ -123,12 +125,12 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
placeholder="Write your notes..."
|
||||
:placeholder="t('memory.notesPlaceholder')"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<NButton size="small" @click="cancelEdit">Cancel</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
|
||||
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +145,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">User Profile</span>
|
||||
<span class="section-title">{{ t('memory.userProfile') }}</span>
|
||||
<span v-if="data?.user_mtime" class="section-mtime">{{ formatTime(data.user_mtime) }}</span>
|
||||
</div>
|
||||
<NButton v-if="editingSection !== 'user'" size="tiny" quaternary @click="startEdit('user')">
|
||||
@@ -153,14 +155,14 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</template>
|
||||
Edit
|
||||
{{ t('common.edit') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<!-- View mode -->
|
||||
<div v-if="editingSection !== 'user'" class="section-body">
|
||||
<MarkdownRenderer v-if="!userEmpty" :content="displayUser" />
|
||||
<p v-else class="empty-text">No profile yet.</p>
|
||||
<p v-else class="empty-text">{{ t('memory.noProfile') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
@@ -168,12 +170,12 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
placeholder="Write your profile..."
|
||||
:placeholder="t('memory.profilePlaceholder')"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="edit-actions">
|
||||
<NButton size="small" @click="cancelEdit">Cancel</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
|
||||
<NButton size="small" @click="cancelEdit">{{ t('common.cancel') }}</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProvidersPanel from '@/components/models/ProvidersPanel.vue'
|
||||
import ProviderFormModal from '@/components/models/ProviderFormModal.vue'
|
||||
import { useModelsStore } from '@/stores/models'
|
||||
|
||||
const { t } = useI18n()
|
||||
const modelsStore = useModelsStore()
|
||||
const showModal = ref(false)
|
||||
|
||||
@@ -29,12 +31,12 @@ async function handleSaved() {
|
||||
<template>
|
||||
<div class="models-view">
|
||||
<header class="models-header">
|
||||
<h2 class="header-title">Models</h2>
|
||||
<h2 class="header-title">{{ t('models.title') }}</h2>
|
||||
<NButton type="primary" @click="openCreateModal">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
Add Provider
|
||||
{{ t('models.addProvider') }}
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
|
||||
+80
-201
@@ -1,128 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
NButton, NSwitch, NSlider, NDataTable, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { onMounted } from 'vue'
|
||||
import { NTabs, NTabPane, NSpin, NSwitch, NInput, NInputNumber, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import DisplaySettings from '@/components/settings/DisplaySettings.vue'
|
||||
import AgentSettings from '@/components/settings/AgentSettings.vue'
|
||||
import MemorySettings from '@/components/settings/MemorySettings.vue'
|
||||
import SessionSettings from '@/components/settings/SessionSettings.vue'
|
||||
import PrivacySettings from '@/components/settings/PrivacySettings.vue'
|
||||
import SettingRow from '@/components/settings/SettingRow.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const testingConnection = ref(false)
|
||||
onMounted(() => {
|
||||
settingsStore.fetchSettings()
|
||||
})
|
||||
|
||||
async function handleTestConnection() {
|
||||
testingConnection.value = true
|
||||
async function saveApiServer(values: Record<string, any>) {
|
||||
try {
|
||||
await appStore.checkConnection()
|
||||
if (appStore.connected) {
|
||||
message.success('Connected successfully')
|
||||
} else {
|
||||
message.error('Connection failed')
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message)
|
||||
} finally {
|
||||
testingConnection.value = false
|
||||
await settingsStore.saveSection('platforms', { api_server: values })
|
||||
message.success(t('settings.saved'))
|
||||
} catch (err: any) {
|
||||
message.error(t('settings.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const providerColumns = [
|
||||
{ title: 'Provider', key: 'provider' },
|
||||
{ title: 'Models', key: 'models' },
|
||||
{ title: 'Base URL', key: 'base_url' },
|
||||
]
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET', endpoint: '/health', description: 'Health Check' },
|
||||
{ method: 'POST', endpoint: '/v1/runs', description: 'Start Async Run' },
|
||||
{ method: 'GET', endpoint: '/v1/runs/{id}/events', description: 'SSE Event Stream' },
|
||||
{ method: 'GET', endpoint: '/api/jobs', description: 'List Jobs' },
|
||||
{ method: 'POST', endpoint: '/api/jobs', description: 'Create Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/run', description: 'Trigger Job Now' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<header class="settings-header">
|
||||
<h2 class="header-title">Settings</h2>
|
||||
<h2 class="header-title">{{ t('settings.title') }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="settings-content">
|
||||
<!-- API Configuration -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">API Configuration</h3>
|
||||
<div class="form-group">
|
||||
<div class="connection-status">
|
||||
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
|
||||
<span>{{ appStore.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
<span v-if="appStore.serverVersion" class="version">v{{ appStore.serverVersion }}</span>
|
||||
</div>
|
||||
<NButton type="primary" size="small" :loading="testingConnection" @click="handleTestConnection">
|
||||
Test Connection
|
||||
</NButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Model Management -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">Model Management</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Current Model</label>
|
||||
<div class="current-model">{{ appStore.selectedModel || 'Not set' }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="appStore.modelGroups.length > 0" class="form-group">
|
||||
<label class="form-label">Available Models</label>
|
||||
<p class="form-hint">Models are discovered from ~/.hermes/auth.json credential pool. Use the sidebar selector to switch.</p>
|
||||
<NDataTable
|
||||
:columns="providerColumns"
|
||||
:data="appStore.modelGroups.map(g => ({
|
||||
provider: g.label,
|
||||
models: g.models.join(', '),
|
||||
base_url: g.base_url,
|
||||
}))"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:row-props="() => ({ style: 'cursor: default;' })"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chat Settings -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">Chat Settings</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Stream Responses</label>
|
||||
<NSwitch v-model:value="appStore.streamEnabled" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Session Persistence</label>
|
||||
<NSwitch v-model:value="appStore.sessionPersistence" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Max Tokens: {{ appStore.maxTokens }}</label>
|
||||
<NSlider v-model:value="appStore.maxTokens" :min="256" :max="32768" :step="256" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">About</h3>
|
||||
<p class="about-text">
|
||||
Hermes Agent Web UI
|
||||
<br />Version 0.1.3
|
||||
</p>
|
||||
<div class="endpoint-table">
|
||||
<NDataTable
|
||||
:columns="[{ title: 'Method', key: 'method', width: 80 }, { title: 'Endpoint', key: 'endpoint' }, { title: 'Description', key: 'description' }]"
|
||||
:data="endpoints"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:row-props="() => ({ style: 'cursor: default;' })"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<NSpin :show="settingsStore.loading">
|
||||
<NTabs type="line" animated>
|
||||
<NTabPane name="display" :tab="t('settings.tabs.display')">
|
||||
<DisplaySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="agent" :tab="t('settings.tabs.agent')">
|
||||
<AgentSettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="memory" :tab="t('settings.tabs.memory')">
|
||||
<MemorySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="session" :tab="t('settings.tabs.session')">
|
||||
<SessionSettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="privacy" :tab="t('settings.tabs.privacy')">
|
||||
<PrivacySettings />
|
||||
</NTabPane>
|
||||
<NTabPane name="api_server" :tab="t('settings.tabs.apiServer')">
|
||||
<section class="settings-section">
|
||||
<SettingRow :label="t('settings.apiServer.enable')" :hint="t('settings.apiServer.enableHint')">
|
||||
<NSwitch
|
||||
:value="settingsStore.platforms?.api_server?.enabled"
|
||||
@update:value="v => saveApiServer({ enabled: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.host')" :hint="t('settings.apiServer.hostHint')">
|
||||
<NInput
|
||||
:value="settingsStore.platforms?.api_server?.host || ''"
|
||||
size="small" style="width: 200px"
|
||||
@update:value="v => saveApiServer({ host: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.port')" :hint="t('settings.apiServer.portHint')">
|
||||
<NInputNumber
|
||||
:value="settingsStore.platforms?.api_server?.port"
|
||||
:min="1024" :max="65535"
|
||||
size="small" style="width: 120px"
|
||||
@update:value="v => v != null && saveApiServer({ port: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.key')" :hint="t('settings.apiServer.keyHint')">
|
||||
<NInput
|
||||
:value="settingsStore.platforms?.api_server?.key || ''"
|
||||
type="password" show-password-on="click"
|
||||
size="small" style="width: 200px"
|
||||
@update:value="v => saveApiServer({ key: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow :label="t('settings.apiServer.cors')" :hint="t('settings.apiServer.corsHint')">
|
||||
<NInput
|
||||
:value="settingsStore.platforms?.api_server?.cors_origins || ''"
|
||||
size="small" style="width: 200px"
|
||||
@update:value="v => saveApiServer({ cors_origins: v })"
|
||||
/>
|
||||
</SettingRow>
|
||||
</section>
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -154,97 +125,5 @@ const endpoints = [
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 28px;
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-secondary;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid $border-light;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.on {
|
||||
background-color: $success;
|
||||
box-shadow: 0 0 6px rgba($success, 0.5);
|
||||
}
|
||||
|
||||
&.off {
|
||||
background-color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.current-model {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
padding: 6px 10px;
|
||||
background: $bg-secondary;
|
||||
border-radius: $radius-sm;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.endpoint-table {
|
||||
:deep(.n-data-table) {
|
||||
--n-td-color: transparent;
|
||||
--n-th-color: rgba($accent-primary, 0.04);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NInput } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SkillList from '@/components/skills/SkillList.vue'
|
||||
import SkillDetail from '@/components/skills/SkillDetail.vue'
|
||||
import { fetchSkills, type SkillCategory } from '@/api/skills'
|
||||
|
||||
const { t } = useI18n()
|
||||
const categories = ref<SkillCategory[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedCategory = ref('')
|
||||
@@ -33,10 +35,10 @@ function handleSelect(category: string, skill: string) {
|
||||
<template>
|
||||
<div class="skills-view">
|
||||
<header class="skills-header">
|
||||
<h2 class="header-title">Skills</h2>
|
||||
<h2 class="header-title">{{ t('skills.title') }}</h2>
|
||||
<NInput
|
||||
v-model:value="searchQuery"
|
||||
placeholder="Search skills..."
|
||||
:placeholder="t('skills.searchPlaceholder')"
|
||||
size="small"
|
||||
clearable
|
||||
class="search-input"
|
||||
|
||||
Reference in New Issue
Block a user