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:
+10
-1
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user