feat: 第五轮开发 - 14项未来路线图功能完整实现
W1-W14 全部完成: - W1: 消息搜索 (ILIKE全文检索 + SearchModal) - W2: 对话导出 (JSON/Markdown/TXT三格式) - W3: 记忆时间线 DevTools 可视化 - W4: 通知推送系统 (WebSocket + Browser Notification API) - W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送) - W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要) - W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行) - W8: 语音输入 (浏览器 Speech Recognition API) - W9: STT服务 (voice-service + whisper.cpp) - W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退) - W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放) - W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索) - W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退) - W14: PWA (Service Worker + 离线页 + install prompt) 总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { SearchModal } from './SearchModal';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -9,6 +10,7 @@ interface AppLayoutProps {
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
return (
|
||||
@@ -36,9 +38,17 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{isLoggedIn && <Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />}
|
||||
{isLoggedIn && (
|
||||
<Header
|
||||
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
onSearchClick={() => setSearchOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<main className="flex-1 min-h-0 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* 搜索弹窗 */}
|
||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listRules,
|
||||
listScenes,
|
||||
createRule,
|
||||
createScene,
|
||||
updateRule,
|
||||
updateScene,
|
||||
deleteRule,
|
||||
deleteScene,
|
||||
triggerRule,
|
||||
executeScene,
|
||||
toggleRule,
|
||||
type AutomationRule,
|
||||
type AutomationScene,
|
||||
} from '@/api/automation';
|
||||
|
||||
interface AutomationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 触发类型中文映射 */
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
schedule: '⏰ 定时',
|
||||
device_state: '📡 设备状态',
|
||||
manual: '🖐️ 手动',
|
||||
};
|
||||
|
||||
/** 触发类型颜色 */
|
||||
const TRIGGER_COLORS: Record<string, string> = {
|
||||
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
device_state: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
manual: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
};
|
||||
|
||||
/** JSON 安全格式化 */
|
||||
function safeJSON(raw: unknown, fallback = '-'): string {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
if (typeof raw === 'string') return raw;
|
||||
return JSON.stringify(raw, null, 1);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认动作模板 */
|
||||
const DEFAULT_ACTIONS = [
|
||||
{
|
||||
type: 'set_device',
|
||||
device_id: '',
|
||||
property: '',
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
|
||||
/** 默认定时触发配置模板 */
|
||||
const DEFAULT_SCHEDULE_TRIGGER = {
|
||||
time: '08:00',
|
||||
days: ['mon', 'tue', 'wed', 'thu', 'fri'],
|
||||
};
|
||||
|
||||
export function AutomationPanel({ onClose }: AutomationPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'rules' | 'scenes'>('rules');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'create' | 'edit'>('list');
|
||||
|
||||
// 数据
|
||||
const [rules, setRules] = useState<AutomationRule[]>([]);
|
||||
const [scenes, setScenes] = useState<AutomationScene[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [successMsg, setSuccessMsg] = useState('');
|
||||
|
||||
// 规则表单
|
||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formTriggerType, setFormTriggerType] = useState('schedule');
|
||||
const [formTriggerConfig, setFormTriggerConfig] = useState(JSON.stringify(DEFAULT_SCHEDULE_TRIGGER, null, 2));
|
||||
const [formConditions, setFormConditions] = useState('');
|
||||
const [formActions, setFormActions] = useState(JSON.stringify(DEFAULT_ACTIONS, null, 2));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 场景表单
|
||||
const [editingSceneId, setEditingSceneId] = useState<string | null>(null);
|
||||
const [sceneFormName, setSceneFormName] = useState('');
|
||||
const [sceneFormIcon, setSceneFormIcon] = useState('🏠');
|
||||
const [sceneFormRuleIds, setSceneFormRuleIds] = useState('');
|
||||
const [sceneSubmitting, setSceneSubmitting] = useState(false);
|
||||
|
||||
// 展开的规则详情
|
||||
const [expandedRuleId, setExpandedRuleId] = useState<string | null>(null);
|
||||
|
||||
const showSuccess = (msg: string) => {
|
||||
setSuccessMsg(msg);
|
||||
setTimeout(() => setSuccessMsg(''), 2000);
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const [rulesResp, scenesResp] = await Promise.all([listRules(), listScenes()]);
|
||||
if (rulesResp.error) {
|
||||
setError(rulesResp.error);
|
||||
} else {
|
||||
setRules(rulesResp.data?.rules ?? []);
|
||||
}
|
||||
if (scenesResp.error) {
|
||||
// 不影响规则列表显示
|
||||
} else {
|
||||
setScenes(scenesResp.data?.scenes ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 重置规则表单
|
||||
const resetRuleForm = () => {
|
||||
setEditingRuleId(null);
|
||||
setFormName('');
|
||||
setFormDesc('');
|
||||
setFormTriggerType('schedule');
|
||||
setFormTriggerConfig(JSON.stringify(DEFAULT_SCHEDULE_TRIGGER, null, 2));
|
||||
setFormConditions('');
|
||||
setFormActions(JSON.stringify(DEFAULT_ACTIONS, null, 2));
|
||||
};
|
||||
|
||||
// 打开规则编辑
|
||||
const openRuleEdit = (rule: AutomationRule) => {
|
||||
setEditingRuleId(rule.id);
|
||||
setFormName(rule.name);
|
||||
setFormDesc(rule.description || '');
|
||||
setFormTriggerType(rule.trigger_type);
|
||||
setFormTriggerConfig(safeJSON(rule.trigger_config, '{}'));
|
||||
setFormConditions(safeJSON(rule.conditions, ''));
|
||||
setFormActions(safeJSON(rule.actions, '[]'));
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
// 提交规则表单
|
||||
const handleRuleSubmit = async () => {
|
||||
if (!formName.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
let triggerConfig: unknown = undefined;
|
||||
let conditions: unknown = undefined;
|
||||
let actions: unknown = undefined;
|
||||
|
||||
try {
|
||||
if (formTriggerConfig.trim()) triggerConfig = JSON.parse(formTriggerConfig);
|
||||
} catch {
|
||||
setError('触发配置 JSON 格式错误');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (formConditions.trim()) conditions = JSON.parse(formConditions);
|
||||
} catch {
|
||||
setError('条件配置 JSON 格式错误');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
actions = JSON.parse(formActions);
|
||||
} catch {
|
||||
setError('动作配置 JSON 格式错误');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingRuleId) {
|
||||
const resp = await updateRule(editingRuleId, {
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
trigger_type: formTriggerType,
|
||||
trigger_config: triggerConfig,
|
||||
conditions,
|
||||
actions,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已更新');
|
||||
resetRuleForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
} else {
|
||||
const resp = await createRule({
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
trigger_type: formTriggerType,
|
||||
trigger_config: triggerConfig,
|
||||
conditions,
|
||||
actions,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已创建');
|
||||
resetRuleForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
// 删除规则
|
||||
const handleDeleteRule = async (id: string) => {
|
||||
if (!confirm('确定要删除这条规则吗?')) return;
|
||||
const resp = await deleteRule(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已删除');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 手动触发规则
|
||||
const handleTriggerRule = async (id: string) => {
|
||||
const resp = await triggerRule(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('规则已触发');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换规则启用状态
|
||||
const handleToggleRule = async (rule: AutomationRule) => {
|
||||
const resp = await toggleRule(rule.id, !rule.enabled);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess(rule.enabled ? '规则已禁用' : '规则已启用');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 重置场景表单
|
||||
const resetSceneForm = () => {
|
||||
setEditingSceneId(null);
|
||||
setSceneFormName('');
|
||||
setSceneFormIcon('🏠');
|
||||
setSceneFormRuleIds('');
|
||||
};
|
||||
|
||||
// 打开场景编辑
|
||||
const openSceneEdit = (scene: AutomationScene) => {
|
||||
setEditingSceneId(scene.id);
|
||||
setSceneFormName(scene.name);
|
||||
setSceneFormIcon(scene.icon || '🏠');
|
||||
setSceneFormRuleIds(safeJSON(scene.rule_ids, ''));
|
||||
setViewMode('edit');
|
||||
};
|
||||
|
||||
// 提交场景表单
|
||||
const handleSceneSubmit = async () => {
|
||||
if (!sceneFormName.trim()) return;
|
||||
setSceneSubmitting(true);
|
||||
setError('');
|
||||
|
||||
let ruleIds: string[] | undefined = undefined;
|
||||
try {
|
||||
if (sceneFormRuleIds.trim()) {
|
||||
ruleIds = JSON.parse(sceneFormRuleIds);
|
||||
}
|
||||
} catch {
|
||||
setError('规则 ID 列表 JSON 格式错误');
|
||||
setSceneSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingSceneId) {
|
||||
const resp = await updateScene(editingSceneId, {
|
||||
name: sceneFormName.trim(),
|
||||
icon: sceneFormIcon,
|
||||
rule_ids: ruleIds,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已更新');
|
||||
resetSceneForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
} else {
|
||||
const resp = await createScene({
|
||||
name: sceneFormName.trim(),
|
||||
icon: sceneFormIcon,
|
||||
rule_ids: ruleIds,
|
||||
});
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已创建');
|
||||
resetSceneForm();
|
||||
setViewMode('list');
|
||||
loadData();
|
||||
}
|
||||
}
|
||||
setSceneSubmitting(false);
|
||||
};
|
||||
|
||||
// 删除场景
|
||||
const handleDeleteScene = async (id: string) => {
|
||||
if (!confirm('确定要删除这个场景吗?')) return;
|
||||
const resp = await deleteScene(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已删除');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 执行场景
|
||||
const handleExecuteScene = async (id: string) => {
|
||||
const resp = await executeScene(id);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showSuccess('场景已执行');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取规则名称映射
|
||||
const ruleNameMap: Record<string, string> = {};
|
||||
rules.forEach((r) => { ruleNameMap[r.id] = r.name; });
|
||||
|
||||
// 解析场景中的规则列表
|
||||
const getSceneRuleNames = (scene: AutomationScene): string[] => {
|
||||
if (!scene.rule_ids) return [];
|
||||
try {
|
||||
const ids: string[] = typeof scene.rule_ids === 'string'
|
||||
? JSON.parse(scene.rule_ids)
|
||||
: (scene.rule_ids as string[]);
|
||||
return ids.map((id) => ruleNameMap[id] || id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-h-full flex flex-col">
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className="flex items-center border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => { setActiveTab('rules'); setViewMode('list'); }}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'rules'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
规则
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('scenes'); setViewMode('list'); }}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'scenes'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
场景
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{successMsg && (
|
||||
<div className="px-3 py-2 text-xs text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400">
|
||||
✅ {successMsg}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-xs text-red-500 bg-red-50 dark:bg-red-900/20">
|
||||
⚠️ {error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 规则面板 ========== */}
|
||||
{activeTab === 'rules' && (
|
||||
<>
|
||||
{/* 列表模式 */}
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{rules.length} 条规则
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetRuleForm(); setViewMode('create'); }}
|
||||
className="px-2.5 py-1 text-[11px] bg-pink-500 text-white rounded-full hover:bg-pink-600 transition-colors"
|
||||
>
|
||||
+ 新建规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⏳ 加载中...
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⚡ 暂无自动化规则,点击「+ 新建规则」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleRule(rule)}
|
||||
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors flex-shrink-0 ${
|
||||
rule.enabled ? 'bg-pink-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
|
||||
rule.enabled ? 'translate-x-[18px]' : 'translate-x-[2px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm truncate ${rule.enabled ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400'}`}>
|
||||
{rule.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${TRIGGER_COLORS[rule.trigger_type] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{TRIGGER_LABELS[rule.trigger_type] || rule.trigger_type}
|
||||
</span>
|
||||
{rule.last_triggered_at && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
上次: {formatTime(rule.last_triggered_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{rule.trigger_type === 'manual' && (
|
||||
<button
|
||||
onClick={() => handleTriggerRule(rule.id)}
|
||||
title="手动触发"
|
||||
className="p-1 text-gray-300 hover:text-green-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openRuleEdit(rule)}
|
||||
title="编辑"
|
||||
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
title="删除"
|
||||
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expandedRuleId === rule.id && (
|
||||
<div className="mt-2 pl-8 space-y-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{rule.description && (
|
||||
<p>📝 {rule.description}</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
|
||||
触发: {safeJSON(rule.trigger_config)}
|
||||
</p>
|
||||
{rule.conditions != null && (
|
||||
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
|
||||
条件: {safeJSON(rule.conditions)}
|
||||
</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap font-mono text-[10px] bg-gray-50 dark:bg-gray-750 p-1 rounded">
|
||||
动作: {safeJSON(rule.actions)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedRuleId(expandedRuleId === rule.id ? null : rule.id)}
|
||||
className="mt-1 ml-8 text-[10px] text-pink-400 hover:text-pink-500"
|
||||
>
|
||||
{expandedRuleId === rule.id ? '收起 ▲' : '展开 ▼'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑规则表单 */}
|
||||
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||
<div className="p-3 space-y-3 overflow-y-auto max-h-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{editingRuleId ? '编辑规则' : '新建规则'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetRuleForm(); }}
|
||||
className="text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="例如:夜间自动关灯"
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">描述</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
placeholder="规则的用途说明"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">触发类型</label>
|
||||
<select
|
||||
value={formTriggerType}
|
||||
onChange={(e) => setFormTriggerType(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
>
|
||||
<option value="schedule">定时 (schedule)</option>
|
||||
<option value="device_state">设备状态 (device_state)</option>
|
||||
<option value="manual">手动 (manual)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">触发配置 (JSON)</label>
|
||||
<textarea
|
||||
value={formTriggerConfig}
|
||||
onChange={(e) => setFormTriggerConfig(e.target.value)}
|
||||
placeholder='{"time":"08:00","days":["mon","tue"]}'
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">条件 (JSON, 可选)</label>
|
||||
<textarea
|
||||
value={formConditions}
|
||||
onChange={(e) => setFormConditions(e.target.value)}
|
||||
placeholder='[{"type":"time_range","start":"22:00","end":"06:00"}]'
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">动作 (JSON) *</label>
|
||||
<textarea
|
||||
value={formActions}
|
||||
onChange={(e) => setFormActions(e.target.value)}
|
||||
placeholder='[{"type":"set_device","device_id":"","property":"","value":""}]'
|
||||
rows={4}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetRuleForm(); }}
|
||||
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRuleSubmit}
|
||||
disabled={submitting || !formName.trim() || !formActions.trim()}
|
||||
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? '保存中...' : editingRuleId ? '更新规则' : '创建规则'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ========== 场景面板 ========== */}
|
||||
{activeTab === 'scenes' && (
|
||||
<>
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{scenes.length} 个场景
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { resetSceneForm(); setViewMode('create'); }}
|
||||
className="px-2.5 py-1 text-[11px] bg-pink-500 text-white rounded-full hover:bg-pink-600 transition-colors"
|
||||
>
|
||||
+ 新建场景
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⏳ 加载中...
|
||||
</div>
|
||||
) : scenes.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🎬 暂无场景,点击「+ 新建场景」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{scenes.map((scene) => {
|
||||
const ruleNames = getSceneRuleNames(scene);
|
||||
return (
|
||||
<div key={scene.id} className="px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{scene.icon || '🏠'}</span>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
|
||||
{scene.name}
|
||||
</span>
|
||||
</div>
|
||||
{ruleNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1 ml-8">
|
||||
{ruleNames.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-pink-50 dark:bg-pink-900/20 text-pink-600 dark:text-pink-400 rounded-full"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 ml-8 mt-1 block">
|
||||
创建于 {formatTime(scene.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleExecuteScene(scene.id)}
|
||||
title="执行场景"
|
||||
className="p-1 text-gray-300 hover:text-green-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openSceneEdit(scene)}
|
||||
title="编辑"
|
||||
className="p-1 text-gray-300 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteScene(scene.id)}
|
||||
title="删除"
|
||||
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑场景表单 */}
|
||||
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||
<div className="p-3 space-y-3 overflow-y-auto max-h-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{editingSceneId ? '编辑场景' : '新建场景'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetSceneForm(); }}
|
||||
className="text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
返回列表
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sceneFormName}
|
||||
onChange={(e) => setSceneFormName(e.target.value)}
|
||||
placeholder="例如:回家模式"
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">图标</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sceneFormIcon}
|
||||
onChange={(e) => setSceneFormIcon(e.target.value)}
|
||||
placeholder="🏠"
|
||||
maxLength={10}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
规则 ID 列表 (JSON 数组)
|
||||
</label>
|
||||
<textarea
|
||||
value={sceneFormRuleIds}
|
||||
onChange={(e) => setSceneFormRuleIds(e.target.value)}
|
||||
placeholder='["rule_id_1","rule_id_2"]'
|
||||
rows={3}
|
||||
className="w-full px-3 py-1.5 text-xs font-mono border border-gray-200 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-750 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
{rules.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<span className="text-[10px] text-gray-400">可用规则:</span>
|
||||
{rules.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
try {
|
||||
const ids: string[] = sceneFormRuleIds.trim()
|
||||
? JSON.parse(sceneFormRuleIds)
|
||||
: [];
|
||||
if (!ids.includes(r.id)) {
|
||||
setSceneFormRuleIds(JSON.stringify([...ids, r.id]));
|
||||
}
|
||||
} catch {
|
||||
setSceneFormRuleIds(JSON.stringify([r.id]));
|
||||
}
|
||||
}}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded hover:bg-pink-100 dark:hover:bg-pink-900/30 transition-colors"
|
||||
title={r.id}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setViewMode('list'); resetSceneForm(); }}
|
||||
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSceneSubmit}
|
||||
disabled={sceneSubmitting || !sceneFormName.trim()}
|
||||
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{sceneSubmitting ? '保存中...' : editingSceneId ? '更新场景' : '创建场景'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getBriefing, getLatestBriefings, generateBriefing, formatBriefingDate, type Briefing } from '@/api/briefings';
|
||||
|
||||
interface BriefingPanelProps {
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 天气 emoji 映射 */
|
||||
function weatherIcon(icon: string): string {
|
||||
return icon || '🌤️';
|
||||
}
|
||||
|
||||
/** 格式化提醒时间 */
|
||||
function formatRemindTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取今天的日期字符串 */
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function BriefingPanel({ userId, onClose }: BriefingPanelProps) {
|
||||
const [briefing, setBriefing] = useState<Briefing | null>(null);
|
||||
const [history, setHistory] = useState<Briefing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedDate, setSelectedDate] = useState(todayStr());
|
||||
const [viewMode, setViewMode] = useState<'today' | 'history'>('today');
|
||||
|
||||
// 加载今日简报
|
||||
useEffect(() => {
|
||||
loadBriefing(todayStr());
|
||||
loadHistory();
|
||||
}, [userId]);
|
||||
|
||||
const loadBriefing = async (date: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await getBriefing(userId, date);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
setBriefing(null);
|
||||
} else if (resp.data?.briefing) {
|
||||
setBriefing(resp.data.briefing);
|
||||
} else {
|
||||
setBriefing(null);
|
||||
}
|
||||
} catch {
|
||||
setError('获取简报失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const resp = await getLatestBriefings(userId, 7);
|
||||
if (resp.data?.briefings) {
|
||||
setHistory(resp.data.briefings);
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await generateBriefing(userId);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else if (resp.data?.success && resp.data.briefing) {
|
||||
setBriefing(resp.data.briefing);
|
||||
// 刷新历史
|
||||
loadHistory();
|
||||
} else {
|
||||
setError(resp.data?.error || '生成简报失败');
|
||||
}
|
||||
} catch {
|
||||
setError('生成简报请求失败');
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleDateChange = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
loadBriefing(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full">
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<button
|
||||
onClick={() => setViewMode('today')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
viewMode === 'today'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📋 今日简报
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('history')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
viewMode === 'history'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📅 历史简报
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 今日简报视图 */}
|
||||
{viewMode === 'today' && (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-pink-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-red-400 mb-2">{error}</p>
|
||||
<button
|
||||
onClick={loadBriefing.bind(null, todayStr())}
|
||||
className="text-xs text-pink-500 hover:text-pink-600"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : briefing ? (
|
||||
<BriefingCard briefing={briefing} />
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400 text-sm mb-3">今日简报尚未生成</p>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-pink-500 hover:bg-pink-600 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
{generating ? '生成中...' : '✨ 生成今日简报'}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 mt-2">
|
||||
简报会在每天早上自动生成
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已生成时可手动重新生成 */}
|
||||
{briefing && (
|
||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-1.5 text-xs text-pink-500 hover:text-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{generating ? '生成中...' : '🔄 重新生成简报'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史简报视图 */}
|
||||
{viewMode === 'history' && (
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
暂无历史简报
|
||||
</div>
|
||||
) : (
|
||||
history.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => {
|
||||
handleDateChange(b.date);
|
||||
setViewMode('today');
|
||||
}}
|
||||
className="w-full text-left p-3 rounded-lg bg-gray-50 dark:bg-gray-750 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{formatBriefingDate(b.date)}
|
||||
</span>
|
||||
{b.weather && (
|
||||
<span className="text-lg">{weatherIcon(b.weather.icon)}</span>
|
||||
)}
|
||||
</div>
|
||||
{b.weather && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{b.weather.condition} · {b.weather.temp.toFixed(0)}°C
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{b.summary || '暂无摘要'}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 简报卡片展示组件 */
|
||||
function BriefingCard({ briefing }: { briefing: Briefing }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 日期 */}
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 text-center">
|
||||
{formatBriefingDate(briefing.date)}
|
||||
</h3>
|
||||
|
||||
{/* 天气卡片 */}
|
||||
{briefing.weather && briefing.weather.condition && (
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 border border-blue-100 dark:border-blue-800/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{weatherIcon(briefing.weather.icon)}</span>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{briefing.weather.temp.toFixed(0)}°C
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{briefing.weather.location} · {briefing.weather.condition}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 待办提醒 */}
|
||||
{briefing.reminders.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-pink-50 dark:bg-pink-900/10 border border-pink-100 dark:border-pink-800/30">
|
||||
<h4 className="text-xs font-semibold text-pink-600 dark:text-pink-400 mb-2">
|
||||
📋 今日待办 ({briefing.reminders.length})
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{briefing.reminders.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-pink-400 flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{r.title}</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
||||
{formatRemindTime(r.remind_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新闻列表 */}
|
||||
{briefing.news.length > 0 && briefing.news[0].title !== '未能获取今日新闻' && (
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30">
|
||||
<h4 className="text-xs font-semibold text-green-600 dark:text-green-400 mb-2">
|
||||
📰 今日新闻
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{briefing.news.map((n, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
{n.url ? (
|
||||
<a
|
||||
href={n.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-gray-700 dark:text-gray-300 hover:text-pink-500 transition-colors"
|
||||
>
|
||||
{n.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{n.title}
|
||||
</span>
|
||||
)}
|
||||
{n.summary && (
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
|
||||
{n.summary}
|
||||
</p>
|
||||
)}
|
||||
{n.source && (
|
||||
<span className="text-[10px] text-gray-400">{n.source}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 摘要 */}
|
||||
{briefing.summary && (
|
||||
<div className="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/10 border border-purple-100 dark:border-purple-800/30">
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="text-sm">🌸</span>
|
||||
<h4 className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
||||
昔涟的每日问候
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
||||
{briefing.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
listFiles,
|
||||
deleteFile,
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
getFileThumbnailUrl,
|
||||
getFileDownloadUrl,
|
||||
type FileInfo,
|
||||
} from '@/api/files';
|
||||
|
||||
interface FilePanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** MIME 类型分类 */
|
||||
function getCategory(mimeType: string): 'image' | 'audio' | 'video' | 'document' | 'other' {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
if (mimeType.startsWith('video/')) return 'video';
|
||||
if (mimeType.startsWith('text/') || mimeType === 'application/pdf' || mimeType.includes('word')) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** 文件类型图标 */
|
||||
function getFileIcon(mimeType: string): string {
|
||||
const cat = getCategory(mimeType);
|
||||
switch (cat) {
|
||||
case 'image': return '🖼️';
|
||||
case 'audio': return '🎵';
|
||||
case 'video': return '🎬';
|
||||
case 'document': return '📄';
|
||||
default: return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(Number(ts));
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 允许的文件扩展名 */
|
||||
const ALLOWED_EXTS = new Set([
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
|
||||
'.pdf', '.txt', '.md', '.doc', '.docx',
|
||||
'.mp3', '.wav', '.ogg',
|
||||
'.mp4', '.webm',
|
||||
]);
|
||||
|
||||
/** 允许的 MIME 类型前缀 */
|
||||
const ALLOWED_MIME_PREFIXES = [
|
||||
'image/', 'audio/', 'video/',
|
||||
'application/pdf', 'text/', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml',
|
||||
];
|
||||
|
||||
export function FilePanel({ onClose }: FilePanelProps) {
|
||||
// 双视图:list / upload
|
||||
const [view, setView] = useState<'list' | 'upload'>('list');
|
||||
|
||||
// 文件列表
|
||||
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 筛选
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
|
||||
// 上传
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploadErr, setUploadErr] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Lightbox 预览
|
||||
const [previewFile, setPreviewFile] = useState<FileInfo | null>(null);
|
||||
|
||||
// 上下文菜单
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; file: FileInfo } | null>(null);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
// 加载文件列表
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { files: f, total: t } = await listFiles(page, limit);
|
||||
setFiles(f);
|
||||
setTotal(t);
|
||||
} catch (e) {
|
||||
setError('加载文件列表失败');
|
||||
console.error('[FilePanel] 加载文件列表失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 验证文件
|
||||
function validateFile(file: File): string | null {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
return `文件 "${file.name}" 超过 20MB 限制`;
|
||||
}
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
const mimeOk = ALLOWED_MIME_PREFIXES.some(p => file.type.startsWith(p));
|
||||
const extOk = ALLOWED_EXTS.has(ext);
|
||||
if (!mimeOk && !extOk) {
|
||||
return `不支持的文件类型: ${file.type || ext}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理上传
|
||||
async function handleUpload(fileList: FileList | File[]) {
|
||||
const filesToUpload = Array.from(fileList);
|
||||
if (filesToUpload.length === 0) return;
|
||||
|
||||
// 验证所有文件
|
||||
for (const f of filesToUpload) {
|
||||
const err = validateFile(f);
|
||||
if (err) {
|
||||
setUploadErr(err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadErr('');
|
||||
setUploadProgress(0);
|
||||
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < filesToUpload.length; i++) {
|
||||
try {
|
||||
await uploadFile(filesToUpload[i]);
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.error('[FilePanel] 上传失败:', e);
|
||||
}
|
||||
setUploadProgress(Math.round(((i + 1) / filesToUpload.length) * 100));
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
|
||||
if (successCount > 0) {
|
||||
setView('list');
|
||||
setPage(1);
|
||||
await loadFiles();
|
||||
}
|
||||
if (successCount < filesToUpload.length) {
|
||||
setUploadErr(`${filesToUpload.length - successCount} 个文件上传失败`);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
async function handleDelete(file: FileInfo) {
|
||||
setContextMenu(null);
|
||||
if (!confirm(`确定删除 "${file.filename}" 吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
const ok = await deleteFile(file.id);
|
||||
if (ok) {
|
||||
setFiles(prev => prev.filter(f => f.id !== file.id));
|
||||
setTotal(prev => prev - 1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FilePanel] 删除失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
function handleDownload(file: FileInfo) {
|
||||
setContextMenu(null);
|
||||
downloadFile(file.id, file.filename).catch(e => {
|
||||
console.error('[FilePanel] 下载失败:', e);
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤文件
|
||||
const filteredFiles = files.filter(f => {
|
||||
if (search && !f.filename.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (typeFilter !== 'all' && getCategory(f.mime_type) !== typeFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Lightbox 关闭
|
||||
useEffect(() => {
|
||||
if (!previewFile) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setPreviewFile(null);
|
||||
}
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [previewFile]);
|
||||
|
||||
// 关闭上下文菜单
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
function handleClick() { setContextMenu(null); }
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, [contextMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 视图切换标签 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
view === 'list'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📋 文件列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setView('upload'); setUploadErr(''); }}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
view === 'upload'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📤 上传
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件列表视图 */}
|
||||
{view === 'list' && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="px-3 py-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="🔍 搜索文件名..."
|
||||
className="flex-1 px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:border-pink-300"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="image">🖼️ 图片</option>
|
||||
<option value="document">📄 文档</option>
|
||||
<option value="audio">🎵 音频</option>
|
||||
<option value="video">🎬 视频</option>
|
||||
<option value="other">📎 其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<div className="flex-1 overflow-y-auto px-3">
|
||||
{loading && files.length === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-gray-400">
|
||||
<div className="animate-spin mr-2">⏳</div> 加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="py-4 text-center text-sm text-red-500">
|
||||
{error}
|
||||
<button onClick={loadFiles} className="ml-2 underline">重试</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredFiles.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-4xl mb-3">📁</span>
|
||||
<p className="text-sm">暂无文件</p>
|
||||
<button
|
||||
onClick={() => setView('upload')}
|
||||
className="mt-3 px-4 py-1.5 text-xs text-pink-500 border border-pink-200 dark:border-pink-800 rounded-lg hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-colors"
|
||||
>
|
||||
📤 上传文件
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 py-2">
|
||||
{filteredFiles.map(f => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group relative flex flex-col items-center p-2 border border-gray-100 dark:border-gray-700 rounded-lg hover:border-pink-200 dark:hover:border-pink-800 hover:bg-pink-50/50 dark:hover:bg-pink-900/10 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
if (getCategory(f.mime_type) === 'image') {
|
||||
setPreviewFile(f);
|
||||
} else {
|
||||
handleDownload(f);
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, file: f });
|
||||
}}
|
||||
>
|
||||
{/* 缩略图或图标 */}
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded bg-gray-50 dark:bg-gray-700 mb-1 overflow-hidden">
|
||||
{getCategory(f.mime_type) === 'image' ? (
|
||||
<img
|
||||
src={getFileThumbnailUrl(f.id)}
|
||||
alt={f.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={`text-2xl ${getCategory(f.mime_type) === 'image' ? 'hidden' : ''}`}>
|
||||
{getFileIcon(f.mime_type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文件名 */}
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300 text-center truncate w-full" title={f.filename}>
|
||||
{f.filename}
|
||||
</p>
|
||||
|
||||
{/* 大小和时间 */}
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{formatSize(f.size)} · {formatTime(f.created_at)}
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 (悬停显示) */}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDownload(f); }}
|
||||
className="p-1 text-gray-400 hover:text-pink-500 bg-white dark:bg-gray-800 rounded shadow-sm"
|
||||
title="下载"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(f); }}
|
||||
className="p-1 text-gray-400 hover:text-red-500 bg-white dark:bg-gray-800 rounded shadow-sm"
|
||||
title="删除"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
共 {total} 个文件
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 disabled:opacity-30 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="px-2 py-0.5 text-xs text-gray-500">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-0.5 text-xs rounded border border-gray-200 dark:border-gray-600 disabled:opacity-30 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传视图 */}
|
||||
{view === 'upload' && (
|
||||
<div className="flex-1 flex flex-col p-4">
|
||||
{/* 拖放区域 */}
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-center border-2 border-dashed rounded-xl transition-colors ${
|
||||
dragOver
|
||||
? 'border-pink-400 bg-pink-50 dark:bg-pink-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-pink-300'
|
||||
}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="animate-spin text-3xl">⏳</div>
|
||||
<p className="text-sm text-gray-500">上传中...</p>
|
||||
<div className="w-48 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-pink-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{uploadProgress}%</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 pointer-events-none">
|
||||
<span className="text-4xl">📤</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{dragOver ? '释放以上传文件' : '拖放文件到此处或点击选择'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
支持图片、文档、音频、视频 · 最大 20MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.svg,.pdf,.txt,.md,.doc,.docx,.mp3,.wav,.ogg,.mp4,.webm"
|
||||
onChange={e => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleUpload(e.target.files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 上传错误 */}
|
||||
{uploadErr && (
|
||||
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600 dark:text-red-400">
|
||||
{uploadErr}
|
||||
<button onClick={() => setUploadErr('')} className="ml-2 underline">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支持的文件类型 */}
|
||||
<div className="mt-3 text-[10px] text-gray-400 space-y-1">
|
||||
<p>🖼️ 图片: JPG, PNG, GIF, WebP, SVG</p>
|
||||
<p>📄 文档: PDF, TXT, MD, DOC, DOCX</p>
|
||||
<p>🎵 音频: MP3, WAV, OGG</p>
|
||||
<p>🎬 视频: MP4, WebM</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-[100] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 min-w-[120px]"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDownload(contextMenu.file)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
{getCategory(contextMenu.file.mime_type) === 'image' && (
|
||||
<button
|
||||
onClick={() => { setPreviewFile(contextMenu.file); setContextMenu(null); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
🔍 预览
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(contextMenu.file)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"
|
||||
>
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox 图片预览 */}
|
||||
{previewFile && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] bg-black/80 flex items-center justify-center p-4"
|
||||
onClick={() => setPreviewFile(null)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setPreviewFile(null)}
|
||||
className="absolute top-4 right-4 text-white/80 hover:text-white text-2xl z-10"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="flex flex-col items-center max-w-full max-h-full" onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={getFileDownloadUrl(previewFile.id)}
|
||||
alt={previewFile.filename}
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
<div className="mt-3 text-center">
|
||||
<p className="text-white text-sm font-medium">{previewFile.filename}</p>
|
||||
<p className="text-white/60 text-xs mt-1">{formatSize(previewFile.size)}</p>
|
||||
<div className="flex gap-3 mt-3 justify-center">
|
||||
<button
|
||||
onClick={() => handleDownload(previewFile)}
|
||||
className="px-4 py-1.5 text-xs text-white bg-pink-500 hover:bg-pink-600 rounded-lg transition-colors"
|
||||
>
|
||||
⬇️ 下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,91 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { MoodIndicator } from '@/components/persona/MoodIndicator';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { usePWA } from '@/hooks/usePWA';
|
||||
import { useNotificationStore } from '@/store/notificationStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { ReminderPanel } from '@/components/layout/ReminderPanel';
|
||||
import { BriefingPanel } from '@/components/layout/BriefingPanel';
|
||||
import { AutomationPanel } from '@/components/layout/AutomationPanel';
|
||||
import { FilePanel } from '@/components/layout/FilePanel';
|
||||
import { KnowledgePanel } from '@/components/layout/KnowledgePanel';
|
||||
import type { AppNotification } from '@/types/chat';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
onSearchClick: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick }: HeaderProps) {
|
||||
/** 通知类型对应的图标和颜色 */
|
||||
const NOTIF_STYLES: Record<string, { icon: string; bg: string; text: string }> = {
|
||||
info: { icon: 'ℹ️', bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-600 dark:text-blue-400' },
|
||||
warning: { icon: '⚠️', bg: 'bg-yellow-50 dark:bg-yellow-900/30', text: 'text-yellow-600 dark:text-yellow-400' },
|
||||
success: { icon: '✅', bg: 'bg-green-50 dark:bg-green-900/30', text: 'text-green-600 dark:text-green-400' },
|
||||
thinking: { icon: '💭', bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-600 dark:text-purple-400' },
|
||||
reminder: { icon: '🔔', bg: 'bg-pink-50 dark:bg-pink-900/30', text: 'text-pink-600 dark:text-pink-400' },
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
return d.toLocaleDateString('zh-CN');
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
export function Header({ onMenuClick, onSearchClick }: HeaderProps) {
|
||||
const { logout } = useAuth();
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isOpen,
|
||||
toggleOpen,
|
||||
setOpen,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} = useNotificationStore();
|
||||
const setCurrentSessionId = useSessionStore((s) => s.setCurrentSessionId);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// PWA Hook
|
||||
const { isInstallable, isInstalled, hasUpdate, install, update } = usePWA();
|
||||
|
||||
// 下拉面板标签页切换:通知 / 提醒 / 简报
|
||||
const [dropdownTab, setDropdownTab] = useState<'notifications' | 'reminders' | 'briefing' | 'automation' | 'files' | 'knowledge'>('notifications');
|
||||
|
||||
// 获取当前用户 ID
|
||||
const userId = localStorage.getItem('user_id') || '';
|
||||
|
||||
// 点击外部关闭下拉
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, setOpen]);
|
||||
|
||||
const handleNotifClick = (n: AppNotification) => {
|
||||
markAsRead(n.id);
|
||||
if (n.data?.session_id) {
|
||||
setCurrentSessionId(n.data.session_id as string);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-pink-100 dark:border-pink-900 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
@@ -31,7 +109,227 @@ export function Header({ onMenuClick }: HeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* PWA 安装按钮 */}
|
||||
{isInstallable && !isInstalled && (
|
||||
<button
|
||||
onClick={install}
|
||||
className="p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
|
||||
title="安装应用到桌面"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* PWA 更新按钮 */}
|
||||
{hasUpdate && (
|
||||
<button
|
||||
onClick={update}
|
||||
className="px-2 py-1 text-xs font-medium text-white bg-pink-500 hover:bg-pink-600 rounded-full transition-colors"
|
||||
title="有新版本可用,点击更新"
|
||||
>
|
||||
更新
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 通知铃铛 */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="relative p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
|
||||
title="通知"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center w-4 h-4 text-[10px] font-bold text-white bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 通知下拉列表 */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-50 overflow-hidden">
|
||||
{/* 标签页切换:通知 / 提醒 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setDropdownTab('notifications')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'notifications'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
通知
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-1 text-[10px] bg-red-500 text-white px-1 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('reminders')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'reminders'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
提醒 ⏰
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('briefing')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'briefing'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
简报 📋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('automation')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'automation'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
⚡ 自动化
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('files')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'files'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📁 文件
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDropdownTab('knowledge')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
dropdownTab === 'knowledge'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📚 知识库
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 通知面板 */}
|
||||
{dropdownTab === 'notifications' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
最近通知
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllAsRead()}
|
||||
className="text-[10px] text-pink-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔔 暂无通知
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{notifications.slice(0, 10).map((n) => {
|
||||
const style = NOTIF_STYLES[n.type] || NOTIF_STYLES.info;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => handleNotifClick(n)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors flex items-start gap-3 ${
|
||||
!n.read ? 'bg-pink-50/50 dark:bg-pink-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg mt-0.5 flex-shrink-0">{style.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-medium ${style.text}`}>
|
||||
{n.title}
|
||||
</span>
|
||||
{!n.read && (
|
||||
<span className="w-2 h-2 bg-pink-500 rounded-full flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{n.body}
|
||||
</p>
|
||||
<span className="text-[10px] text-gray-400 mt-1 block">
|
||||
{formatTime(n.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提醒面板 */}
|
||||
{dropdownTab === 'reminders' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<ReminderPanel userId={userId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 简报面板 */}
|
||||
{dropdownTab === 'briefing' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<BriefingPanel userId={userId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 自动化面板 */}
|
||||
{dropdownTab === 'automation' && (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<AutomationPanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件面板 */}
|
||||
{dropdownTab === 'files' && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<FilePanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 知识库面板 */}
|
||||
{dropdownTab === 'knowledge' && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<KnowledgePanel onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 搜索按钮 */}
|
||||
<button
|
||||
onClick={onSearchClick}
|
||||
className="p-1.5 text-gray-400 hover:text-pink-500 transition-colors rounded-lg"
|
||||
title="搜索消息 (Ctrl+K)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-gray-400 hidden sm:block">🌸 永远在你身边</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { KnowledgeBase, KnowledgeDocument, SearchChunkResult } from '@/api/knowledge';
|
||||
import {
|
||||
createKB,
|
||||
listKBs,
|
||||
updateKB,
|
||||
deleteKB,
|
||||
addDocument,
|
||||
listDocuments,
|
||||
deleteDocument,
|
||||
searchKnowledge,
|
||||
} from '@/api/knowledge';
|
||||
|
||||
interface KnowledgePanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN');
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
export function KnowledgePanel({ onClose }: KnowledgePanelProps) {
|
||||
// 双标签页:知识库管理 / 搜索
|
||||
const [tab, setTab] = useState<'bases' | 'search'>('bases');
|
||||
|
||||
// ========== 知识库管理状态 ==========
|
||||
const [bases, setBases] = useState<KnowledgeBase[]>([]);
|
||||
const [loadingBases, setLoadingBases] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 创建/编辑知识库
|
||||
const [showKBForm, setShowKBForm] = useState(false);
|
||||
const [editingKB, setEditingKB] = useState<KnowledgeBase | null>(null);
|
||||
const [kbName, setKbName] = useState('');
|
||||
const [kbDesc, setKbDesc] = useState('');
|
||||
const [savingKB, setSavingKB] = useState(false);
|
||||
|
||||
// 选中知识库查看文档
|
||||
const [selectedKB, setSelectedKB] = useState<KnowledgeBase | null>(null);
|
||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||
const [loadingDocs, setLoadingDocs] = useState(false);
|
||||
|
||||
// 添加文档
|
||||
const [showDocForm, setShowDocForm] = useState(false);
|
||||
const [docTitle, setDocTitle] = useState('');
|
||||
const [docContent, setDocContent] = useState('');
|
||||
const [savingDoc, setSavingDoc] = useState(false);
|
||||
|
||||
// ========== 搜索状态 ==========
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchChunkResult[]>([]);
|
||||
const [searchTotal, setSearchTotal] = useState(0);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchErr, setSearchErr] = useState('');
|
||||
|
||||
// ========== 加载知识库列表 ==========
|
||||
const loadBases = useCallback(async () => {
|
||||
setLoadingBases(true);
|
||||
setError('');
|
||||
try {
|
||||
const list = await listKBs();
|
||||
setBases(list);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载知识库失败');
|
||||
} finally {
|
||||
setLoadingBases(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadBases();
|
||||
}, [loadBases]);
|
||||
|
||||
// ========== 创建/更新知识库 ==========
|
||||
const handleSaveKB = async () => {
|
||||
if (!kbName.trim()) return;
|
||||
setSavingKB(true);
|
||||
setError('');
|
||||
try {
|
||||
if (editingKB) {
|
||||
await updateKB(editingKB.id, kbName.trim(), kbDesc.trim() || undefined);
|
||||
} else {
|
||||
await createKB(kbName.trim(), kbDesc.trim() || undefined);
|
||||
}
|
||||
setShowKBForm(false);
|
||||
setEditingKB(null);
|
||||
setKbName('');
|
||||
setKbDesc('');
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '保存知识库失败');
|
||||
} finally {
|
||||
setSavingKB(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKB = (kb: KnowledgeBase) => {
|
||||
setEditingKB(kb);
|
||||
setKbName(kb.name);
|
||||
setKbDesc(kb.description || '');
|
||||
setShowKBForm(true);
|
||||
};
|
||||
|
||||
const handleDeleteKB = async (id: string) => {
|
||||
if (!confirm('确定要删除此知识库?所有文档将被永久删除。')) return;
|
||||
try {
|
||||
await deleteKB(id);
|
||||
if (selectedKB?.id === id) {
|
||||
setSelectedKB(null);
|
||||
setDocuments([]);
|
||||
}
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '删除知识库失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 查看文档 ==========
|
||||
const handleSelectKB = async (kb: KnowledgeBase) => {
|
||||
setSelectedKB(kb);
|
||||
setLoadingDocs(true);
|
||||
try {
|
||||
const docs = await listDocuments(kb.id);
|
||||
setDocuments(docs);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载文档失败');
|
||||
} finally {
|
||||
setLoadingDocs(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 添加文档 ==========
|
||||
const handleAddDoc = async () => {
|
||||
if (!docTitle.trim() || !docContent.trim() || !selectedKB) return;
|
||||
setSavingDoc(true);
|
||||
setError('');
|
||||
try {
|
||||
await addDocument(selectedKB.id, docTitle.trim(), docContent.trim());
|
||||
setShowDocForm(false);
|
||||
setDocTitle('');
|
||||
setDocContent('');
|
||||
// 刷新文档列表
|
||||
const docs = await listDocuments(selectedKB.id);
|
||||
setDocuments(docs);
|
||||
// 刷新知识库列表 (更新文档计数)
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '添加文档失败');
|
||||
} finally {
|
||||
setSavingDoc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDoc = async (id: string) => {
|
||||
if (!confirm('确定要删除此文档?')) return;
|
||||
try {
|
||||
await deleteDocument(id);
|
||||
if (selectedKB) {
|
||||
const docs = await listDocuments(selectedKB.id);
|
||||
setDocuments(docs);
|
||||
}
|
||||
await loadBases();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '删除文档失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 搜索 ==========
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
setSearching(true);
|
||||
setSearchErr('');
|
||||
try {
|
||||
const res = await searchKnowledge(searchQuery.trim());
|
||||
setSearchResults(res.results);
|
||||
setSearchTotal(res.total);
|
||||
} catch (e: any) {
|
||||
setSearchErr(e.message || '搜索失败');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<button
|
||||
onClick={() => setTab('bases')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
tab === 'bases'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
📚 知识库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('search')}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
tab === 'search'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
🔍 搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg shrink-0">
|
||||
{error}
|
||||
<button className="ml-2 underline" onClick={() => setError('')}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 知识库管理 ========== */}
|
||||
{tab === 'bases' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!selectedKB ? (
|
||||
/* 知识库列表 */
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
我的知识库
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingKB(null);
|
||||
setKbName('');
|
||||
setKbDesc('');
|
||||
setShowKBForm(true);
|
||||
}}
|
||||
className="text-xs text-pink-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKBForm && (
|
||||
<div className="mx-3 mb-2 p-3 bg-gray-50 dark:bg-gray-750 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="知识库名称"
|
||||
value={kbName}
|
||||
onChange={(e) => setKbName(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300"
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
placeholder="描述 (可选)"
|
||||
value={kbDesc}
|
||||
onChange={(e) => setKbDesc(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveKB}
|
||||
disabled={savingKB || !kbName.trim()}
|
||||
className="px-3 py-1 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingKB ? '保存中...' : editingKB ? '更新' : '创建'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowKBForm(false);
|
||||
setEditingKB(null);
|
||||
}}
|
||||
className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingBases ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">加载中...</div>
|
||||
) : bases.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
📚 暂无知识库,点击「+ 新建」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{bases.map((kb) => (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => handleSelectKB(kb)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
📚 {kb.name}
|
||||
</div>
|
||||
{kb.description && (
|
||||
<div className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||||
{kb.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{kb.document_count || 0} 篇文档 · {formatTime(kb.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditKB(kb);
|
||||
}}
|
||||
className="text-[10px] text-gray-400 hover:text-pink-500 px-1"
|
||||
title="编辑"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteKB(kb.id);
|
||||
}}
|
||||
className="text-[10px] text-gray-400 hover:text-red-500 px-1"
|
||||
title="删除"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 文档列表 */
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedKB(null);
|
||||
setDocuments([]);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-pink-500 transition-colors"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 flex-1">
|
||||
📚 {selectedKB.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowDocForm(true)}
|
||||
className="text-xs text-pink-500 hover:text-pink-600 transition-colors"
|
||||
>
|
||||
+ 添加文档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDocForm && (
|
||||
<div className="mx-3 my-2 p-3 bg-gray-50 dark:bg-gray-750 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="文档标题"
|
||||
value={docTitle}
|
||||
onChange={(e) => setDocTitle(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300"
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
placeholder="文档内容"
|
||||
value={docContent}
|
||||
onChange={(e) => setDocContent(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 mb-2 focus:outline-none focus:border-pink-300 resize-none"
|
||||
rows={5}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddDoc}
|
||||
disabled={savingDoc || !docTitle.trim() || !docContent.trim()}
|
||||
className="px-3 py-1 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingDoc ? '保存中...' : '添加'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDocForm(false)}
|
||||
className="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingDocs ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">加载中...</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
📄 暂无文档,点击「+ 添加文档」添加
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
📄 {doc.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{doc.source_type === 'text'
|
||||
? '📝 文本'
|
||||
: doc.source_type === 'file'
|
||||
? '📎 文件'
|
||||
: '🔗 URL'}{' '}
|
||||
· {doc.chunk_count || 0} 个片段 · {formatTime(doc.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteDoc(doc.id)}
|
||||
className="text-[10px] text-gray-400 hover:text-red-500 px-1 shrink-0"
|
||||
title="删除"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 搜索 ========== */}
|
||||
{tab === 'search' && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 flex gap-2 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索知识库内容..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="flex-1 px-2 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:outline-none focus:border-pink-300"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-pink-500 text-white rounded hover:bg-pink-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchErr && (
|
||||
<div className="mx-3 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
{searchErr}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{searchResults.length === 0 && !searching && searchQuery && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔍 未找到匹配结果
|
||||
</div>
|
||||
)}
|
||||
{searchResults.length === 0 && !searching && !searchQuery && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔍 输入关键词搜索你的知识库
|
||||
</div>
|
||||
)}
|
||||
{searching && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">搜索中...</div>
|
||||
)}
|
||||
{searchResults.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] text-gray-400">
|
||||
共 {searchTotal} 条结果
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{searchResults.map((r) => (
|
||||
<div
|
||||
key={r.chunk_id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="text-[10px] text-pink-500 mb-0.5">
|
||||
📚 {r.kb_name} {'>'} 📄 {r.doc_title}
|
||||
</div>
|
||||
{r.headline && (
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{r.headline}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed line-clamp-3">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
listReminders,
|
||||
createReminder,
|
||||
cancelReminder,
|
||||
deleteReminder,
|
||||
type Reminder,
|
||||
} from '@/api/reminders';
|
||||
|
||||
interface ReminderPanelProps {
|
||||
/** 从 Header 传入用户 ID */
|
||||
userId: string;
|
||||
/** 关闭面板 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 状态标签颜色映射 */
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
completed: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||
};
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待提醒',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
/** 重复类型中文映射 */
|
||||
const REPEAT_LABELS: Record<string, string> = {
|
||||
none: '不重复',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
function formatDateTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
/** 转换为 datetime-local 输入框的格式 */
|
||||
function toDatetimeLocal(isoStr: string): string {
|
||||
try {
|
||||
const d = new Date(isoStr);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function ReminderPanel({ userId, onClose }: ReminderPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'create'>('list');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [reminders, setReminders] = useState<Reminder[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 创建表单
|
||||
const [formTitle, setFormTitle] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formTime, setFormTime] = useState('');
|
||||
const [formRepeat, setFormRepeat] = useState('none');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchReminders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const resp = await listReminders(userId, statusFilter || undefined, 50);
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
setReminders([]);
|
||||
} else {
|
||||
setReminders(resp.data?.reminders ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [userId, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReminders();
|
||||
}, [fetchReminders]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formTitle.trim()) return;
|
||||
if (!formTime) return;
|
||||
|
||||
setSubmitting(true);
|
||||
const remindAt = new Date(formTime).toISOString();
|
||||
const resp = await createReminder({
|
||||
title: formTitle.trim(),
|
||||
description: formDesc.trim(),
|
||||
remind_at: remindAt,
|
||||
repeat_type: formRepeat,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
setFormTitle('');
|
||||
setFormDesc('');
|
||||
setFormTime('');
|
||||
setFormRepeat('none');
|
||||
setActiveTab('list');
|
||||
fetchReminders();
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await cancelReminder(id);
|
||||
fetchReminders();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这条提醒吗?')) return;
|
||||
await deleteReminder(id);
|
||||
fetchReminders();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-h-full flex flex-col">
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center border-b border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('list')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'list'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
我的提醒
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('create')}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === 'create'
|
||||
? 'text-pink-500 border-b-2 border-pink-500'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
+ 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-xs text-red-500 bg-red-50 dark:bg-red-900/20">
|
||||
⚠️ {error}
|
||||
<button
|
||||
onClick={() => setError('')}
|
||||
className="ml-2 underline hover:no-underline"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建表单 */}
|
||||
{activeTab === 'create' && (
|
||||
<div className="p-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
标题 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="例如:喝水提醒"
|
||||
maxLength={200}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
placeholder="提醒详情 (可选)"
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
提醒时间 *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formTime}
|
||||
onChange={(e) => setFormTime(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
重复
|
||||
</label>
|
||||
<select
|
||||
value={formRepeat}
|
||||
onChange={(e) => setFormRepeat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-pink-400"
|
||||
>
|
||||
<option value="none">不重复</option>
|
||||
<option value="daily">每天</option>
|
||||
<option value="weekly">每周</option>
|
||||
<option value="monthly">每月</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('list');
|
||||
setError('');
|
||||
}}
|
||||
className="flex-1 py-1.5 text-xs border border-gray-200 dark:border-gray-600 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={submitting || !formTitle.trim() || !formTime}
|
||||
className="flex-1 py-1.5 text-xs bg-pink-500 text-white rounded-lg hover:bg-pink-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? '创建中...' : '创建提醒'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提醒列表 */}
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
{/* 状态筛选 */}
|
||||
<div className="flex gap-1 px-3 py-2 border-b border-gray-50 dark:border-gray-700">
|
||||
{['', 'pending', 'completed', 'cancelled'].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded-full transition-colors ${
|
||||
statusFilter === s
|
||||
? 'bg-pink-100 text-pink-600 dark:bg-pink-900/40 dark:text-pink-400'
|
||||
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{s === '' ? '全部' : STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 列表内容 */}
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{loading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
⏳ 加载中...
|
||||
</div>
|
||||
) : reminders.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
🔔 暂无提醒,点击「+ 新建」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50 dark:divide-gray-700">
|
||||
{reminders.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
|
||||
{r.title}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 ${
|
||||
STATUS_STYLES[r.status] || STATUS_STYLES.pending
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[r.status] || r.status}
|
||||
</span>
|
||||
</div>
|
||||
{r.description && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate mt-0.5">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
⏰ {formatDateTime(r.remind_at)}
|
||||
</span>
|
||||
{r.repeat_type && r.repeat_type !== 'none' && (
|
||||
<span className="text-[10px] text-pink-400">
|
||||
🔄 {REPEAT_LABELS[r.repeat_type] || r.repeat_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{r.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(r.id)}
|
||||
title="取消提醒"
|
||||
className="p-0.5 text-gray-300 hover:text-yellow-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
title="删除"
|
||||
className="p-0.5 text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { searchMessages, type SearchResult } from '@/api/sessions';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 高亮文本中的关键词 */
|
||||
function highlightText(text: string, keyword: string): React.ReactNode {
|
||||
if (!keyword.trim()) return text;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escaped})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-yellow-200 dark:bg-yellow-700 text-inherit rounded px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 截取围绕关键词的上下文片段 */
|
||||
function snippetAroundKeyword(text: string, keyword: string, contextLen: number = 40): string {
|
||||
if (!keyword.trim()) {
|
||||
return text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||
}
|
||||
const idx = text.toLowerCase().indexOf(keyword.toLowerCase());
|
||||
if (idx === -1) return text.length > 100 ? text.slice(0, 100) + '...' : text;
|
||||
|
||||
const start = Math.max(0, idx - contextLen);
|
||||
const end = Math.min(text.length, idx + keyword.length + contextLen);
|
||||
let snippet = text.slice(start, end);
|
||||
if (start > 0) snippet = '…' + snippet;
|
||||
if (end < text.length) snippet = snippet + '…';
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/** 格式化时间戳 */
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const setCurrentSessionId = useSessionStore((s) => s.setCurrentSessionId);
|
||||
const loadMessagesFromServer = useSessionStore((s) => s.loadMessagesFromServer);
|
||||
|
||||
const userId = localStorage.getItem('user_id') || '';
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
const resp = await searchMessages(q.trim(), userId, 50, 0);
|
||||
setResults(resp.results);
|
||||
setTotal(resp.total);
|
||||
setLoading(false);
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 防抖输入
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setQuery(val);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
doSearch(val);
|
||||
}, 300);
|
||||
},
|
||||
[doSearch]
|
||||
);
|
||||
|
||||
// 打开时聚焦输入框
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setSearched(false);
|
||||
setLoading(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 点击结果跳转到对应会话
|
||||
const handleResultClick = useCallback(
|
||||
async (result: SearchResult) => {
|
||||
onClose();
|
||||
setCurrentSessionId(result.session_id);
|
||||
await loadMessagesFromServer(result.session_id);
|
||||
},
|
||||
[onClose, setCurrentSessionId, loadMessagesFromServer]
|
||||
);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* 搜索面板 */}
|
||||
<div className="relative w-full max-w-lg mx-4 bg-white dark:bg-gray-850 rounded-2xl shadow-2xl border border-pink-100 dark:border-pink-800 flex flex-col max-h-[60vh]">
|
||||
{/* 搜索输入框 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-pink-100 dark:border-pink-800">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 text-gray-400 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
placeholder="搜索历史消息…"
|
||||
className="flex-1 bg-transparent border-none outline-none text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-pink-400 border-t-transparent" />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!searched && query.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
输入关键词搜索你的历史消息
|
||||
</div>
|
||||
)}
|
||||
{searched && loading && results.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
{searched && !loading && results.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-400">
|
||||
未找到匹配的消息
|
||||
</div>
|
||||
)}
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<div className="px-4 py-2 text-xs text-gray-400 border-b border-pink-50 dark:border-pink-900">
|
||||
找到 {total} 条结果
|
||||
</div>
|
||||
{results.map((result) => (
|
||||
<button
|
||||
key={result.message_id}
|
||||
onClick={() => handleResultClick(result)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-pink-50 dark:hover:bg-pink-900/20 transition-colors border-b border-pink-50 dark:border-pink-900/50 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-pink-500 dark:text-pink-400 truncate max-w-[60%]">
|
||||
{result.session_title || '新的对话'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-auto shrink-0">
|
||||
{formatTime(result.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
|
||||
{highlightText(snippetAroundKeyword(result.content, query), query)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="px-4 py-2 border-t border-pink-100 dark:border-pink-800 text-xs text-gray-400 text-center">
|
||||
<kbd className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">ESC</kbd> 关闭
|
||||
{' · '}
|
||||
点击结果跳转到对应会话
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useSession } from '@/hooks/useSession';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { exportSession, type ExportFormat } from '@/api/sessions';
|
||||
import type { Session } from '@/types/session';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -26,6 +27,24 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
sessionId?: string;
|
||||
} | null>(null);
|
||||
|
||||
// 导出下拉状态
|
||||
const [exportMenuId, setExportMenuId] = useState<string | null>(null);
|
||||
const [exportingId, setExportingId] = useState<string | null>(null);
|
||||
|
||||
/** 执行导出 */
|
||||
const handleExport = useCallback(async (sessionId: string, format: ExportFormat) => {
|
||||
setExportMenuId(null);
|
||||
setExportingId(sessionId);
|
||||
try {
|
||||
await exportSession(sessionId, format);
|
||||
} catch (err) {
|
||||
console.error('[Sidebar] 导出失败:', err);
|
||||
alert(err instanceof Error ? err.message : '导出失败');
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 按 updated_at 降序排列
|
||||
const displaySessions = [...sessions].sort((a, b) => {
|
||||
const ta = typeof a.updated_at === 'string' ? parseInt(a.updated_at, 10) : (a.updated_at as unknown as number);
|
||||
@@ -174,28 +193,84 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteClick(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<div className="flex items-center gap-0.5 relative">
|
||||
{/* 导出按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExportMenuId(exportMenuId === session.id ? null : session.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-blue-400 transition-all"
|
||||
title="导出会话"
|
||||
disabled={exportingId === session.id}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{exportingId === session.id ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* 格式选择下拉菜单 */}
|
||||
{exportMenuId === session.id && (
|
||||
<div className="absolute right-0 top-full mt-1 z-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 min-w-[120px]">
|
||||
{(['json', 'markdown', 'txt'] as ExportFormat[]).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(session.id, fmt);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{fmt === 'json' && '📦 JSON'}
|
||||
{fmt === 'markdown' && '📝 Markdown'}
|
||||
{fmt === 'txt' && '📄 TXT'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteClick(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
|
||||
title="删除会话"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user