Files
happy-export/frontend/src/components/AIAssistant.jsx
T
Drsakura 4c3ec995b6 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 宽度下不再折成两行
2026-09-10 23:56:52 +08:00

180 lines
8.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import { BotIcon, CloseIcon, MinusIcon, SendIcon, SparkIcon } from './Icons'
const POS_KEY = 'happy.aiPos'
const WIN_WIDTH = 348
const starterMessages = [
{ 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 winRef = useRef(null)
const dragRef = useRef(null)
useEffect(() => {
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])
useEffect(() => {
if (!isOpen) return
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
const sendMessage = async (text = input) => {
const prompt = text.trim()
if (!prompt || loading) return
setInput('')
setMessages(prev => [...prev, { role: 'user', text: prompt }])
setLoading(true)
try {
const response = await axios.post('/api/assistant/command', { prompt })
setMessages(prev => [...prev, { role: 'assistant', text: response.data.reply || '我已收到指令,但当前还没有可执行的结果。', meta: response.data.meta }])
if (response.data.context) setContext(response.data.context)
} catch {
setMessages(prev => [...prev, { role: 'assistant', text: '暂时无法连接系统能力服务,请确认后端服务正在运行。' }])
} finally {
setLoading(false)
}
}
return (
<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>
{!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 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>
)
}
export default AIAssistant