feat(workspace): 日历再瘦身 + 右侧面板等高填满 + 日期跳转按钮 + 小屁浮窗化
日历 - 单元格 72→60px,日号 19→17px、字号 11→10px,事件条与周标同步收紧, 标题区 padding 与提示文案一起压小 - 工具条右边的「月」按钮删掉,换成日历小图标按钮:悬浮/展开变蓝(primary 实底), 点击弹出迷你月历(自带年月切换 + 回到今天),点某天即跳月并选中该日 右侧面板 - 「今日 to do」与「待办事项」改成按列高 58/42 平分,列表自身 flex 撑满并可滚动; 之前待办被挤到底部只剩一条空文案,现在两块都填满整列 - 跳转浮层 z-index 82,压过小屁浮窗(78);设置遮罩提到 90,保证仍是最高层 AI 助手 → 小屁 - 全屏遮罩 + 右侧整条抽屉删掉,改成右上角 348px 宽浮动小窗: 可拖动(位置记 localStorage,越界自动夹回视口)、可最小化成小条、双击标题栏切换 - App.jsx 不再因为小屁打开而 inert 主界面(只有设置才是模态), 所以可以一边让小屁干活一边自己点系统:实测开着窗导航、选日期、填备忘录都正常 - 文案全部改名小屁,顶栏按钮、面板标题、工作台摘要里的引导语一并更新 顺手 - 顶栏搜索的 ⌘K 提示块加 nowrap,1024 宽度下不再折成两行
This commit is contained in:
@@ -1,25 +1,58 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { BotIcon, CloseIcon, SendIcon, SparkIcon } from './Icons'
|
||||
import { BotIcon, CloseIcon, MinusIcon, SendIcon, SparkIcon } from './Icons'
|
||||
|
||||
const POS_KEY = 'happy.aiPos'
|
||||
const WIN_WIDTH = 348
|
||||
|
||||
const starterMessages = [
|
||||
{ role: 'assistant', text: '你好,我是你的供应链 AI 助手。可以帮你查看客户、产品、供应商、订单、商机和待办,也可以协助发起系统内的业务操作。' },
|
||||
{ role: 'assistant', text: '我是小屁,你的外贸业务助理。可以帮你查客户、产品、供应商、订单、商机和待办,也能代你发起系统内的操作。' },
|
||||
]
|
||||
|
||||
function readStoredPos() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(POS_KEY) || 'null')
|
||||
if (raw && Number.isFinite(raw.left) && Number.isFinite(raw.top)) return { left: raw.left, top: raw.top }
|
||||
} catch { /* 存储不可用时用默认位置 */ }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 拖动或改窗口大小后,把窗口夹回视口,保证整窗(至少是标题栏)能点到 */
|
||||
function clampPos(pos, height) {
|
||||
if (!pos) return pos
|
||||
const maxLeft = Math.max(8, window.innerWidth - WIN_WIDTH - 8)
|
||||
const usable = Math.min(height || 320, window.innerHeight - 60)
|
||||
const maxTop = Math.max(8, window.innerHeight - usable - 8)
|
||||
return {
|
||||
left: Math.min(Math.max(8, pos.left), maxLeft),
|
||||
top: Math.min(Math.max(8, pos.top), maxTop)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小屁 —— 右上角浮动小窗。
|
||||
* 不是模态:没有遮罩、不 inert 主界面,所以可以一边让它干活一边自己点系统。
|
||||
*/
|
||||
function AIAssistant({ isOpen, onClose }) {
|
||||
const [messages, setMessages] = useState(starterMessages)
|
||||
const [input, setInput] = useState('')
|
||||
const [context, setContext] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [pos, setPos] = useState(readStoredPos)
|
||||
|
||||
const inputRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
const winRef = useRef(null)
|
||||
const dragRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
inputRef.current?.focus()
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
if (isOpen && !minimized) inputRef.current?.focus()
|
||||
}, [isOpen, minimized])
|
||||
|
||||
// Esc 只收窗,不做任何全局拦截
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
const onKeyDown = (event) => { if (event.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
@@ -29,11 +62,41 @@ function AIAssistant({ isOpen, onClose }) {
|
||||
axios.get('/api/assistant/context').then(response => setContext(response.data)).catch(() => setContext(null))
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pos) return undefined
|
||||
const onResize = () => setPos(current => clampPos(current, winRef.current?.offsetHeight))
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [isOpen, pos])
|
||||
|
||||
const onDragStart = (event) => {
|
||||
if (event.target.closest('button')) return
|
||||
const rect = winRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
dragRef.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top }
|
||||
try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* 合成事件或指针已释放 */ }
|
||||
}
|
||||
|
||||
const onDragMove = (event) => {
|
||||
if (!dragRef.current) return
|
||||
setPos(clampPos({ left: event.clientX - dragRef.current.dx, top: event.clientY - dragRef.current.dy }, winRef.current?.offsetHeight))
|
||||
}
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
if (!dragRef.current) return
|
||||
dragRef.current = null
|
||||
try { event.currentTarget.releasePointerCapture(event.pointerId) } catch { /* 指针已释放 */ }
|
||||
const rect = winRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
try { localStorage.setItem(POS_KEY, JSON.stringify({ left: rect.left, top: rect.top })) } catch { /* 仅本次会话 */ }
|
||||
}
|
||||
}
|
||||
|
||||
const capabilities = useMemo(() => [
|
||||
'读取全局业务概览与实时统计',
|
||||
'检索客户、供应商、产品和订单',
|
||||
'查看商机、待办与近期活动',
|
||||
'发起已授权的新增、更新和状态操作',
|
||||
'发起已授权的新增、更新和状态操作'
|
||||
], [])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -56,29 +119,60 @@ function AIAssistant({ isOpen, onClose }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="assistant-layer" onClick={onClose}>
|
||||
<aside ref={panelRef} className="ai-assistant-panel" role="dialog" aria-modal="true" aria-labelledby="ai-assistant-title" onClick={event => event.stopPropagation()}>
|
||||
<div className="ai-panel-header">
|
||||
<div className="ai-panel-title"><span className="ai-orb"><BotIcon size={20} /></span><div><span className="section-label">[SYSTEM COPILOT]</span><h2 id="ai-assistant-title">AI 助手</h2></div></div>
|
||||
<button className="ai-close-btn" onClick={onClose} aria-label="关闭 AI 助手"><CloseIcon size={18} /></button>
|
||||
<aside
|
||||
ref={winRef}
|
||||
className={`ai-window${minimized ? ' is-minimized' : ''}`}
|
||||
style={pos ? { left: pos.left, top: pos.top, right: 'auto' } : undefined}
|
||||
role="complementary"
|
||||
aria-labelledby="ai-assistant-title"
|
||||
>
|
||||
<div
|
||||
className="ai-window-bar"
|
||||
onPointerDown={onDragStart}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={onDragEnd}
|
||||
onPointerCancel={onDragEnd}
|
||||
onDoubleClick={() => setMinimized(value => !value)}
|
||||
>
|
||||
<div className="ai-panel-title">
|
||||
<span className="ai-orb"><BotIcon size={16} /></span>
|
||||
<div><span className="section-label">[COPILOT]</span><h2 id="ai-assistant-title">小屁</h2></div>
|
||||
</div>
|
||||
<div className="ai-window-actions">
|
||||
{!minimized && <span className="ai-live-dot" title="已连接系统业务层" />}
|
||||
<button
|
||||
type="button"
|
||||
className="ai-icon-btn"
|
||||
onClick={() => setMinimized(value => !value)}
|
||||
aria-label={minimized ? '展开小屁' : '最小化小屁'}
|
||||
title={minimized ? '展开' : '最小化(也可以双击标题栏)'}
|
||||
>
|
||||
{minimized ? <SparkIcon size={15} /> : <MinusIcon size={16} />}
|
||||
</button>
|
||||
<button type="button" className="ai-icon-btn" onClick={onClose} aria-label="关闭小屁" title="关闭">
|
||||
<CloseIcon size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
|
||||
|
||||
<div className="ai-panel-body">
|
||||
<div className="ai-capability-card"><div className="ai-capability-heading"><SparkIcon size={15} />我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
|
||||
{context && <div className="ai-context-strip"><span>当前数据快照</span><b>{context.stats?.customers || 0} 客户</b><b>{context.stats?.products || 0} 产品</b><b>{context.stats?.orders || 0} 订单</b></div>}
|
||||
<div className="ai-message-list">
|
||||
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <BotIcon size={15} /> : 'W'}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
|
||||
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><BotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
|
||||
{!minimized && (
|
||||
<React.Fragment>
|
||||
<div className="ai-panel-body">
|
||||
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
|
||||
<div className="ai-capability-card"><div className="ai-capability-heading"><SparkIcon size={15} />我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
|
||||
{context && <div className="ai-context-strip"><span>当前数据快照</span><b>{context.stats?.customers || 0} 客户</b><b>{context.stats?.products || 0} 产品</b><b>{context.stats?.orders || 0} 订单</b></div>}
|
||||
<div className="ai-message-list">
|
||||
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <BotIcon size={15} /> : 'W'}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
|
||||
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><BotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-suggestion-row"><button onClick={() => sendMessage('汇总今天最重要的事项')}>汇总今天事项</button><button onClick={() => sendMessage('查看系统数据概览')}>查看系统概览</button></div>
|
||||
<form className="ai-input-row" onSubmit={event => { event.preventDefault(); sendMessage() }}><input ref={inputRef} value={input} onChange={event => setInput(event.target.value)} placeholder="告诉 AI 你要查什么或做什么…" aria-label="输入给 AI 助手的内容" /><button type="submit" disabled={!input.trim() || loading} aria-label="发送"><SendIcon size={18} /></button></form>
|
||||
<div className="ai-panel-footer">AI 会先说明将要执行的操作,再进行需要写入数据的动作。</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div className="ai-suggestion-row"><button onClick={() => sendMessage('汇总今天最重要的事项')}>汇总今天事项</button><button onClick={() => sendMessage('查看系统数据概览')}>查看系统概览</button></div>
|
||||
<form className="ai-input-row" onSubmit={event => { event.preventDefault(); sendMessage() }}><input ref={inputRef} value={input} onChange={event => setInput(event.target.value)} placeholder="告诉小屁你要查什么或做什么…" aria-label="输入给小屁的内容" /><button type="submit" disabled={!input.trim() || loading} aria-label="发送"><SendIcon size={18} /></button></form>
|
||||
<div className="ai-panel-footer">小屁会先说明将要执行的操作,再进行需要写入数据的动作。</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ function GlobalHeader({ onOpenAssistant }) {
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开 AI 助手"><BotIcon size={19} /><span>AI 助手</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
|
||||
<div className="global-header-actions"><TimeZoneClock now={now} /><FxCalculator /><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开小屁"><BotIcon size={19} /><span>小屁</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,12 @@ export const CalendarIcon = ({ size = 20, className = '' }) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MinusIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const BellIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
|
||||
Reference in New Issue
Block a user