feat(canvas): TapNow - 支持公开画布导入 (#255)
(cherry picked from commit 9b96135a5e409b4718e6204ab4212b7ef22635aa)
This commit is contained in:
@@ -80,6 +80,7 @@ func main() {
|
||||
handler.RegisterAdminAnalyticsRoutes(api, svc)
|
||||
handler.RegisterAnnouncementRoutes(api, svc)
|
||||
handler.RegisterFinanceRoutes(api, svc)
|
||||
handler.RegisterTapNowRoutes(api, svc)
|
||||
// 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。
|
||||
handler.RegisterChannelModelRoutes(api, svc)
|
||||
handler.RegisterSystemProxyRoutes(api, svc)
|
||||
|
||||
36
backend/internal/handler/tapnow.go
Normal file
36
backend/internal/handler/tapnow.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterTapNowRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/canvas-projects/:id/import/tapnow", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
failService(c, service.Unauthorized("请先登录"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
||||
var req service.TapNowImportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
result, err := svc.ImportTapNow(user.ID, c.Param("id"), strings.TrimSpace(req.ShareID))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
}
|
||||
222
backend/internal/service/tapnow.go
Normal file
222
backend/internal/service/tapnow.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tapNowShareURLPrefix = "https://app.tapnow.media/api/conversation/v1/canvas-share/"
|
||||
tapNowMaxResponseBytes = 16 << 20
|
||||
tapNowMaxNodes = 1000
|
||||
tapNowMaxConnections = 5000
|
||||
)
|
||||
|
||||
type TapNowImportRequest struct {
|
||||
ShareID string `json:"shareId"`
|
||||
}
|
||||
|
||||
type TapNowImportResult struct {
|
||||
BatchID string `json:"batchId"`
|
||||
BatchCreatedAt time.Time `json:"batchCreatedAt"`
|
||||
ShareID string `json:"shareId"`
|
||||
ProjectName string `json:"projectName"`
|
||||
Nodes []TapNowCanvasNode `json:"nodes"`
|
||||
Connections []TapNowCanvasConnection `json:"connections"`
|
||||
ImportedNodeCount int `json:"importedNodeCount"`
|
||||
ImportedConnectionCount int `json:"importedConnectionCount"`
|
||||
SkippedNodes []TapNowImportIssue `json:"skippedNodes"`
|
||||
SkippedConnections []TapNowImportIssue `json:"skippedConnections"`
|
||||
Warnings []TapNowImportWarning `json:"warnings"`
|
||||
MultiResultNodeCount int `json:"multiResultNodeCount"`
|
||||
ReusedFailedNodeCount int `json:"reusedFailedNodeCount"`
|
||||
PlaceholderNodeCount int `json:"placeholderNodeCount"`
|
||||
}
|
||||
|
||||
type TapNowCanvasNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Content string `json:"content"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Seconds string `json:"seconds,omitempty"`
|
||||
VQuality string `json:"vquality,omitempty"`
|
||||
GenerateAudio string `json:"generateAudio,omitempty"`
|
||||
NaturalWidth int `json:"naturalWidth,omitempty"`
|
||||
NaturalHeight int `json:"naturalHeight,omitempty"`
|
||||
DurationMs int64 `json:"durationMs,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ErrorDetails string `json:"errorDetails,omitempty"`
|
||||
Metadata TapNowImportMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
type TapNowImportMetadata struct {
|
||||
Provider string `json:"provider"`
|
||||
ShareID string `json:"shareId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
BatchID string `json:"batchId"`
|
||||
SourceType string `json:"sourceType,omitempty"`
|
||||
}
|
||||
|
||||
type TapNowCanvasConnection struct {
|
||||
ID string `json:"id"`
|
||||
FromNodeID string `json:"fromNodeId"`
|
||||
ToNodeID string `json:"toNodeId"`
|
||||
FromHandleID string `json:"fromHandleId,omitempty"`
|
||||
ToHandleID string `json:"toHandleId,omitempty"`
|
||||
}
|
||||
|
||||
type TapNowImportIssue struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type TapNowImportWarning struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type tapNowEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type tapNowDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Nodes []tapNowRawNode `json:"nodes"`
|
||||
Connections []tapNowRawConnection `json:"connections"`
|
||||
}
|
||||
|
||||
type tapNowRawNode struct {
|
||||
ID string `json:"id"`
|
||||
ShortID int64 `json:"short_id"`
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
Position struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
} `json:"position"`
|
||||
Measured struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
} `json:"measured"`
|
||||
}
|
||||
|
||||
type tapNowRawConnection struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
SourceHandle string `json:"sourceHandle"`
|
||||
TargetHandle string `json:"targetHandle"`
|
||||
Data map[string]any `json:"data"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type tapNowNodeData struct {
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Text string `json:"text"`
|
||||
Src string `json:"src"`
|
||||
Options []string `json:"options"`
|
||||
Type string `json:"type"`
|
||||
Params map[string]any `json:"params"`
|
||||
TaskInfo map[string]any `json:"taskInfo"`
|
||||
Metadata struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Duration float64 `json:"duration"`
|
||||
} `json:"__metadata"`
|
||||
Error string `json:"error"`
|
||||
FailedReason string `json:"failedReason"`
|
||||
}
|
||||
|
||||
// 读取公开分享前先校验目标画布归属,避免把导入结果写入他人画布。
|
||||
func (s *Service) ImportTapNow(userID, canvasProjectID, shareID string) (*TapNowImportResult, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
canvasProjectID = strings.TrimSpace(canvasProjectID)
|
||||
if userID == "" || canvasProjectID == "" {
|
||||
return nil, Unauthorized("请先打开已同步的故事创作画布")
|
||||
}
|
||||
if _, err := s.repo.CanvasProjectForUser(userID, canvasProjectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, normalizedShareID, err := fetchTapNowDetail(strings.TrimSpace(shareID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adaptTapNowDetail(detail, normalizedShareID)
|
||||
}
|
||||
|
||||
func fetchTapNowDetail(shareID string) (*tapNowDetail, string, error) {
|
||||
if !isTapNowShareID(shareID) {
|
||||
return nil, "", BadAuthRequest("TapNow 分享 ID 格式无效")
|
||||
}
|
||||
endpoint, err := url.Parse(tapNowShareURLPrefix + url.PathEscape(shareID))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
client := OutboundHTTPClient(20 * time.Second)
|
||||
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
||||
return errors.New("TapNow API 不允许重定向")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("TapNow 请求失败,请检查网络或分享链接")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, tapNowMaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, "", errors.New("读取 TapNow 响应失败")
|
||||
}
|
||||
if len(body) > tapNowMaxResponseBytes {
|
||||
return nil, "", errors.New("TapNow 画布响应过大")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("TapNow 请求失败(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
var envelope tapNowEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, "", errors.New("TapNow 响应格式无效")
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
msg := strings.TrimSpace(envelope.Msg)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(envelope.Message)
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "TapNow 返回业务错误"
|
||||
}
|
||||
return nil, "", errors.New(msg)
|
||||
}
|
||||
var detail tapNowDetail
|
||||
if err := json.Unmarshal(envelope.Data, &detail); err != nil {
|
||||
return nil, "", errors.New("TapNow 画布数据格式无效")
|
||||
}
|
||||
return &detail, shareID, nil
|
||||
}
|
||||
407
backend/internal/service/tapnow_adapter.go
Normal file
407
backend/internal/service/tapnow_adapter.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func adaptTapNowDetail(detail *tapNowDetail, shareID string) (*TapNowImportResult, error) {
|
||||
if detail == nil {
|
||||
return nil, errors.New("TapNow 画布数据为空")
|
||||
}
|
||||
shareID = strings.TrimSpace(shareID)
|
||||
if !isTapNowShareID(shareID) {
|
||||
return nil, BadAuthRequest("TapNow 分享 ID 格式无效")
|
||||
}
|
||||
if len(detail.Nodes) > tapNowMaxNodes {
|
||||
return nil, fmt.Errorf("TapNow 画布节点过多(最多支持 %d 个)", tapNowMaxNodes)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
batchID := "tapnow-" + strconv.FormatInt(now.UnixMilli(), 36) + "-" + strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
|
||||
result := &TapNowImportResult{
|
||||
BatchID: batchID,
|
||||
BatchCreatedAt: now,
|
||||
ShareID: shareID,
|
||||
ProjectName: strings.TrimSpace(detail.Name),
|
||||
Nodes: make([]TapNowCanvasNode, 0, len(detail.Nodes)),
|
||||
Connections: make([]TapNowCanvasConnection, 0, minInt(len(detail.Connections), tapNowMaxConnections)),
|
||||
SkippedNodes: make([]TapNowImportIssue, 0),
|
||||
SkippedConnections: make([]TapNowImportIssue, 0),
|
||||
Warnings: make([]TapNowImportWarning, 0),
|
||||
}
|
||||
if result.ProjectName == "" {
|
||||
result.ProjectName = "TapNow 画布"
|
||||
}
|
||||
|
||||
mapping := make(map[string]string, len(detail.Nodes))
|
||||
seenNodeIDs := make(map[string]struct{}, len(detail.Nodes))
|
||||
for _, raw := range detail.Nodes {
|
||||
if strings.TrimSpace(raw.DeletedAt) != "" {
|
||||
continue
|
||||
}
|
||||
nodeID := strings.TrimSpace(raw.ID)
|
||||
if nodeID == "" && raw.ShortID != 0 {
|
||||
nodeID = strconv.FormatInt(raw.ShortID, 10)
|
||||
}
|
||||
if nodeID == "" {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{Reason: "节点缺少 ID"})
|
||||
continue
|
||||
}
|
||||
if len(nodeID) > 128 {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID[:128], Reason: "节点 ID 过长"})
|
||||
continue
|
||||
}
|
||||
if _, exists := seenNodeIDs[nodeID]; exists {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点 ID 重复"})
|
||||
continue
|
||||
}
|
||||
seenNodeIDs[nodeID] = struct{}{}
|
||||
|
||||
data, err := decodeTapNowNodeData(raw.Data)
|
||||
if err != nil {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Reason: "节点数据格式无效"})
|
||||
continue
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(raw.Type))
|
||||
if kind == "" {
|
||||
kind = strings.ToLower(strings.TrimSpace(data.Type))
|
||||
}
|
||||
if kind != "image" && kind != "video" && kind != "audio" && kind != "text" {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "暂不支持的节点类型"})
|
||||
continue
|
||||
}
|
||||
if !finiteNumber(raw.Position.X) || !finiteNumber(raw.Position.Y) {
|
||||
result.SkippedNodes = append(result.SkippedNodes, TapNowImportIssue{ID: nodeID, Name: strings.TrimSpace(data.Title), Reason: "节点坐标无效"})
|
||||
continue
|
||||
}
|
||||
|
||||
content, mediaIndex := firstTapNowMediaURL(data)
|
||||
if kind == "text" {
|
||||
content = strings.TrimSpace(data.Text)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(data.Prompt)
|
||||
}
|
||||
mediaIndex = -1
|
||||
}
|
||||
width, height := raw.Measured.Width, raw.Measured.Height
|
||||
if !positiveFiniteNumber(width) {
|
||||
width = 480
|
||||
}
|
||||
if !positiveFiniteNumber(height) {
|
||||
height = 300
|
||||
}
|
||||
item := TapNowCanvasNode{
|
||||
ID: "tapnow-" + batchID + "-" + nodeID,
|
||||
Type: kind,
|
||||
Title: strings.TrimSpace(data.Title),
|
||||
X: raw.Position.X,
|
||||
Y: raw.Position.Y,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Content: content,
|
||||
Prompt: firstNonEmptyString(strings.TrimSpace(data.Prompt), firstTapNowParam(data.Params, "prompt")),
|
||||
Model: tapNowMapString(data.Params, "model"),
|
||||
Size: firstTapNowParam(data.Params, "aspectRatio", "size"),
|
||||
Quality: tapNowImageQuality(data.Params),
|
||||
Seconds: firstTapNowParam(data.Params, "duration", "seconds"),
|
||||
VQuality: firstTapNowParam(data.Params, "resolution", "vquality"),
|
||||
GenerateAudio: tapNowBoolParam(data.Params, "generateAudio"),
|
||||
Metadata: TapNowImportMetadata{
|
||||
Provider: "tapnow",
|
||||
ShareID: shareID,
|
||||
NodeID: nodeID,
|
||||
BatchID: batchID,
|
||||
SourceType: strings.TrimSpace(data.Type),
|
||||
},
|
||||
}
|
||||
if item.Title == "" {
|
||||
item.Title = kind + " 节点"
|
||||
}
|
||||
item.NaturalWidth = data.Metadata.Width
|
||||
item.NaturalHeight = data.Metadata.Height
|
||||
if data.Metadata.Duration > 0 && !math.IsNaN(data.Metadata.Duration) && !math.IsInf(data.Metadata.Duration, 0) {
|
||||
item.DurationMs = int64(math.Round(data.Metadata.Duration * 1000))
|
||||
}
|
||||
item.MimeType = inferTapNowMime(kind, content, firstNonEmptyString(data.Prompt, data.Title))
|
||||
taskStatus := tapNowTaskStatus(data.TaskInfo)
|
||||
hasContent := strings.TrimSpace(content) != ""
|
||||
if hasContent {
|
||||
item.Status = "success"
|
||||
if tapNowTaskFailed(taskStatus) {
|
||||
result.ReusedFailedNodeCount++
|
||||
}
|
||||
} else if tapNowTaskFailed(taskStatus) {
|
||||
item.Status = "error"
|
||||
item.ErrorDetails = tapNowErrorDetails(data)
|
||||
if item.ErrorDetails == "" {
|
||||
item.ErrorDetails = "TapNow 生成任务失败"
|
||||
}
|
||||
result.PlaceholderNodeCount++
|
||||
} else {
|
||||
item.Status = "idle"
|
||||
result.PlaceholderNodeCount++
|
||||
}
|
||||
mapping[nodeID] = item.ID
|
||||
if raw.ShortID != 0 {
|
||||
mapping[strconv.FormatInt(raw.ShortID, 10)] = item.ID
|
||||
}
|
||||
result.Nodes = append(result.Nodes, item)
|
||||
if !positiveFiniteNumber(raw.Measured.Width) || !positiveFiniteNumber(raw.Measured.Height) {
|
||||
result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”尺寸无效,已使用默认尺寸。", item.Title)})
|
||||
}
|
||||
if mediaIndex >= 0 && len(data.Options) > 1 {
|
||||
result.MultiResultNodeCount++
|
||||
result.Warnings = append(result.Warnings, TapNowImportWarning{ID: nodeID, Message: fmt.Sprintf("节点“%s”包含多个结果,已导入首个结果。", item.Title)})
|
||||
}
|
||||
}
|
||||
|
||||
if len(detail.Connections) > tapNowMaxConnections {
|
||||
result.Warnings = append(result.Warnings, TapNowImportWarning{Message: fmt.Sprintf("连接超过 %d 条,仅处理前 %d 条。", tapNowMaxConnections, tapNowMaxConnections)})
|
||||
}
|
||||
seenConnectionIDs := make(map[string]struct{}, len(detail.Connections))
|
||||
for index, raw := range detail.Connections {
|
||||
if index >= tapNowMaxConnections {
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(raw.DeletedAt) != "" {
|
||||
continue
|
||||
}
|
||||
connectionID := strings.TrimSpace(raw.ID)
|
||||
if connectionID == "" {
|
||||
result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{Reason: "连接缺少 ID"})
|
||||
continue
|
||||
}
|
||||
if _, exists := seenConnectionIDs[connectionID]; exists {
|
||||
result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接 ID 重复"})
|
||||
continue
|
||||
}
|
||||
seenConnectionIDs[connectionID] = struct{}{}
|
||||
from, okFrom := mapping[strings.TrimSpace(raw.Source)]
|
||||
to, okTo := mapping[strings.TrimSpace(raw.Target)]
|
||||
if !okFrom || !okTo {
|
||||
result.SkippedConnections = append(result.SkippedConnections, TapNowImportIssue{ID: connectionID, Reason: "连接端点节点未导入"})
|
||||
continue
|
||||
}
|
||||
fromHandleID := strings.TrimSpace(raw.SourceHandle)
|
||||
toHandleID := strings.TrimSpace(raw.TargetHandle)
|
||||
if toHandleID == "" {
|
||||
// TapNow 将图片输入句柄放在边 data.valueKey,而不是 targetHandle。
|
||||
toHandleID = tapNowMapString(raw.Data, "valueKey")
|
||||
}
|
||||
result.Connections = append(result.Connections, TapNowCanvasConnection{
|
||||
ID: "tapnow-" + batchID + "-" + connectionID,
|
||||
FromNodeID: from,
|
||||
ToNodeID: to,
|
||||
FromHandleID: fromHandleID,
|
||||
ToHandleID: toHandleID,
|
||||
})
|
||||
}
|
||||
result.ImportedNodeCount = len(result.Nodes)
|
||||
result.ImportedConnectionCount = len(result.Connections)
|
||||
if result.ImportedNodeCount == 0 {
|
||||
return nil, errors.New("TapNow 画布没有可导入的有效节点")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeTapNowNodeData(raw json.RawMessage) (tapNowNodeData, error) {
|
||||
var data tapNowNodeData
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if strings.HasPrefix(trimmed, `"`) {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return data, err
|
||||
}
|
||||
raw = json.RawMessage(encoded)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return data, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func firstTapNowMediaURL(data tapNowNodeData) (string, int) {
|
||||
values := make([]string, 0, len(data.Options)+2)
|
||||
values = append(values, data.Src)
|
||||
values = append(values, data.Options...)
|
||||
values = append(values, data.Metadata.URL)
|
||||
for index, value := range values {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || !isTapNowMediaHost(parsed.Hostname()) {
|
||||
continue
|
||||
}
|
||||
return parsed.String(), index
|
||||
}
|
||||
return "", -1
|
||||
}
|
||||
|
||||
func isTapNowMediaHost(host string) bool {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
// 导入后的媒体还可能作为后续生成的参考素材,只接受已核验的官方文件域名,
|
||||
// 不能根据品牌前缀放行未经确认的同名域,避免把外部 URL 带入后端出网链路。
|
||||
return host == "files.tapnow.media"
|
||||
}
|
||||
|
||||
func inferTapNowMime(kind, rawURL, hint string) string {
|
||||
lower := strings.ToLower(rawURL + " " + hint)
|
||||
switch kind {
|
||||
case "image":
|
||||
switch {
|
||||
case strings.Contains(lower, ".png"):
|
||||
return "image/png"
|
||||
case strings.Contains(lower, ".jpg") || strings.Contains(lower, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.Contains(lower, ".webp"):
|
||||
return "image/webp"
|
||||
case strings.Contains(lower, ".gif"):
|
||||
return "image/gif"
|
||||
default:
|
||||
return "image/*"
|
||||
}
|
||||
case "video":
|
||||
switch {
|
||||
case strings.Contains(lower, ".webm"):
|
||||
return "video/webm"
|
||||
case strings.Contains(lower, ".mov"):
|
||||
return "video/quicktime"
|
||||
default:
|
||||
return "video/mp4"
|
||||
}
|
||||
case "audio":
|
||||
switch {
|
||||
case strings.Contains(lower, ".wav"):
|
||||
return "audio/wav"
|
||||
case strings.Contains(lower, ".flac"):
|
||||
return "audio/flac"
|
||||
case strings.Contains(lower, ".ogg") || strings.Contains(lower, ".oga"):
|
||||
return "audio/ogg"
|
||||
case strings.Contains(lower, ".m4a"):
|
||||
return "audio/mp4"
|
||||
default:
|
||||
return "audio/mpeg"
|
||||
}
|
||||
case "text":
|
||||
return "text/plain"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func tapNowMapString(values map[string]any, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if stringValue, ok := value.(string); ok {
|
||||
return strings.TrimSpace(stringValue)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func firstTapNowParam(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := tapNowMapString(values, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TapNow 部分渠道用 imageSize(例如 "2K"),画布合同则使用小写质量档位。
|
||||
// 只迁移已知档位,避免把渠道私有值静默写成无效的本地质量设置。
|
||||
func tapNowImageQuality(values map[string]any) string {
|
||||
value := firstTapNowParam(values, "quality")
|
||||
if value == "" {
|
||||
value = firstTapNowParam(values, "imageSize")
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "1k", "2k", "4k", "auto", "low", "medium", "high":
|
||||
return normalized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func tapNowBoolParam(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if boolean, ok := value.(bool); ok {
|
||||
return strconv.FormatBool(boolean)
|
||||
}
|
||||
return tapNowMapString(values, key)
|
||||
}
|
||||
|
||||
func tapNowTaskStatus(values map[string]any) string {
|
||||
status := strings.ToLower(strings.TrimSpace(tapNowMapString(values, "status")))
|
||||
return status
|
||||
}
|
||||
|
||||
func tapNowTaskFailed(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "failed", "error", "failure":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tapNowErrorDetails(data tapNowNodeData) string {
|
||||
if value := strings.TrimSpace(data.Error); value != "" {
|
||||
return value
|
||||
}
|
||||
if value := strings.TrimSpace(data.FailedReason); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, key := range []string{"error", "failedReason", "message"} {
|
||||
if value := tapNowMapString(data.TaskInfo, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTapNowShareID(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func finiteNumber(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func positiveFiniteNumber(value float64) bool {
|
||||
return finiteNumber(value) && value > 0
|
||||
}
|
||||
|
||||
func minInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
91
backend/internal/service/tapnow_test.go
Normal file
91
backend/internal/service/tapnow_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdaptTapNowDetailSupportsMediaAndTextNodes(t *testing.T) {
|
||||
detail := &tapNowDetail{
|
||||
Name: "TapNow 测试画布",
|
||||
Nodes: []tapNowRawNode{
|
||||
{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://files.tapnow.media/api/conversation/storage/uploads/image.png","prompt":"画面提示词","params":{"model":"gpt-image-2","imageSize":"2K"},"__metadata":{"width":1024,"height":768}}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 10, Y: 20}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 448, Height: 250}},
|
||||
{ID: "video-1", Type: "video", Data: []byte(`{"title":"视频","type":"generate","src":"","taskInfo":{"status":"failed","failedReason":"上游失败"},"params":{"model":"seedance-2.0","aspectRatio":"16:9","duration":8,"resolution":"720p","generateAudio":true},"__metadata":{"width":1280,"height":720,"duration":4.5}}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 500, Y: 20}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 444, Height: 250}},
|
||||
{ID: "audio-1", Type: "audio", Data: []byte(`{"title":"音频","type":"upload","src":"https://files.tapnow.media/api/conversation/storage/uploads/audio-file","prompt":"就这样.wav"}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 10, Y: 400}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 444, Height: 250}},
|
||||
{ID: "text-1", Type: "text", Data: []byte(`{"title":"文本","type":"generate","text":"故事文本"}`), Position: struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}{X: 500, Y: 400}, Measured: struct {
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}{Width: 300, Height: 200}},
|
||||
},
|
||||
Connections: []tapNowRawConnection{{ID: "edge-1", Source: "image-1", Target: "video-1", SourceHandle: "images", TargetHandle: "prompt"}},
|
||||
}
|
||||
|
||||
result, err := adaptTapNowDetail(detail, "8872a294")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if result.ImportedNodeCount != 4 || result.ImportedConnectionCount != 1 {
|
||||
t.Fatalf("counts = (%d, %d), want (4, 1)", result.ImportedNodeCount, result.ImportedConnectionCount)
|
||||
}
|
||||
if result.Nodes[0].Metadata.Provider != "tapnow" || result.Nodes[0].Model != "gpt-image-2" || result.Nodes[0].Quality != "2k" || result.Nodes[0].NaturalWidth != 1024 {
|
||||
t.Fatalf("image mapping = %#v", result.Nodes[0])
|
||||
}
|
||||
if result.Nodes[1].Status != "error" || result.Nodes[1].ErrorDetails != "上游失败" || result.PlaceholderNodeCount != 1 {
|
||||
t.Fatalf("failed video mapping = %#v", result.Nodes[1])
|
||||
}
|
||||
if result.Nodes[1].Size != "16:9" || result.Nodes[1].Seconds != "8" || result.Nodes[1].VQuality != "720p" || result.Nodes[1].GenerateAudio != "true" {
|
||||
t.Fatalf("video generation params = %#v", result.Nodes[1])
|
||||
}
|
||||
if result.Nodes[2].MimeType != "audio/wav" || result.Nodes[3].Type != "text" || result.Nodes[3].Content != "故事文本" {
|
||||
t.Fatalf("audio/text mapping = %#v %#v", result.Nodes[2], result.Nodes[3])
|
||||
}
|
||||
if result.Connections[0].FromHandleID != "images" || result.Connections[0].ToHandleID != "prompt" {
|
||||
t.Fatalf("connection handles = %#v", result.Connections[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptTapNowDetailUsesValueKeyAsTargetHandle(t *testing.T) {
|
||||
detail := &tapNowDetail{
|
||||
Nodes: []tapNowRawNode{
|
||||
{ID: "source", Type: "image", Data: []byte(`{"title":"源图","src":"https://files.tapnow.media/source"}`)},
|
||||
{ID: "target", Type: "image", Data: []byte(`{"title":"目标图","src":"https://files.tapnow.media/target"}`)},
|
||||
},
|
||||
Connections: []tapNowRawConnection{{ID: "edge", Source: "source", Target: "target", Data: map[string]any{"valueKey": "images"}}},
|
||||
}
|
||||
result, err := adaptTapNowDetail(detail, "8872a294")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if got := result.Connections[0].ToHandleID; got != "images" {
|
||||
t.Fatalf("target handle = %q, want images", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptTapNowDetailRejectsUntrustedMediaAndKeepsPlaceholder(t *testing.T) {
|
||||
detail := &tapNowDetail{Nodes: []tapNowRawNode{{ID: "image-1", Type: "image", Data: []byte(`{"title":"图片","type":"generate","src":"https://evil.example/image.png"}`)}}}
|
||||
result, err := adaptTapNowDetail(detail, "share_1")
|
||||
if err != nil {
|
||||
t.Fatalf("adaptTapNowDetail() error = %v", err)
|
||||
}
|
||||
if result.Nodes[0].Content != "" || result.Nodes[0].Status != "idle" || result.PlaceholderNodeCount != 1 {
|
||||
t.Fatalf("untrusted media handling = %#v", result.Nodes[0])
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
|
||||
25
web/src/lib/canvas/tapnow-import.ts
Normal file
25
web/src/lib/canvas/tapnow-import.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const TAPNOW_SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const TAPNOW_SHARE_HOST = "app.tapnow.media";
|
||||
|
||||
export function parseTapNowShareID(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
let candidate = trimmed;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
||||
if (hostname !== TAPNOW_SHARE_HOST || !/^\/tapflow\/view\/?[^/]*\/?$/.test(parsed.pathname)) return "";
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
candidate = parts.at(-1) || "";
|
||||
} catch {
|
||||
candidate = trimmed;
|
||||
}
|
||||
return TAPNOW_SHARE_ID_PATTERN.test(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
export function formatTapNowBatchTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "未知";
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Modal, Tooltip } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
@@ -28,6 +28,7 @@ type CanvasTopBarProps = {
|
||||
onCreateProject: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onImportImage: () => void;
|
||||
onImportTapNow: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onShare: () => void;
|
||||
@@ -58,6 +59,7 @@ export function CanvasTopBar({
|
||||
onCreateProject,
|
||||
onDeleteProject,
|
||||
onImportImage,
|
||||
onImportTapNow,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onShare,
|
||||
@@ -191,6 +193,9 @@ export function CanvasTopBar({
|
||||
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
|
||||
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
|
||||
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} onClick={onImportTapNow} aria-label="导入 TapNow 画布" title="导入 TapNow 画布">
|
||||
<span className="hidden xl:inline">导入 TapNow</span>
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
|
||||
185
web/src/pages/canvas/components/tapnow-import-dialog.tsx
Normal file
185
web/src/pages/canvas/components/tapnow-import-dialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { App, Button, Input, Modal, Tag } from "antd";
|
||||
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { formatTapNowBatchTime, parseTapNowShareID } from "@/lib/canvas/tapnow-import";
|
||||
import { importTapNowCanvas, type TapNowImportResult } from "@/services/api/tapnow";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
projectId: string;
|
||||
viewport: ViewportTransform;
|
||||
viewportSize: { width: number; height: number };
|
||||
onClose: () => void;
|
||||
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
|
||||
};
|
||||
|
||||
function buildCanvasNodes(result: TapNowImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) {
|
||||
const minX = Math.min(...result.nodes.map((node) => node.x));
|
||||
const minY = Math.min(...result.nodes.map((node) => node.y));
|
||||
const maxX = Math.max(...result.nodes.map((node) => node.x + node.width));
|
||||
const maxY = Math.max(...result.nodes.map((node) => node.y + node.height));
|
||||
const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2;
|
||||
const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2;
|
||||
return result.nodes.map<CanvasNodeData>((node) => ({
|
||||
id: node.id,
|
||||
type: node.type === "video" ? CanvasNodeType.Video : node.type === "audio" ? CanvasNodeType.Audio : node.type === "text" ? CanvasNodeType.Text : CanvasNodeType.Image,
|
||||
title: node.title,
|
||||
position: { x: node.x + offsetX, y: node.y + offsetY },
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
metadata: {
|
||||
content: node.content,
|
||||
prompt: node.prompt,
|
||||
model: node.model,
|
||||
size: node.size,
|
||||
quality: node.quality,
|
||||
seconds: node.seconds,
|
||||
vquality: node.vquality,
|
||||
generateAudio: node.generateAudio,
|
||||
status: node.status || "idle",
|
||||
errorDetails: node.errorDetails,
|
||||
naturalWidth: node.naturalWidth,
|
||||
naturalHeight: node.naturalHeight,
|
||||
durationMs: node.durationMs,
|
||||
mimeType: node.mimeType,
|
||||
importSource: node.metadata,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function TapNowImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [value, setValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<TapNowImportResult | null>(null);
|
||||
const shareID = useMemo(() => parseTapNowShareID(value), [value]);
|
||||
|
||||
const reset = () => {
|
||||
setValue("");
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (loading) return;
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const changeValue = (nextValue: string) => {
|
||||
setValue(nextValue);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!shareID) {
|
||||
message.error("请填写有效的 TapNow 画布分享链接或分享 ID");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
setResult(await importTapNowCanvas(projectId, shareID));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取 TapNow 画布失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
if (!result) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections);
|
||||
reset();
|
||||
onClose();
|
||||
message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存导入结果失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasWarnings = result ? Boolean(result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.warnings.length) : false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal"
|
||||
open={open}
|
||||
onCancel={close}
|
||||
title="导入 TapNow 画布"
|
||||
width={620}
|
||||
footer={
|
||||
result ? (
|
||||
[
|
||||
<Button key="close" onClick={close}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
|
||||
确认导入
|
||||
</Button>,
|
||||
]
|
||||
) : (
|
||||
<Button type="primary" loading={loading} onClick={() => void load()}>
|
||||
读取画布
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">TapNow 画布分享链接或分享 ID</label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => changeValue(event.target.value)}
|
||||
placeholder="粘贴 https://app.tapnow.media/tapflow/view/…"
|
||||
disabled={loading}
|
||||
suffix={shareID && value !== shareID ? <ExternalLink className="size-4 text-foreground/35" /> : null}
|
||||
/>
|
||||
</div>
|
||||
{loading && !result ? (
|
||||
<div className="flex items-center gap-2 text-sm text-foreground/55">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在读取 TapNow 画布…
|
||||
</div>
|
||||
) : null}
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{result.projectName || "TapNow 画布"}</div>
|
||||
<div className="mt-1 text-sm text-foreground/60">
|
||||
可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线
|
||||
</div>
|
||||
</div>
|
||||
<Tag color="blue">批次:{formatTapNowBatchTime(result.batchCreatedAt)}</Tag>
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-5 text-foreground/50">支持图片、视频、音频和文本节点;节点会保留相对位置,并整体放到当前可视区域中心。</div>
|
||||
</div>
|
||||
{hasWarnings ? (
|
||||
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
|
||||
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
|
||||
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
|
||||
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
|
||||
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
|
||||
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
|
||||
{result.warnings.map((warning, index) => (
|
||||
<div key={`${warning.id || "warning"}-${index}`}>{warning.message}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag>等待确认导入</Tag>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
|
||||
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { CanvasTopBar } from "./canvas-project-top-bar";
|
||||
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
|
||||
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
|
||||
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
|
||||
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
|
||||
@@ -190,6 +191,7 @@ function InfiniteCanvasPage() {
|
||||
const [workspaceMode, setWorkspaceMode] = useState<CanvasWorkspaceMode>(readCanvasWorkspaceMode);
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [tapNowImportOpen, setTapNowImportOpen] = useState(false);
|
||||
const [nodeSearchOpen, setNodeSearchOpen] = useState(false);
|
||||
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
||||
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
|
||||
@@ -331,6 +333,28 @@ function InfiniteCanvasPage() {
|
||||
cleanupAssetImages,
|
||||
cleanupCanvasFiles,
|
||||
});
|
||||
|
||||
const applyTapNowImport = useCallback(
|
||||
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
|
||||
const previousNodes = nodesRef.current;
|
||||
const previousConnections = connectionsRef.current;
|
||||
const nextNodes = [...nodesRef.current, ...importedNodes];
|
||||
const nextConnections = [...connectionsRef.current, ...importedConnections];
|
||||
nodesRef.current = nextNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
const saved = await saveCanvasProject();
|
||||
if (!saved) {
|
||||
nodesRef.current = previousNodes;
|
||||
connectionsRef.current = previousConnections;
|
||||
setNodes(previousNodes);
|
||||
setConnections(previousConnections);
|
||||
throw new Error("画布保存失败,已撤销本次 TapNow 导入");
|
||||
}
|
||||
},
|
||||
[saveCanvasProject, setConnections, setNodes],
|
||||
);
|
||||
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
|
||||
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
|
||||
const refetchLinkedProject = linkedProjectQuery.refetch;
|
||||
@@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() {
|
||||
onCreateProject={createAndOpenProject}
|
||||
onDeleteProject={deleteCurrentProject}
|
||||
onImportImage={() => handleUploadRequest()}
|
||||
onImportTapNow={() => setTapNowImportOpen(true)}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
@@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
|
||||
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
|
||||
<TapNowImportDialog open={tapNowImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setTapNowImportOpen(false)} onApply={applyTapNowImport} />
|
||||
|
||||
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
|
||||
|
||||
|
||||
57
web/src/services/api/tapnow.ts
Normal file
57
web/src/services/api/tapnow.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { CanvasConnection } from "@/types/canvas";
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
|
||||
export type TapNowImportIssue = { id?: string; name?: string; reason: string };
|
||||
export type TapNowImportWarning = { id?: string; message: string };
|
||||
|
||||
export type TapNowImportNode = {
|
||||
id: string;
|
||||
type: "image" | "video" | "audio" | "text";
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
seconds?: string;
|
||||
vquality?: string;
|
||||
generateAudio?: string;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
durationMs?: number;
|
||||
mimeType?: string;
|
||||
status?: "idle" | "success" | "error";
|
||||
errorDetails?: string;
|
||||
metadata: {
|
||||
provider: "tapnow";
|
||||
shareId: string;
|
||||
nodeId: string;
|
||||
batchId: string;
|
||||
sourceType?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TapNowImportResult = {
|
||||
batchId: string;
|
||||
batchCreatedAt: string;
|
||||
shareId: string;
|
||||
projectName: string;
|
||||
nodes: TapNowImportNode[];
|
||||
connections: CanvasConnection[];
|
||||
importedNodeCount: number;
|
||||
importedConnectionCount: number;
|
||||
skippedNodes: TapNowImportIssue[];
|
||||
skippedConnections: TapNowImportIssue[];
|
||||
warnings: TapNowImportWarning[];
|
||||
multiResultNodeCount: number;
|
||||
reusedFailedNodeCount: number;
|
||||
placeholderNodeCount: number;
|
||||
};
|
||||
|
||||
export function importTapNowCanvas(projectId: string, shareId: string) {
|
||||
return request<TapNowImportResult>(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/tapnow`, { shareId }));
|
||||
}
|
||||
@@ -142,6 +142,13 @@ export type CanvasSkillSnapshot = {
|
||||
};
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
importSource?: {
|
||||
provider: "tapnow";
|
||||
shareId: string;
|
||||
nodeId: string;
|
||||
batchId: string;
|
||||
sourceType?: string;
|
||||
};
|
||||
content?: string;
|
||||
richText?: Record<string, unknown>;
|
||||
composerContent?: string;
|
||||
|
||||
23
web/test/tapnow-import.test.ts
Normal file
23
web/test/tapnow-import.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { formatTapNowBatchTime, parseTapNowShareID } from "../src/lib/canvas/tapnow-import";
|
||||
|
||||
describe("TapNow canvas import helpers", () => {
|
||||
test("extracts share IDs from TapNow links", () => {
|
||||
expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/8872a294")).toBe("8872a294");
|
||||
expect(parseTapNowShareID("8872a294")).toBe("8872a294");
|
||||
expect(parseTapNowShareID("https://app.tapnow.media/tapflow/view/abc_DEF-12?source=share")).toBe("abc_DEF-12");
|
||||
});
|
||||
|
||||
test("rejects unrelated links and malformed IDs", () => {
|
||||
expect(parseTapNowShareID("https://example.com/tapflow/view/8872a294")).toBe("");
|
||||
expect(parseTapNowShareID("https://app.tapnow.ai/tapflow/view/8872a294")).toBe("");
|
||||
expect(parseTapNowShareID("https://app.tapnow.media/canvas/8872a294")).toBe("");
|
||||
expect(parseTapNowShareID("bad/id")).toBe("");
|
||||
});
|
||||
|
||||
test("formats the readable batch label", () => {
|
||||
const localTime = new Date(2026, 7, 19, 5, 2);
|
||||
expect(formatTapNowBatchTime(localTime.toISOString())).toBe("202608190502");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user