feat(快捷短语): 添加快捷短语UI组件

QuickPhraseItem:
- 快捷短语列表项组件
- 支持图标显示和内容预览
- 提供悬停时的编辑和删除操作

QuickPhrasesPopover:
- 快捷短语弹出层组件
- 包含触发按钮和数量徽章
- 支持快速插入、编辑和删除短语

QuickPhrasesModal:
- 快捷短语管理模态框
- 左侧列表右侧编辑的双栏布局
- 支持图标选择器和分类设置
This commit is contained in:
gaoziman 2025-12-24 00:07:58 +08:00
parent 4499f7befd
commit caf19f4c09
3 changed files with 594 additions and 0 deletions

View File

@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import * as LucideIcons from 'lucide-react';
import type { QuickPhrase } from '@/types';
import { cn } from '@/lib/utils';
import { Tooltip } from '@/components/ui/Tooltip';
interface QuickPhraseItemProps {
phrase: QuickPhrase;
onSelect: (phrase: QuickPhrase) => void;
onEdit?: (phrase: QuickPhrase) => void;
onDelete?: (id: string) => void;
showActions?: boolean;
}
/**
*
*/
export function QuickPhraseItem({
phrase,
onSelect,
onEdit,
onDelete,
showActions = true,
}: QuickPhraseItemProps) {
const [isHovered, setIsHovered] = useState(false);
// 获取图标组件
const IconComponent = phrase.icon
? (LucideIcons[phrase.icon as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string }>)
: LucideIcons.MessageSquare;
// 截断内容预览(最多显示 50 个字符)
const contentPreview = phrase.content.length > 50
? phrase.content.substring(0, 50) + '...'
: phrase.content;
const handleSelect = () => {
onSelect(phrase);
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
onEdit?.(phrase);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete?.(phrase.id);
};
return (
<div
className={cn(
'group relative flex items-start gap-2 px-2.5 py-1.5 rounded-md cursor-pointer transition-all duration-150',
'hover:bg-[var(--color-bg-hover)]',
'border border-transparent hover:border-[var(--color-border)]'
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleSelect}
>
{/* 图标 */}
{IconComponent && (
<div className="flex-shrink-0 mt-0.5">
<IconComponent
size={16}
className="text-[var(--color-text-tertiary)]"
/>
</div>
)}
{/* 内容区域 */}
<div className="flex-1 min-w-0">
{/* 标题 */}
<div className="text-xs font-medium text-[var(--color-text-primary)] mb-0.5 truncate">
{phrase.title}
</div>
{/* 内容预览 */}
<div className="text-[11px] text-[var(--color-text-tertiary)] line-clamp-2 leading-tight">
{contentPreview}
</div>
</div>
{/* 操作按钮 */}
{showActions && (isHovered || false) && (
<div className="flex items-center gap-1 flex-shrink-0">
{onEdit && (
<Tooltip content="编辑">
<button
onClick={handleEdit}
className={cn(
'w-6 h-6 flex items-center justify-center rounded transition-colors',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-secondary)]'
)}
>
<LucideIcons.Pencil size={14} />
</button>
</Tooltip>
)}
{onDelete && (
<Tooltip content="删除">
<button
onClick={handleDelete}
className={cn(
'w-6 h-6 flex items-center justify-center rounded transition-colors',
'text-[var(--color-text-tertiary)] hover:text-red-500',
'hover:bg-red-50 dark:hover:bg-red-500/10'
)}
>
<LucideIcons.Trash2 size={14} />
</button>
</Tooltip>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,327 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Trash2, Check } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { IconPicker } from '@/components/ui/IconPicker';
import { cn } from '@/lib/utils';
import type { QuickPhrase } from '@/types';
interface QuickPhrasesModalProps {
isOpen: boolean;
onClose: () => void;
phrases: QuickPhrase[];
onAdd: (phrase: Omit<QuickPhrase, 'id' | 'createdAt' | 'updatedAt' | 'sortOrder'>) => void;
onUpdate: (id: string, updates: Partial<Omit<QuickPhrase, 'id' | 'createdAt'>>) => void;
onDelete: (id: string) => void;
}
/**
*
*/
export function QuickPhrasesModal({
isOpen,
onClose,
phrases,
onAdd,
onUpdate,
onDelete,
}: QuickPhrasesModalProps) {
const [selectedPhraseId, setSelectedPhraseId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
// 表单状态
const [formData, setFormData] = useState({
title: '',
content: '',
icon: 'MessageSquare' as string,
category: '',
});
// 当选中短语变化时,更新表单数据
useEffect(() => {
if (selectedPhraseId) {
const phrase = phrases.find((p) => p.id === selectedPhraseId);
if (phrase) {
setFormData({
title: phrase.title,
content: phrase.content,
icon: phrase.icon || 'MessageSquare',
category: phrase.category || '',
});
setIsEditing(true);
}
} else {
// 重置表单
setFormData({
title: '',
content: '',
icon: 'MessageSquare',
category: '',
});
setIsEditing(false);
}
}, [selectedPhraseId, phrases]);
const handleSelectPhrase = (id: string) => {
setSelectedPhraseId(id);
};
const handleNewPhrase = () => {
setSelectedPhraseId(null);
setFormData({
title: '',
content: '',
icon: 'MessageSquare',
category: '',
});
setIsEditing(false);
};
const handleSave = () => {
// 验证表单
if (!formData.title.trim() || !formData.content.trim()) {
alert('请填写标题和内容');
return;
}
if (isEditing && selectedPhraseId) {
// 更新现有短语
onUpdate(selectedPhraseId, {
title: formData.title.trim(),
content: formData.content.trim(),
icon: formData.icon,
category: formData.category.trim() || undefined,
});
} else {
// 添加新短语
onAdd({
title: formData.title.trim(),
content: formData.content.trim(),
icon: formData.icon,
category: formData.category.trim() || undefined,
});
}
// 重置表单
handleNewPhrase();
};
const handleDelete = (id: string) => {
if (confirm('确定要删除这个快捷短语吗?')) {
onDelete(id);
if (selectedPhraseId === id) {
handleNewPhrase();
}
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
maxWidth="1000px"
className="overflow-hidden"
>
{/* 标题 */}
<div className="px-6 py-4 border-b border-[var(--color-border)] bg-gradient-to-b from-[var(--color-bg-secondary)] to-[var(--color-bg-primary)]">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">
</h2>
</div>
{/* 内容区域 */}
<div className="flex h-[600px]">
{/* 左侧:短语列表 - 固定300px宽度 */}
<div className="w-[300px] border-r border-[var(--color-border)] flex flex-col bg-[var(--color-bg-secondary)]">
{/* 新建按钮 */}
<div className="p-4">
<button
onClick={handleNewPhrase}
className={cn(
'w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-md transition-all duration-200',
'text-sm font-semibold',
!isEditing && !selectedPhraseId
? 'bg-[var(--color-primary)] text-white hover:shadow-md'
: 'text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] hover:bg-[var(--color-bg-hover)] border border-[var(--color-border)]'
)}
>
<Plus size={16} />
<span></span>
</button>
</div>
{/* 短语列表 */}
<div className="flex-1 overflow-y-auto px-3 pb-3">
{phrases.length === 0 ? (
<div className="text-center py-8 text-[var(--color-text-tertiary)]">
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : (
phrases.map((phrase) => (
<div
key={phrase.id}
className={cn(
'group relative flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer transition-all duration-200 mb-2',
'bg-[var(--color-bg-primary)]',
selectedPhraseId === phrase.id
? 'border-2 border-[var(--color-primary)] shadow-sm'
: 'border-2 border-transparent hover:border-[var(--color-border)]'
)}
onClick={() => handleSelectPhrase(phrase.id)}
>
<div className="flex-1 min-w-0">
<div className={cn(
"text-sm font-semibold truncate mb-0.5",
selectedPhraseId === phrase.id
? 'text-[var(--color-primary)]'
: 'text-[var(--color-text-primary)]'
)}>
{phrase.title}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate">
{phrase.content.substring(0, 30)}...
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(phrase.id);
}}
className={cn(
'opacity-0 group-hover:opacity-100 transition-opacity',
'w-6 h-6 flex items-center justify-center rounded',
'text-[var(--color-text-tertiary)] hover:text-red-500',
'hover:bg-red-50 dark:hover:bg-red-500/10'
)}
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
</div>
{/* 右侧:编辑表单 - flex-1占据剩余空间 */}
<div className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl">
{/* 标题输入 */}
<div className="mb-5">
<label className="block text-sm font-semibold text-[var(--color-text-primary)] mb-2">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="输入短语标题"
className={cn(
'w-full px-3 py-2.5 rounded border-2 border-[var(--color-border)]',
'bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]',
'placeholder:text-[var(--color-text-placeholder)]',
'focus:outline-none focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/10',
'transition-all text-sm'
)}
/>
</div>
{/* 内容输入 */}
<div className="mb-5">
<label className="block text-sm font-semibold text-[var(--color-text-primary)] mb-2">
<span className="text-red-500">*</span>
</label>
<textarea
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
placeholder="输入短语内容,将插入到输入框中"
rows={5}
className={cn(
'w-full px-3 py-2.5 rounded border-2 border-[var(--color-border)]',
'bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]',
'placeholder:text-[var(--color-text-placeholder)]',
'focus:outline-none focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/10',
'transition-all resize-none text-sm leading-relaxed'
)}
/>
</div>
{/* 图标选择 */}
<div className="mb-5">
<label className="block text-sm font-semibold text-[var(--color-text-primary)] mb-2">
</label>
<IconPicker
value={formData.icon}
onChange={(icon) => setFormData({ ...formData, icon })}
/>
</div>
{/* 分类输入(可选)*/}
<div className="mb-6">
<label className="block text-sm font-semibold text-[var(--color-text-primary)] mb-2">
</label>
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="输入分类名称"
className={cn(
'w-full px-3 py-2.5 rounded border-2 border-[var(--color-border)]',
'bg-[var(--color-bg-primary)] text-[var(--color-text-primary)]',
'placeholder:text-[var(--color-text-placeholder)]',
'focus:outline-none focus:border-[var(--color-primary)] focus:ring-2 focus:ring-[var(--color-primary)]/10',
'transition-all text-sm'
)}
/>
</div>
{/* 提示信息 */}
<div className="mt-6 p-4 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 rounded">
<div className="text-sm font-semibold text-[var(--color-primary)] mb-1">
💡 使
</div>
<div className="text-xs text-[var(--color-text-secondary)] leading-relaxed">
</div>
</div>
</div>
</div>
{/* 底部操作按钮 */}
<div className="px-6 py-4 border-t border-[var(--color-border)] flex items-center justify-end gap-3 bg-[var(--color-bg-secondary)]">
<button
onClick={onClose}
className={cn(
'px-5 py-2.5 rounded-md transition-all',
'text-sm font-semibold text-[var(--color-text-secondary)]',
'bg-[var(--color-bg-primary)] border-2 border-[var(--color-border)]',
'hover:bg-[var(--color-bg-hover)]'
)}
>
</button>
<button
onClick={handleSave}
disabled={!formData.title.trim() || !formData.content.trim()}
className={cn(
'px-5 py-2.5 rounded-md transition-all duration-200',
'text-sm font-semibold text-white',
'flex items-center gap-2',
formData.title.trim() && formData.content.trim()
? 'bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)]'
: 'bg-gray-300 cursor-not-allowed opacity-50'
)}
>
<Check size={16} />
<span>{isEditing ? '保存修改' : '添加短语'}</span>
</button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,144 @@
'use client';
import { MessageSquareQuote, Plus, Settings2 } from 'lucide-react';
import { Tooltip } from '@/components/ui/Tooltip';
import { QuickPhraseItem } from './QuickPhraseItem';
import { cn } from '@/lib/utils';
import type { QuickPhrase } from '@/types';
// 触发按钮组件
interface QuickPhrasesTriggerProps {
phrasesCount: number;
onClick: () => void;
}
export function QuickPhrasesTrigger({ phrasesCount, onClick }: QuickPhrasesTriggerProps) {
return (
<div className="relative">
<Tooltip content="快捷短语" position="top">
<button
onClick={onClick}
className="w-8 h-8 flex items-center justify-center rounded-md transition-colors cursor-pointer text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]"
>
<MessageSquareQuote size={20} />
</button>
</Tooltip>
{/* 数量徽章 */}
{phrasesCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 bg-[var(--color-primary)] text-white text-[10px] font-semibold rounded-full flex items-center justify-center px-1 pointer-events-none">
{phrasesCount}
</span>
)}
</div>
);
}
// 弹出层内容组件
interface QuickPhrasesPopoverProps {
phrases: QuickPhrase[];
isOpen: boolean;
onInsert: (content: string) => void;
onEdit: (phrase: QuickPhrase) => void;
onDelete: (id: string) => void;
onManage: () => void;
onAdd: () => void;
onClose: () => void;
}
/**
*
*/
export function QuickPhrasesPopover({
phrases,
isOpen,
onInsert,
onEdit,
onDelete,
onManage,
onAdd,
onClose,
}: QuickPhrasesPopoverProps) {
const handleSelect = (phrase: QuickPhrase) => {
onInsert(phrase.content);
onClose();
};
const handleManage = () => {
onManage();
onClose();
};
return (
<div
className={cn(
'absolute bottom-full left-0 right-0 mb-2 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md shadow-lg z-50',
'transition-all duration-150',
isOpen
? 'opacity-100 visible translate-y-0'
: 'opacity-0 invisible translate-y-2 pointer-events-none'
)}
>
{/* 顶部操作栏 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--color-border-light)]">
<span className="text-xs font-medium text-[var(--color-text-primary)]">
</span>
<Tooltip content="添加短语">
<button
onClick={onAdd}
className={cn(
'w-6 h-6 flex items-center justify-center rounded-md transition-colors',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
>
<Plus size={16} />
</button>
</Tooltip>
</div>
{/* 短语列表 */}
<div className="max-h-[320px] overflow-y-auto p-1.5">
{phrases.length === 0 ? (
<div className="text-center py-8 text-[var(--color-text-tertiary)]">
<MessageSquareQuote
size={32}
className="mx-auto mb-2 opacity-50"
/>
<p className="text-sm"></p>
<p className="text-xs mt-1"> + </p>
</div>
) : (
phrases.map((phrase) => (
<QuickPhraseItem
key={phrase.id}
phrase={phrase}
onSelect={handleSelect}
onEdit={onEdit}
onDelete={onDelete}
showActions={true}
/>
))
)}
</div>
{/* 底部操作栏 */}
{phrases.length > 0 && (
<div className="border-t border-[var(--color-border-light)] p-1.5">
<button
onClick={handleManage}
className={cn(
'w-full flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-md transition-colors',
'text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
>
<Settings2 size={14} />
<span></span>
</button>
</div>
)}
</div>
);
}