mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-31 13:41:18 +08:00
feat(config): add JSON import/export support for user preferences and configurations
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 配置与用户偏好支持通过配置文件导入和导出。
|
||||
+ [新增] 模型渠道新增火山方舟协议。
|
||||
+ [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。
|
||||
+ [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。
|
||||
|
||||
## v0.10.0 - 2026-07-25
|
||||
|
||||
|
||||
@@ -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 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。
|
||||
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
|
||||
- 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
|
||||
|
||||
@@ -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<AppSyncDomainKey, WebdavDomainProg
|
||||
|
||||
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
||||
const { message } = App.useApp();
|
||||
const configInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<ConfigTabKey>(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 (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-3 dark:border-stone-800">
|
||||
<div className="text-xs text-stone-500">JSON 文件包含 API Key 和 WebDAV 凭据,请妥善保管。</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}>
|
||||
导入配置
|
||||
</Button>
|
||||
<Button icon={<Download className="size-4" />} onClick={exportAppConfig}>
|
||||
导出配置
|
||||
</Button>
|
||||
<input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as ConfigTabKey)}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
|
||||
{prompt ? (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{prompt.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" /> : <div className="grid aspect-[4/3] w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-9" /></div>}
|
||||
{prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-3 gap-2">{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => <img key={url} src={url} alt="" className="aspect-square w-full rounded-md object-cover" loading="lazy" />)}</div> : null}
|
||||
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||
{prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null}
|
||||
<Space wrap className="mt-5">
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
||||
复制提示词
|
||||
</Button>
|
||||
{onSaveAsset ? (
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||
加入我的资产
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={720} centered styles={{ body: { height: "calc(85vh - 55px)", overflow: "hidden" } }}>
|
||||
{prompt ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 space-y-3 pb-4">
|
||||
{prompt.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="h-48 w-full rounded-lg object-cover sm:h-56" /> : <div className="grid h-48 w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600 sm:h-56"><FileText className="size-9" /></div>}
|
||||
{prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-6 gap-2">{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => <img key={url} src={url} alt="" className="aspect-square w-full rounded-md object-cover" loading="lazy" />)}</div> : null}
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto border-y border-stone-200 py-4 pr-2 dark:border-stone-800">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Tag key={tag} className="m-0">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
|
||||
{prompt.preview ? <pre className="mt-4 whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||
{prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null}
|
||||
</div>
|
||||
<div className="shrink-0 pt-4">
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
||||
复制提示词
|
||||
</Button>
|
||||
{onSaveAsset ? (
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}>
|
||||
加入我的资产
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user