fix(*): 资源与技能 - 精简列表载荷并禁止对象存储静默降级
This commit is contained in:
@@ -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<Skill[]>([]);
|
||||
|
||||
@@ -73,6 +73,11 @@ export function changesRequireOSSRetest(changedValues: Record<string, unknown>)
|
||||
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,
|
||||
|
||||
@@ -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<Asset, { kind: "entity" }>;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -57,6 +57,9 @@ export type Skill = {
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
/** /skills/added 只返回运行时目录需要的引用字段,不携带编辑器和同步详情。 */
|
||||
export type AddedSkillReference = Pick<Skill, "skillId" | "skillName" | "description" | "versionId" | "version" | "tag" | "isLike" | "isAdded" | "isOwner">;
|
||||
|
||||
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<void>((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;
|
||||
|
||||
@@ -242,12 +242,10 @@ async function waitForRemoteProjectLoads() {
|
||||
}
|
||||
|
||||
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[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<string>()): Set<string> {
|
||||
}
|
||||
|
||||
async function loadReferencedAssets(ids: Iterable<string>) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user