fix(*): 资源与技能 - 精简列表载荷并禁止对象存储静默降级
This commit is contained in:
@@ -58,7 +58,7 @@ func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter U
|
|||||||
}
|
}
|
||||||
rawAssets := make([]json.RawMessage, 0, len(assets))
|
rawAssets := make([]json.RawMessage, 0, len(assets))
|
||||||
for _, asset := range assets {
|
for _, asset := range assets {
|
||||||
if payload := clientAssetPayload(asset); len(payload) > 0 {
|
if payload := clientAssetListPayload(asset); len(payload) > 0 {
|
||||||
rawAssets = append(rawAssets, payload)
|
rawAssets = append(rawAssets, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
_ "image/png"
|
_ "image/png"
|
||||||
"infinite-canvas/backend/internal/kernel"
|
"infinite-canvas/backend/internal/kernel"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -432,10 +431,9 @@ func writeLocalResourceObject(filePath string, body io.Reader) error {
|
|||||||
return closeErr
|
return closeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cloud write is successful only after the configured origin accepts it.
|
// storeResourceObject writes to the configured origin. When object storage is
|
||||||
// storeResourceObject writes to the configured origin and degrades to local storage
|
// enabled, a failed origin write must fail the resource instead of silently
|
||||||
// when the external origin is unavailable. The resource binding is rewritten before
|
// changing its storage location to local disk.
|
||||||
// the caller persists the ready state, so later reads follow the actual object location.
|
|
||||||
func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) {
|
func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) {
|
||||||
if resource == nil {
|
if resource == nil {
|
||||||
return "", errors.New("资源不存在")
|
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)
|
return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body)
|
||||||
}
|
}
|
||||||
setting, settingErr := s.ossSettingForResource(resource.UserID, resource)
|
setting, settingErr := s.ossSettingForResource(resource.UserID, resource)
|
||||||
var etag string
|
if settingErr != nil {
|
||||||
var putErr error
|
return "", settingErr
|
||||||
if settingErr == nil {
|
|
||||||
etag, putErr = putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
|
|
||||||
}
|
}
|
||||||
if putErr == nil && settingErr == nil {
|
etag, err := putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
|
||||||
return etag, nil
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
fallbackErr := putErr
|
return etag, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) {
|
func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) {
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ func newResourceFallbackTestService(t *testing.T) (*Service, *gorm.DB) {
|
|||||||
return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db
|
return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStoreResourceDegradesToLocalWhenOSSUnavailable 覆盖对象存储不可用时的降级:
|
// TestStoreResourceFailsWhenOSSUnavailable ensures an enabled object-storage
|
||||||
// 配置了 OSS(enabled=1、endpoint 不可达)时上传应回退本地存储成功,
|
// configuration does not silently change the resource to local storage.
|
||||||
// 而不是把上传标记为失败——本地媒体导入不应因外部存储故障整体失败。
|
func TestStoreResourceFailsWhenOSSUnavailable(t *testing.T) {
|
||||||
func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) {
|
|
||||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||||
service, db := newResourceFallbackTestService(t)
|
service, db := newResourceFallbackTestService(t)
|
||||||
seedOSSEnabled(t, db, "user-1", "http://127.0.0.1:1")
|
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,
|
"user-1", "video", "intro.mp4", "video/mp4", 1024,
|
||||||
1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false,
|
1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("storeResource: %v", err)
|
t.Fatal("storeResource() error = nil, want OSS upload error")
|
||||||
}
|
}
|
||||||
if !created {
|
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 {
|
if resource != nil {
|
||||||
t.Fatalf("resource.Status = %q, want %q", resource.Status, model.ResourceStatusReady)
|
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 != "" {
|
if stored.Status != model.ResourceStatusFailed {
|
||||||
t.Fatalf("resource storage binding not cleared after degrade: endpoint=%q bucket=%q setting=%q",
|
t.Fatalf("resource.Status = %q, want %q", stored.Status, model.ResourceStatusFailed)
|
||||||
resource.Endpoint, resource.Bucket, resource.StorageSettingID)
|
|
||||||
}
|
}
|
||||||
payload, err := os.ReadFile(filepath.Join(service.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)))
|
if stored.Provider != "aliyun" || stored.Endpoint != "http://127.0.0.1:1" || stored.Bucket != "test-bucket" || stored.StorageSettingID == "" {
|
||||||
if err != nil {
|
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)
|
||||||
t.Fatalf("local object not written: %v", err)
|
|
||||||
}
|
}
|
||||||
if string(payload) != "fake-mp4-bytes" {
|
if _, err := os.Stat(filepath.Join(service.dataDir, "resources")); !os.IsNotExist(err) {
|
||||||
t.Fatalf("local object content = %q, want fake-mp4-bytes", payload)
|
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) {
|
func seedOSSEnabled(t *testing.T, db *gorm.DB, userID string, endpoint string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
setting := model.UserOSSSetting{
|
setting := model.UserOSSSetting{
|
||||||
|
ID: newID(),
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
ValueJSON: `{"provider":"aliyun","endpoint":"` + endpoint + `","bucket":"test-bucket","accessKeyId":"ak-test","accessKeySecret":"sk-plaintext","region":"cn-shenzhen"}`,
|
ValueJSON: `{"provider":"aliyun","endpoint":"` + endpoint + `","bucket":"test-bucket","accessKeyId":"ak-test","accessKeySecret":"sk-plaintext","region":"cn-shenzhen"}`,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type (
|
|||||||
SkillShowcaseMedia = skills.SkillShowcaseMedia
|
SkillShowcaseMedia = skills.SkillShowcaseMedia
|
||||||
SkillEffectiveUser = skills.SkillEffectiveUser
|
SkillEffectiveUser = skills.SkillEffectiveUser
|
||||||
SkillItem = skills.SkillItem
|
SkillItem = skills.SkillItem
|
||||||
|
AddedSkillReference = skills.AddedSkillReference
|
||||||
SkillCategory = skills.SkillCategory
|
SkillCategory = skills.SkillCategory
|
||||||
SkillListRequest = skills.SkillListRequest
|
SkillListRequest = skills.SkillListRequest
|
||||||
SkillList = skills.SkillList
|
SkillList = skills.SkillList
|
||||||
@@ -41,7 +42,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
|
|||||||
return s.skillDomain().Skills(userID, req)
|
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)
|
return s.skillDomain().AddedSkills(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,51 @@ func ClientAssetPayload(asset model.Asset) json.RawMessage {
|
|||||||
return encoded
|
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 {
|
func deriveAssetCoverURL(payload map[string]any) string {
|
||||||
if value, ok := payload["coverUrl"].(string); ok && strings.TrimSpace(value) != "" {
|
if value, ok := payload["coverUrl"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -60,3 +60,24 @@ func TestClientAssetPayloadPreservesCompleteDocument(t *testing.T) {
|
|||||||
t.Fatalf("payload changed: %s", raw)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ type SkillItem struct {
|
|||||||
IsOwner bool `json:"isOwner"`
|
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 {
|
type SkillCategory struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
@@ -151,12 +167,24 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
|
|||||||
}, nil
|
}, 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})
|
rows, _, err := s.repo.Skills(repository.SkillListFilter{UserID: userID, Scope: "mine", Sort: "updated", Limit: -1})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) {
|
||||||
|
|||||||
@@ -58,3 +58,24 @@ func TestParseShowcaseMediaAcceptsStoredSnakeCase(t *testing.T) {
|
|||||||
t.Fatalf("unexpected camelCase parse: %+v", items)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
|||||||
## 工作区 100% 缩放下的界面密度
|
## 工作区 100% 缩放下的界面密度
|
||||||
|
|
||||||
- 已收紧用户工作区侧栏、顶栏和导航行、画布桌面端 Dock、Agent 内边距及默认浮窗尺寸;Agent 旧的手动调整尺寸仍保留,可点标题栏「恢复 Agent 紧凑窗口」自行重置。技能组合标签维持透明底和彩色描边。
|
- 已收紧用户工作区侧栏、顶栏和导航行、画布桌面端 Dock、Agent 内边距及默认浮窗尺寸;Agent 旧的手动调整尺寸仍保留,可点标题栏「恢复 Agent 紧凑窗口」自行重置。技能组合标签维持透明底和彩色描边。
|
||||||
|
- 画布编辑器左侧图标菜单与展开内容改用一块低圆角、半透明磨砂表面;删除双重标题、硬分隔线和搜索框边框。侧栏现在浮在真实画布上方,打开/折叠不再改变画布视口宽度;顶栏和左下缩放控件避开侧栏。工作区下拉菜单及共享单选下拉去掉高亮描边,用背景和阴影建立层次,键盘焦点保留低对比提示。仍待登录态真实页面验收明暗主题、画布节点透视、窄屏抽屉、菜单键盘导航和弹层层级。
|
||||||
- [ ] 在已登录的真实画布和用户工作区以浏览器 100% 缩放验收明暗主题、侧栏展开/折叠、顶部/底部工具栏、Agent 历史与设置、输入区和标签横向滚动;再检查窄屏与触屏下是否截断或遮挡。当前本地独立浏览器会跳转登录页,未将静态检查视为视觉验收。
|
- [ ] 在已登录的真实画布和用户工作区以浏览器 100% 缩放验收明暗主题、侧栏展开/折叠、顶部/底部工具栏、Agent 历史与设置、输入区和标签横向滚动;再检查窄屏与触屏下是否截断或遮挡。当前本地独立浏览器会跳转登录页,未将静态检查视为视觉验收。
|
||||||
|
|
||||||
## 画布资源断链、前端发布兼容与画布热点
|
## 画布资源断链、前端发布兼容与画布热点
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Select } from "antd";
|
|
||||||
|
|
||||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||||
import { SKILL_RUNTIME_PROFILES, type SkillRuntimeProfile } from "@/services/skill-runtime";
|
import { SKILL_RUNTIME_PROFILES, type SkillRuntimeProfile } from "@/services/skill-runtime";
|
||||||
|
import { Select } from "@/components/ui/base/select";
|
||||||
|
|
||||||
export function useSkillRuntimeCatalog() {
|
export function useSkillRuntimeCatalog() {
|
||||||
const [skills, setSkills] = useState<Skill[]>([]);
|
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));
|
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 {
|
export function normalizeOSSConnectionTestInput(input: OSSConnectionTestDraft): OSSConnectionTestInput {
|
||||||
return {
|
return {
|
||||||
provider: input.provider,
|
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 { 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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 type { MenuProps } from "antd";
|
||||||
import { useNavigate } from "react-router";
|
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 { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||||
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
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 { useUserStore } from "@/stores/use-user-store";
|
||||||
import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data";
|
import { createAssetFolder, deleteAssetFolder, listAssetFolders, listRemoteAssetsPage, moveRemoteAssetsToFolder, updateAssetFolder, type AssetFolder } from "@/services/api/user-data";
|
||||||
import { AssetBatchUploadModal } from "./asset-batch-upload-modal";
|
import { AssetBatchUploadModal } from "./asset-batch-upload-modal";
|
||||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||||
|
import { Select } from "@/components/ui/base/select";
|
||||||
|
|
||||||
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
|
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
|
||||||
|
|
||||||
@@ -268,29 +269,49 @@ export default function AssetsPage() {
|
|||||||
setIsAssetOpen(true);
|
setIsAssetOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (asset: LibraryAsset) => {
|
const openEdit = async (asset: LibraryAsset) => {
|
||||||
setEditingAsset(asset);
|
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);
|
setImageFile(null);
|
||||||
setImageUploading(false);
|
setImageUploading(false);
|
||||||
setImageUploadProgress(null);
|
setImageUploadProgress(null);
|
||||||
setFormKind(asset.kind);
|
setFormKind(editableAsset.kind);
|
||||||
setImageDraft(asset.kind === "image" ? asset.data : null);
|
setImageDraft(editableAsset.kind === "image" ? editableAsset.data : null);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
kind: asset.kind,
|
kind: editableAsset.kind,
|
||||||
category: asset.category || "other",
|
category: editableAsset.category || "other",
|
||||||
folderId: asset.folderId || "",
|
folderId: editableAsset.folderId || "",
|
||||||
title: asset.title,
|
title: editableAsset.title,
|
||||||
coverUrl: asset.coverUrl,
|
coverUrl: editableAsset.coverUrl,
|
||||||
tags: asset.tags || [],
|
tags: editableAsset.tags || [],
|
||||||
source: asset.source,
|
source: editableAsset.source,
|
||||||
note: asset.note,
|
note: editableAsset.note,
|
||||||
content: asset.kind === "text" ? asset.data.content : "",
|
content: editableAsset.kind === "text" ? editableAsset.data.content : "",
|
||||||
arkAssetId: asset.arkAssetId || "",
|
arkAssetId: editableAsset.arkAssetId || "",
|
||||||
portraitCertified: asset.portraitCertified === true,
|
portraitCertified: editableAsset.portraitCertified === true,
|
||||||
});
|
});
|
||||||
setIsAssetOpen(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 saveAsset = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
let imageData = imageDraft;
|
let imageData = imageDraft;
|
||||||
@@ -434,9 +455,10 @@ export default function AssetsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restoreAsset = async (asset: LibraryAsset) => {
|
const restoreAsset = async (asset: LibraryAsset) => {
|
||||||
updateAsset(asset.id, { status: "confirmed" });
|
|
||||||
await flushAssetStorePersistence();
|
|
||||||
try {
|
try {
|
||||||
|
await ensureAssetsInStore([asset.id]);
|
||||||
|
updateAsset(asset.id, { status: "confirmed" });
|
||||||
|
await flushAssetStorePersistence();
|
||||||
await saveRemoteUserDataNow();
|
await saveRemoteUserDataNow();
|
||||||
message.success(`已还原素材「${asset.title}」`);
|
message.success(`已还原素材「${asset.title}」`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -446,13 +468,12 @@ export default function AssetsPage() {
|
|||||||
|
|
||||||
const batchRestore = async () => {
|
const batchRestore = async () => {
|
||||||
if (!selectedIds.length) return;
|
if (!selectedIds.length) return;
|
||||||
for (const id of selectedIds) {
|
|
||||||
updateAsset(id, { status: "confirmed" });
|
|
||||||
}
|
|
||||||
const count = selectedIds.length;
|
|
||||||
setSelectedIds([]);
|
|
||||||
await flushAssetStorePersistence();
|
|
||||||
try {
|
try {
|
||||||
|
await ensureAssetsInStore(selectedIds);
|
||||||
|
for (const id of selectedIds) updateAsset(id, { status: "confirmed" });
|
||||||
|
const count = selectedIds.length;
|
||||||
|
setSelectedIds([]);
|
||||||
|
await flushAssetStorePersistence();
|
||||||
await saveRemoteUserDataNow();
|
await saveRemoteUserDataNow();
|
||||||
message.success(`已还原 ${count} 个素材`);
|
message.success(`已还原 ${count} 个素材`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -461,9 +482,10 @@ export default function AssetsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const archiveAsset = async (asset: LibraryAsset) => {
|
const archiveAsset = async (asset: LibraryAsset) => {
|
||||||
updateAsset(asset.id, { status: "archived" });
|
|
||||||
await flushAssetStorePersistence();
|
|
||||||
try {
|
try {
|
||||||
|
await ensureAssetsInStore([asset.id]);
|
||||||
|
updateAsset(asset.id, { status: "archived" });
|
||||||
|
await flushAssetStorePersistence();
|
||||||
await saveRemoteUserDataNow();
|
await saveRemoteUserDataNow();
|
||||||
message.success(`已将「${asset.title}」移入回收站`);
|
message.success(`已将「${asset.title}」移入回收站`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -473,13 +495,12 @@ export default function AssetsPage() {
|
|||||||
|
|
||||||
const batchArchive = async () => {
|
const batchArchive = async () => {
|
||||||
if (!selectedIds.length) return;
|
if (!selectedIds.length) return;
|
||||||
for (const id of selectedIds) {
|
|
||||||
updateAsset(id, { status: "archived" });
|
|
||||||
}
|
|
||||||
const count = selectedIds.length;
|
|
||||||
setSelectedIds([]);
|
|
||||||
await flushAssetStorePersistence();
|
|
||||||
try {
|
try {
|
||||||
|
await ensureAssetsInStore(selectedIds);
|
||||||
|
for (const id of selectedIds) updateAsset(id, { status: "archived" });
|
||||||
|
const count = selectedIds.length;
|
||||||
|
setSelectedIds([]);
|
||||||
|
await flushAssetStorePersistence();
|
||||||
await saveRemoteUserDataNow();
|
await saveRemoteUserDataNow();
|
||||||
message.success(`已将 ${count} 个素材移入回收站`);
|
message.success(`已将 ${count} 个素材移入回收站`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -737,7 +758,7 @@ export default function AssetsPage() {
|
|||||||
retentionDays={retentionDays}
|
retentionDays={retentionDays}
|
||||||
onSelect={(selected) => setSelectedIds((current) => (selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id)))}
|
onSelect={(selected) => setSelectedIds((current) => (selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id)))}
|
||||||
onOpen={() => setPreviewAsset(asset)}
|
onOpen={() => setPreviewAsset(asset)}
|
||||||
onEdit={() => openEdit(asset)}
|
onEdit={() => void openEdit(asset)}
|
||||||
onCopy={copyAssetText}
|
onCopy={copyAssetText}
|
||||||
onDownload={downloadImage}
|
onDownload={downloadImage}
|
||||||
onRestore={() => void restoreAsset(asset)}
|
onRestore={() => void restoreAsset(asset)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
|
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 { 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";
|
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 { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
|
||||||
import { SkillInstallModal } from "@/pages/skills/skill-install-modal";
|
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 { 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 = [
|
const scopeOptions = [
|
||||||
{ label: "技能广场", value: "public", icon: Sparkles },
|
{ label: "技能广场", value: "public", icon: Sparkles },
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export type Skill = {
|
|||||||
isOwner: boolean;
|
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 };
|
export type SkillCategory = { value: string; label: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +189,8 @@ async function readAddedSkillsWithRetry() {
|
|||||||
const retryDelays = [300, 900, 1800];
|
const retryDelays = [300, 900, 1800];
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
try {
|
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) {
|
} catch (cause) {
|
||||||
if (!(cause instanceof ApiError) || !cause.retryable || attempt >= retryDelays.length) throw cause;
|
if (!(cause instanceof ApiError) || !cause.retryable || attempt >= retryDelays.length) throw cause;
|
||||||
await new Promise<void>((resolve) => globalThis.setTimeout(resolve, retryDelays[attempt]));
|
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() {
|
function invalidateAddedSkillsCache() {
|
||||||
addedSkillsCacheVersion += 1;
|
addedSkillsCacheVersion += 1;
|
||||||
addedSkillsCache = null;
|
addedSkillsCache = null;
|
||||||
|
|||||||
@@ -242,12 +242,10 @@ async function waitForRemoteProjectLoads() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[0]) {
|
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[0]) {
|
||||||
const epoch = sessionEpoch;
|
|
||||||
const result = await listRemoteAssetsPage(options);
|
const result = await listRemoteAssetsPage(options);
|
||||||
await withRemoteUserDataSyncExclusive(async () => {
|
// 分页列表是展示数据,不是同步快照。不要把每一页都合并进全局
|
||||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新读取素材");
|
// asset store,否则滚动/翻页会不断重建并持久化整个素材数组。
|
||||||
acceptRemoteAssets(result.assets);
|
// 真正被画布引用的素材仍由 loadAssetsForUse 按 ID 拉取并写入 store。
|
||||||
});
|
|
||||||
return { ...result, assets: parseAssetRecordList(result.assets) };
|
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>) {
|
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) {
|
for (let offset = 0; offset < pending.length; offset += 100) {
|
||||||
const { assets } = await getRemoteAssetsByIds(pending.slice(offset, offset + 100));
|
const { assets } = await getRemoteAssetsByIds(pending.slice(offset, offset + 100));
|
||||||
acceptRemoteAssets(assets);
|
acceptRemoteAssets(assets);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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", () => {
|
describe("OSS settings helpers", () => {
|
||||||
test("provides editable S3 endpoint hints for known presets", () => {
|
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");
|
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", () => {
|
test("normalizes a Tencent COS test draft when S3-only fields are not mounted", () => {
|
||||||
const input = normalizeOSSConnectionTestInput({
|
const input = normalizeOSSConnectionTestInput({
|
||||||
provider: "tencent",
|
provider: "tencent",
|
||||||
|
|||||||
Reference in New Issue
Block a user