feat: add mobile responsiveness support

- Hamburger menu + drawer sidebar for mobile navigation
- Auto-collapse chat session list on mobile
- Responsive grids, modals, forms, and settings
- Touch-friendly nav items (44px targets)
- Skills page sidebar toggle on mobile
- Memory sections stack vertically on mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-15 09:12:54 +08:00
parent 29f19ddb30
commit 9556db2f90
24 changed files with 273 additions and 43 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { onMounted, onUnmounted, computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NConfigProvider, NMessageProvider, NDialogProvider, NNotificationProvider } from 'naive-ui'
import { themeOverrides } from '@/styles/theme'
@@ -14,6 +14,11 @@ const ready = ref(false)
const isLoginPage = computed(() => route.name === 'login')
// Close mobile sidebar on route change
watch(() => route.path, () => {
appStore.closeSidebar()
})
// Wait for router to resolve before rendering layout
router.isReady().then(() => {
ready.value = true
@@ -39,6 +44,10 @@ useKeyboard()
<NDialogProvider>
<NNotificationProvider>
<div v-if="ready" class="app-layout" :class="{ 'no-sidebar': isLoginPage }">
<button v-if="!isLoginPage" class="hamburger-btn" @click="appStore.toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" />
<AppSidebar v-if="!isLoginPage" />
<main class="app-main">
<router-view />
+28 -1
View File
@@ -2,7 +2,7 @@
import { renameSession } from '@/api/sessions'
import { useChatStore, type Session } from '@/stores/chat'
import { NButton, NDropdown, NInput, NModal, NPopconfirm, NTooltip, useMessage } from 'naive-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ChatInput from './ChatInput.vue'
import MessageList from './MessageList.vue'
@@ -12,6 +12,23 @@ const message = useMessage()
const { t } = useI18n()
const showSessions = ref(true)
let mobileQuery: MediaQueryList | null = null
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && showSessions.value) {
showSessions.value = false
}
}
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
})
onUnmounted(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
})
const showRenameModal = ref(false)
const renameValue = ref('')
const renameSessionId = ref<string | null>(null)
@@ -603,4 +620,14 @@ async function handleRenameConfirm() {
color: $text-muted;
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.chat-header {
padding: 16px 12px 16px 52px;
}
.context-info {
padding: 0 12px 4px;
}
}
</style>
+3
View File
@@ -36,6 +36,7 @@ const renderedHtml = computed(() => md.render(props.content))
.markdown-body {
font-size: 14px;
line-height: 1.65;
overflow-x: auto;
p {
margin: 0 0 8px;
@@ -93,6 +94,8 @@ const renderedHtml = computed(() => md.render(props.content))
width: 100%;
border-collapse: collapse;
margin: 8px 0;
display: block;
overflow-x: auto;
th, td {
padding: 6px 12px;
+14
View File
@@ -467,4 +467,18 @@ const formattedToolResult = computed(() => {
transform: scale(1);
}
}
@media (max-width: $breakpoint-mobile) {
.msg-user .msg-body {
max-width: 90%;
}
.msg-assistant .msg-body {
max-width: 92%;
}
.msg-system .msg-body {
max-width: 92%;
}
}
</style>
+1 -1
View File
@@ -112,7 +112,7 @@ function handleClose() {
v-model:show="showModal"
preset="card"
:title="isEdit ? t('jobs.editJob') : t('jobs.createJob')"
:style="{ width: '520px' }"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
+1 -1
View File
@@ -55,7 +55,7 @@ const jobsStore = useJobsStore()
.jobs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px;
}
</style>
+21 -2
View File
@@ -66,7 +66,7 @@ function handleNav(key: string) {
</script>
<template>
<aside class="sidebar">
<aside class="sidebar" :class="{ open: appStore.sidebarOpen }">
<div class="sidebar-logo" @click="router.push('/chat')">
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
@@ -376,7 +376,7 @@ function handleNav(key: string) {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
padding: 12px;
border: none;
background: none;
color: $text-secondary;
@@ -442,4 +442,23 @@ function handleNav(key: string) {
font-size: 11px;
color: $text-muted;
}
@media (max-width: $breakpoint-mobile) {
.logo-dance {
display: none;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform $transition-normal;
&.open {
transform: translateX(0);
}
}
}
</style>
+1 -1
View File
@@ -24,7 +24,7 @@ function handleChange(val: string) {
:options="options"
size="tiny"
:consistent-menu-width="false"
style="width: 90px"
class="input-sm"
@update:value="handleChange"
/>
</template>
+1 -1
View File
@@ -150,7 +150,7 @@ function handleClose() {
v-model:show="showModal"
preset="card"
:title="t('models.addProvider')"
:style="{ width: '520px' }"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
:mask-closable="!loading"
@after-leave="emit('close')"
>
+1 -1
View File
@@ -48,7 +48,7 @@ const modelsStore = useModelsStore()
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
</style>
+4 -4
View File
@@ -24,7 +24,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.agent.max_turns"
:min="1" :max="200" :step="5"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ max_turns: v })"
/>
</SettingRow>
@@ -32,7 +32,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.agent.gateway_timeout"
:min="60" :max="7200" :step="60"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ gateway_timeout: v })"
/>
</SettingRow>
@@ -40,7 +40,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.agent.restart_drain_timeout"
:min="10" :max="300" :step="10"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ restart_drain_timeout: v })"
/>
</SettingRow>
@@ -52,7 +52,7 @@ async function save(values: Record<string, any>) {
{ label: t('settings.agent.always'), value: 'always' },
{ label: t('settings.agent.never'), value: 'never' },
]"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => save({ tool_use_enforcement: v })"
/>
</SettingRow>
+2 -2
View File
@@ -30,7 +30,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.memory.memory_char_limit"
:min="100" :max="10000" :step="100"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ memory_char_limit: v })"
/>
</SettingRow>
@@ -38,7 +38,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.memory.user_char_limit"
:min="100" :max="10000" :step="100"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ user_char_limit: v })"
/>
</SettingRow>
+13 -13
View File
@@ -164,7 +164,7 @@ const platforms = [
<!-- Telegram -->
<template v-if="p.key === 'telegram'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('telegram').token || ''" clearable size="small" style="width: 300px" placeholder="123456:ABC-DEF..." @update:value="v => saveCredentials('telegram', { token: v })" />
<NInput :value="getCreds('telegram').token || ''" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => saveCredentials('telegram', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.telegram.require_mention" @update:value="v => saveChannel('telegram', { require_mention: v })" />
@@ -183,7 +183,7 @@ const platforms = [
<!-- Discord -->
<template v-if="p.key === 'discord'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('discord').token || ''" clearable size="small" style="width: 300px" placeholder="Bot token..." @update:value="v => saveCredentials('discord', { token: v })" />
<NInput :value="getCreds('discord').token || ''" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => saveCredentials('discord', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.discord.require_mention" @update:value="v => saveChannel('discord', { require_mention: v })" />
@@ -211,7 +211,7 @@ const platforms = [
<!-- Slack -->
<template v-if="p.key === 'slack'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :value="getCreds('slack').token || ''" clearable size="small" style="width: 300px" placeholder="xoxb-..." @update:value="v => saveCredentials('slack', { token: v })" />
<NInput :value="getCreds('slack').token || ''" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => saveCredentials('slack', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.slack.require_mention" @update:value="v => saveChannel('slack', { require_mention: v })" />
@@ -243,10 +243,10 @@ const platforms = [
<!-- Matrix -->
<template v-if="p.key === 'matrix'">
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
<NInput :value="getCreds('matrix').token || ''" clearable size="small" style="width: 300px" placeholder="syt_..." @update:value="v => saveCredentials('matrix', { token: v })" />
<NInput :value="getCreds('matrix').token || ''" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => saveCredentials('matrix', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
<NInput :value="getCreds('matrix').extra?.homeserver || ''" clearable size="small" style="width: 300px" placeholder="https://matrix.org" @update:value="v => saveCredentials('matrix', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
<NInput :value="getCreds('matrix').extra?.homeserver || ''" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => saveCredentials('matrix', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
<NSwitch :value="settingsStore.matrix.require_mention" @update:value="v => saveChannel('matrix', { require_mention: v })" />
@@ -265,10 +265,10 @@ const platforms = [
<!-- Feishu -->
<template v-if="p.key === 'feishu'">
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
<NInput :value="getCreds('feishu').extra?.app_id || ''" clearable size="small" style="width: 300px" placeholder="cli_..." @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
<NInput :value="getCreds('feishu').extra?.app_id || ''" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
<NInput :value="getCreds('feishu').extra?.app_secret || ''" clearable size="small" style="width: 300px" placeholder="App Secret" @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
<NInput :value="getCreds('feishu').extra?.app_secret || ''" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => saveCredentials('feishu', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.feishu.require_mention" @update:value="v => saveChannel('feishu', { require_mention: v })" />
@@ -281,10 +281,10 @@ const platforms = [
<!-- DingTalk -->
<template v-if="p.key === 'dingtalk'">
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
<NInput :value="getCreds('dingtalk').extra?.client_id || ''" clearable size="small" style="width: 300px" placeholder="Client ID" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
<NInput :value="getCreds('dingtalk').extra?.client_id || ''" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" clearable size="small" style="width: 300px" placeholder="Client Secret" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
<NInput :value="getCreds('dingtalk').extra?.client_secret || ''" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => saveCredentials('dingtalk', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.dingtalk.require_mention" @update:value="v => saveChannel('dingtalk', { require_mention: v })" />
@@ -314,20 +314,20 @@ const platforms = [
</div>
</div>
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
<NInput :value="getCreds('weixin').token || ''" clearable size="small" style="width: 300px" placeholder="Token" @update:value="v => saveCredentials('weixin', { token: v })" />
<NInput :value="getCreds('weixin').token || ''" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => saveCredentials('weixin', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
<NInput :value="getCreds('weixin').extra?.account_id || ''" clearable size="small" style="width: 300px" placeholder="Account ID" @update:value="v => saveCredentials('weixin', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
<NInput :value="getCreds('weixin').extra?.account_id || ''" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => saveCredentials('weixin', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
</SettingRow>
</template>
<!-- WeCom -->
<template v-if="p.key === 'wecom'">
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
<NInput :value="getCreds('wecom').extra?.bot_id || ''" clearable size="small" style="width: 300px" placeholder="Bot ID" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
<NInput :value="getCreds('wecom').extra?.bot_id || ''" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
<NInput :value="getCreds('wecom').extra?.secret || ''" clearable size="small" style="width: 300px" placeholder="Secret" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, secret: v } })" />
<NInput :value="getCreds('wecom').extra?.secret || ''" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => saveCredentials('wecom', { extra: { ...getCreds('wecom').extra, secret: v } })" />
</SettingRow>
</template>
</PlatformCard>
+3 -3
View File
@@ -28,7 +28,7 @@ async function save(values: Record<string, any>) {
{ label: t('settings.session.modeIdle'), value: 'idle' },
{ label: t('settings.session.modeHourly'), value: 'hourly' },
]"
size="small" style="width: 140px"
size="small" class="input-md"
@update:value="v => save({ mode: v })"
/>
</SettingRow>
@@ -36,7 +36,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.sessionReset.idle_minutes"
:min="10" :max="10080" :step="30"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ idle_minutes: v })"
/>
</SettingRow>
@@ -44,7 +44,7 @@ async function save(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.sessionReset.at_hour"
:min="0" :max="23" :step="1"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && save({ at_hour: v })"
/>
</SettingRow>
+16
View File
@@ -52,4 +52,20 @@ defineProps<{
.setting-control {
flex-shrink: 0;
}
@media (max-width: $breakpoint-mobile) {
.setting-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.setting-info {
margin-right: 0;
}
.setting-control {
width: 100%;
}
}
</style>
+6
View File
@@ -88,4 +88,10 @@ function formatCost(n: number): string {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stat-cards {
grid-template-columns: 1fr;
}
}
</style>
+13
View File
@@ -3,6 +3,8 @@ import { ref } from 'vue'
import { checkHealth, fetchAvailableModels, updateDefaultModel, type AvailableModelGroup } from '@/api/system'
export const useAppStore = defineStore('app', () => {
const sidebarOpen = ref(false)
const connected = ref(false)
const serverVersion = ref('')
const modelGroups = ref<AvailableModelGroup[]>([])
@@ -59,7 +61,18 @@ export const useAppStore = defineStore('app', () => {
}
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function closeSidebar() {
sidebarOpen.value = false
}
return {
sidebarOpen,
toggleSidebar,
closeSidebar,
connected,
serverVersion,
modelGroups,
+53
View File
@@ -74,3 +74,56 @@ a {
font-weight: 600;
color: $text-primary;
}
// Responsive utility classes for inline width replacement
.input-sm { width: 90px; }
.input-md { width: 200px; }
.input-lg { width: 300px; }
// Mobile drawer backdrop
.mobile-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 999;
}
// Hamburger button (mobile only)
.hamburger-btn {
display: none;
position: fixed;
top: 16px;
left: 12px;
z-index: 1001;
width: 36px;
height: 36px;
border: none;
background: $bg-card;
border-radius: $radius-sm;
cursor: pointer;
align-items: center;
justify-content: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
// Mobile responsive
@media (max-width: $breakpoint-mobile) {
.mobile-backdrop {
display: block;
}
.hamburger-btn {
display: flex;
}
.page-header {
padding: 16px 12px 16px 52px;
}
.input-sm,
.input-md,
.input-lg {
width: 100%;
}
}
+1
View File
@@ -45,6 +45,7 @@ $font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
$header-height: 60px;
$breakpoint-mobile: 768px;
// Radius
$radius-sm: 6px;
+6 -1
View File
@@ -89,11 +89,16 @@ async function handleLogin() {
.login-card {
width: 480px;
padding: 56px 56px;
max-width: calc(100vw - 32px);
padding: 56px;
border: 1px solid $border-color;
border-radius: $radius-lg;
background: $bg-card;
text-align: center;
@media (max-width: $breakpoint-mobile) {
padding: 32px 24px;
}
}
.login-logo {
+3 -3
View File
@@ -93,21 +93,21 @@ onMounted(async () => {
v-model:value="selectedLog"
:options="logOptions"
size="small"
style="width: 200px"
class="input-md"
@update:value="loadLogs"
/>
<NSelect
:value="levelFilter"
:options="levelOptions"
size="small"
style="width: 110px"
class="input-sm"
@update:value="(v: string) => { levelFilter = v; loadLogs() }"
/>
<NSelect
:value="lineCount"
:options="lineOptions"
size="small"
style="width: 80px"
class="input-sm"
@update:value="(v: number) => { lineCount = v; loadLogs() }"
/>
<input
+4
View File
@@ -215,6 +215,10 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
gap: 16px;
flex: 1;
min-height: 0;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
}
}
.memory-section {
+4 -4
View File
@@ -63,7 +63,7 @@ async function saveApiServer(values: Record<string, any>) {
<SettingRow :label="t('settings.apiServer.host')" :hint="t('settings.apiServer.hostHint')">
<NInput
:value="settingsStore.platforms?.api_server?.host || ''"
size="small" style="width: 200px"
size="small" class="input-md"
@update:value="v => saveApiServer({ host: v })"
/>
</SettingRow>
@@ -71,7 +71,7 @@ async function saveApiServer(values: Record<string, any>) {
<NInputNumber
:value="settingsStore.platforms?.api_server?.port"
:min="1024" :max="65535"
size="small" style="width: 120px"
size="small" class="input-sm"
@update:value="v => v != null && saveApiServer({ port: v })"
/>
</SettingRow>
@@ -79,14 +79,14 @@ async function saveApiServer(values: Record<string, any>) {
<NInput
:value="settingsStore.platforms?.api_server?.key || ''"
type="password" show-password-on="click"
size="small" style="width: 200px"
size="small" class="input-md"
@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"
size="small" class="input-md"
@update:value="v => saveApiServer({ cors_origins: v })"
/>
</SettingRow>
+64 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { NInput } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import SkillList from '@/components/skills/SkillList.vue'
@@ -12,8 +12,23 @@ const loading = ref(false)
const selectedCategory = ref('')
const selectedSkill = ref('')
const searchQuery = ref('')
const showSidebar = ref(true)
let mobileQuery: MediaQueryList | null = null
onMounted(loadSkills)
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
showSidebar.value = !e.matches
}
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
loadSkills()
})
onUnmounted(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
})
async function loadSkills() {
loading.value = true
@@ -35,7 +50,12 @@ function handleSelect(category: string, skill: string) {
<template>
<div class="skills-view">
<header class="page-header">
<h2 class="header-title">{{ t('skills.title') }}</h2>
<div style="display: flex; align-items: center; gap: 8px;">
<h2 class="header-title">{{ t('skills.title') }}</h2>
<button v-if="!showSidebar" class="sidebar-toggle" @click="showSidebar = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
<NInput
v-model:value="searchQuery"
:placeholder="t('skills.searchPlaceholder')"
@@ -48,7 +68,7 @@ function handleSelect(category: string, skill: string) {
<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">
<div v-if="showSidebar" class="skills-sidebar">
<SkillList
:categories="categories"
:selected-skill="selectedCategory && selectedSkill ? `${selectedCategory}/${selectedSkill}` : null"
@@ -87,6 +107,10 @@ function handleSelect(category: string, skill: string) {
.search-input {
width: 220px;
@media (max-width: $breakpoint-mobile) {
width: 100%;
}
}
.skills-content {
@@ -123,6 +147,42 @@ function handleSelect(category: string, skill: string) {
overflow-y: auto;
padding: 16px 20px;
min-width: 0;
}
.sidebar-toggle {
display: none;
border: none;
background: none;
cursor: pointer;
color: $text-secondary;
padding: 4px;
border-radius: $radius-sm;
&:hover {
background: rgba($accent-primary, 0.06);
}
}
@media (max-width: $breakpoint-mobile) {
.sidebar-toggle {
display: flex;
}
.skills-sidebar {
position: absolute;
left: 0;
top: 0;
height: 100%;
z-index: 10;
background: $bg-card;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.skills-layout {
position: relative;
}
}
min-width: 0;
min-height: 0;
}