From 3b254fce110345ed9fadd3bcf7d3105347f01cda Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:57:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E8=B5=84=E6=BA=90=E4=B8=8E=E6=8A=80?= =?UTF-8?q?=E8=83=BD=20-=20=E7=B2=BE=E7=AE=80=E5=88=97=E8=A1=A8=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E5=B9=B6=E7=A6=81=E6=AD=A2=E5=AF=B9=E8=B1=A1=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/asset_library.go | 2 +- backend/internal/app/resource.go | 41 ++------- .../app/resource_oss_fallback_test.go | 38 ++++---- backend/internal/app/skills_bridge.go | 3 +- .../canvas/user_asset_client_payload.go | 45 ++++++++++ .../canvas/user_asset_client_payload_test.go | 21 +++++ backend/internal/skills/skills.go | 32 ++++++- backend/internal/skills/skills_json_test.go | 21 +++++ docs/content/docs/progress/pending-test.mdx | 1 + .../skills/skill-runtime-picker.tsx | 3 +- web/src/lib/oss-settings.ts | 5 ++ web/src/pages/assets/index.tsx | 89 ++++++++++++------- web/src/pages/skills/index.tsx | 3 +- web/src/services/api/skills.ts | 48 +++++++++- web/src/services/user-data-sync.ts | 11 ++- web/test/oss-settings.test.ts | 7 +- 16 files changed, 271 insertions(+), 99 deletions(-) diff --git a/backend/internal/app/asset_library.go b/backend/internal/app/asset_library.go index 38719abe6..c9817eb1d 100644 --- a/backend/internal/app/asset_library.go +++ b/backend/internal/app/asset_library.go @@ -58,7 +58,7 @@ func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter U } rawAssets := make([]json.RawMessage, 0, len(assets)) for _, asset := range assets { - if payload := clientAssetPayload(asset); len(payload) > 0 { + if payload := clientAssetListPayload(asset); len(payload) > 0 { rawAssets = append(rawAssets, payload) } } diff --git a/backend/internal/app/resource.go b/backend/internal/app/resource.go index 63099424b..ba62475ce 100644 --- a/backend/internal/app/resource.go +++ b/backend/internal/app/resource.go @@ -15,7 +15,6 @@ import ( _ "image/png" "infinite-canvas/backend/internal/kernel" "io" - "log" "mime" "mime/multipart" "net/http" @@ -432,10 +431,9 @@ func writeLocalResourceObject(filePath string, body io.Reader) error { return closeErr } -// A cloud write is successful only after the configured origin accepts it. -// storeResourceObject writes to the configured origin and degrades to local storage -// when the external origin is unavailable. The resource binding is rewritten before -// the caller persists the ready state, so later reads follow the actual object location. +// storeResourceObject writes to the configured origin. When object storage is +// enabled, a failed origin write must fail the resource instead of silently +// changing its storage location to local disk. func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) { if resource == nil { return "", errors.New("资源不存在") @@ -444,35 +442,14 @@ func (s *Service) storeResourceObject(resource *model.Resource, fileName string, return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body) } setting, settingErr := s.ossSettingForResource(resource.UserID, resource) - var etag string - var putErr error - if settingErr == nil { - etag, putErr = putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body) + if settingErr != nil { + return "", settingErr } - if putErr == nil && settingErr == nil { - return etag, nil + etag, err := putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body) + if err != nil { + return "", err } - fallbackErr := putErr - if fallbackErr == nil { - fallbackErr = settingErr - } - if seeker, ok := body.(io.Seeker); ok { - if _, seekErr := seeker.Seek(0, io.SeekStart); seekErr != nil { - return "", errors.Join(fallbackErr, fmt.Errorf("降级本地存储时重置读取位置失败:%w", seekErr)) - } - } - localKey := localObjectKey(resource.UserID, resource.Kind, fileName, resource.MimeType, time.Now()) - resource.Provider = "local" - resource.ObjectKey = localKey - resource.Endpoint = "" - resource.Bucket = "" - resource.StorageSettingID = "" - resource.ETag = "" - if localErr := writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(localKey)), body); localErr != nil { - return "", errors.Join(fallbackErr, fmt.Errorf("降级本地存储失败:%w", localErr)) - } - log.Printf("object storage upload degraded to local storage: resource=%s error=%v", resource.ID, fallbackErr) - return "", nil + return etag, nil } func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) { diff --git a/backend/internal/app/resource_oss_fallback_test.go b/backend/internal/app/resource_oss_fallback_test.go index 697aa6bff..cc944b004 100644 --- a/backend/internal/app/resource_oss_fallback_test.go +++ b/backend/internal/app/resource_oss_fallback_test.go @@ -31,10 +31,9 @@ func newResourceFallbackTestService(t *testing.T) (*Service, *gorm.DB) { return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db } -// TestStoreResourceDegradesToLocalWhenOSSUnavailable 覆盖对象存储不可用时的降级: -// 配置了 OSS(enabled=1、endpoint 不可达)时上传应回退本地存储成功, -// 而不是把上传标记为失败——本地媒体导入不应因外部存储故障整体失败。 -func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) { +// TestStoreResourceFailsWhenOSSUnavailable ensures an enabled object-storage +// configuration does not silently change the resource to local storage. +func TestStoreResourceFailsWhenOSSUnavailable(t *testing.T) { t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") service, db := newResourceFallbackTestService(t) seedOSSEnabled(t, db, "user-1", "http://127.0.0.1:1") @@ -43,28 +42,28 @@ func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) { "user-1", "video", "intro.mp4", "video/mp4", 1024, 1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false, ) - if err != nil { - t.Fatalf("storeResource: %v", err) + if err == nil { + t.Fatal("storeResource() error = nil, want OSS upload error") } if !created { - t.Fatal("storeResource returned created=false, want true") + t.Fatal("storeResource returned created=false, want true for persisted failed resource") } - if resource.Status != model.ResourceStatusReady { - t.Fatalf("resource.Status = %q, want %q", resource.Status, model.ResourceStatusReady) + if resource != nil { + t.Fatalf("storeResource returned resource = %#v, want nil on failed upload", resource) } - if resource.Provider != "local" { - t.Fatalf("resource.Provider = %q, want local (degraded from OSS)", resource.Provider) + + var stored model.Resource + if err := db.First(&stored).Error; err != nil { + t.Fatalf("failed resource was not persisted: %v", err) } - if resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" { - t.Fatalf("resource storage binding not cleared after degrade: endpoint=%q bucket=%q setting=%q", - resource.Endpoint, resource.Bucket, resource.StorageSettingID) + if stored.Status != model.ResourceStatusFailed { + t.Fatalf("resource.Status = %q, want %q", stored.Status, model.ResourceStatusFailed) } - payload, err := os.ReadFile(filepath.Join(service.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))) - if err != nil { - t.Fatalf("local object not written: %v", err) + if stored.Provider != "aliyun" || stored.Endpoint != "http://127.0.0.1:1" || stored.Bucket != "test-bucket" || stored.StorageSettingID == "" { + t.Fatalf("resource OSS binding changed after failed upload: provider=%q endpoint=%q bucket=%q setting=%q", stored.Provider, stored.Endpoint, stored.Bucket, stored.StorageSettingID) } - if string(payload) != "fake-mp4-bytes" { - t.Fatalf("local object content = %q, want fake-mp4-bytes", payload) + if _, err := os.Stat(filepath.Join(service.dataDir, "resources")); !os.IsNotExist(err) { + t.Fatalf("local resources directory exists after failed OSS upload: err=%v", err) } } @@ -98,6 +97,7 @@ func TestStoreResourceLocalPathUnaffectedByOSS(t *testing.T) { func seedOSSEnabled(t *testing.T, db *gorm.DB, userID string, endpoint string) { t.Helper() setting := model.UserOSSSetting{ + ID: newID(), UserID: userID, Enabled: true, ValueJSON: `{"provider":"aliyun","endpoint":"` + endpoint + `","bucket":"test-bucket","accessKeyId":"ak-test","accessKeySecret":"sk-plaintext","region":"cn-shenzhen"}`, diff --git a/backend/internal/app/skills_bridge.go b/backend/internal/app/skills_bridge.go index 1a50398e7..19284399c 100644 --- a/backend/internal/app/skills_bridge.go +++ b/backend/internal/app/skills_bridge.go @@ -13,6 +13,7 @@ type ( SkillShowcaseMedia = skills.SkillShowcaseMedia SkillEffectiveUser = skills.SkillEffectiveUser SkillItem = skills.SkillItem + AddedSkillReference = skills.AddedSkillReference SkillCategory = skills.SkillCategory SkillListRequest = skills.SkillListRequest SkillList = skills.SkillList @@ -41,7 +42,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error return s.skillDomain().Skills(userID, req) } -func (s *Service) AddedSkills(userID string) ([]SkillItem, error) { +func (s *Service) AddedSkills(userID string) ([]AddedSkillReference, error) { return s.skillDomain().AddedSkills(userID) } diff --git a/backend/internal/canvas/user_asset_client_payload.go b/backend/internal/canvas/user_asset_client_payload.go index 91fb8b77c..4d1ef061c 100644 --- a/backend/internal/canvas/user_asset_client_payload.go +++ b/backend/internal/canvas/user_asset_client_payload.go @@ -46,6 +46,51 @@ func ClientAssetPayload(asset model.Asset) json.RawMessage { return encoded } +// ClientAssetListPayload is the list variant used by the paginated asset +// library. The list only needs enough metadata to render cards and perform +// an action; generation prompts and other verbose metadata belong to the +// asset detail/snapshot paths. Keeping them out of every page also avoids +// repeatedly parsing and copying the same prompt strings in the browser. +func ClientAssetListPayload(asset model.Asset) json.RawMessage { + raw := ClientAssetPayload(asset) + if len(raw) == 0 { + return nil + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil || payload == nil { + return raw + } + if metadata, ok := payload["metadata"].(map[string]any); ok { + // 列表只需要项目归属提示。canvasId 仍保留,避免列表卡片把画布素材 + // 错显示成“未关联项目”;节点 ID、生成提示和其他同步上下文在详情路径读取。 + for key := range metadata { + if key != "projectName" && key != "projectIds" && key != "canvasId" { + delete(metadata, key) + } + } + if len(metadata) == 0 { + delete(payload, "metadata") + } + } + // 图片封面和 dataUrl、视频封面和 url 通常是同一个地址;列表保留 + // coverUrl 字段以满足前端合同,但不再重复传输这段长 URL。 + if cover, ok := payload["coverUrl"].(string); ok { + if data, ok := payload["data"].(map[string]any); ok { + for _, key := range []string{"dataUrl", "url"} { + if value, ok := data[key].(string); ok && value == cover { + payload["coverUrl"] = "" + break + } + } + } + } + encoded, err := json.Marshal(payload) + if err != nil { + return raw + } + return encoded +} + func deriveAssetCoverURL(payload map[string]any) string { if value, ok := payload["coverUrl"].(string); ok && strings.TrimSpace(value) != "" { return value diff --git a/backend/internal/canvas/user_asset_client_payload_test.go b/backend/internal/canvas/user_asset_client_payload_test.go index f051994dd..0f483860f 100644 --- a/backend/internal/canvas/user_asset_client_payload_test.go +++ b/backend/internal/canvas/user_asset_client_payload_test.go @@ -60,3 +60,24 @@ func TestClientAssetPayloadPreservesCompleteDocument(t *testing.T) { t.Fatalf("payload changed: %s", raw) } } + +func TestClientAssetListPayloadDropsVerboseMetadata(t *testing.T) { + asset := model.Asset{PayloadJSON: `{"id":"asset-1","kind":"image","title":"列表","coverUrl":"https://example.com/a.png","tags":[],"data":{"dataUrl":"https://example.com/a.png","width":2,"height":3,"bytes":1,"mimeType":"image/png"},"metadata":{"prompt":"很长的生成提示词","nodeId":"node-1","projectIds":["project-1"]}}`} + var payload map[string]any + if err := json.Unmarshal(ClientAssetListPayload(asset), &payload); err != nil { + t.Fatal(err) + } + metadata := payload["metadata"].(map[string]any) + if _, found := metadata["prompt"]; found { + t.Fatalf("list payload still contains prompt: %#v", metadata) + } + if _, found := metadata["nodeId"]; found { + t.Fatalf("list payload still contains node metadata: %#v", metadata) + } + if metadata["projectIds"].([]any)[0] != "project-1" { + t.Fatalf("list payload dropped useful metadata: %#v", metadata) + } + if payload["coverUrl"] != "" { + t.Fatalf("duplicate cover URL was not removed: %#v", payload["coverUrl"]) + } +} diff --git a/backend/internal/skills/skills.go b/backend/internal/skills/skills.go index ce5598a8b..cfda32443 100644 --- a/backend/internal/skills/skills.go +++ b/backend/internal/skills/skills.go @@ -83,6 +83,22 @@ type SkillItem struct { IsOwner bool `json:"isOwner"` } +// AddedSkillReference is the small contract used by the runtime catalog. +// The full SkillItem contains editor, sync, author and showcase fields that +// are only needed by the skill detail/market pages, not when selecting an +// installed skill for a generation request. +type AddedSkillReference struct { + SkillID string `json:"skillId"` + SkillName string `json:"skillName"` + Description string `json:"description"` + VersionID string `json:"versionId"` + Version string `json:"version"` + Tag string `json:"tag"` + IsLike bool `json:"isLike"` + IsAdded bool `json:"isAdded"` + IsOwner bool `json:"isOwner"` +} + type SkillCategory struct { Value string `json:"value"` Label string `json:"label"` @@ -151,12 +167,24 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error }, nil } -func (s *Service) AddedSkills(userID string) ([]SkillItem, error) { +func (s *Service) AddedSkills(userID string) ([]AddedSkillReference, error) { rows, _, err := s.repo.Skills(repository.SkillListFilter{UserID: userID, Scope: "mine", Sort: "updated", Limit: -1}) if err != nil { return nil, err } - return s.skillItems(userID, rows, false) + items, err := s.skillItems(userID, rows, false) + if err != nil { + return nil, err + } + references := make([]AddedSkillReference, 0, len(items)) + for _, item := range items { + references = append(references, AddedSkillReference{ + SkillID: item.SkillID, SkillName: item.SkillName, Description: item.Description, + VersionID: item.VersionID, Version: item.Version, Tag: item.Tag, + IsLike: item.IsLike, IsAdded: item.IsAdded, IsOwner: item.IsOwner, + }) + } + return references, nil } func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) { diff --git a/backend/internal/skills/skills_json_test.go b/backend/internal/skills/skills_json_test.go index 6474ef4dc..f741d0d20 100644 --- a/backend/internal/skills/skills_json_test.go +++ b/backend/internal/skills/skills_json_test.go @@ -58,3 +58,24 @@ func TestParseShowcaseMediaAcceptsStoredSnakeCase(t *testing.T) { t.Fatalf("unexpected camelCase parse: %+v", items) } } + +func TestAddedSkillReferenceJSONExcludesEditorAndSyncPayload(t *testing.T) { + body, err := json.Marshal(AddedSkillReference{ + SkillID: "skill-1", SkillName: "镜头拆解", Description: "用于镜头规划", VersionID: "v1", + Version: "1.0.0", Tag: "creative", IsAdded: true, + }) + if err != nil { + t.Fatal(err) + } + text := string(body) + for _, key := range []string{"instruction", "extraInfo", "showcaseMedia", "sourceUrl", "syncStatus", "effectiveUser"} { + if strings.Contains(text, `"`+key+`"`) { + t.Fatalf("added skill reference contains %s: %s", key, text) + } + } + for _, key := range []string{"skillId", "skillName", "versionId", "tag", "isAdded"} { + if !strings.Contains(text, `"`+key+`"`) { + t.Fatalf("added skill reference misses %s: %s", key, text) + } + } +} diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 1299b9834..bf79327fd 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -14,6 +14,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 ## 工作区 100% 缩放下的界面密度 - 已收紧用户工作区侧栏、顶栏和导航行、画布桌面端 Dock、Agent 内边距及默认浮窗尺寸;Agent 旧的手动调整尺寸仍保留,可点标题栏「恢复 Agent 紧凑窗口」自行重置。技能组合标签维持透明底和彩色描边。 +- 画布编辑器左侧图标菜单与展开内容改用一块低圆角、半透明磨砂表面;删除双重标题、硬分隔线和搜索框边框。侧栏现在浮在真实画布上方,打开/折叠不再改变画布视口宽度;顶栏和左下缩放控件避开侧栏。工作区下拉菜单及共享单选下拉去掉高亮描边,用背景和阴影建立层次,键盘焦点保留低对比提示。仍待登录态真实页面验收明暗主题、画布节点透视、窄屏抽屉、菜单键盘导航和弹层层级。 - [ ] 在已登录的真实画布和用户工作区以浏览器 100% 缩放验收明暗主题、侧栏展开/折叠、顶部/底部工具栏、Agent 历史与设置、输入区和标签横向滚动;再检查窄屏与触屏下是否截断或遮挡。当前本地独立浏览器会跳转登录页,未将静态检查视为视觉验收。 ## 画布资源断链、前端发布兼容与画布热点 diff --git a/web/src/components/skills/skill-runtime-picker.tsx b/web/src/components/skills/skill-runtime-picker.tsx index ab56e2a0e..fcd90931a 100644 --- a/web/src/components/skills/skill-runtime-picker.tsx +++ b/web/src/components/skills/skill-runtime-picker.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useState } from "react"; -import { Select } from "antd"; + import { listAddedSkills, type Skill } from "@/services/api/skills"; import { SKILL_RUNTIME_PROFILES, type SkillRuntimeProfile } from "@/services/skill-runtime"; +import { Select } from "@/components/ui/base/select"; export function useSkillRuntimeCatalog() { const [skills, setSkills] = useState([]); diff --git a/web/src/lib/oss-settings.ts b/web/src/lib/oss-settings.ts index 8008adf6e..0de2ce58f 100644 --- a/web/src/lib/oss-settings.ts +++ b/web/src/lib/oss-settings.ts @@ -73,6 +73,11 @@ export function changesRequireOSSRetest(changedValues: Record) return Object.keys(changedValues).some((key) => CONNECTION_FIELDS.has(key)); } +export function validateOSSConnectionDraft(input: OSSConnectionTestDraft) { + if (!trimConnectionValue(input.bucket)) return "请填写对象存储 Bucket"; + return ""; +} + export function normalizeOSSConnectionTestInput(input: OSSConnectionTestDraft): OSSConnectionTestInput { return { provider: input.provider, diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index 12eb7954f..6384b0ae4 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -4,7 +4,7 @@ import { DeleteButton } from "@/components/ui/base/buttons/delete-button"; import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; -import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Select, Space, Tag, Typography } from "antd"; +import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Space, Tag, Typography } from "antd"; import type { MenuProps } from "antd"; import { useNavigate } from "react-router"; @@ -26,11 +26,12 @@ import { downloadBrowserMedia } from "@/services/browser-download"; import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store"; import { exportAssets, readAssetPackage } from "./asset-transfer"; import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage"; -import { deleteAssetWithRemoteSync, deleteAssetsWithRemoteSync, loadAssetLibraryPage, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; +import { deleteAssetWithRemoteSync, deleteAssetsWithRemoteSync, loadAssetLibraryPage, loadAssetsForUse, localSavedRemotePendingMessage, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { useUserStore } from "@/stores/use-user-store"; import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data"; import { AssetBatchUploadModal } from "./asset-batch-upload-modal"; import { useAppearanceStore } from "@/stores/use-appearance-store"; +import { Select } from "@/components/ui/base/select"; type LibraryAsset = Exclude; @@ -268,29 +269,49 @@ export default function AssetsPage() { setIsAssetOpen(true); }; - const openEdit = (asset: LibraryAsset) => { - setEditingAsset(asset); + const openEdit = async (asset: LibraryAsset) => { + let editableAsset = useAssetStore.getState().assets.find((item): item is LibraryAsset => item.id === asset.id && item.kind !== "entity"); + if (!editableAsset) { + try { + // 分页卡片是轻量 DTO,编辑前补齐完整记录,避免保存时覆盖远端 metadata。 + await loadAssetsForUse([asset.id]); + editableAsset = useAssetStore.getState().assets.find((item): item is LibraryAsset => item.id === asset.id && item.kind !== "entity"); + } catch (error) { + message.error(error instanceof Error ? error.message : "素材详情读取失败,请重试"); + return; + } + } + if (!editableAsset) { + message.error("素材详情读取失败,请重试"); + return; + } + setEditingAsset(editableAsset); setImageFile(null); setImageUploading(false); setImageUploadProgress(null); - setFormKind(asset.kind); - setImageDraft(asset.kind === "image" ? asset.data : null); + setFormKind(editableAsset.kind); + setImageDraft(editableAsset.kind === "image" ? editableAsset.data : null); form.setFieldsValue({ - kind: asset.kind, - category: asset.category || "other", - folderId: asset.folderId || "", - title: asset.title, - coverUrl: asset.coverUrl, - tags: asset.tags || [], - source: asset.source, - note: asset.note, - content: asset.kind === "text" ? asset.data.content : "", - arkAssetId: asset.arkAssetId || "", - portraitCertified: asset.portraitCertified === true, + kind: editableAsset.kind, + category: editableAsset.category || "other", + folderId: editableAsset.folderId || "", + title: editableAsset.title, + coverUrl: editableAsset.coverUrl, + tags: editableAsset.tags || [], + source: editableAsset.source, + note: editableAsset.note, + content: editableAsset.kind === "text" ? editableAsset.data.content : "", + arkAssetId: editableAsset.arkAssetId || "", + portraitCertified: editableAsset.portraitCertified === true, }); setIsAssetOpen(true); }; + const ensureAssetsInStore = async (assetIds: string[]) => { + const missingIds = assetIds.filter((id) => !useAssetStore.getState().assets.some((asset) => asset.id === id)); + if (missingIds.length) await loadAssetsForUse(missingIds); + }; + const saveAsset = async () => { const values = await form.validateFields(); let imageData = imageDraft; @@ -434,9 +455,10 @@ export default function AssetsPage() { }; const restoreAsset = async (asset: LibraryAsset) => { - updateAsset(asset.id, { status: "confirmed" }); - await flushAssetStorePersistence(); try { + await ensureAssetsInStore([asset.id]); + updateAsset(asset.id, { status: "confirmed" }); + await flushAssetStorePersistence(); await saveRemoteUserDataNow(); message.success(`已还原素材「${asset.title}」`); } catch (error) { @@ -446,13 +468,12 @@ export default function AssetsPage() { const batchRestore = async () => { if (!selectedIds.length) return; - for (const id of selectedIds) { - updateAsset(id, { status: "confirmed" }); - } - const count = selectedIds.length; - setSelectedIds([]); - await flushAssetStorePersistence(); try { + await ensureAssetsInStore(selectedIds); + for (const id of selectedIds) updateAsset(id, { status: "confirmed" }); + const count = selectedIds.length; + setSelectedIds([]); + await flushAssetStorePersistence(); await saveRemoteUserDataNow(); message.success(`已还原 ${count} 个素材`); } catch (error) { @@ -461,9 +482,10 @@ export default function AssetsPage() { }; const archiveAsset = async (asset: LibraryAsset) => { - updateAsset(asset.id, { status: "archived" }); - await flushAssetStorePersistence(); try { + await ensureAssetsInStore([asset.id]); + updateAsset(asset.id, { status: "archived" }); + await flushAssetStorePersistence(); await saveRemoteUserDataNow(); message.success(`已将「${asset.title}」移入回收站`); } catch (error) { @@ -473,13 +495,12 @@ export default function AssetsPage() { const batchArchive = async () => { if (!selectedIds.length) return; - for (const id of selectedIds) { - updateAsset(id, { status: "archived" }); - } - const count = selectedIds.length; - setSelectedIds([]); - await flushAssetStorePersistence(); try { + await ensureAssetsInStore(selectedIds); + for (const id of selectedIds) updateAsset(id, { status: "archived" }); + const count = selectedIds.length; + setSelectedIds([]); + await flushAssetStorePersistence(); await saveRemoteUserDataNow(); message.success(`已将 ${count} 个素材移入回收站`); } catch (error) { @@ -737,7 +758,7 @@ export default function AssetsPage() { retentionDays={retentionDays} onSelect={(selected) => setSelectedIds((current) => (selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id)))} onOpen={() => setPreviewAsset(asset)} - onEdit={() => openEdit(asset)} + onEdit={() => void openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onRestore={() => void restoreAsset(asset)} diff --git a/web/src/pages/skills/index.tsx b/web/src/pages/skills/index.tsx index 61b092407..568918734 100644 --- a/web/src/pages/skills/index.tsx +++ b/web/src/pages/skills/index.tsx @@ -1,5 +1,5 @@ import { CollectionToolbar } from "@/components/layout/collection-toolbar"; -import { App, Button, Dropdown, Input, Select } from "antd"; +import { App, Button, Dropdown, Input } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, MoreHorizontal, Palette, Plus, Puzzle, Search, ShoppingBag, Sparkles, UserRound } from "lucide-react"; @@ -14,6 +14,7 @@ import { SkillDetailModal } from "@/pages/skills/skill-detail-drawer"; import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer"; import { SkillInstallModal } from "@/pages/skills/skill-install-modal"; import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills"; +import { Select } from "@/components/ui/base/select"; const scopeOptions = [ { label: "技能广场", value: "public", icon: Sparkles }, diff --git a/web/src/services/api/skills.ts b/web/src/services/api/skills.ts index 2c979a8c8..18974b34a 100644 --- a/web/src/services/api/skills.ts +++ b/web/src/services/api/skills.ts @@ -57,6 +57,9 @@ export type Skill = { isOwner: boolean; }; +/** /skills/added 只返回运行时目录需要的引用字段,不携带编辑器和同步详情。 */ +export type AddedSkillReference = Pick; + export type SkillCategory = { value: string; label: string }; /** @@ -186,7 +189,8 @@ async function readAddedSkillsWithRetry() { const retryDelays = [300, 900, 1800]; for (let attempt = 0; ; attempt += 1) { try { - return await http.get<{ skills: Skill[] }>("/skills/added"); + const response = await http.get<{ skills: AddedSkillReference[] }>("/skills/added"); + return { skills: response.skills.map(normalizeAddedSkillReference) }; } catch (cause) { if (!(cause instanceof ApiError) || !cause.retryable || attempt >= retryDelays.length) throw cause; await new Promise((resolve) => globalThis.setTimeout(resolve, retryDelays[attempt])); @@ -194,6 +198,48 @@ async function readAddedSkillsWithRetry() { } } +function normalizeAddedSkillReference(skill: AddedSkillReference): Skill { + // 兼容旧后端/测试桩返回的最小关系对象;正式接口返回完整的轻量引用时 + // 才补齐宽 Skill 类型的展示默认值。 + if (!skill.skillName && !skill.versionId) return skill as unknown as Skill; + return { + skillId: skill.skillId, + skillName: skill.skillName, + description: skill.description, + versionId: skill.versionId, + version: skill.version, + contentHash: "", + fileCount: 0, + totalBytes: 0, + sourceType: "", + sourceUrl: "", + sourceRef: "", + sourceSubdir: "", + sourceCommit: "", + syncStatus: "synced", + autoUpdate: false, + status: 1, + markdownUrl: "", + createdAt: "", + updatedAt: "", + source: 0, + tag: skill.tag, + sortWeight: 0, + isPrivate: false, + likeCount: 0, + isLike: skill.isLike, + ownerUid: "", + effectiveUser: { name: "", avatarUrl: "", uid: "" }, + originalSkillId: null, + showcaseMedia: [], + addedCount: 0, + isTest: false, + extraInfo: "", + isAdded: skill.isAdded, + isOwner: skill.isOwner, + }; +} + function invalidateAddedSkillsCache() { addedSkillsCacheVersion += 1; addedSkillsCache = null; diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index c26638ee0..3fd25ca54 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -242,12 +242,10 @@ async function waitForRemoteProjectLoads() { } export async function loadAssetLibraryPage(options: Parameters[0]) { - const epoch = sessionEpoch; const result = await listRemoteAssetsPage(options); - await withRemoteUserDataSyncExclusive(async () => { - if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新读取素材"); - acceptRemoteAssets(result.assets); - }); + // 分页列表是展示数据,不是同步快照。不要把每一页都合并进全局 + // asset store,否则滚动/翻页会不断重建并持久化整个素材数组。 + // 真正被画布引用的素材仍由 loadAssetsForUse 按 ID 拉取并写入 store。 return { ...result, assets: parseAssetRecordList(result.assets) }; } @@ -274,7 +272,8 @@ function collectAssetIds(value: unknown, ids = new Set()): Set { } async function loadReferencedAssets(ids: Iterable) { - const pending = [...new Set(ids)].filter((id) => !verifiedAssets.has(id)); + const loadedIds = new Set(useAssetStore.getState().assets.map((asset) => asset.id)); + const pending = [...new Set(ids)].filter((id) => !loadedIds.has(id) || !verifiedAssets.has(id)); for (let offset = 0; offset < pending.length; offset += 100) { const { assets } = await getRemoteAssetsByIds(pending.slice(offset, offset + 100)); acceptRemoteAssets(assets); diff --git a/web/test/oss-settings.test.ts b/web/test/oss-settings.test.ts index 6e3d3bc18..9f614627d 100644 --- a/web/test/oss-settings.test.ts +++ b/web/test/oss-settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, normalizeOSSConnectionTestInput } from "../src/lib/oss-settings"; +import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, normalizeOSSConnectionTestInput, validateOSSConnectionDraft } from "../src/lib/oss-settings"; describe("OSS settings helpers", () => { test("provides editable S3 endpoint hints for known presets", () => { @@ -18,6 +18,11 @@ describe("OSS settings helpers", () => { expect(DEFAULT_OSS_PATH_PREFIX).toBe("open-ai-canvas"); }); + test("rejects a connection test draft without a bucket", () => { + expect(validateOSSConnectionDraft({ provider: "aliyun", bucket: " " })).toBe("请填写对象存储 Bucket"); + expect(validateOSSConnectionDraft({ provider: "aliyun", bucket: "canvas-assets" })).toBe(""); + }); + test("normalizes a Tencent COS test draft when S3-only fields are not mounted", () => { const input = normalizeOSSConnectionTestInput({ provider: "tencent",