- {prompts.map((prompt) => (
+ const toggleExpand = useCallback((promptId: string) => {
+ setExpandedIds(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(promptId)) {
+ newSet.delete(promptId);
+ } else {
+ newSet.add(promptId);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const hasChildren = useCallback((promptId: string) => {
+ return prompts.some(p => p.parentId === promptId);
+ }, [prompts]);
+
+ const getChildren = useCallback((parentId: string | null) => {
+ return prompts
+ .filter(p => p.parentId === parentId)
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
+ }, [prompts]);
+
+ const handleDragStart = useCallback((e: React.DragEvent, promptId: string) => {
+ setDraggingId(promptId);
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', promptId);
+ }, []);
+
+ const handleDragEnd = useCallback(() => {
+ setDraggingId(null);
+ setDropTargetId(null);
+ setDropPosition(null);
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ }, []);
+
+ const handleDragEnter = useCallback((e: React.DragEvent, promptId: string) => {
+ e.preventDefault();
+ if (draggingId !== promptId) {
+ setDropTargetId(promptId);
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ const y = e.clientY - rect.top;
+ const height = rect.height;
+
+ if (y < height / 3) {
+ setDropPosition('before');
+ } else if (y > height * 2 / 3) {
+ setDropPosition('after');
+ } else {
+ setDropPosition('inside');
+ }
+ }
+ }, [draggingId]);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setDropTargetId(null);
+ setDropPosition(null);
+ }, []);
+
+ const handleDrop = useCallback((e: React.DragEvent, targetPromptId: string) => {
+ e.preventDefault();
+ if (draggingId && draggingId !== targetPromptId) {
+ const targetPrompt = prompts.find(p => p.id === targetPromptId);
+ const draggingPrompt = prompts.find(p => p.id === draggingId);
+
+ if (targetPrompt && draggingPrompt) {
+ if (dropPosition === 'inside') {
+ const childCount = prompts.filter(p => p.parentId === targetPromptId).length;
+ onMovePrompt(draggingId, targetPromptId, childCount);
+ } else {
+ const newParentId = targetPrompt.parentId;
+ let targetOrder = targetPrompt.order || 0;
+
+ if (dropPosition === 'after') {
+ targetOrder += 1;
+ }
+
+ if (draggingPrompt.parentId === newParentId && draggingPrompt.order && draggingPrompt.order < targetOrder) {
+ targetOrder -= 1;
+ }
+
+ onMovePrompt(draggingId, newParentId, targetOrder);
+ }
+ }
+ }
+ setDraggingId(null);
+ setDropTargetId(null);
+ setDropPosition(null);
+ }, [draggingId, dropPosition, prompts, onMovePrompt]);
+
+ const isSelected = useCallback((promptId: string) => {
+ return selectedId === promptId || selectedIds.includes(promptId);
+ }, [selectedId, selectedIds]);
+
+ const isDragging = useCallback((promptId: string) => {
+ return draggingId === promptId;
+ }, [draggingId]);
+
+ const isDropTarget = useCallback((promptId: string) => {
+ return dropTargetId === promptId;
+ }, [dropTargetId]);
+
+ const renderTreeNode = useCallback((prompt: Prompt, depth: number) => {
+ const hasKids = hasChildren(prompt.id);
+ const isExpanded = expandedIds.has(prompt.id);
+
+ return (
+
handleDragStart(e, prompt.id)}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ onDragEnter={(e) => handleDragEnter(e, prompt.id)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, prompt.id)}
onClick={() => onSelect(prompt.id)}
onContextMenu={(e) => onContextMenu(e, prompt)}
className={`
flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer
- transition-colors duration-quick
- ${selectedId === prompt.id
+ transition-colors duration-quick relative
+ ${isSelected(prompt.id)
? 'bg-primary/10 border-l-2 border-l-primary'
- : 'hover:bg-accent/50'
+ : isDropTarget(prompt.id) && dropPosition === 'inside'
+ ? 'bg-primary/20 border-l-2 border-l-primary'
+ : 'hover:bg-accent/50'
}
+ ${isDragging(prompt.id) ? 'opacity-50' : ''}
+ ${isDropTarget(prompt.id) && dropPosition === 'inside' ? 'ring-2 ring-primary/50 ring-inset' : ''}
+ ${isDropTarget(prompt.id) && dropPosition === 'before' ? 'border-t-2 border-t-primary' : ''}
+ ${isDropTarget(prompt.id) && dropPosition === 'after' ? 'border-b-2 border-b-primary' : ''}
`}
+ style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
- {/* Title and description */}
- {/* 标题和描述 */}
+
+ {hasKids && (
+
+ )}
+ {!hasKids && }
+
+
+
@@ -85,24 +270,18 @@ export function PromptListView({
)}
- {/* Usage count */}
- {/* 使用次数 */}
{prompt.usageCount || 0}
- {/* Update time */}
- {/* 更新时间 */}
{formatDate(prompt.updatedAt)}
- {/* Action buttons */}
- {/* 操作按钮 */}
- ))}
+
+ {hasKids && isExpanded && (
+
+ {getChildren(prompt.id).map((child) => renderTreeNode(child, depth + 1))}
+
+ )}
+
+ );
+ }, [hasChildren, expandedIds, toggleExpand, getChildren, isSelected, isDragging, isDropTarget, dropPosition, onSelect, onContextMenu, handleDragStart, handleDragEnd, handleDragOver, handleDragEnter, handleDragLeave, handleDrop, onCopy, onToggleFavorite, t]);
+
+ const rootNodes = getChildren(null);
+
+ return (
+
+ {rootNodes.map((node) => renderTreeNode(node, 0))}
);
-}
+}
\ No newline at end of file
diff --git a/apps/desktop/src/renderer/services/database.ts b/apps/desktop/src/renderer/services/database.ts
index 36d35d39..767d0507 100644
--- a/apps/desktop/src/renderer/services/database.ts
+++ b/apps/desktop/src/renderer/services/database.ts
@@ -356,6 +356,39 @@ export async function movePrompts(
}
}
+export async function movePrompt(
+ promptId: string,
+ newParentId: string | null,
+ newOrder: number,
+): Promise
{
+ if (window.api?.prompt?.move) {
+ await window.api.prompt.move(promptId, newParentId, newOrder);
+ return;
+ }
+
+ const database = await getDatabase();
+ return new Promise((resolve, reject) => {
+ const transaction = database.transaction(STORES.PROMPTS, "readwrite");
+ const store = transaction.objectStore(STORES.PROMPTS);
+ const getRequest = store.get(promptId);
+
+ getRequest.onsuccess = () => {
+ const prompt = getRequest.result;
+ if (prompt) {
+ prompt.parentId = newParentId;
+ prompt.order = newOrder;
+ prompt.updatedAt = new Date().toISOString();
+ const putRequest = store.put(prompt);
+ putRequest.onsuccess = () => resolve();
+ putRequest.onerror = () => reject(putRequest.error);
+ } else {
+ resolve();
+ }
+ };
+ getRequest.onerror = () => reject(getRequest.error);
+ });
+}
+
// ==================== Version 操作 ====================
// ==================== Version Operations ====================
diff --git a/apps/desktop/src/renderer/stores/prompt.store.ts b/apps/desktop/src/renderer/stores/prompt.store.ts
index 37a36624..19e1c30a 100644
--- a/apps/desktop/src/renderer/stores/prompt.store.ts
+++ b/apps/desktop/src/renderer/stores/prompt.store.ts
@@ -60,6 +60,7 @@ interface PromptState {
setGalleryImageSize: (size: GalleryImageSize) => void;
setKanbanColumns: (columns: KanbanColumns) => void;
incrementUsageCount: (id: string) => Promise;
+ movePrompt: (promptId: string, newParentId: string | null, newOrder: number) => Promise;
}
export const usePromptStore = create()(
@@ -228,6 +229,12 @@ export const usePromptStore = create()(
}));
}
},
+
+ movePrompt: async (promptId, newParentId, newOrder) => {
+ await db.movePrompt(promptId, newParentId, newOrder);
+ await get().fetchPrompts();
+ scheduleAllSaveSync("prompt:move");
+ },
}),
{
name: "prompt-store",
diff --git a/packages/db/src/prompt.ts b/packages/db/src/prompt.ts
index 5b253d53..b587331f 100644
--- a/packages/db/src/prompt.ts
+++ b/packages/db/src/prompt.ts
@@ -24,6 +24,8 @@ interface PromptRow {
variables: string | null;
tags: string | null;
folder_id: string | null;
+ parent_id: string | null;
+ sort_order: number;
images: string | null;
videos: string | null;
is_favorite: number;
@@ -65,9 +67,9 @@ export class PromptDB {
const stmt = this.db.prepare(`
INSERT INTO prompts (
id, visibility, title, description, prompt_type, system_prompt, system_prompt_en, user_prompt,
- user_prompt_en, variables, tags, folder_id, images, videos, source, notes,
+ user_prompt_en, variables, tags, folder_id, parent_id, sort_order, images, videos, source, notes,
last_ai_response, is_favorite, current_version, usage_count, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
@@ -83,16 +85,18 @@ export class PromptDB {
JSON.stringify(data.variables || []),
JSON.stringify(data.tags || []),
data.folderId || null,
+ null,
+ 0,
JSON.stringify(data.images || []),
JSON.stringify(data.videos || []),
data.source || null,
data.notes || null,
null,
0,
- 0,
- 0,
- now,
- now,
+ 0,
+ 0,
+ now,
+ now,
);
// Create initial version
@@ -183,6 +187,14 @@ export class PromptDB {
updates.push("folder_id = ?");
values.push(data.folderId);
}
+ if (data.parentId !== undefined) {
+ updates.push("parent_id = ?");
+ values.push(data.parentId);
+ }
+ if (data.order !== undefined) {
+ updates.push("sort_order = ?");
+ values.push(data.order);
+ }
if (data.images !== undefined) {
updates.push("images = ?");
values.push(JSON.stringify(data.images));
@@ -260,6 +272,8 @@ export class PromptDB {
...(data.variables !== undefined && { variables: data.variables }),
...(data.tags !== undefined && { tags: data.tags }),
...(data.folderId !== undefined && { folderId: data.folderId }),
+ ...(data.parentId !== undefined && { parentId: data.parentId }),
+ ...(data.order !== undefined && { order: data.order }),
...(data.images !== undefined && { images: data.images }),
...(data.videos !== undefined && { videos: data.videos }),
...(data.isFavorite !== undefined && { isFavorite: data.isFavorite }),
@@ -506,11 +520,11 @@ export class PromptDB {
.prepare(
`INSERT OR REPLACE INTO prompts (
id, visibility, title, description, prompt_type, system_prompt, system_prompt_en, user_prompt,
- user_prompt_en, variables, tags, folder_id, images, videos, is_favorite, is_pinned,
+ user_prompt_en, variables, tags, folder_id, parent_id, sort_order, images, videos, is_favorite, is_pinned,
current_version, usage_count, source, notes, last_ai_response, created_at, updated_at
) VALUES (
@id, @visibility, @title, @description, @prompt_type, @system_prompt, @system_prompt_en, @user_prompt,
- @user_prompt_en, @variables, @tags, @folder_id, @images, @videos, @is_favorite, @is_pinned,
+ @user_prompt_en, @variables, @tags, @folder_id, @parent_id, @sort_order, @images, @videos, @is_favorite, @is_pinned,
@current_version, @usage_count, @source, @notes, @last_ai_response, @created_at, @updated_at
)`,
)
@@ -527,6 +541,8 @@ export class PromptDB {
"@variables": JSON.stringify(prompt.variables ?? []),
"@tags": JSON.stringify(prompt.tags ?? []),
"@folder_id": prompt.folderId ?? null,
+ "@parent_id": prompt.parentId ?? null,
+ "@sort_order": prompt.order ?? 0,
"@images": JSON.stringify(prompt.images ?? []),
"@videos": JSON.stringify(prompt.videos ?? []),
"@is_favorite": prompt.isFavorite ? 1 : 0,
@@ -669,6 +685,74 @@ export class PromptDB {
txn();
}
+ /**
+ * Move prompt to a new parent or reorder within the same parent
+ * 移动提示词到新的父节点或在同级中重新排序
+ */
+ movePrompt(promptId: string, newParentId: string | null, newOrder: number): void {
+ const txn = this.db.transaction(() => {
+ // Get current parent and order
+ const current = this.db
+ .prepare("SELECT parent_id, sort_order FROM prompts WHERE id = ?")
+ .get(promptId) as { parent_id: string | null; sort_order: number } | undefined;
+
+ if (!current) return;
+
+ const oldParentId = current.parent_id;
+ const oldOrder = current.sort_order;
+
+ // Update the moved prompt
+ this.db
+ .prepare("UPDATE prompts SET parent_id = ?, sort_order = ?, updated_at = ? WHERE id = ?")
+ .run(newParentId, newOrder, Date.now(), promptId);
+
+ // If parent changed, adjust orders in old parent
+ if (oldParentId !== newParentId && oldParentId !== null) {
+ this.db
+ .prepare("UPDATE prompts SET sort_order = sort_order - 1 WHERE parent_id = ? AND sort_order > ?")
+ .run(oldParentId, oldOrder);
+ }
+
+ // Adjust orders in new parent to make room
+ if (newParentId !== null) {
+ this.db
+ .prepare("UPDATE prompts SET sort_order = sort_order + 1 WHERE parent_id = ? AND sort_order >= ? AND id != ?")
+ .run(newParentId, newOrder, promptId);
+ } else {
+ // No parent (root level)
+ this.db
+ .prepare("UPDATE prompts SET sort_order = sort_order + 1 WHERE parent_id IS NULL AND sort_order >= ? AND id != ?")
+ .run(newOrder, promptId);
+ }
+ });
+
+ txn();
+ }
+
+ /**
+ * Get children of a prompt
+ * 获取提示词的子节点
+ */
+ getChildren(parentId: string | null): Prompt[] {
+ const stmt = this.db.prepare(
+ parentId === null
+ ? "SELECT * FROM prompts WHERE parent_id IS NULL ORDER BY sort_order"
+ : "SELECT * FROM prompts WHERE parent_id = ? ORDER BY sort_order"
+ );
+ const rows = stmt.all(parentId === null ? [] : [parentId]) as PromptRow[];
+ return rows.map((row) => this.rowToPrompt(row));
+ }
+
+ /**
+ * Get all prompts with hierarchical structure
+ * 获取所有提示词(包含层级结构)
+ */
+ getAllWithHierarchy(): Prompt[] {
+ const stmt = this.db.prepare("SELECT * FROM prompts ORDER BY parent_id NULLS FIRST, sort_order");
+ const rows = stmt.all() as PromptRow[];
+ return rows.map((row) => this.rowToPrompt(row));
+ }
+
/**
* Convert database row to Prompt object
@@ -689,6 +773,8 @@ export class PromptDB {
variables: JSON.parse(row.variables || "[]"),
tags: JSON.parse(row.tags || "[]"),
folderId: row.folder_id,
+ parentId: row.parent_id,
+ order: row.sort_order,
images: JSON.parse(row.images || "[]"),
videos: JSON.parse(row.videos || "[]"),
isFavorite: row.is_favorite === 1,
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 58d08d5c..03837a31 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS prompts (
variables TEXT,
tags TEXT,
folder_id TEXT,
+ parent_id TEXT,
+ sort_order INTEGER DEFAULT 0,
images TEXT,
videos TEXT,
is_favorite INTEGER DEFAULT 0,
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS prompts (
last_ai_response TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
- FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
+ FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL,
+ FOREIGN KEY (parent_id) REFERENCES prompts(id) ON DELETE CASCADE
);
-- 版本表
@@ -226,6 +229,8 @@ CREATE INDEX IF NOT EXISTS idx_folders_sort ON folders(sort_order);
CREATE INDEX IF NOT EXISTS idx_prompts_folder_favorite ON prompts(folder_id, is_favorite);
CREATE INDEX IF NOT EXISTS idx_prompts_folder_updated ON prompts(folder_id, updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_prompts_parent ON prompts(parent_id);
+CREATE INDEX IF NOT EXISTS idx_prompts_sort_order ON prompts(sort_order);
-- 全文搜索 (FTS5)
CREATE VIRTUAL TABLE IF NOT EXISTS prompts_fts USING fts5(
diff --git a/packages/shared/constants/ipc-channels.ts b/packages/shared/constants/ipc-channels.ts
index 5dbeb49d..5bd0a80d 100644
--- a/packages/shared/constants/ipc-channels.ts
+++ b/packages/shared/constants/ipc-channels.ts
@@ -26,6 +26,7 @@ export const IPC_CHANNELS = {
* 若 SQLite 已有数据则直接返回 { imported: false }(防止覆盖)。
*/
PROMPT_MIGRATE_IDB_BATCH: "prompt:migrateIdbBatch",
+ PROMPT_MOVE: "prompt:move",
// Version
VERSION_GET_ALL: "version:getAll",
diff --git a/packages/shared/types/prompt.ts b/packages/shared/types/prompt.ts
index b2a2b2b6..f4ff302a 100644
--- a/packages/shared/types/prompt.ts
+++ b/packages/shared/types/prompt.ts
@@ -21,6 +21,8 @@ export interface Prompt {
variables: Variable[];
tags: string[];
folderId?: string | null;
+ parentId?: string | null; // Parent prompt ID for hierarchical structure
+ order?: number; // Sort order within the same parent
images?: string[];
videos?: string[]; // Video file names for preview / 视频预览文件名
isFavorite: boolean;
@@ -91,6 +93,8 @@ export interface UpdatePromptDTO {
variables?: Variable[];
tags?: string[];
folderId?: string | null;
+ parentId?: string | null;
+ order?: number;
images?: string[];
videos?: string[];
isFavorite?: boolean;