chore(*): 代码同步 - 合并远端 main 最新变更
This commit is contained in:
commit
e1f2f16e85
5 files changed
+303
-59
No files matched your search
@@ -2,10 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"infinite-canvas/backend/internal/model"
|
"infinite-canvas/backend/internal/model"
|
||||||
|
|
||||||
|
"github.com/tencentyun/cos-go-sdk-v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,27 +79,9 @@ func (s *Service) testOSSSetting(scope string, ownerID string, actorID string, r
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
testKey := path.Join(value.PathPrefix, ".yingce-tests", scope, newID())
|
testKey := path.Join(value.PathPrefix, ".yingce-tests", scope, newID())
|
||||||
payload := []byte("yingce-storage-test")
|
if err := verifyOSSConnection(value, testKey); err != nil {
|
||||||
if _, err := putOSSObject(value, testKey, "application/octet-stream", int64(len(payload)), bytes.NewReader(payload)); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stream, err := getOSSObjectRange(value, testKey, "bytes=0-3")
|
|
||||||
var rangeErr error
|
|
||||||
if err != nil {
|
|
||||||
rangeErr = err
|
|
||||||
} else {
|
|
||||||
data, readErr := io.ReadAll(io.LimitReader(stream.body, 5))
|
|
||||||
closeErr := stream.body.Close()
|
|
||||||
if readErr != nil || closeErr != nil || string(data) != "ying" {
|
|
||||||
rangeErr = errors.New("对象存储 Range 读取测试失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := deleteOSSObject(value, testKey); err != nil {
|
|
||||||
return nil, fmt.Errorf("对象存储删除测试失败:%w", err)
|
|
||||||
}
|
|
||||||
if rangeErr != nil {
|
|
||||||
return nil, rangeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
location, err := s.upsertStorageLocation(scope, ownerID, value)
|
location, err := s.upsertStorageLocation(scope, ownerID, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,6 +96,79 @@ func (s *Service) testOSSSetting(scope string, ownerID string, actorID string, r
|
|||||||
return &OSSConnectionTestResult{OK: true, Message: "连接测试通过", TestedAt: now, TestedDigest: location.TestedDigest}, nil
|
return &OSSConnectionTestResult{OK: true, Message: "连接测试通过", TestedAt: now, TestedDigest: location.TestedDigest}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verifyOSSConnection(value ossSettingValue, testKey string) error {
|
||||||
|
payload := []byte("yingce-storage-test")
|
||||||
|
// 连接测试验证服务端到对象存储的真实读写权限。CDN 是浏览器读取出口,
|
||||||
|
// 可能存在回源鉴权或边缘同步延迟,不能参与刚写入对象的最小读写测试。
|
||||||
|
testValue := value
|
||||||
|
testValue.CDNBaseURL = ""
|
||||||
|
if _, err := putOSSObject(testValue, testKey, "application/octet-stream", int64(len(payload)), bytes.NewReader(payload)); err != nil {
|
||||||
|
return storageConnectionTestError(testValue.Provider, "写入", err)
|
||||||
|
}
|
||||||
|
stream, err := getOSSObjectRange(testValue, testKey, "bytes=0-3")
|
||||||
|
var rangeErr error
|
||||||
|
if err != nil {
|
||||||
|
rangeErr = err
|
||||||
|
} else {
|
||||||
|
data, readErr := io.ReadAll(io.LimitReader(stream.body, 5))
|
||||||
|
closeErr := stream.body.Close()
|
||||||
|
if readErr != nil || closeErr != nil || string(data) != "ying" {
|
||||||
|
rangeErr = errors.New("对象存储 Range 读取测试失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := deleteOSSObject(testValue, testKey); err != nil {
|
||||||
|
return storageConnectionTestError(testValue.Provider, "删除", err)
|
||||||
|
}
|
||||||
|
if rangeErr != nil {
|
||||||
|
return storageConnectionTestError(testValue.Provider, "Range 读取", rangeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageConnectionTestError(provider string, operation string, cause error) error {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
providerName := "对象存储"
|
||||||
|
switch provider {
|
||||||
|
case aliyunOSSProvider:
|
||||||
|
providerName = "阿里云 OSS"
|
||||||
|
case tencentCOSProvider:
|
||||||
|
providerName = "腾讯云 COS"
|
||||||
|
case qiniuKodoProvider:
|
||||||
|
providerName = "七牛云 Kodo"
|
||||||
|
case s3Provider:
|
||||||
|
providerName = "S3 兼容存储"
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(cause, context.DeadlineExceeded) {
|
||||||
|
return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause)
|
||||||
|
}
|
||||||
|
var networkErr net.Error
|
||||||
|
if errors.As(cause, &networkErr) && networkErr.Timeout() {
|
||||||
|
return WrapAppError(http.StatusGatewayTimeout, fmt.Sprintf("%s %s测试超时,请检查 Endpoint 和服务端网络", providerName, operation), cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == tencentCOSProvider {
|
||||||
|
var responseErr *cos.ErrorResponse
|
||||||
|
if errors.As(cause, &responseErr) && responseErr.Response != nil {
|
||||||
|
switch responseErr.Response.StatusCode {
|
||||||
|
case http.StatusBadRequest, http.StatusUnprocessableEntity:
|
||||||
|
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s请求被拒绝,请检查 Bucket、Region 和 Endpoint", operation), cause)
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s鉴权失败,请检查 SecretId、SecretKey 和 Bucket 权限", operation), cause)
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s未找到 Bucket,请检查 Bucket、Region 和 Endpoint", operation), cause)
|
||||||
|
case http.StatusTooManyRequests:
|
||||||
|
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s请求过于频繁,请稍后重试", operation), cause)
|
||||||
|
}
|
||||||
|
if responseErr.Response.StatusCode >= http.StatusInternalServerError {
|
||||||
|
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("腾讯云 COS %s服务暂时不可用,请稍后重试", operation), cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return WrapAppError(http.StatusBadGateway, fmt.Sprintf("%s %s测试失败,请检查 Endpoint、Bucket、访问密钥和存储桶权限", providerName, operation), cause)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) upsertStorageLocation(scope string, ownerID string, value ossSettingValue) (*model.StorageLocation, error) {
|
func (s *Service) upsertStorageLocation(scope string, ownerID string, value ossSettingValue) (*model.StorageLocation, error) {
|
||||||
digest := storageLocationDigest(value)
|
digest := storageLocationDigest(value)
|
||||||
location, err := s.repo.StorageLocationByDigest(scope, ownerID, value.Provider, digest)
|
location, err := s.repo.StorageLocationByDigest(scope, ownerID, value.Provider, digest)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"hash/crc64"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTencentCOSConnectionTestUsesStorageEndpointInsteadOfCDN(t *testing.T) {
|
||||||
|
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||||
|
var storageMethods []string
|
||||||
|
storageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
storageMethods = append(storageMethods, r.Method)
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/open-ai-canvas/.yingce-tests/platform/") {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Header.Get("Authorization"), "q-sign-algorithm=sha1") {
|
||||||
|
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPut:
|
||||||
|
payload, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if string(payload) != "yingce-storage-test" {
|
||||||
|
t.Errorf("payload = %q", payload)
|
||||||
|
}
|
||||||
|
w.Header().Set("ETag", `"cos-test-etag"`)
|
||||||
|
w.Header().Set("x-cos-hash-crc64ecma", strconv.FormatUint(crc64.Checksum(payload, crc64.MakeTable(crc64.ECMA)), 10))
|
||||||
|
case http.MethodGet:
|
||||||
|
if r.Header.Get("Range") != "bytes=0-3" {
|
||||||
|
t.Errorf("Range = %q", r.Header.Get("Range"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Content-Range", "bytes 0-3/19")
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
_, _ = io.WriteString(w, "ying")
|
||||||
|
case http.MethodDelete:
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected method %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer storageServer.Close()
|
||||||
|
|
||||||
|
cdnRequests := 0
|
||||||
|
cdnServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
cdnRequests++
|
||||||
|
http.Error(w, "CDN must not be used by storage connection tests", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer cdnServer.Close()
|
||||||
|
|
||||||
|
err := verifyOSSConnection(ossSettingValue{
|
||||||
|
Provider: tencentCOSProvider,
|
||||||
|
Region: "ap-guangzhou",
|
||||||
|
Endpoint: storageServer.URL,
|
||||||
|
CDNBaseURL: cdnServer.URL,
|
||||||
|
Bucket: "example-1250000000",
|
||||||
|
AccessKeyID: "secret-id",
|
||||||
|
AccessKeySecret: "secret-key",
|
||||||
|
PathPrefix: defaultOSSPathPrefix,
|
||||||
|
}, defaultOSSPathPrefix+"/.yingce-tests/platform/test-id")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(storageMethods, ",") != "PUT,GET,DELETE" {
|
||||||
|
t.Fatalf("storage methods = %v", storageMethods)
|
||||||
|
}
|
||||||
|
if cdnRequests != 0 {
|
||||||
|
t.Fatalf("CDN requests = %d", cdnRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTencentCOSConnectionTestReturnsActionableAuthError(t *testing.T) {
|
||||||
|
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = io.WriteString(w, `<Error><Code>SignatureDoesNotMatch</Code><Message>signature mismatch</Message><RequestId>request-id</RequestId></Error>`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := verifyOSSConnection(ossSettingValue{
|
||||||
|
Provider: tencentCOSProvider,
|
||||||
|
Region: "ap-guangzhou",
|
||||||
|
Endpoint: server.URL,
|
||||||
|
Bucket: "example-1250000000",
|
||||||
|
AccessKeyID: "secret-id",
|
||||||
|
AccessKeySecret: "secret-key",
|
||||||
|
PathPrefix: defaultOSSPathPrefix,
|
||||||
|
}, defaultOSSPathPrefix+"/.yingce-tests/platform/test-id")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("verifyOSSConnection() error = nil")
|
||||||
|
}
|
||||||
|
var appErr *AppError
|
||||||
|
if !errors.As(err, &appErr) {
|
||||||
|
t.Fatalf("error type = %T, error = %v", err, err)
|
||||||
|
}
|
||||||
|
if appErr.Status != http.StatusBadGateway || !strings.Contains(appErr.Message, "腾讯云 COS 写入鉴权失败") {
|
||||||
|
t.Fatalf("AppError = %#v", appErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(appErr.Message, "secret-key") || strings.Contains(appErr.Message, "signature mismatch") {
|
||||||
|
t.Fatalf("public error leaked upstream detail: %q", appErr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -427,7 +427,9 @@ description: 影策已实现功能的手工验证清单与未确认边界
|
|||||||
- [x] 后端 S3 单次上传、Range 读取、预签名地址、Session Token、幂等删除、配置版本和迁移清单自动化测试通过。
|
- [x] 后端 S3 单次上传、Range 读取、预签名地址、Session Token、幂等删除、配置版本和迁移清单自动化测试通过。
|
||||||
- [x] 前端 S3 预设、关键字段重测判断、默认 `open-ai-canvas` 路径前缀、TypeScript 检查和生产构建通过。
|
- [x] 前端 S3 预设、关键字段重测判断、默认 `open-ai-canvas` 路径前缀、TypeScript 检查和生产构建通过。
|
||||||
- [x] 平台或用户从未保存 OSS 配置时,后端统一返回标准化默认值(含 `s3Preset: custom`);缺省读取回归测试与后端全量测试通过。
|
- [x] 平台或用户从未保存 OSS 配置时,后端统一返回标准化默认值(含 `s3Preset: custom`);缺省读取回归测试与后端全量测试通过。
|
||||||
|
- [x] 腾讯云 COS 连接测试固定通过服务端 Endpoint 执行写入、Range 读取和删除,不再误走可选 CDN;COS 鉴权失败会返回可操作且不泄露上游详情的提示,专项回归测试通过。
|
||||||
- [ ] 使用全新数据库登录管理员并打开 `/admin/settings/storage`,确认页面直接显示服务器本地存储且可切换阿里云 OSS、腾讯云 COS、七牛云 Kodo 和 S3 兼容存储。
|
- [ ] 使用全新数据库登录管理员并打开 `/admin/settings/storage`,确认页面直接显示服务器本地存储且可切换阿里云 OSS、腾讯云 COS、七牛云 Kodo 和 S3 兼容存储。
|
||||||
|
- [ ] 使用真实腾讯云 COS 配置测试成功与错误 SecretKey 两种场景;同时填写 CDN 加速域名时确认连接测试仍走 COS Endpoint,错误凭据提示为鉴权失败而非“系统处理失败”。
|
||||||
- [ ] 分别使用 AWS S3、Cloudflare R2、Backblaze B2 和 RustFS 完成写入、Range 读取、删除及历史资源恢复验证。
|
- [ ] 分别使用 AWS S3、Cloudflare R2、Backblaze B2 和 RustFS 完成写入、Range 读取、删除及历史资源恢复验证。
|
||||||
- [ ] 验证 Path Style 开启时强制路径寻址,关闭时 AWS 使用虚拟主机寻址、其他自定义 Endpoint 自动使用路径寻址。
|
- [ ] 验证 Path Style 开启时强制路径寻址,关闭时 AWS 使用虚拟主机寻址、其他自定义 Endpoint 自动使用路径寻址。
|
||||||
- [ ] 验证平台切换到本机后只影响新上传,历史 S3 资源仍可读取和删除;个人 S3 停用后正确回退平台存储。
|
- [ ] 验证平台切换到本机后只影响新上传,历史 S3 资源仍可读取和删除;个人 S3 停用后正确回退平台存储。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
|
import { useEffect, useMemo, useRef, useState, type ComponentType, type SVGProps } from "react";
|
||||||
import { Input, Popover } from "antd";
|
import { Button, Input, Modal } from "antd";
|
||||||
import { Cpu, Search, X } from "lucide-react";
|
import { Cpu, Search, X } from "lucide-react";
|
||||||
|
|
||||||
import { toc } from "@lobehub/icons/es/toc";
|
import { toc } from "@lobehub/icons/es/toc";
|
||||||
@@ -65,51 +65,124 @@ export function ModelLogo({ icon, size = 18, className }: { icon?: string; size?
|
|||||||
export function ModelIconPicker({ value, onChange }: { value?: string; onChange?: (value: string) => void }) {
|
export function ModelIconPicker({ value, onChange }: { value?: string; onChange?: (value: string) => void }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const searchInputRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const timer = setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
setKeyword("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const filteredIcons = useMemo(() => {
|
const filteredIcons = useMemo(() => {
|
||||||
const query = keyword.trim().toLowerCase();
|
const query = keyword.trim().toLowerCase();
|
||||||
return query ? iconOptions.filter((item) => `${item.id} ${item.title}`.toLowerCase().includes(query)) : iconOptions;
|
return query ? iconOptions.filter((item) => `${item.id} ${item.title}`.toLowerCase().includes(query)) : iconOptions;
|
||||||
}, [keyword]);
|
}, [keyword]);
|
||||||
|
|
||||||
const content = (
|
const selectedOption = useMemo(() => (value ? iconOptions.find((item) => item.id === value) : undefined), [value]);
|
||||||
<div className="w-full max-w-xl space-y-2" data-canvas-no-zoom>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input size="small" prefix={<Search className="size-3.5 text-foreground/40" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索 Logo" allowClear />
|
|
||||||
{value ? (
|
|
||||||
<button type="button" className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-foreground/45 hover:text-foreground" onClick={() => onChange?.("")} aria-label="清除 Logo">
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="grid max-h-96 grid-cols-12 gap-1 overflow-y-auto pr-1" role="listbox" aria-label="模型 Logo">
|
|
||||||
{filteredIcons.map((item) => {
|
|
||||||
const selected = value === item.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
title={item.title}
|
|
||||||
className={cn("flex size-10 items-center justify-center rounded-md text-foreground/75 hover:bg-muted/40", selected && "bg-muted/60 text-foreground")}
|
|
||||||
onClick={() => {
|
|
||||||
onChange?.(item.id);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModelLogo icon={item.id} size={20} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover trigger="click" open={open} onOpenChange={setOpen} arrow={{ pointAtCenter: true }} placement="bottomLeft" classNames={{ root: "model-logo-picker-popover" }} content={content}>
|
<>
|
||||||
<button type="button" className="flex min-h-9 w-full items-center gap-2 rounded-md border border-border/60 bg-background px-2.5 text-left text-sm hover:bg-muted/20" aria-label="选择模型 Logo">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-h-9 w-full items-center gap-2 rounded-md border border-border/60 bg-background px-2.5 text-left text-sm transition-colors hover:border-border hover:bg-muted/20 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||||
|
aria-label="选择模型 Logo"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
<ModelLogo icon={value} size={20} />
|
<ModelLogo icon={value} size={20} />
|
||||||
<span className="min-w-0 flex-1 truncate text-foreground/70">{value ? iconOptions.find((item) => item.id === value)?.title || value : "选择 Logo"}</span>
|
<span className="min-w-0 flex-1 truncate text-foreground/70">{selectedOption?.title || value || "选择 Logo"}</span>
|
||||||
|
{value ? (
|
||||||
|
<span
|
||||||
|
className="ml-auto inline-flex size-5 shrink-0 items-center justify-center rounded text-foreground/40 hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onChange?.("");
|
||||||
|
}}
|
||||||
|
title="清除 Logo"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-foreground/35">浏览</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Popover>
|
|
||||||
|
<Modal
|
||||||
|
title="选择模型 Logo"
|
||||||
|
open={open}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
width={540}
|
||||||
|
centered
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between py-1 text-xs text-foreground/45">
|
||||||
|
<span>共 {filteredIcons.length} 个可用 Logo</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{value ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
onChange?.("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清除 Logo
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" size="small" onClick={() => setOpen(false)}>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
prefix={<Search className="size-4 text-foreground/40" />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder="搜索品牌或模型名称(如 OpenAI, Claude, Google, Flux...)"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="grid max-h-[380px] grid-cols-8 gap-2 overflow-y-auto pr-1 sm:grid-cols-9"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="模型 Logo 列表"
|
||||||
|
>
|
||||||
|
{filteredIcons.length ? (
|
||||||
|
filteredIcons.map((item) => {
|
||||||
|
const selected = value === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
title={item.title}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex size-12 flex-col items-center justify-center rounded-lg border border-border/50 bg-surface text-foreground/75 transition-all hover:scale-105 hover:border-primary/60 hover:bg-surface-active hover:text-foreground",
|
||||||
|
selected && "border-primary bg-primary/10 text-primary shadow-xs ring-1 ring-primary"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange?.(item.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModelLogo icon={item.id} size={24} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full py-12 text-center text-xs text-foreground/45">
|
||||||
|
未找到与 “{keyword}” 相关的 Logo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ describe("章节分镜写入分镜制作", () => {
|
|||||||
|
|
||||||
expect(shots).toHaveLength(1);
|
expect(shots).toHaveLength(1);
|
||||||
expect(shots[0]).toMatchObject({
|
expect(shots[0]).toMatchObject({
|
||||||
title: "SC.01",
|
title: "镜头01",
|
||||||
description: "林默推门进入雨夜的旧屋。",
|
description: "林默推门进入雨夜的旧屋。",
|
||||||
durationMs: 5000,
|
durationMs: 5000,
|
||||||
revision: {
|
revision: {
|
||||||
|
|||||||
Reference in new issue
Block a user