diff --git a/static/icons/align-left.svg b/static/icons/align-left.svg new file mode 100644 index 0000000..b779e07 --- /dev/null +++ b/static/icons/align-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/chat-bubble.svg b/static/icons/chat-bubble.svg new file mode 100644 index 0000000..7026581 --- /dev/null +++ b/static/icons/chat-bubble.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/icons/copy.svg b/static/icons/copy.svg new file mode 100644 index 0000000..75c8d63 --- /dev/null +++ b/static/icons/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/mobile-overlay-demo.html b/static/mobile-overlay-demo.html new file mode 100644 index 0000000..95a3c29 --- /dev/null +++ b/static/mobile-overlay-demo.html @@ -0,0 +1,341 @@ + + + + + + 移动端多面板示例 + + + +
+
+

对话区域

+ 仅占满屏幕,其他面板以弹层呈现 +
+
你好!我在移动端也可以干活 🙌
+
我想看看其它面板在哪里?
+
点击下方按钮就能半覆盖地拉出侧栏。
+
+ + + +
+
+
+
+
对话记录
+ +
+
+
+ 快速操作 +

新建对话 · 搜索 · 会话列表

+
+
+

#1234 需求讨论 · 刚刚

+
+
+

#1227 UI 迭代 · 1 小时前

+
+
+
+
+ +
+
+
+
+
三合一工作台
+ +
+
+
+ AI Agent v2.4 +

已连接 · 思考模式

+
+
+

项目文件

+

/src/App.vue

+

/stores/ui.ts

+
+
+

待办列表

+

1. 完成移动端布局

+

2. 录制演示

+
+
+

子智能体

+

#05 构建状态 · 运行中

+
+
+
+
+ +
+
+
+
+
聚焦面板
+ +
+
+
+

App.vue

+

...main-container / panel 切换逻辑...

+
+
+

ui.ts

+

...isMobileViewport · activeMobileSheet...

+
+
+

styles/_responsive.scss

+

...overlay 动画与遮罩...

+
+
+
+
+ + + + diff --git a/static/mobile-overlay-fab.html b/static/mobile-overlay-fab.html new file mode 100644 index 0000000..a3a92c9 --- /dev/null +++ b/static/mobile-overlay-fab.html @@ -0,0 +1,437 @@ + + + + + + 移动端半覆盖面板(浮动按钮) + + + +
+ +
+ + + +
+
+ +
+

对话区域

+
你好!移动端默认只显示聊天内容。
+
其它面板怎么进入?
+
点击左上角的悬浮按钮,展开二级菜单即可。
+
每个选项会以半覆盖弹层的方式显示对应面板。
+ +
+
+ + +
+
+
+ + +
+
+
+
+
+
对话记录
+
最近 10 条 · 可搜索
+
+ +
+
+
+ 新建对话 +

用于创建新的任务空间

+
+
+

#1243 Bug 跟踪 · 刚刚

+
+
+

#1235 UI 迭代 · 30 分钟前

+
+
+
+
+ + +
+
+
+
+
+
三合一工作台
+
Logo 卡片 + 文件 + 待办 + 子体
+
+ +
+
+
+ AI Agent v2.4 +

思考模式 · 已连接

+
+
+

项目文件

+

/src/App.vue

+

/stores/ui.ts

+
+
+

待办

+

1. 适配移动端布局

+

2. 补充交互说明

+
+
+

子智能体

+

#07 日志总结 · 进行中

+
+
+
+
+ + +
+
+
+
+
+
聚焦面板
+
最多 3 个热点文件
+
+ +
+
+
+

App.vue

+

移动端 Teleport 逻辑

+
+
+

ui.ts

+

activeMobileSheet 状态

+
+
+

styles/_responsive.scss

+

半覆盖动画与遮罩

+
+
+
+
+ + + + diff --git a/static/src/App.vue b/static/src/App.vue index 433972a..383194c 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -39,6 +39,7 @@ @@ -177,5 +296,12 @@ diff --git a/static/src/app.ts b/static/src/app.ts index 1ce0608..eef6f1a 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -123,6 +123,7 @@ const appOptions = { skipConversationHistoryReload: false, _scrollListenerReady: false, historyLoading: false, + mobileViewportQuery: null, // 工具控制菜单 icons: ICONS, @@ -167,7 +168,10 @@ const appOptions = { document.addEventListener('click', this.handleClickOutsideQuickMenu); document.addEventListener('click', this.handleClickOutsidePanelMenu); + document.addEventListener('click', this.handleClickOutsideMobileMenu); window.addEventListener('popstate', this.handlePopState); + window.addEventListener('keydown', this.handleMobileOverlayEscape); + this.setupMobileViewportWatcher(); this.subAgentFetch(); this.subAgentStartPolling(); @@ -204,7 +208,10 @@ const appOptions = { 'quotaToast', 'toastQueue', 'confirmDialog', - 'easterEgg' + 'easterEgg', + 'isMobileViewport', + 'mobileOverlayMenuOpen', + 'activeMobileOverlay' ]), ...mapWritableState(useConversationStore, [ 'conversations', @@ -260,7 +267,10 @@ const appOptions = { beforeUnmount() { document.removeEventListener('click', this.handleClickOutsideQuickMenu); document.removeEventListener('click', this.handleClickOutsidePanelMenu); + document.removeEventListener('click', this.handleClickOutsideMobileMenu); window.removeEventListener('popstate', this.handlePopState); + window.removeEventListener('keydown', this.handleMobileOverlayEscape); + this.teardownMobileViewportWatcher(); this.subAgentStopPolling(); this.resourceStopContainerStatsPolling(); this.resourceStopProjectStoragePolling(); @@ -309,11 +319,108 @@ const appOptions = { this.initScrollListener(); this._scrollListenerReady = true; }, + setupMobileViewportWatcher() { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + this.updateMobileViewportState(false); + return; + } + const query = window.matchMedia('(max-width: 768px)'); + this.mobileViewportQuery = query; + this.updateMobileViewportState(query.matches); + if (typeof query.addEventListener === 'function') { + query.addEventListener('change', this.handleMobileViewportQueryChange); + } else if (typeof query.addListener === 'function') { + query.addListener(this.handleMobileViewportQueryChange); + } + }, + teardownMobileViewportWatcher() { + const query = this.mobileViewportQuery; + if (!query) { + return; + } + if (typeof query.removeEventListener === 'function') { + query.removeEventListener('change', this.handleMobileViewportQueryChange); + } else if (typeof query.removeListener === 'function') { + query.removeListener(this.handleMobileViewportQueryChange); + } + this.mobileViewportQuery = null; + }, + handleMobileViewportQueryChange(event) { + this.updateMobileViewportState(event.matches); + }, + updateMobileViewportState(isMobile) { + this.uiSetMobileViewport(!!isMobile); + if (!isMobile) { + this.uiSetMobileOverlayMenuOpen(false); + this.closeMobileOverlay(); + } + }, + toggleMobileOverlayMenu() { + if (!this.isMobileViewport) { + return; + } + this.uiToggleMobileOverlayMenu(); + }, + openMobileOverlay(target) { + if (!this.isMobileViewport) { + return; + } + if (this.activeMobileOverlay === target) { + this.closeMobileOverlay(); + return; + } + if (this.activeMobileOverlay === 'conversation') { + this.uiSetSidebarCollapsed(true); + } + if (target === 'conversation') { + this.uiSetSidebarCollapsed(false); + } + this.uiSetActiveMobileOverlay(target); + this.uiSetMobileOverlayMenuOpen(false); + }, + closeMobileOverlay() { + if (!this.activeMobileOverlay) { + this.uiCloseMobileOverlay(); + return; + } + if (this.activeMobileOverlay === 'conversation') { + this.uiSetSidebarCollapsed(true); + } + this.uiCloseMobileOverlay(); + }, + handleClickOutsideMobileMenu(event) { + if (!this.isMobileViewport || !this.mobileOverlayMenuOpen) { + return; + } + const trigger = this.$refs.mobilePanelTrigger; + if (trigger && typeof trigger.contains === 'function' && trigger.contains(event.target)) { + return; + } + this.uiSetMobileOverlayMenuOpen(false); + }, + handleMobileOverlayEscape(event) { + if (event.key !== 'Escape' || !this.isMobileViewport) { + return; + } + if (this.mobileOverlayMenuOpen) { + this.uiSetMobileOverlayMenuOpen(false); + return; + } + if (this.activeMobileOverlay) { + this.closeMobileOverlay(); + } + }, ...mapActions(useUiStore, { uiToggleSidebar: 'toggleSidebar', + uiSetSidebarCollapsed: 'setSidebarCollapsed', uiSetPanelMode: 'setPanelMode', uiSetPanelMenuOpen: 'setPanelMenuOpen', uiTogglePanelMenu: 'togglePanelMenu', + uiSetMobileViewport: 'setIsMobileViewport', + uiSetMobileOverlayMenuOpen: 'setMobileOverlayMenuOpen', + uiToggleMobileOverlayMenu: 'toggleMobileOverlayMenu', + uiSetActiveMobileOverlay: 'setActiveMobileOverlay', + uiCloseMobileOverlay: 'closeMobileOverlay', uiPushToast: 'pushToast', uiUpdateToast: 'updateToast', uiDismissToast: 'dismissToast', @@ -1610,7 +1717,21 @@ const appOptions = { this.searchConversations(); }, + handleMobileOverlaySelect(conversationId) { + this.loadConversation(conversationId); + this.closeMobileOverlay(); + }, + handleMobilePersonalClick() { + this.closeMobileOverlay(); + this.uiSetMobileOverlayMenuOpen(false); + this.openPersonalPage(); + }, + toggleSidebar() { + if (this.isMobileViewport && this.activeMobileOverlay === 'conversation') { + this.closeMobileOverlay(); + return; + } this.uiToggleSidebar(); }, diff --git a/static/src/components/panels/FocusPanel.vue b/static/src/components/panels/FocusPanel.vue index e7c8668..a9c0f14 100644 --- a/static/src/components/panels/FocusPanel.vue +++ b/static/src/components/panels/FocusPanel.vue @@ -5,6 +5,15 @@ 聚焦文件 ({{ focusedCount }}/3) +
暂无聚焦文件
@@ -35,6 +44,11 @@ const props = defineProps<{ width: number; iconStyle: (key: string) => Record; getLanguageClass: (path: string) => string; + showCloseButton?: boolean; +}>(); + +defineEmits<{ + (event: 'close'): void; }>(); const focusStore = useFocusStore(); @@ -44,4 +58,5 @@ const focusedCount = computed(() => Object.keys(focusedFileMap.value).length); const languageClass = (path: string) => props.getLanguageClass(path); const formatSize = (size: number) => `${(size / 1024).toFixed(1)}KB`; +const showCloseButton = computed(() => props.showCloseButton === true); diff --git a/static/src/components/sidebar/ConversationSidebar.vue b/static/src/components/sidebar/ConversationSidebar.vue index ef128ca..085bd22 100644 --- a/static/src/components/sidebar/ConversationSidebar.vue +++ b/static/src/components/sidebar/ConversationSidebar.vue @@ -61,9 +61,14 @@ + 新建对话 -
@@ -153,6 +158,7 @@ diff --git a/static/src/stores/ui.ts b/static/src/stores/ui.ts index f2a2064..ec2cf92 100644 --- a/static/src/stores/ui.ts +++ b/static/src/stores/ui.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia'; type PanelMode = 'files' | 'todo' | 'subAgents'; type ResizingPanel = 'left' | 'right' | null; +type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | null; interface QuotaToast { message: string; @@ -64,6 +65,9 @@ interface UiState { confirmDialog: ConfirmDialogState | null; pendingConfirmResolver: ((value: boolean) => void) | null; easterEgg: EasterEggState; + isMobileViewport: boolean; + mobileOverlayMenuOpen: boolean; + activeMobileOverlay: MobileOverlayTarget; } export const useUiStore = defineStore('ui', { @@ -92,7 +96,10 @@ export const useUiStore = defineStore('ui', { cleanupTimer: null, destroying: false, destroyPromise: null - } + }, + isMobileViewport: false, + mobileOverlayMenuOpen: false, + activeMobileOverlay: null }), actions: { setSidebarCollapsed(collapsed: boolean) { @@ -127,6 +134,25 @@ export const useUiStore = defineStore('ui', { this.minPanelWidth = min; this.maxPanelWidth = max; }, + setIsMobileViewport(isMobile: boolean) { + this.isMobileViewport = isMobile; + if (!isMobile) { + this.mobileOverlayMenuOpen = false; + this.activeMobileOverlay = null; + } + }, + setMobileOverlayMenuOpen(open: boolean) { + this.mobileOverlayMenuOpen = open; + }, + toggleMobileOverlayMenu() { + this.mobileOverlayMenuOpen = !this.mobileOverlayMenuOpen; + }, + setActiveMobileOverlay(target: MobileOverlayTarget) { + this.activeMobileOverlay = target; + }, + closeMobileOverlay() { + this.activeMobileOverlay = null; + }, showQuotaToastMessage(message: string, type: string = 'fast', duration = 5000) { this.quotaToast = { message, type }; if (this.quotaToastTimer) { diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 5d05775..f29cbfb 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -373,26 +373,26 @@ } .copy-code-btn { - background: transparent; - color: var(--claude-text-secondary); - border: 1px solid rgba(121, 109, 94, 0.35); - padding: 6px 10px; - border-radius: 6px; - font-size: 16px; + width: 32px; + height: 32px; + border: none; + padding: 0; + border-radius: 50%; + background-color: transparent; + background-image: url('/static/icons/copy.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: 20px; cursor: pointer; - transition: all 0.2s ease; - line-height: 1; + transition: opacity 0.2s ease; } .copy-code-btn:hover { - background: rgba(218, 119, 86, 0.12); - color: var(--claude-accent-strong); + opacity: 0.7; } .copy-code-btn.copied { - background: var(--claude-success); - border-color: var(--claude-success); - color: #f6fff8; + opacity: 0.35; } .code-block-wrapper pre { diff --git a/static/src/styles/components/overlays/_overlays.scss b/static/src/styles/components/overlays/_overlays.scss index 4b3e61b..b5dcd41 100644 --- a/static/src/styles/components/overlays/_overlays.scss +++ b/static/src/styles/components/overlays/_overlays.scss @@ -167,6 +167,234 @@ .toggle-switch input:checked + .switch-slider::before { transform: translateX(20px); } + +/* ========================================= */ +/* 移动端面板入口 */ +/* ========================================= */ + +.mobile-panel-trigger { + position: fixed; + top: 16px; + left: 16px; + z-index: 1500; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + pointer-events: none; + opacity: 1; + transition: opacity 0.2s ease; +} + +.mobile-panel-trigger.is-hidden { + opacity: 0; +} + +.mobile-panel-fab { + width: 48px; + height: 48px; + border: none; + padding: 6px; + border-radius: 12px; + background: transparent; + color: var(--claude-accent); + cursor: pointer; + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + box-shadow: none; +} + +.mobile-panel-fab img { + width: 32px; + height: 32px; +} + +.mobile-panel-menu { + pointer-events: auto; + display: grid; + grid-auto-flow: column; + gap: 8px; + padding: 8px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.35); + backdrop-filter: blur(10px); + box-shadow: 0 12px 30px rgba(38, 28, 18, 0.15); +} + +.mobile-menu-btn { + width: 44px; + height: 44px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--claude-text); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.mobile-menu-btn img, +.mobile-menu-svg { + width: 26px; + height: 26px; + display: block; +} + +.mobile-menu-btn--conversation .mobile-menu-svg { + width: 30px; + height: 30px; + transition: color 0.2s ease; +} + +.mobile-panel-menu-enter-active, +.mobile-panel-menu-leave-active { + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.mobile-panel-menu-enter-from, +.mobile-panel-menu-leave-to { + opacity: 0; + transform: translateY(-8px) scale(0.95); +} + +/* ========================================= */ +/* 移动端半覆盖面板 */ +/* ========================================= */ + +.mobile-panel-overlay { + position: fixed; + inset: 0; + background: rgba(16, 11, 7, 0.45); + backdrop-filter: blur(6px); + z-index: 1400; + display: flex; + align-items: stretch; + padding: 0; +} + +.mobile-panel-overlay--left { + justify-content: flex-start; +} + +.mobile-panel-overlay--right { + justify-content: flex-end; +} + +.mobile-panel-sheet { + width: 100%; + height: 100%; + background: var(--claude-left-rail); + border-radius: 0; + box-shadow: none; + padding: 0; + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.mobile-panel-sheet--conversation, +.mobile-panel-sheet--workspace { + background: var(--claude-left-rail); +} + +.mobile-panel-sheet--focus { + background: var(--claude-panel); +} + +.mobile-overlay-close { + position: absolute; + top: 10px; + right: 10px; + width: 32px; + height: 32px; + border-radius: 16px; + border: none; + background: rgba(0, 0, 0, 0.08); + color: var(--claude-text); + font-size: 18px; + cursor: pointer; + z-index: 5; +} + +.mobile-overlay-content { + margin-top: 0; + flex: 1; + width: 100%; + height: 100%; + overflow: hidden; +} + +.mobile-panel-sheet .conversation-sidebar, +.mobile-panel-sheet .sidebar, +.mobile-panel-sheet .right-sidebar { + width: 100% !important; + max-width: 100%; + min-width: 0; + height: 100%; + border: none; + border-radius: 0; +} + +.mobile-panel-sheet .conversation-sidebar { + height: 100%; +} + +@media (max-width: 768px) { + .mobile-panel-sheet .left-sidebar, + .mobile-panel-sheet .right-sidebar { + display: flex !important; + flex-direction: column; + } + + .mobile-panel-sheet .conversation-sidebar { + position: relative; + transform: none !important; + box-shadow: none; + } + + .mobile-panel-sheet .conversation-sidebar.collapsed { + transform: none !important; + } + + .mobile-panel-sheet .sidebar-header { + position: static; + border: none; + padding: 12px 16px 0; + } + + .mobile-panel-sheet .focused-files { + padding-top: 8px; + } +} + +.mobile-panel-overlay-enter-active, +.mobile-panel-overlay-leave-active { + transition: opacity 0.25s ease; +} + +.mobile-panel-overlay-enter-from, +.mobile-panel-overlay-leave-to { + opacity: 0; +} + +.mobile-panel-overlay .mobile-panel-sheet { + transform: translateX(0); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.mobile-panel-overlay--left.mobile-panel-overlay-enter-from .mobile-panel-sheet, +.mobile-panel-overlay--left.mobile-panel-overlay-leave-to .mobile-panel-sheet { + transform: translateX(-28px); +} + +.mobile-panel-overlay--right.mobile-panel-overlay-enter-from .mobile-panel-sheet, +.mobile-panel-overlay--right.mobile-panel-overlay-leave-to .mobile-panel-sheet { + transform: translateX(28px); +} .confirm-overlay { position: fixed; inset: 0; diff --git a/static/src/styles/components/panels/_focus-panel.scss b/static/src/styles/components/panels/_focus-panel.scss index 9bfaa03..9f0ea40 100644 --- a/static/src/styles/components/panels/_focus-panel.scss +++ b/static/src/styles/components/panels/_focus-panel.scss @@ -1,9 +1,47 @@ -/* 聚焦文件 */ -.focused-files { - padding: 16px; +/* 聚焦文件面板 */ +.sidebar.right-sidebar { + display: flex; + flex-direction: column; } -o-files { +.sidebar.right-sidebar .sidebar-header { + padding: 20px 20px 8px; + border-bottom: 1px solid var(--claude-border); + background: var(--claude-panel); + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sidebar.right-sidebar .sidebar-header h3 { + font-size: 15px; + font-weight: 600; + color: var(--claude-text); + margin: 0; +} + +.focused-files { + flex: 1 1 auto; + padding: 12px 20px 20px; + overflow-y: auto; +} + +.focus-close-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 16px; + background: rgba(0, 0, 0, 0.08); + color: var(--claude-text); + font-size: 20px; + cursor: pointer; +} + +.no-files { text-align: center; color: var(--claude-text-secondary); padding: 60px 20px; @@ -47,7 +85,7 @@ o-files { .file-content { max-height: 320px; overflow-y: auto; - background: #1e1e1e; + background: #ffffff; } .file-content pre { @@ -59,5 +97,5 @@ o-files { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 13px; line-height: 1.5; - color: #aed581; + color: #1f2227; }