feat: add model selector, skills/memory pages, and config management
- Add model selector in sidebar that discovers models from auth.json credential pool - Add per-session model display (badge in chat header and session list) - Add skills browser page and memory editor page - Add BFF routes for skills, memory, and config model management - Model switching updates config.yaml provider field to bypass env auto-detection - Refactor Settings page, simplify ChatInput with file upload - Add attachment upload support via BFF /upload endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
|
||||
import { fetchMemory, saveMemory, type MemoryData } from '@/api/skills'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const data = ref<MemoryData | null>(null)
|
||||
const editingSection = ref<'memory' | 'user' | null>(null)
|
||||
const editContent = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(loadMemory)
|
||||
|
||||
async function loadMemory() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await fetchMemory()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load memory:', err)
|
||||
message.error('Failed to load memory')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(section: 'memory' | 'user') {
|
||||
editingSection.value = section
|
||||
editContent.value = data.value?.[section] || ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editingSection.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await saveMemory(editingSection.value, editContent.value)
|
||||
await loadMemory()
|
||||
editingSection.value = null
|
||||
editContent.value = ''
|
||||
message.success('Saved')
|
||||
} catch (err: any) {
|
||||
message.error(`Save failed: ${err.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const memoryEmpty = computed(() => !data.value?.memory?.trim())
|
||||
const userEmpty = computed(() => !data.value?.user?.trim())
|
||||
|
||||
const displayMemory = computed(() => (data.value?.memory || '').replace(/§/g, '\n\n'))
|
||||
const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="memory-view">
|
||||
<header class="memory-header">
|
||||
<h2 class="header-title">Memory</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">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</template>
|
||||
Refresh
|
||||
</NButton>
|
||||
</header>
|
||||
|
||||
<div class="memory-content">
|
||||
<div v-if="loading && !data" class="memory-loading">Loading...</div>
|
||||
<div v-else class="memory-sections">
|
||||
<!-- My Notes -->
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<span class="section-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">My Notes</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')">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<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
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="section-edit">
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
placeholder="Write your notes..."
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-row">
|
||||
<span class="section-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="section-title">User Profile</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')">
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<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
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="section-edit">
|
||||
<textarea
|
||||
v-model="editContent"
|
||||
class="edit-textarea"
|
||||
placeholder="Write your profile..."
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.memory-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.memory-sections {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.memory-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: $bg-secondary;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
color: $text-secondary;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.section-mtime {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: $text-muted;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-edit {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-input;
|
||||
color: $text-primary;
|
||||
font-family: $font-code;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
+53
-60
@@ -1,26 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
NButton, NInput, NSwitch, NSlider, NSelect, NDataTable, useMessage,
|
||||
NButton, NSwitch, NSlider, NDataTable, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { setServerUrl, setApiKey, getBaseUrlValue } from '@/api/client'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const message = useMessage()
|
||||
|
||||
const serverUrl = ref(getBaseUrlValue())
|
||||
const apiKey = ref(localStorage.getItem('hermes_api_key') || '')
|
||||
const testingConnection = ref(false)
|
||||
|
||||
const modelOptions = computed(() =>
|
||||
appStore.models.map(m => ({ label: m.id, value: m.id })),
|
||||
)
|
||||
|
||||
async function handleTestConnection() {
|
||||
testingConnection.value = true
|
||||
setServerUrl(serverUrl.value)
|
||||
if (apiKey.value) setApiKey(apiKey.value)
|
||||
try {
|
||||
await appStore.checkConnection()
|
||||
if (appStore.connected) {
|
||||
@@ -35,34 +26,18 @@ async function handleTestConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveApiKey() {
|
||||
setApiKey(apiKey.value)
|
||||
message.success('API key saved')
|
||||
}
|
||||
|
||||
const endpointColumns = [
|
||||
{ title: 'Method', key: 'method', width: 80 },
|
||||
{ title: 'Endpoint', key: 'endpoint' },
|
||||
{ title: 'Description', key: 'description' },
|
||||
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: 'GET', endpoint: '/v1/health', description: 'Health Check (v1)' },
|
||||
{ method: 'GET', endpoint: '/v1/models', description: 'Model List' },
|
||||
{ method: 'POST', endpoint: '/v1/chat/completions', description: 'Chat Completions (OpenAI compatible)' },
|
||||
{ method: 'POST', endpoint: '/v1/responses', description: 'Create Response (stateful)' },
|
||||
{ method: 'GET', endpoint: '/v1/responses/{id}', description: 'Get Stored Response' },
|
||||
{ method: 'DELETE', endpoint: '/v1/responses/{id}', description: 'Delete Response' },
|
||||
{ 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: 'GET', endpoint: '/api/jobs/{id}', description: 'Get Job Detail' },
|
||||
{ method: 'PATCH', endpoint: '/api/jobs/{id}', description: 'Update Job' },
|
||||
{ method: 'DELETE', endpoint: '/api/jobs/{id}', description: 'Delete Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/pause', description: 'Pause Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/resume', description: 'Resume Job' },
|
||||
{ method: 'POST', endpoint: '/api/jobs/{id}/run', description: 'Trigger Job Now' },
|
||||
]
|
||||
</script>
|
||||
@@ -77,17 +52,6 @@ const endpoints = [
|
||||
<!-- API Configuration -->
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">API Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Server URL</label>
|
||||
<NInput v-model:value="serverUrl" placeholder="http://127.0.0.1:8642" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">API Key (optional)</label>
|
||||
<div class="input-with-action">
|
||||
<NInput v-model:value="apiKey" type="password" show-password-on="click" placeholder="Enter API key" />
|
||||
<NButton size="small" @click="handleSaveApiKey">Save</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="connection-status">
|
||||
<span class="status-dot" :class="{ on: appStore.connected, off: !appStore.connected }"></span>
|
||||
@@ -100,17 +64,34 @@ const endpoints = [
|
||||
</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">Default Model</label>
|
||||
<NSelect
|
||||
v-model:value="appStore.selectedModel"
|
||||
:options="modelOptions"
|
||||
placeholder="Select model"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Stream Responses</label>
|
||||
<NSwitch v-model:value="appStore.streamEnabled" />
|
||||
@@ -130,11 +111,11 @@ const endpoints = [
|
||||
<h3 class="section-title">About</h3>
|
||||
<p class="about-text">
|
||||
Hermes Agent Web UI
|
||||
<br />Version 0.1.0
|
||||
<br />Version 0.1.3
|
||||
</p>
|
||||
<div class="endpoint-table">
|
||||
<NDataTable
|
||||
:columns="endpointColumns"
|
||||
:columns="[{ title: 'Method', key: 'method', width: 80 }, { title: 'Endpoint', key: 'endpoint' }, { title: 'Description', key: 'description' }]"
|
||||
:data="endpoints"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
@@ -202,14 +183,10 @@ const endpoints = [
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-action {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.n-input {
|
||||
flex: 1;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@@ -241,6 +218,22 @@ const endpoints = [
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NInput } from 'naive-ui'
|
||||
import SkillList from '@/components/skills/SkillList.vue'
|
||||
import SkillDetail from '@/components/skills/SkillDetail.vue'
|
||||
import { fetchSkills, type SkillCategory } from '@/api/skills'
|
||||
|
||||
const categories = ref<SkillCategory[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedCategory = ref('')
|
||||
const selectedSkill = ref('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
onMounted(loadSkills)
|
||||
|
||||
async function loadSkills() {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = await fetchSkills()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load skills:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(category: string, skill: string) {
|
||||
selectedCategory.value = category
|
||||
selectedSkill.value = skill
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skills-view">
|
||||
<header class="skills-header">
|
||||
<h2 class="header-title">Skills</h2>
|
||||
<NInput
|
||||
v-model:value="searchQuery"
|
||||
placeholder="Search skills..."
|
||||
size="small"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="skills-content">
|
||||
<div v-if="loading && categories.length === 0" class="skills-loading">Loading...</div>
|
||||
<div v-else class="skills-layout">
|
||||
<div class="skills-sidebar">
|
||||
<SkillList
|
||||
:categories="categories"
|
||||
:selected-skill="selectedCategory && selectedSkill ? `${selectedCategory}/${selectedSkill}` : null"
|
||||
:search-query="searchQuery"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
<div class="skills-main">
|
||||
<SkillDetail
|
||||
v-if="selectedCategory && selectedSkill"
|
||||
:category="selectedCategory"
|
||||
:skill="selectedSkill"
|
||||
/>
|
||||
<div v-else class="empty-detail">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
<span>Select a skill from the list</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skills-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skills-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.skills-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.skills-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skills-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid $border-color;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skills-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user