Compare commits
No commits in common. "aa469438c2627859d22e42197c159b97a772a6c2" and "b400781b89d0f2a0ae404c29b3c5863b64fa5296" have entirely different histories.
aa469438c2
...
b400781b89
@ -1,52 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { conversations, messages } from '@/drizzle/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
// GET /api/conversations/all - 获取统计信息
|
||||
export async function GET() {
|
||||
try {
|
||||
// 获取对话数量
|
||||
const conversationCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(conversations);
|
||||
|
||||
// 获取消息数量
|
||||
const messageCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(messages);
|
||||
|
||||
return NextResponse.json({
|
||||
conversationCount: Number(conversationCount[0]?.count || 0),
|
||||
messageCount: Number(messageCount[0]?.count || 0),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to get stats' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/conversations/all - 清除所有对话和消息
|
||||
export async function DELETE() {
|
||||
try {
|
||||
// 先删除所有消息
|
||||
await db.delete(messages);
|
||||
|
||||
// 再删除所有对话
|
||||
await db.delete(conversations);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'All conversations and messages have been deleted',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete all conversations' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/drizzle/db';
|
||||
import { conversations, messages } from '@/drizzle/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
// GET /api/conversations/export - 导出所有对话数据
|
||||
export async function GET() {
|
||||
try {
|
||||
// 获取所有对话
|
||||
const allConversations = await db.query.conversations.findMany({
|
||||
orderBy: [desc(conversations.createdAt)],
|
||||
});
|
||||
|
||||
// 获取所有消息
|
||||
const allMessages = await db.query.messages.findMany({
|
||||
orderBy: [desc(messages.createdAt)],
|
||||
});
|
||||
|
||||
// 按对话组织数据
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
totalConversations: allConversations.length,
|
||||
totalMessages: allMessages.length,
|
||||
conversations: allConversations.map((conv) => {
|
||||
// 获取该对话的所有消息
|
||||
const convMessages = allMessages
|
||||
.filter((msg) => msg.conversationId === conv.conversationId)
|
||||
.sort((a, b) => new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime())
|
||||
.map((msg) => ({
|
||||
messageId: msg.messageId,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
thinkingContent: msg.thinkingContent,
|
||||
toolCalls: msg.toolCalls,
|
||||
toolResults: msg.toolResults,
|
||||
inputTokens: msg.inputTokens,
|
||||
outputTokens: msg.outputTokens,
|
||||
status: msg.status,
|
||||
feedback: msg.feedback,
|
||||
createdAt: msg.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
conversationId: conv.conversationId,
|
||||
title: conv.title,
|
||||
summary: conv.summary,
|
||||
model: conv.model,
|
||||
tools: conv.tools,
|
||||
enableThinking: conv.enableThinking,
|
||||
systemPrompt: conv.systemPrompt,
|
||||
temperature: conv.temperature,
|
||||
messageCount: conv.messageCount,
|
||||
totalTokens: conv.totalTokens,
|
||||
isArchived: conv.isArchived,
|
||||
isPinned: conv.isPinned,
|
||||
createdAt: conv.createdAt,
|
||||
updatedAt: conv.updatedAt,
|
||||
lastMessageAt: conv.lastMessageAt,
|
||||
messages: convMessages,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return NextResponse.json(exportData);
|
||||
} catch (error) {
|
||||
console.error('Failed to export conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to export conversations' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -207,7 +207,7 @@ export default function ChatPage({ params }: PageProps) {
|
||||
)}
|
||||
>
|
||||
{/* 固定顶部 Header */}
|
||||
<header className="h-[var(--header-height)] px-4 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-primary)] sticky top-0 z-10">
|
||||
<header className="h-[var(--header-height)] px-4 flex items-center justify-between border-b border-[var(--color-border)] bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<h1 className="text-base font-medium text-[var(--color-text-primary)] truncate max-w-[300px]">
|
||||
@ -280,12 +280,7 @@ export default function ChatPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* 固定底部输入框 */}
|
||||
<div
|
||||
className="sticky bottom-0 pt-4"
|
||||
style={{
|
||||
background: `linear-gradient(to top, var(--color-bg-secondary) 0%, var(--color-bg-secondary) 80%, transparent 100%)`
|
||||
}}
|
||||
>
|
||||
<div className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent pt-4">
|
||||
<div className="max-w-[900px] mx-auto px-4 pb-4">
|
||||
{isStreaming && (
|
||||
<div className="flex justify-center mb-3">
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
--shadow-input-focus: 0 4px 16px rgba(224, 107, 62, 0.1);
|
||||
|
||||
/* 布局 */
|
||||
--sidebar-width: 280px;
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 56px;
|
||||
--input-max-width: 900px;
|
||||
|
||||
@ -64,48 +64,6 @@
|
||||
--font-size-base: 15px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
暗色模式
|
||||
主背景色: #29252B
|
||||
======================================== */
|
||||
[data-theme="dark"] {
|
||||
/* 品牌色 - 保持不变 */
|
||||
--color-primary: #E06B3E;
|
||||
--color-primary-hover: #E8805A;
|
||||
--color-primary-light: rgba(224, 107, 62, 0.15);
|
||||
--color-primary-alpha: rgba(224, 107, 62, 0.2);
|
||||
|
||||
/* 背景色 */
|
||||
--color-bg-primary: #29252B;
|
||||
--color-bg-secondary: #201D23;
|
||||
--color-bg-tertiary: #332E38;
|
||||
--color-bg-hover: #3A353E;
|
||||
|
||||
/* 文字色 */
|
||||
--color-text-primary: #F5F5F5;
|
||||
--color-text-secondary: #A8A8A8;
|
||||
--color-text-tertiary: #6B6B6B;
|
||||
--color-text-placeholder: #5A5A5A;
|
||||
|
||||
/* 边框色 */
|
||||
--color-border: #3A353E;
|
||||
--color-border-light: #332E38;
|
||||
--color-border-focus: #E06B3E;
|
||||
|
||||
/* 消息气泡色 */
|
||||
--color-message-user: #4A4352;
|
||||
--color-message-assistant-bg: #332E38;
|
||||
--color-message-assistant-border: #3A353E;
|
||||
|
||||
/* 阴影 - 暗色模式下增强 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-dropdown: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-input: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
--shadow-input-focus: 0 4px 16px rgba(224, 107, 62, 0.2);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* 品牌色 */
|
||||
--color-primary: var(--color-primary);
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw, Moon, Sun, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw, Moon, Sun, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Toggle } from '@/components/ui/Toggle';
|
||||
import { ModelCardSelector } from '@/components/ui/ModelCardSelector';
|
||||
import { FontSizePicker } from '@/components/ui/FontSizePicker';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSettings, useModels, useTools } from '@/hooks/useSettings';
|
||||
|
||||
@ -60,12 +59,6 @@ export default function SettingsPage() {
|
||||
const [temperature, setTemperature] = useState('0.7');
|
||||
const [promptSaveStatus, setPromptSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
// 导出和清除状态
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||
const [clearLoading, setClearLoading] = useState(false);
|
||||
const [chatStats, setChatStats] = useState<{ conversationCount: number; messageCount: number } | null>(null);
|
||||
|
||||
// 当设置加载完成后,更新本地状态
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@ -205,70 +198,6 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 导出聊天数据
|
||||
const handleExportData = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/conversations/export');
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// 创建 Blob 并下载
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
a.download = `cchcode-chat-export-${date}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to export data:', error);
|
||||
alert('导出失败,请稍后重试');
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开清除对话框(获取统计信息)
|
||||
const handleOpenClearDialog = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/conversations/all');
|
||||
if (response.ok) {
|
||||
const stats = await response.json();
|
||||
setChatStats(stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get stats:', error);
|
||||
}
|
||||
setClearDialogOpen(true);
|
||||
};
|
||||
|
||||
// 清除所有聊天
|
||||
const handleClearAllChats = async () => {
|
||||
setClearLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/conversations/all', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed');
|
||||
}
|
||||
setClearDialogOpen(false);
|
||||
// 跳转到首页
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
console.error('Failed to clear chats:', error);
|
||||
alert('清除失败,请稍后重试');
|
||||
} finally {
|
||||
setClearLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
@ -581,17 +510,9 @@ export default function SettingsPage() {
|
||||
label="导出数据"
|
||||
description="下载所有聊天历史"
|
||||
>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
disabled={exportLoading}
|
||||
className="btn-ghost inline-flex items-center gap-2"
|
||||
>
|
||||
{exportLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<button className="btn-ghost inline-flex items-center gap-2">
|
||||
<Download size={16} />
|
||||
)}
|
||||
{exportLoading ? '导出中...' : '导出'}
|
||||
导出
|
||||
</button>
|
||||
</SettingsItem>
|
||||
|
||||
@ -599,49 +520,12 @@ export default function SettingsPage() {
|
||||
label="清除所有聊天"
|
||||
description="删除所有对话历史"
|
||||
>
|
||||
<button
|
||||
onClick={handleOpenClearDialog}
|
||||
className="btn-ghost text-red-600 hover:text-red-700 inline-flex items-center gap-2"
|
||||
>
|
||||
<button className="btn-ghost text-red-600 hover:text-red-700 inline-flex items-center gap-2">
|
||||
<Trash2 size={16} />
|
||||
清除
|
||||
</button>
|
||||
</SettingsItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* 清除确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={clearDialogOpen}
|
||||
onClose={() => setClearDialogOpen(false)}
|
||||
onConfirm={handleClearAllChats}
|
||||
title="确认清除所有聊天记录?"
|
||||
variant="danger"
|
||||
confirmText="确认清除"
|
||||
cancelText="取消"
|
||||
loading={clearLoading}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||
此操作将永久删除:
|
||||
</p>
|
||||
<ul className="text-sm text-[var(--color-text-primary)] space-y-1 ml-4">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||||
<span><strong>{chatStats?.conversationCount || 0}</strong> 个对话</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||||
<span><strong>{chatStats?.messageCount || 0}</strong> 条消息</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-red-500/10 border border-red-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-red-500">
|
||||
此操作不可撤销!建议先导出数据备份。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@ -659,22 +543,22 @@ function SettingsSection({ title, description, children, variant = 'default' }:
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'bg-[var(--color-bg-primary)] border rounded-md mb-6 overflow-hidden',
|
||||
variant === 'danger' ? 'border-red-500/20' : 'border-[var(--color-border)]'
|
||||
'bg-white border rounded-md mb-6 overflow-hidden',
|
||||
variant === 'danger' ? 'border-red-200' : 'border-[var(--color-border)]'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'px-5 py-4 border-b',
|
||||
variant === 'danger'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'border-[var(--color-border-light)]'
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
className={cn(
|
||||
'text-base font-semibold',
|
||||
variant === 'danger' ? 'text-red-500' : 'text-[var(--color-text-primary)]'
|
||||
variant === 'danger' ? 'text-red-600' : 'text-[var(--color-text-primary)]'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
|
||||
@ -52,7 +52,7 @@ export function ChatInput({
|
||||
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]',
|
||||
'flex flex-col bg-white border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]',
|
||||
'transition-all duration-150',
|
||||
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]'
|
||||
)}
|
||||
|
||||
@ -44,7 +44,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
|
||||
{/* 下拉菜单 */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-full right-0 mb-2 min-w-[160px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-xl shadow-lg p-1 z-50',
|
||||
'absolute bottom-full right-0 mb-2 min-w-[160px] bg-white border border-[var(--color-border)] rounded-xl shadow-lg p-1 z-50',
|
||||
'transition-all duration-150',
|
||||
isOpen
|
||||
? 'opacity-100 visible translate-y-0'
|
||||
|
||||
@ -29,7 +29,7 @@ export function QuickActions({ actions, onSelect, className }: QuickActionsProps
|
||||
onClick={() => onSelect(action)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2',
|
||||
'bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-full',
|
||||
'bg-white border border-[var(--color-border)] rounded-full',
|
||||
'text-sm text-[var(--color-text-secondary)]',
|
||||
'hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)]',
|
||||
'transition-all duration-150'
|
||||
|
||||
@ -63,7 +63,7 @@ export function ToolsDropdown({ tools, onToolToggle, onEnableAllToggle }: ToolsD
|
||||
{/* 下拉菜单 */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-full left-0 mb-2 min-w-[220px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-xl shadow-lg p-2 z-50',
|
||||
'absolute bottom-full left-0 mb-2 min-w-[220px] bg-white border border-[var(--color-border)] rounded-xl shadow-lg p-2 z-50',
|
||||
'transition-all duration-150',
|
||||
isOpen
|
||||
? 'opacity-100 visible translate-y-0'
|
||||
|
||||
@ -69,7 +69,7 @@ export function Sidebar({ user, isOpen = true }: SidebarProps) {
|
||||
{/* 侧边栏 */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-0 left-0 bottom-0 z-50 bg-[var(--color-bg-primary)] border-r border-[var(--color-border-light)] flex flex-col transition-all duration-300 ease-in-out overflow-hidden',
|
||||
'fixed top-0 left-0 bottom-0 z-50 bg-white border-r border-[var(--color-border-light)] flex flex-col transition-all duration-300 ease-in-out overflow-hidden',
|
||||
isOpen ? 'w-[var(--sidebar-width)]' : 'w-0 border-r-0'
|
||||
)}
|
||||
>
|
||||
@ -146,10 +146,10 @@ export function Sidebar({ user, isOpen = true }: SidebarProps) {
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{menuOpen === conversation.conversationId && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]">
|
||||
<div className="absolute right-0 top-full mt-1 bg-white border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]">
|
||||
<button
|
||||
onClick={(e) => handleDeleteConversation(conversation.conversationId, e)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !loading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose, loading]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dialogRef.current &&
|
||||
!dialogRef.current.contains(e.target as Node) &&
|
||||
!loading
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose, loading]);
|
||||
|
||||
// 禁止背景滚动
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/50 animate-fade-in" />
|
||||
|
||||
{/* 对话框 */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
'relative z-10 w-full max-w-md mx-4',
|
||||
'bg-[var(--color-bg-primary)] rounded-lg shadow-xl',
|
||||
'animate-pop-up'
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border-light)]">
|
||||
<div className="flex items-center gap-3">
|
||||
{variant === 'danger' && (
|
||||
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-red-500/10">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="p-1 rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="px-5 py-4">
|
||||
{description && (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 px-5 py-4 border-t border-[var(--color-border-light)] bg-[var(--color-bg-secondary)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50',
|
||||
variant === 'danger'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-dark)]'
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
confirmText
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -168,7 +168,7 @@ export function UserMenu({ user }: UserMenuProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full px-4 py-3',
|
||||
'text-sm text-red-500',
|
||||
'hover:bg-[var(--color-bg-hover)] transition-colors'
|
||||
'hover:bg-red-50 transition-colors'
|
||||
)}
|
||||
role="menuitem"
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user