refactor(frontend): move views into app and pages structure

Reorganize the frontend around app-level routing and page modules so the runtime and feature screens share a clearer navigation and composition layout for future work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:13:12 +08:00
parent a27736a832
commit b024a2bcb5
25 changed files with 2628 additions and 1656 deletions

View File

@@ -0,0 +1,314 @@
import { computed, onMounted, ref } from 'vue'
import { settingsApi, type LLMConfig, type LLMModelConfig, type LLMType, type SchedulerConfig } from '@/api/settings'
type ToastState = {
show: boolean
message: string
type: 'success' | 'error'
}
type EditingSnapshot = {
type: string
index: number
data: LLMModelConfig
}
type ProfileState = {
email: string
full_name: string
created_at: string
}
function cloneLLMConfig(config: LLMConfig): LLMConfig {
return JSON.parse(JSON.stringify(config)) as LLMConfig
}
function cloneSchedulerConfig(config: SchedulerConfig): SchedulerConfig {
return JSON.parse(JSON.stringify(config)) as SchedulerConfig
}
function getErrorMessage(error: unknown, fallback: string) {
return (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || fallback
}
export function useSettingsView() {
const loading = ref(false)
const saving = ref(false)
const savingModel = ref<string | null>(null)
const toast = ref<ToastState>({
show: false,
message: '',
type: 'success',
})
const expandedRow = ref<string | null>(null)
const editingSnapshot = ref<EditingSnapshot | null>(null)
const profile = ref<ProfileState>({
email: '',
full_name: '',
created_at: '',
})
const originalProfile = ref({ email: '', full_name: '' })
const newPassword = ref('')
const llmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: [],
})
const originalLlmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: [],
})
const schedulerConfig = ref<SchedulerConfig>({
daily_plan_time: '08:00',
forum_scan_interval_minutes: 30,
todo_ai_generate_time: '08:00',
enabled: true,
})
const originalSchedulerConfig = ref<SchedulerConfig>({})
const showRequiredWarning = computed(() => {
return (llmConfig.value.chat?.length || 0) === 0 ||
(llmConfig.value.embedding?.length || 0) === 0 ||
(llmConfig.value.rerank?.length || 0) === 0
})
const isProfileDirty = computed(() => {
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
})
const isSchedulerDirty = computed(() => {
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
})
function showToast(message: string, type: 'success' | 'error' = 'success') {
toast.value = { show: true, message, type }
window.setTimeout(() => {
toast.value.show = false
}, 3000)
}
function createEmptyModel(type: string): LLMModelConfig {
return {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat'
? 'gpt-4o'
: type === 'vlm'
? 'gpt-4o'
: type === 'embedding'
? 'text-embedding-3-small'
: 'bge-reranker-v2',
base_url: '',
api_key: '',
enabled: true,
}
}
function getRowKey(type: string, index: number): string {
return `${type}-${index}`
}
function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = []
}
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
return
}
const newModel = createEmptyModel(type)
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
expandedRow.value = getRowKey(type, newIndex)
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) as LLMModelConfig }
}
async function removeModel(type: string, index: number) {
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
return
}
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
expandedRow.value = null
editingSnapshot.value = null
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
showToast('删除成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '删除失败'), 'error')
}
}
function toggleRow(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
if (expandedRow.value === key) {
expandedRow.value = null
editingSnapshot.value = null
} else {
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) as LLMModelConfig }
expandedRow.value = key
}
}
function updateModel(type: string, index: number, model: LLMModelConfig) {
llmConfig.value[type as keyof LLMConfig]![index] = model
}
async function loadSettings() {
loading.value = true
try {
const response = await settingsApi.get()
profile.value = {
email: response.data.profile.email,
full_name: response.data.profile.full_name || '',
created_at: response.data.profile.created_at,
}
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
if (response.data.llm_config) {
llmConfig.value = {
chat: response.data.llm_config.chat || [],
vlm: response.data.llm_config.vlm || [],
embedding: response.data.llm_config.embedding || [],
rerank: response.data.llm_config.rerank || [],
}
} else {
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
}
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
if (response.data.scheduler_config && Object.keys(response.data.scheduler_config).length > 0) {
schedulerConfig.value = response.data.scheduler_config as SchedulerConfig
}
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
} catch (error) {
console.error('加载设置失败', error)
showToast('加载设置失败', 'error')
} finally {
loading.value = false
}
}
async function saveProfile() {
saving.value = true
try {
await settingsApi.updateProfile({
full_name: profile.value.full_name,
password: newPassword.value || undefined,
})
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
newPassword.value = ''
showToast('资料保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
saving.value = false
}
}
async function saveModel(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
llmConfig.value[type as keyof LLMConfig]![index] = JSON.parse(JSON.stringify(model)) as LLMModelConfig
savingModel.value = key
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
expandedRow.value = null
editingSnapshot.value = null
showToast('保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
savingModel.value = null
}
}
async function testModel(type: string, index: number, model: LLMModelConfig) {
try {
const response = await settingsApi.testLLM({
type: type as LLMType,
provider: model.provider,
model: model.model,
base_url: model.base_url,
api_key: model.api_key,
})
if (response.data.success) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
showToast('连接成功')
} else {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast(`连接失败: ${response.data.error}`, 'error')
}
} catch (error) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast('测试连接失败', 'error')
}
}
async function saveScheduler() {
saving.value = true
try {
await settingsApi.updateScheduler(schedulerConfig.value)
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
showToast('定时任务配置保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
saving.value = false
}
}
function resetProfile() {
profile.value.full_name = originalProfile.value.full_name
newPassword.value = ''
}
function resetScheduler() {
schedulerConfig.value = cloneSchedulerConfig(originalSchedulerConfig.value)
}
onMounted(loadSettings)
return {
loading,
saving,
savingModel,
toast,
expandedRow,
editingSnapshot,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
}
}

View File

@@ -0,0 +1,633 @@
<script setup lang="ts">
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
import { Save, RotateCcw, Plus } from 'lucide-vue-next'
import { useSettingsView } from '@/pages/settings/composables/useSettingsView'
const {
loading,
saving,
toast,
expandedRow,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
} = useSettingsView()
</script>
<template>
<div class="settings-view scanlines">
<!-- Background -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<span class="header-title">// SETTINGS</span>
</div>
<!-- Toast -->
<Transition name="fade">
<div v-if="toast.show" class="toast" :class="toast.type">
{{ toast.message }}
</div>
</Transition>
<!-- Loading -->
<div v-if="loading" class="loading-overlay">
<div class="loading-text">LOADING...</div>
</div>
<!-- Content -->
<div class="settings-content">
<!-- Profile Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">PROFILE</span>
<button v-if="isProfileDirty" class="reset-btn" @click="resetProfile">
<RotateCcw :size="12" /> 重置
</button>
</div>
<div class="form-group">
<label class="form-label">// EMAIL</label>
<input v-model="profile.email" type="email" disabled class="form-input disabled" />
</div>
<div class="form-group">
<label class="form-label">// NAME</label>
<input v-model="profile.full_name" type="text" class="form-input" placeholder="Your name" />
</div>
<div class="form-group">
<label class="form-label">// NEW PASSWORD (留空保持不变)</label>
<input v-model="newPassword" type="password" class="form-input" placeholder="••••••••" />
</div>
<button class="save-btn" @click="saveProfile" :disabled="saving || !isProfileDirty">
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE PROFILE' }}</span>
</button>
</div>
<!-- LLM Config Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">// LLM CONFIGURATION</span>
</div>
<!-- 必填警告 -->
<div v-if="showRequiredWarning" class="warning-bar">
chat / embedding / rerank 为知识库必填请确保已配置
</div>
<!-- Chat Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">CHAT</span>
<button class="add-btn" @click="addModel('chat')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.chat" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('chat', index)"
@toggle="toggleRow('chat', index, model)"
@update="(m) => updateModel('chat', index, m)"
@delete="removeModel('chat', index)"
@test="(m) => testModel('chat', index, m)"
@save="(m) => saveModel('chat', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 chat 模型配置</div>
</div>
<!-- VLM Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
<button class="add-btn" @click="addModel('vlm')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.vlm" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('vlm', index)"
@toggle="toggleRow('vlm', index, model)"
@update="(m) => updateModel('vlm', index, m)"
@delete="removeModel('vlm', index)"
@test="(m) => testModel('vlm', index, m)"
@save="(m) => saveModel('vlm', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 vlm 模型配置</div>
</div>
<!-- Embedding Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
<button class="add-btn" @click="addModel('embedding')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.embedding" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('embedding', index)"
@toggle="toggleRow('embedding', index, model)"
@update="(m) => updateModel('embedding', index, m)"
@delete="removeModel('embedding', index)"
@test="(m) => testModel('embedding', index, m)"
@save="(m) => saveModel('embedding', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 embedding 模型配置</div>
</div>
<!-- Rerank Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">RERANK <span class="required-tag">(知识库)</span></span>
<button class="add-btn" @click="addModel('rerank')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.rerank" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('rerank', index)"
@toggle="toggleRow('rerank', index, model)"
@update="(m) => updateModel('rerank', index, m)"
@delete="removeModel('rerank', index)"
@test="(m) => testModel('rerank', index, m)"
@save="(m) => saveModel('rerank', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 rerank 模型配置</div>
</div>
</div>
<!-- Scheduler Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">SCHEDULER</span>
<button v-if="isSchedulerDirty" class="reset-btn" @click="resetScheduler">
<RotateCcw :size="12" /> 重置
</button>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">// DAILY PLAN TIME</label>
<input v-model="schedulerConfig.daily_plan_time" type="time" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">// TODO AI GENERATE TIME</label>
<input v-model="schedulerConfig.todo_ai_generate_time" type="time" class="form-input" />
</div>
</div>
<div class="form-group">
<label class="form-label">// FORUM SCAN INTERVAL (minutes)</label>
<input
v-model.number="schedulerConfig.forum_scan_interval_minutes"
type="number"
min="5"
max="1440"
class="form-input"
/>
</div>
<div class="form-group toggle-group">
<label class="form-label">// SCHEDULER ENABLED</label>
<button
class="toggle-btn"
:class="{ active: schedulerConfig.enabled }"
@click="schedulerConfig.enabled = !schedulerConfig.enabled"
>
<span class="toggle-knob"></span>
</button>
</div>
<button
class="save-btn"
@click="saveScheduler"
:disabled="saving || !isSchedulerDirty"
>
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE SCHEDULER' }}</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.settings-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
/* Toast */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 12px;
z-index: 1000;
animation: slide-in 0.3s ease;
}
.toast.success {
background: rgba(0, 245, 212, 0.15);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
}
.toast.error {
background: rgba(255, 71, 87, 0.15);
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* Loading */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.8);
z-index: 50;
}
.loading-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
animation: pulse 1s ease-in-out infinite;
}
/* Content */
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Card */
.settings-card {
background: rgba(13,21,37,0.9);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
}
.reset-btn, .add-btn, .test-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
transition: all var(--transition-fast);
}
.reset-btn:hover, .add-btn:hover, .test-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.add-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.test-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
/* LLM Type Section */
.llm-type-section {
margin-bottom: 20px;
}
.llm-type-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.llm-type-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.optional-tag {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.required-tag {
font-size: 9px;
color: var(--accent-red);
letter-spacing: 0.1em;
}
/* Warning Bar */
.warning-bar {
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 16px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
/* Form */
.form-group {
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-input, .form-select {
width: 100%;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
transition: all var(--transition-fast);
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1);
}
.form-input.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
cursor: pointer;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle .form-input {
flex: 1;
}
.toggle-visibility {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.toggle-visibility:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Toggle */
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-btn {
width: 44px;
height: 22px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: 11px;
padding: 2px;
cursor: pointer;
transition: all 0.25s;
}
.toggle-btn.active {
background: rgba(0,245,212,.15);
border-color: var(--accent-cyan);
}
.toggle-knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
transition: all 0.25s;
}
.toggle-btn.active .toggle-knob {
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
transform: translateX(22px);
}
/* Save Button */
.save-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
}
.save-btn:hover:not(:disabled) {
background: rgba(0,245,212,0.15);
box-shadow: 0 0 12px rgba(0,245,212,0.2);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-btn.full-width {
margin-top: 0;
}
.btn-loader {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>