diff --git a/CHANGELOG.md b/CHANGELOG.md index 709437a..b133992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ## Unreleased ++ [新增] 配置与用户偏好支持通过配置文件导入和导出。 + [新增] 模型渠道新增火山方舟协议。 + [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。 ++ [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。 ## v0.10.0 - 2026-07-25 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 41ab8dd..8583948 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,9 +5,11 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 - 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。 - 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。 +- 提示词详情弹窗:封面和参考图应固定显示在上方,复制及加入资产操作栏固定在底部,只有中间的标签、描述及提示词内容区域可以滚动;弹窗宽高应受视口限制且不超出屏幕。 - 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。 - 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。 - 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。 diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index a3f3ed8..df77c2d 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -1,10 +1,11 @@ import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd"; -import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; +import { exportAppConfig, importAppConfig } from "@/services/config-file"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation"; @@ -51,6 +52,7 @@ function createWebdavDomainProgress(): Record(null); const [activeTab, setActiveTab] = useState(initialTab); const [editingChannelId, setEditingChannelId] = useState(""); const [testingWebdav, setTestingWebdav] = useState(false); @@ -80,6 +82,17 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" clearPromptContinue(); }; + const loadConfigFile = async (file: File) => { + try { + await importAppConfig(file); + message.success("配置与用户偏好已导入"); + } catch (error) { + message.error(error instanceof Error ? error.message : "配置文件读取失败"); + } finally { + if (configInputRef.current) configInputRef.current.value = ""; + } + }; + const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels)); const addChannel = () => { @@ -153,6 +166,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" return ( <> +
+
JSON 文件包含 API Key 和 WebDAV 凭据,请妥善保管。
+
+ + + event.target.files?.[0] && void loadConfigFile(event.target.files[0])} /> +
+
setActiveTab(key as ConfigTabKey)} diff --git a/web/src/pages/prompts/components/prompt-detail-dialog.tsx b/web/src/pages/prompts/components/prompt-detail-dialog.tsx index 01d6e4b..7d17f09 100644 --- a/web/src/pages/prompts/components/prompt-detail-dialog.tsx +++ b/web/src/pages/prompts/components/prompt-detail-dialog.tsx @@ -5,42 +5,40 @@ import { formatPromptDate, type Prompt } from "@/services/api/prompts"; export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) { return ( - <> - - {prompt ? ( - <> -
-
- {prompt.coverUrl ? {prompt.title} :
} - {prompt.referenceImageUrls.length > 1 ?
{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => )}
: null} - {prompt.preview ?
{prompt.preview}
: null} -
-
-
- {prompt.tags.map((tag) => ( - - {tag} - - ))} -
- {prompt.description ?

{prompt.description}

: null} -

{prompt.prompt}

- {prompt.createdAt || prompt.updatedAt ?
{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}
: null} - - - {onSaveAsset ? ( - - ) : null} - -
+ + {prompt ? ( +
+
+ {prompt.coverUrl ? {prompt.title} :
} + {prompt.referenceImageUrls.length > 1 ?
{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => )}
: null} +
+
+
+ {prompt.tags.map((tag) => ( + + {tag} + + ))}
- - ) : null} - - + {prompt.description ?

{prompt.description}

: null} + {prompt.preview ?
{prompt.preview}
: null} +

{prompt.prompt}

+ {prompt.createdAt || prompt.updatedAt ?
{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}
: null} +
+
+ + + {onSaveAsset ? ( + + ) : null} + +
+
+ ) : null} +
); } diff --git a/web/src/services/config-file.ts b/web/src/services/config-file.ts new file mode 100644 index 0000000..7bf4bb4 --- /dev/null +++ b/web/src/services/config-file.ts @@ -0,0 +1,36 @@ +import { saveAs } from "file-saver"; + +import { useConfigStore, type AiConfig, type WebdavSyncConfig } from "@/stores/use-config-store"; +import { usePromptSourceStore, type PromptSourceSchedule } from "@/stores/use-prompt-source-store"; +import type { PromptSource } from "@/services/api/prompt-source-presets"; + +type AppConfigFile = { + app: "infinite-canvas"; + version: 1; + exportedAt: string; + config: AiConfig; + webdav: WebdavSyncConfig; + promptSources: { + sources: PromptSource[]; + schedule: PromptSourceSchedule; + }; +}; + +export function exportAppConfig() { + const { config, webdav } = useConfigStore.getState(); + const { sources, schedule } = usePromptSourceStore.getState(); + const data: AppConfigFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), config, webdav, promptSources: { sources, schedule } }; + saveAs(new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }), "infinite-canvas-config.json"); +} + +export async function importAppConfig(file: File) { + let data: AppConfigFile; + try { + data = JSON.parse(await file.text()) as AppConfigFile; + } catch { + throw new Error("配置文件格式不正确"); + } + if (data.app !== "infinite-canvas" || data.version !== 1 || !data.config || !data.webdav || !data.promptSources) throw new Error("配置文件格式不正确"); + useConfigStore.setState({ config: data.config, webdav: data.webdav }); + usePromptSourceStore.setState(data.promptSources); +}