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:
Drsakura
2026-09-10 23:56:52 +08:00
parent b9d6296591
commit 4c3ec995b6
6 changed files with 294 additions and 62 deletions
+122 -28
View File
@@ -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>
)
}