mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
feat(ui): optimize prompt card layout and enhance prompt select dialog for improved usability
This commit is contained in:
@@ -12,6 +12,8 @@
|
|||||||
+ [优化] Agent 输入区控件随面板宽度在图标与图标加文字之间自适应。
|
+ [优化] Agent 输入区控件随面板宽度在图标与图标加文字之间自适应。
|
||||||
+ [修复] Agent Skill 草稿期间保持 MCP 活动画布隔离,支持停止临时生成任务,并禁止并发修改 Skill。
|
+ [修复] Agent Skill 草稿期间保持 MCP 活动画布隔离,支持停止临时生成任务,并禁止并发修改 Skill。
|
||||||
+ [修复] Agent 新建或恢复对话时统一同步版本化会话与 MCP 初始化状态,避免多页面竞态误触 `409`、提前发送或任务后残留初始化卡片。
|
+ [修复] Agent 新建或恢复对话时统一同步版本化会话与 MCP 初始化状态,避免多页面竞态误触 `409`、提前发送或任务后残留初始化卡片。
|
||||||
|
+ [优化] 提示词中心缩小卡片、每行展示四张,移除“加入我的资产”按钮边框。
|
||||||
|
+ [优化] 缩小生图与视频工作台的提示词库弹窗,精简卡片。
|
||||||
|
|
||||||
## v0.13.0 - 2026-08-03
|
## v0.13.0 - 2026-08-03
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ The current release needs manual verification in these areas:
|
|||||||
- Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management.
|
- Canvas Agent startup, MCP status, model and permission controls, approvals, version reporting, diagnostics, streaming responses, history consistency, multi-tab isolation, and local Skill management.
|
||||||
- Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped.
|
- Agent header tabs in English and Chinese at different panel widths; labels should collapse to icons and counts before either edge is clipped.
|
||||||
- Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior.
|
- Agent message layout, Markdown, code blocks, attachments, token statistics, progress timelines, command groups, and scroll-follow behavior.
|
||||||
- Prompt Center layout, source synchronization, search, asset insertion, and prompt detail dialogs.
|
- Prompt Center layout, source synchronization, search, asset insertion, prompt detail dialogs, and compact split-layout prompt pickers in the image and video workbenches.
|
||||||
- Settings import/export, Volcengine Ark protocol handling, and WebDAV-related configuration.
|
- Settings import/export, Volcengine Ark protocol handling, and WebDAV-related configuration.
|
||||||
|
|
||||||
The Chinese version contains the complete item-by-item acceptance checklist.
|
The Chinese version contains the complete item-by-item acceptance checklist.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function PromptCard({
|
|||||||
actionIcon = <Copy className="size-3.5" />,
|
actionIcon = <Copy className="size-3.5" />,
|
||||||
actionType = "text",
|
actionType = "text",
|
||||||
extraAction,
|
extraAction,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
item: Prompt;
|
item: Prompt;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
@@ -21,41 +22,30 @@ export function PromptCard({
|
|||||||
actionIcon?: ReactNode;
|
actionIcon?: ReactNode;
|
||||||
actionType?: "text" | "primary";
|
actionType?: "text" | "primary";
|
||||||
extraAction?: ReactNode;
|
extraAction?: ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { i18n, t } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
className="overflow-hidden"
|
className={compact ? "group cursor-pointer overflow-hidden transition-transform duration-200 hover:-translate-y-1" : "flex h-full flex-col overflow-hidden"}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: compact ? { padding: 0 } : { display: "flex", flex: 1, flexDirection: "column", padding: 0 } }}
|
||||||
cover={
|
cover={
|
||||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
<button type="button" className="block w-full cursor-pointer text-left" onClick={onOpen}>
|
||||||
{item.coverUrl ? <img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" loading="lazy" /> : <span className="grid aspect-[4/3] w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-8" /></span>}
|
{item.coverUrl ? <img src={item.coverUrl} alt={item.title} className={compact ? "aspect-square w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" : "aspect-[4/3] w-full object-cover"} loading="lazy" /> : <span className={compact ? "grid aspect-square w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600" : "grid aspect-[4/3] w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"}><FileText className="size-8" /></span>}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
<button type="button" className={compact ? "block w-full cursor-pointer text-left" : "block w-full flex-1 cursor-pointer text-left"} onClick={onOpen}>
|
||||||
<div className="p-4">
|
<div className={compact ? "px-3 py-2.5" : "p-4"}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
|
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
|
||||||
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt, i18n.resolvedLanguage)}</span>
|
{!compact ? <span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt, i18n.resolvedLanguage)}</span> : null}
|
||||||
</div>
|
|
||||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
||||||
{item.tags.map((tag) => (
|
|
||||||
<Tag key={tag} className="m-0 text-[11px]">
|
|
||||||
{tag}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
{!compact ? <><p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p><div className="mt-3 flex flex-wrap gap-1.5">{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}</div></> : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 px-4 pb-4">
|
{!compact ? <div className="mt-auto flex items-center gap-2 px-4 pb-4"><Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>{actionLabel || t("common.copy")}</Button>{extraAction}</div> : null}
|
||||||
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>
|
|
||||||
{actionLabel || t("common.copy")}
|
|
||||||
</Button>
|
|
||||||
{extraAction}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Check, Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { type UIEvent, useEffect, useState } from "react";
|
import { type UIEvent, useEffect, useState } from "react";
|
||||||
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
|
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -34,54 +34,50 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={t("prompts.library")} open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
|
<Modal title={t("prompts.library")} open={open} onCancel={() => onOpenChange(false)} footer={null} width={880} centered>
|
||||||
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
|
<div className="grid h-[62dvh] min-h-0 gap-5 sm:grid-cols-[200px_minmax(0,1fr)]" data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
|
||||||
<div className="mx-auto max-w-2xl">
|
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r border-stone-200 pr-4 dark:border-stone-800">
|
||||||
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder={t("prompts.searchTitle")} />
|
<div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{t("prompts.category")}</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<div className="mt-5 grid gap-3">
|
{promptCategories.map((category) => (
|
||||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
|
||||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{t("prompts.category")}</div>
|
{category === ALL_PROMPTS_OPTION ? t("common.all") : category}
|
||||||
<div className="flex flex-wrap gap-2">
|
</Tag.CheckableTag>
|
||||||
{promptCategories.map((category) => (
|
|
||||||
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
|
|
||||||
{category === ALL_PROMPTS_OPTION ? t("common.all") : category}
|
|
||||||
</Tag.CheckableTag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
|
||||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{t("prompts.tags")}</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{promptTags.map((tag) => {
|
|
||||||
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
|
|
||||||
return (
|
|
||||||
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
|
|
||||||
{tag === ALL_PROMPTS_OPTION ? t("common.all") : tag}
|
|
||||||
</Tag.CheckableTag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
|
|
||||||
{query.isLoading ? (
|
|
||||||
<div className="flex h-40 items-center justify-center">
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{items.map((item) => (
|
|
||||||
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel={t("prompts.use")} actionIcon={<Check className="size-3.5" />} actionType="primary" />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("prompts.empty")} className="py-8" /> : null}
|
<div className="mb-2 mt-5 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{t("prompts.tags")}</div>
|
||||||
{query.isFetchingNextPage ? (
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<div className="py-4 text-center">
|
{promptTags.map((tag) => {
|
||||||
<Spin size="small" />
|
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
|
||||||
|
return (
|
||||||
|
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
|
||||||
|
{tag === ALL_PROMPTS_OPTION ? t("common.all") : tag}
|
||||||
|
</Tag.CheckableTag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section className="flex min-h-0 min-w-0 flex-col">
|
||||||
|
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder={t("prompts.searchTitle")} />
|
||||||
|
<div className="thin-scrollbar mt-4 min-h-0 flex-1 overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<div className="flex h-40 items-center justify-center">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} compact />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("prompts.empty")} className="py-8" /> : null}
|
||||||
</div>
|
{query.isFetchingNextPage ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function PromptsPage() {
|
|||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder={t("prompts.search")} onChange={(event) => setTitleKeyword(event.target.value)} />
|
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder={t("prompts.search")} onChange={(event) => setTitleKeyword(event.target.value)} />
|
||||||
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
||||||
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>{t("common.addToAssets")}</Button>} onCopy={(item) => copyText(item.prompt, t("common.promptCopied"))} emptyText={t("prompts.empty")} /></div> : null}
|
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>{t("common.addToAssets")}</Button>} onCopy={(item) => copyText(item.prompt, t("common.promptCopied"))} emptyText={t("prompts.empty")} /></div> : null}
|
||||||
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? t("prompts.loading") : query.hasNextPage ? t("prompts.loadMore") : promptItems.length > 0 ? t("prompts.end") : null}</div>
|
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? t("prompts.loading") : query.hasNextPage ? t("prompts.loadMore") : promptItems.length > 0 ? t("prompts.end") : null}</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,5 +83,5 @@ function PromptFilter({ label, options, selected, onChange }: { label: string; o
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
|
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
|
||||||
return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
|
return <div><div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user