Просмотр исходного кода

✨ feat(sync): multi-language sync wizard, backend locale support, and conflict modal UX improvements

Frontend (web)
- ModelsActions.jsx
  - Replace “Sync Official” with “Sync” and open a new two-step SyncWizard.
  - Pass selected locale through to preview, sync, and overwrite flows.
  - Keep conflict resolution flow; inject locale into overwrite submission.

- New: models/modals/SyncWizardModal.jsx
  - Two-step wizard: (1) method selection (config-sync disabled for now), (2) language selection (en/zh/ja).
  - Horizontal, centered Radio cards; returns { option, locale } via onConfirm.

- UpstreamConflictModal.jsx
  - Add search input (model fuzzy search) and native pagination.
  - Column header checkbox now only applies to rows in the current filtered result.
  - Fix “Cannot access ‘filteredDataSource’ before initialization”.
  - Refactor with useMemo/useCallback; extract helpers to remove duplicated logic:
    - getPresentRowsForField, getHeaderState, applyHeaderChange
  - Minor code cleanups and stability improvements.

- i18n (en.json)
  - Add strings for the sync wizard and related actions (Sync, Sync Wizard, Select method/source/language, etc.).
  - Adjust minor translations.

Hooks
- useModelsData.jsx
  - Extend previewUpstreamDiff, syncUpstream, applyUpstreamOverwrite to accept options with locale.
  - Send locale via query/body accordingly.

Backend (Go)
- controller/model_sync.go
  - Accept locale from query/body and resolve i18n upstream URLs.
  - Add SYNC_UPSTREAM_BASE for upstream base override (default: https://basellm.github.io/llm-metadata).
  - Make HTTP timeouts/retries/limits configurable:
    - SYNC_HTTP_TIMEOUT_SECONDS, SYNC_HTTP_RETRY, SYNC_HTTP_MAX_MB
  - Add ETag-based caching and support both envelope and pure array JSON formats.
  - Concurrently fetch vendors and models; improve error responses with locale and source URLs.
  - Include source meta (locale, models_url, vendors_url) in success payloads.

Notes
- No breaking changes expected.
- Lint passes for touched files.
t0ng7u 3 месяцев назад
Родитель
Сommit
8c65264474

+ 553 - 412
controller/model_sync.go

@@ -1,463 +1,604 @@
 package controller
 
 import (
-    "context"
-    "encoding/json"
-    "errors"
-    "io"
-    "net"
-    "net/http"
-    "strings"
-    "time"
-
-    "one-api/model"
-
-    "github.com/gin-gonic/gin"
-    "gorm.io/gorm"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"one-api/common"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
 )
 
 // 上游地址
 const (
-    upstreamModelsURL  = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
-    upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
+	upstreamModelsURL  = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
+	upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
 )
 
+func normalizeLocale(locale string) (string, bool) {
+	l := strings.ToLower(strings.TrimSpace(locale))
+	switch l {
+	case "en", "zh", "ja":
+		return l, true
+	default:
+		return "", false
+	}
+}
+
+func getUpstreamBase() string {
+	return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
+}
+
+func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
+	base := strings.TrimRight(getUpstreamBase(), "/")
+	if l, ok := normalizeLocale(locale); ok && l != "" {
+		return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
+			fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
+	}
+	return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
+}
+
 type upstreamEnvelope[T any] struct {
-    Success bool   `json:"success"`
-    Message string `json:"message"`
-    Data    []T    `json:"data"`
+	Success bool   `json:"success"`
+	Message string `json:"message"`
+	Data    []T    `json:"data"`
 }
 
 type upstreamModel struct {
-    Description string          `json:"description"`
-    Endpoints   json.RawMessage `json:"endpoints"`
-    Icon        string          `json:"icon"`
-    ModelName   string          `json:"model_name"`
-    NameRule    int             `json:"name_rule"`
-    Status      int             `json:"status"`
-    Tags        string          `json:"tags"`
-    VendorName  string          `json:"vendor_name"`
+	Description string          `json:"description"`
+	Endpoints   json.RawMessage `json:"endpoints"`
+	Icon        string          `json:"icon"`
+	ModelName   string          `json:"model_name"`
+	NameRule    int             `json:"name_rule"`
+	Status      int             `json:"status"`
+	Tags        string          `json:"tags"`
+	VendorName  string          `json:"vendor_name"`
 }
 
 type upstreamVendor struct {
-    Description string `json:"description"`
-    Icon        string `json:"icon"`
-    Name        string `json:"name"`
-    Status      int    `json:"status"`
+	Description string `json:"description"`
+	Icon        string `json:"icon"`
+	Name        string `json:"name"`
+	Status      int    `json:"status"`
 }
 
+var (
+	etagCache  = make(map[string]string)
+	bodyCache  = make(map[string][]byte)
+	cacheMutex sync.RWMutex
+)
+
 type overwriteField struct {
-    ModelName string   `json:"model_name"`
-    Fields    []string `json:"fields"`
+	ModelName string   `json:"model_name"`
+	Fields    []string `json:"fields"`
 }
 
 type syncRequest struct {
-    Overwrite []overwriteField `json:"overwrite"`
+	Overwrite []overwriteField `json:"overwrite"`
+	Locale    string           `json:"locale"`
 }
 
 func newHTTPClient() *http.Client {
-    dialer := &net.Dialer{Timeout: 10 * time.Second}
-    transport := &http.Transport{
-        MaxIdleConns:        100,
-        IdleConnTimeout:     90 * time.Second,
-        TLSHandshakeTimeout: 10 * time.Second,
-        ExpectContinueTimeout: 1 * time.Second,
-        ResponseHeaderTimeout: 10 * time.Second,
-    }
-    transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
-        host, _, err := net.SplitHostPort(addr)
-        if err != nil {
-            host = addr
-        }
-        if strings.HasSuffix(host, "github.io") {
-            if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
-                return conn, nil
-            }
-            return dialer.DialContext(ctx, "tcp6", addr)
-        }
-        return dialer.DialContext(ctx, network, addr)
-    }
-    return &http.Client{Transport: transport}
+	timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
+	dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
+	transport := &http.Transport{
+		MaxIdleConns:          100,
+		IdleConnTimeout:       90 * time.Second,
+		TLSHandshakeTimeout:   time.Duration(timeoutSec) * time.Second,
+		ExpectContinueTimeout: 1 * time.Second,
+		ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
+	}
+	transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+		host, _, err := net.SplitHostPort(addr)
+		if err != nil {
+			host = addr
+		}
+		if strings.HasSuffix(host, "github.io") {
+			if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
+				return conn, nil
+			}
+			return dialer.DialContext(ctx, "tcp6", addr)
+		}
+		return dialer.DialContext(ctx, network, addr)
+	}
+	return &http.Client{Transport: transport}
 }
 
 var httpClient = newHTTPClient()
 
 func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
-    var lastErr error
-    for attempt := 0; attempt < 3; attempt++ {
-        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
-        if err != nil {
-            return err
-        }
-        resp, err := httpClient.Do(req)
-        if err != nil {
-            lastErr = err
-            time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
-            continue
-        }
-        func() {
-            defer resp.Body.Close()
-            if resp.StatusCode != http.StatusOK {
-                lastErr = errors.New(resp.Status)
-                return
-            }
-            limited := io.LimitReader(resp.Body, 10<<20)
-            if err := json.NewDecoder(limited).Decode(out); err != nil {
-                lastErr = err
-                return
-            }
-            if !out.Success && len(out.Data) == 0 && out.Message == "" {
-                out.Success = true
-            }
-            lastErr = nil
-        }()
-        if lastErr == nil {
-            return nil
-        }
-        time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
-    }
-    return lastErr
+	var lastErr error
+	attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
+	if attempts < 1 {
+		attempts = 1
+	}
+	baseDelay := 200 * time.Millisecond
+	maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
+	maxBytes := int64(maxMB) << 20
+	for attempt := 0; attempt < attempts; attempt++ {
+		req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+		if err != nil {
+			return err
+		}
+		// ETag conditional request
+		cacheMutex.RLock()
+		if et := etagCache[url]; et != "" {
+			req.Header.Set("If-None-Match", et)
+		}
+		cacheMutex.RUnlock()
+
+		resp, err := httpClient.Do(req)
+		if err != nil {
+			lastErr = err
+			// backoff with jitter
+			sleep := baseDelay * time.Duration(1<<attempt)
+			jitter := time.Duration(rand.Intn(150)) * time.Millisecond
+			time.Sleep(sleep + jitter)
+			continue
+		}
+		func() {
+			defer resp.Body.Close()
+			switch resp.StatusCode {
+			case http.StatusOK:
+				// read body into buffer for caching and flexible decode
+				limited := io.LimitReader(resp.Body, maxBytes)
+				buf, err := io.ReadAll(limited)
+				if err != nil {
+					lastErr = err
+					return
+				}
+				// cache body and ETag
+				cacheMutex.Lock()
+				if et := resp.Header.Get("ETag"); et != "" {
+					etagCache[url] = et
+				}
+				bodyCache[url] = buf
+				cacheMutex.Unlock()
+
+				// Try decode as envelope first
+				if err := json.Unmarshal(buf, out); err != nil {
+					// Try decode as pure array
+					var arr []T
+					if err2 := json.Unmarshal(buf, &arr); err2 != nil {
+						lastErr = err
+						return
+					}
+					out.Success = true
+					out.Data = arr
+					out.Message = ""
+				} else {
+					if !out.Success && len(out.Data) == 0 && out.Message == "" {
+						out.Success = true
+					}
+				}
+				lastErr = nil
+			case http.StatusNotModified:
+				// use cache
+				cacheMutex.RLock()
+				buf := bodyCache[url]
+				cacheMutex.RUnlock()
+				if len(buf) == 0 {
+					lastErr = errors.New("cache miss for 304 response")
+					return
+				}
+				if err := json.Unmarshal(buf, out); err != nil {
+					var arr []T
+					if err2 := json.Unmarshal(buf, &arr); err2 != nil {
+						lastErr = err
+						return
+					}
+					out.Success = true
+					out.Data = arr
+					out.Message = ""
+				} else {
+					if !out.Success && len(out.Data) == 0 && out.Message == "" {
+						out.Success = true
+					}
+				}
+				lastErr = nil
+			default:
+				lastErr = errors.New(resp.Status)
+			}
+		}()
+		if lastErr == nil {
+			return nil
+		}
+		sleep := baseDelay * time.Duration(1<<attempt)
+		jitter := time.Duration(rand.Intn(150)) * time.Millisecond
+		time.Sleep(sleep + jitter)
+	}
+	return lastErr
 }
 
 func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
-    if vendorName == "" {
-        return 0
-    }
-    if id, ok := vendorIDCache[vendorName]; ok {
-        return id
-    }
-    var existing model.Vendor
-    if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
-        vendorIDCache[vendorName] = existing.Id
-        return existing.Id
-    }
-    uv := vendorByName[vendorName]
-    v := &model.Vendor{
-        Name:        vendorName,
-        Description: uv.Description,
-        Icon:        coalesce(uv.Icon, ""),
-        Status:      chooseStatus(uv.Status, 1),
-    }
-    if err := v.Insert(); err == nil {
-        *createdVendors++
-        vendorIDCache[vendorName] = v.Id
-        return v.Id
-    }
-    vendorIDCache[vendorName] = 0
-    return 0
+	if vendorName == "" {
+		return 0
+	}
+	if id, ok := vendorIDCache[vendorName]; ok {
+		return id
+	}
+	var existing model.Vendor
+	if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
+		vendorIDCache[vendorName] = existing.Id
+		return existing.Id
+	}
+	uv := vendorByName[vendorName]
+	v := &model.Vendor{
+		Name:        vendorName,
+		Description: uv.Description,
+		Icon:        coalesce(uv.Icon, ""),
+		Status:      chooseStatus(uv.Status, 1),
+	}
+	if err := v.Insert(); err == nil {
+		*createdVendors++
+		vendorIDCache[vendorName] = v.Id
+		return v.Id
+	}
+	vendorIDCache[vendorName] = 0
+	return 0
 }
 
 // SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
 func SyncUpstreamModels(c *gin.Context) {
-    var req syncRequest
-    // 允许空体
-    _ = c.ShouldBindJSON(&req)
-    // 1) 获取未配置模型列表
-    missing, err := model.GetMissingModels()
-    if err != nil {
-        c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
-        return
-    }
-    if len(missing) == 0 {
-        c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
-            "created_models":  0,
-            "created_vendors": 0,
-            "skipped_models":  []string{},
-        }})
-        return
-    }
-
-    // 2) 拉取上游 vendors 与 models
-    ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
-    defer cancel()
-
-    var vendorsEnv upstreamEnvelope[upstreamVendor]
-    _ = fetchJSON(ctx, upstreamVendorsURL, &vendorsEnv) // 若失败不拦截,后续降级
-
-    var modelsEnv upstreamEnvelope[upstreamModel]
-    if err := fetchJSON(ctx, upstreamModelsURL, &modelsEnv); err != nil {
-        c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + err.Error()})
-        return
-    }
-
-    // 建立映射
-    vendorByName := make(map[string]upstreamVendor)
-    for _, v := range vendorsEnv.Data {
-        if v.Name != "" {
-            vendorByName[v.Name] = v
-        }
-    }
-    modelByName := make(map[string]upstreamModel)
-    for _, m := range modelsEnv.Data {
-        if m.ModelName != "" {
-            modelByName[m.ModelName] = m
-        }
-    }
-
-    // 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
-    createdModels := 0
-    createdVendors := 0
-    updatedModels := 0
-    var skipped []string
-    var createdList []string
-    var updatedList []string
-
-    // 本地缓存:vendorName -> id
-    vendorIDCache := make(map[string]int)
-
-    for _, name := range missing {
-        up, ok := modelByName[name]
-        if !ok {
-            skipped = append(skipped, name)
-            continue
-        }
-
-        // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
-        var existing model.Model
-        if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
-            if existing.SyncOfficial == 0 {
-                skipped = append(skipped, name)
-                continue
-            }
-        }
-
-        // 确保 vendor 存在
-        vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
-
-        // 创建模型
-        mi := &model.Model{
-            ModelName:   name,
-            Description: up.Description,
-            Icon:        up.Icon,
-            Tags:        up.Tags,
-            VendorID:    vendorID,
-            Status:      chooseStatus(up.Status, 1),
-            NameRule:    up.NameRule,
-        }
-        if err := mi.Insert(); err == nil {
-            createdModels++
-            createdList = append(createdList, name)
-        } else {
-            skipped = append(skipped, name)
-        }
-    }
-
-    // 4) 处理可选覆盖(更新本地已有模型的差异字段)
-    if len(req.Overwrite) > 0 {
-        // vendorIDCache 已用于创建阶段,可复用
-        for _, ow := range req.Overwrite {
-            up, ok := modelByName[ow.ModelName]
-            if !ok {
-                continue
-            }
-            var local model.Model
-            if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
-                continue
-            }
-
-            // 跳过被禁用官方同步的模型
-            if local.SyncOfficial == 0 {
-                continue
-            }
-
-            // 映射 vendor
-            newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
-
-            // 应用字段覆盖(事务)
-            _ = model.DB.Transaction(func(tx *gorm.DB) error {
-                needUpdate := false
-                if containsField(ow.Fields, "description") {
-                    local.Description = up.Description
-                    needUpdate = true
-                }
-                if containsField(ow.Fields, "icon") {
-                    local.Icon = up.Icon
-                    needUpdate = true
-                }
-                if containsField(ow.Fields, "tags") {
-                    local.Tags = up.Tags
-                    needUpdate = true
-                }
-                if containsField(ow.Fields, "vendor") {
-                    local.VendorID = newVendorID
-                    needUpdate = true
-                }
-                if containsField(ow.Fields, "name_rule") {
-                    local.NameRule = up.NameRule
-                    needUpdate = true
-                }
-                if containsField(ow.Fields, "status") {
-                    local.Status = chooseStatus(up.Status, local.Status)
-                    needUpdate = true
-                }
-                if !needUpdate {
-                    return nil
-                }
-                if err := tx.Save(&local).Error; err != nil {
-                    return err
-                }
-                updatedModels++
-                updatedList = append(updatedList, ow.ModelName)
-                return nil
-            })
-        }
-    }
-
-    c.JSON(http.StatusOK, gin.H{
-        "success": true,
-        "data": gin.H{
-            "created_models":  createdModels,
-            "created_vendors": createdVendors,
-            "updated_models":  updatedModels,
-            "skipped_models":  skipped,
-            "created_list":    createdList,
-            "updated_list":    updatedList,
-        },
-    })
+	var req syncRequest
+	// 允许空体
+	_ = c.ShouldBindJSON(&req)
+	// 1) 获取未配置模型列表
+	missing, err := model.GetMissingModels()
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+	if len(missing) == 0 {
+		c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
+			"created_models":  0,
+			"created_vendors": 0,
+			"skipped_models":  []string{},
+		}})
+		return
+	}
+
+	// 2) 拉取上游 vendors 与 models
+	timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
+	ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
+	defer cancel()
+
+	modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
+	var vendorsEnv upstreamEnvelope[upstreamVendor]
+	var modelsEnv upstreamEnvelope[upstreamModel]
+	var fetchErr error
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		// vendor 失败不拦截
+		_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
+	}()
+	go func() {
+		defer wg.Done()
+		if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
+			fetchErr = err
+		}
+	}()
+	wg.Wait()
+	if fetchErr != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
+		return
+	}
+
+	// 建立映射
+	vendorByName := make(map[string]upstreamVendor)
+	for _, v := range vendorsEnv.Data {
+		if v.Name != "" {
+			vendorByName[v.Name] = v
+		}
+	}
+	modelByName := make(map[string]upstreamModel)
+	for _, m := range modelsEnv.Data {
+		if m.ModelName != "" {
+			modelByName[m.ModelName] = m
+		}
+	}
+
+	// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
+	createdModels := 0
+	createdVendors := 0
+	updatedModels := 0
+	var skipped []string
+	var createdList []string
+	var updatedList []string
+
+	// 本地缓存:vendorName -> id
+	vendorIDCache := make(map[string]int)
+
+	for _, name := range missing {
+		up, ok := modelByName[name]
+		if !ok {
+			skipped = append(skipped, name)
+			continue
+		}
+
+		// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
+		var existing model.Model
+		if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
+			if existing.SyncOfficial == 0 {
+				skipped = append(skipped, name)
+				continue
+			}
+		}
+
+		// 确保 vendor 存在
+		vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
+
+		// 创建模型
+		mi := &model.Model{
+			ModelName:   name,
+			Description: up.Description,
+			Icon:        up.Icon,
+			Tags:        up.Tags,
+			VendorID:    vendorID,
+			Status:      chooseStatus(up.Status, 1),
+			NameRule:    up.NameRule,
+		}
+		if err := mi.Insert(); err == nil {
+			createdModels++
+			createdList = append(createdList, name)
+		} else {
+			skipped = append(skipped, name)
+		}
+	}
+
+	// 4) 处理可选覆盖(更新本地已有模型的差异字段)
+	if len(req.Overwrite) > 0 {
+		// vendorIDCache 已用于创建阶段,可复用
+		for _, ow := range req.Overwrite {
+			up, ok := modelByName[ow.ModelName]
+			if !ok {
+				continue
+			}
+			var local model.Model
+			if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
+				continue
+			}
+
+			// 跳过被禁用官方同步的模型
+			if local.SyncOfficial == 0 {
+				continue
+			}
+
+			// 映射 vendor
+			newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
+
+			// 应用字段覆盖(事务)
+			_ = model.DB.Transaction(func(tx *gorm.DB) error {
+				needUpdate := false
+				if containsField(ow.Fields, "description") {
+					local.Description = up.Description
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "icon") {
+					local.Icon = up.Icon
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "tags") {
+					local.Tags = up.Tags
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "vendor") {
+					local.VendorID = newVendorID
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "name_rule") {
+					local.NameRule = up.NameRule
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "status") {
+					local.Status = chooseStatus(up.Status, local.Status)
+					needUpdate = true
+				}
+				if !needUpdate {
+					return nil
+				}
+				if err := tx.Save(&local).Error; err != nil {
+					return err
+				}
+				updatedModels++
+				updatedList = append(updatedList, ow.ModelName)
+				return nil
+			})
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"created_models":  createdModels,
+			"created_vendors": createdVendors,
+			"updated_models":  updatedModels,
+			"skipped_models":  skipped,
+			"created_list":    createdList,
+			"updated_list":    updatedList,
+			"source": gin.H{
+				"locale":      req.Locale,
+				"models_url":  modelsURL,
+				"vendors_url": vendorsURL,
+			},
+		},
+	})
 }
 
 func containsField(fields []string, key string) bool {
-    key = strings.ToLower(strings.TrimSpace(key))
-    for _, f := range fields {
-        if strings.ToLower(strings.TrimSpace(f)) == key {
-            return true
-        }
-    }
-    return false
+	key = strings.ToLower(strings.TrimSpace(key))
+	for _, f := range fields {
+		if strings.ToLower(strings.TrimSpace(f)) == key {
+			return true
+		}
+	}
+	return false
 }
 
 func coalesce(a, b string) string {
-    if strings.TrimSpace(a) != "" {
-        return a
-    }
-    return b
+	if strings.TrimSpace(a) != "" {
+		return a
+	}
+	return b
 }
 
 func chooseStatus(primary, fallback int) int {
-    if primary == 0 && fallback != 0 {
-        return fallback
-    }
-    if primary != 0 {
-        return primary
-    }
-    return 1
+	if primary == 0 && fallback != 0 {
+		return fallback
+	}
+	if primary != 0 {
+		return primary
+	}
+	return 1
 }
 
 // SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
 func SyncUpstreamPreview(c *gin.Context) {
-    // 1) 拉取上游数据
-    ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
-    defer cancel()
-
-    var vendorsEnv upstreamEnvelope[upstreamVendor]
-    _ = fetchJSON(ctx, upstreamVendorsURL, &vendorsEnv)
-
-    var modelsEnv upstreamEnvelope[upstreamModel]
-    if err := fetchJSON(ctx, upstreamModelsURL, &modelsEnv); err != nil {
-        c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + err.Error()})
-        return
-    }
-
-    vendorByName := make(map[string]upstreamVendor)
-    for _, v := range vendorsEnv.Data {
-        if v.Name != "" {
-            vendorByName[v.Name] = v
-        }
-    }
-    modelByName := make(map[string]upstreamModel)
-    upstreamNames := make([]string, 0, len(modelsEnv.Data))
-    for _, m := range modelsEnv.Data {
-        if m.ModelName != "" {
-            modelByName[m.ModelName] = m
-            upstreamNames = append(upstreamNames, m.ModelName)
-        }
-    }
-
-    // 2) 本地已有模型
-    var locals []model.Model
-    if len(upstreamNames) > 0 {
-        _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
-    }
-
-    // 本地 vendor 名称映射
-    vendorIdSet := make(map[int]struct{})
-    for _, m := range locals {
-        if m.VendorID != 0 {
-            vendorIdSet[m.VendorID] = struct{}{}
-        }
-    }
-    vendorIDs := make([]int, 0, len(vendorIdSet))
-    for id := range vendorIdSet {
-        vendorIDs = append(vendorIDs, id)
-    }
-    idToVendorName := make(map[int]string)
-    if len(vendorIDs) > 0 {
-        var dbVendors []model.Vendor
-        _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
-        for _, v := range dbVendors {
-            idToVendorName[v.Id] = v.Name
-        }
-    }
-
-    // 3) 缺失且上游存在的模型
-    missingList, _ := model.GetMissingModels()
-    var missing []string
-    for _, name := range missingList {
-        if _, ok := modelByName[name]; ok {
-            missing = append(missing, name)
-        }
-    }
-
-    // 4) 计算冲突字段
-    type conflictField struct {
-        Field    string      `json:"field"`
-        Local    interface{} `json:"local"`
-        Upstream interface{} `json:"upstream"`
-    }
-    type conflictItem struct {
-        ModelName string          `json:"model_name"`
-        Fields    []conflictField `json:"fields"`
-    }
-
-    var conflicts []conflictItem
-    for _, local := range locals {
-        up, ok := modelByName[local.ModelName]
-        if !ok {
-            continue
-        }
-        fields := make([]conflictField, 0, 6)
-        if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
-            fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
-        }
-        if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
-            fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
-        }
-        if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
-            fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
-        }
-        // vendor 对比使用名称
-        localVendor := idToVendorName[local.VendorID]
-        if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
-            fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
-        }
-        if local.NameRule != up.NameRule {
-            fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
-        }
-        if local.Status != chooseStatus(up.Status, local.Status) {
-            fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
-        }
-        if len(fields) > 0 {
-            conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
-        }
-    }
-
-    c.JSON(http.StatusOK, gin.H{
-        "success": true,
-        "data": gin.H{
-            "missing":   missing,
-            "conflicts": conflicts,
-        },
-    })
+	// 1) 拉取上游数据
+	timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
+	ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
+	defer cancel()
+
+	locale := c.Query("locale")
+	modelsURL, vendorsURL := getUpstreamURLs(locale)
+
+	var vendorsEnv upstreamEnvelope[upstreamVendor]
+	var modelsEnv upstreamEnvelope[upstreamModel]
+	var fetchErr error
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
+	}()
+	go func() {
+		defer wg.Done()
+		if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
+			fetchErr = err
+		}
+	}()
+	wg.Wait()
+	if fetchErr != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
+		return
+	}
+
+	vendorByName := make(map[string]upstreamVendor)
+	for _, v := range vendorsEnv.Data {
+		if v.Name != "" {
+			vendorByName[v.Name] = v
+		}
+	}
+	modelByName := make(map[string]upstreamModel)
+	upstreamNames := make([]string, 0, len(modelsEnv.Data))
+	for _, m := range modelsEnv.Data {
+		if m.ModelName != "" {
+			modelByName[m.ModelName] = m
+			upstreamNames = append(upstreamNames, m.ModelName)
+		}
+	}
+
+	// 2) 本地已有模型
+	var locals []model.Model
+	if len(upstreamNames) > 0 {
+		_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
+	}
+
+	// 本地 vendor 名称映射
+	vendorIdSet := make(map[int]struct{})
+	for _, m := range locals {
+		if m.VendorID != 0 {
+			vendorIdSet[m.VendorID] = struct{}{}
+		}
+	}
+	vendorIDs := make([]int, 0, len(vendorIdSet))
+	for id := range vendorIdSet {
+		vendorIDs = append(vendorIDs, id)
+	}
+	idToVendorName := make(map[int]string)
+	if len(vendorIDs) > 0 {
+		var dbVendors []model.Vendor
+		_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
+		for _, v := range dbVendors {
+			idToVendorName[v.Id] = v.Name
+		}
+	}
+
+	// 3) 缺失且上游存在的模型
+	missingList, _ := model.GetMissingModels()
+	var missing []string
+	for _, name := range missingList {
+		if _, ok := modelByName[name]; ok {
+			missing = append(missing, name)
+		}
+	}
+
+	// 4) 计算冲突字段
+	type conflictField struct {
+		Field    string      `json:"field"`
+		Local    interface{} `json:"local"`
+		Upstream interface{} `json:"upstream"`
+	}
+	type conflictItem struct {
+		ModelName string          `json:"model_name"`
+		Fields    []conflictField `json:"fields"`
+	}
+
+	var conflicts []conflictItem
+	for _, local := range locals {
+		up, ok := modelByName[local.ModelName]
+		if !ok {
+			continue
+		}
+		fields := make([]conflictField, 0, 6)
+		if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
+			fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
+		}
+		if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
+			fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
+		}
+		if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
+			fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
+		}
+		// vendor 对比使用名称
+		localVendor := idToVendorName[local.VendorID]
+		if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
+			fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
+		}
+		if local.NameRule != up.NameRule {
+			fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
+		}
+		if local.Status != chooseStatus(up.Status, local.Status) {
+			fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
+		}
+		if len(fields) > 0 {
+			conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"missing":   missing,
+			"conflicts": conflicts,
+			"source": gin.H{
+				"locale":      locale,
+				"models_url":  modelsURL,
+				"vendors_url": vendorsURL,
+			},
+		},
+	})
 }
-
-

+ 12 - 12
model/model_meta.go

@@ -20,18 +20,18 @@ type BoundChannel struct {
 }
 
 type Model struct {
-	Id          int            `json:"id"`
-	ModelName   string         `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
-	Description string         `json:"description,omitempty" gorm:"type:text"`
-	Icon        string         `json:"icon,omitempty" gorm:"type:varchar(128)"`
-	Tags        string         `json:"tags,omitempty" gorm:"type:varchar(255)"`
-	VendorID    int            `json:"vendor_id,omitempty" gorm:"index"`
-	Endpoints   string         `json:"endpoints,omitempty" gorm:"type:text"`
-	Status      int            `json:"status" gorm:"default:1"`
-	SyncOfficial int           `json:"sync_official" gorm:"default:1"`
-	CreatedTime int64          `json:"created_time" gorm:"bigint"`
-	UpdatedTime int64          `json:"updated_time" gorm:"bigint"`
-	DeletedAt   gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
+	Id           int            `json:"id"`
+	ModelName    string         `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
+	Description  string         `json:"description,omitempty" gorm:"type:text"`
+	Icon         string         `json:"icon,omitempty" gorm:"type:varchar(128)"`
+	Tags         string         `json:"tags,omitempty" gorm:"type:varchar(255)"`
+	VendorID     int            `json:"vendor_id,omitempty" gorm:"index"`
+	Endpoints    string         `json:"endpoints,omitempty" gorm:"type:text"`
+	Status       int            `json:"status" gorm:"default:1"`
+	SyncOfficial int            `json:"sync_official" gorm:"default:1"`
+	CreatedTime  int64          `json:"created_time" gorm:"bigint"`
+	UpdatedTime  int64          `json:"updated_time" gorm:"bigint"`
+	DeletedAt    gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
 
 	BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
 	EnableGroups  []string       `json:"enable_groups,omitempty" gorm:"-"`

+ 1 - 1
web/src/components/dashboard/ApiInfoPanel.jsx

@@ -100,7 +100,7 @@ const ApiInfoPanel = ({
             </React.Fragment>
           ))
         ) : (
-          <div className='flex justify-center items-center py-8'>
+          <div className='flex justify-center items-center min-h-[20rem] w-full'>
             <Empty
               image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
               darkModeImage={

+ 30 - 7
web/src/components/table/models/ModelsActions.jsx

@@ -21,11 +21,12 @@ import React, { useState } from 'react';
 import MissingModelsModal from './modals/MissingModelsModal';
 import PrefillGroupManagement from './modals/PrefillGroupManagement';
 import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
-import { Button, Modal, Popover } from '@douyinfe/semi-ui';
+import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui';
 import { showSuccess, showError, copy } from '../../../helpers';
 import CompactModeToggle from '../../common/ui/CompactModeToggle';
 import SelectionNotification from './components/SelectionNotification';
 import UpstreamConflictModal from './modals/UpstreamConflictModal';
+import SyncWizardModal from './modals/SyncWizardModal';
 
 const ModelsActions = ({
   selectedKeys,
@@ -50,10 +51,12 @@ const ModelsActions = ({
   const [prefillInit, setPrefillInit] = useState({ id: undefined });
   const [showConflict, setShowConflict] = useState(false);
   const [conflicts, setConflicts] = useState([]);
+  const [showSyncModal, setShowSyncModal] = useState(false);
+  const [syncLocale, setSyncLocale] = useState('zh');
 
-  const handleSyncUpstream = async () => {
+  const handleSyncUpstream = async (locale) => {
     // 先预览
-    const data = await previewUpstreamDiff?.();
+    const data = await previewUpstreamDiff?.({ locale });
     const conflictItems = data?.conflicts || [];
     if (conflictItems.length > 0) {
       setConflicts(conflictItems);
@@ -61,7 +64,7 @@ const ModelsActions = ({
       return;
     }
     // 无冲突,直接同步缺失
-    await syncUpstream?.();
+    await syncUpstream?.({ locale });
   };
 
   // Handle delete selected models with confirmation
@@ -151,9 +154,12 @@ const ModelsActions = ({
             className='flex-1 md:flex-initial'
             size='small'
             loading={syncing || previewing}
-            onClick={handleSyncUpstream}
+            onClick={() => {
+              setSyncLocale('zh');
+              setShowSyncModal(true);
+            }}
           >
-            {t('同步官方')}
+            {t('同步')}
           </Button>
         </Popover>
 
@@ -196,6 +202,20 @@ const ModelsActions = ({
         </div>
       </Modal>
 
+      <SyncWizardModal
+        visible={showSyncModal}
+        onClose={() => setShowSyncModal(false)}
+        loading={syncing || previewing}
+        t={t}
+        onConfirm={async ({ option, locale }) => {
+          setSyncLocale(locale);
+          if (option === 'official') {
+            await handleSyncUpstream(locale);
+          }
+          setShowSyncModal(false);
+        }}
+      />
+
       <MissingModelsModal
         visible={showMissingModal}
         onClose={() => setShowMissingModal(false)}
@@ -224,7 +244,10 @@ const ModelsActions = ({
         onClose={() => setShowConflict(false)}
         conflicts={conflicts}
         onSubmit={async (payload) => {
-          return await applyUpstreamOverwrite?.(payload);
+          return await applyUpstreamOverwrite?.({
+            ...payload,
+            locale: syncLocale,
+          });
         }}
         t={t}
         loading={syncing}

+ 1 - 1
web/src/components/table/models/modals/MissingModelsModal.jsx

@@ -96,7 +96,7 @@ const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => {
       title: '',
       dataIndex: 'operate',
       fixed: 'right',
-      width: 100,
+      width: 120,
       render: (text, record) => (
         <Button
           type='primary'

+ 132 - 0
web/src/components/table/models/modals/SyncWizardModal.jsx

@@ -0,0 +1,132 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useState } from 'react';
+import { Modal, RadioGroup, Radio, Steps, Button } from '@douyinfe/semi-ui';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const SyncWizardModal = ({ visible, onClose, onConfirm, loading, t }) => {
+  const [step, setStep] = useState(0);
+  const [option, setOption] = useState('official');
+  const [locale, setLocale] = useState('zh');
+  const isMobile = useIsMobile();
+
+  useEffect(() => {
+    if (visible) {
+      setStep(0);
+      setOption('official');
+      setLocale('zh');
+    }
+  }, [visible]);
+
+  return (
+    <Modal
+      title={t('同步向导')}
+      visible={visible}
+      onCancel={onClose}
+      footer={
+        <div className='flex justify-end'>
+          {step === 1 && (
+            <Button onClick={() => setStep(0)}>{t('上一步')}</Button>
+          )}
+          <Button onClick={onClose}>{t('取消')}</Button>
+          {step === 0 && (
+            <Button
+              type='primary'
+              onClick={() => setStep(1)}
+              disabled={option !== 'official'}
+            >
+              {t('下一步')}
+            </Button>
+          )}
+          {step === 1 && (
+            <Button
+              type='primary'
+              theme='solid'
+              loading={loading}
+              onClick={async () => {
+                await onConfirm?.({ option, locale });
+              }}
+            >
+              {t('开始同步')}
+            </Button>
+          )}
+        </div>
+      }
+      width={isMobile ? '100%' : 'small'}
+    >
+      <div className='mb-3'>
+        <Steps type='basic' current={step} size='small'>
+          <Steps.Step title={t('选择方式')} description={t('选择同步来源')} />
+          <Steps.Step title={t('选择语言')} description={t('选择同步语言')} />
+        </Steps>
+      </div>
+
+      {step === 0 && (
+        <div className='mt-2 flex justify-center'>
+          <RadioGroup
+            value={option}
+            onChange={(e) => setOption(e?.target?.value ?? e)}
+            type='card'
+            direction='horizontal'
+            aria-label='同步方式选择'
+            name='sync-mode-selection'
+          >
+            <Radio value='official' extra={t('从官方模型库同步')}>
+              {t('官方模型同步')}
+            </Radio>
+            <Radio value='config' extra={t('从配置文件同步')} disabled>
+              {t('配置文件同步')}
+            </Radio>
+          </RadioGroup>
+        </div>
+      )}
+
+      {step === 1 && (
+        <div className='mt-2'>
+          <div className='mb-2 text-[var(--semi-color-text-2)]'>
+            {t('请选择同步语言')}
+          </div>
+          <div className='flex justify-center'>
+            <RadioGroup
+              value={locale}
+              onChange={(e) => setLocale(e?.target?.value ?? e)}
+              type='card'
+              direction='horizontal'
+              aria-label='语言选择'
+              name='sync-locale-selection'
+            >
+              <Radio value='en' extra='English'>
+                EN
+              </Radio>
+              <Radio value='zh' extra='中文'>
+                ZH
+              </Radio>
+              <Radio value='ja' extra='日本語'>
+                JA
+              </Radio>
+            </RadioGroup>
+          </div>
+        </div>
+      )}
+    </Modal>
+  );
+};
+
+export default SyncWizardModal;

+ 123 - 46
web/src/components/table/models/modals/UpstreamConflictModal.jsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact [email protected]
 */
 
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useState, useCallback } from 'react';
 import {
   Modal,
   Table,
@@ -26,9 +26,12 @@ import {
   Empty,
   Tag,
   Popover,
+  Input,
 } from '@douyinfe/semi-ui';
 import { MousePointerClick } from 'lucide-react';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
+import { IconSearch } from '@douyinfe/semi-icons';
 
 const { Text } = Typography;
 
@@ -52,6 +55,8 @@ const UpstreamConflictModal = ({
 }) => {
   const [selections, setSelections] = useState({});
   const isMobile = useIsMobile();
+  const [currentPage, setCurrentPage] = useState(1);
+  const [searchKeyword, setSearchKeyword] = useState('');
 
   const formatValue = (v) => {
     if (v === null || v === undefined) return '-';
@@ -70,12 +75,14 @@ const UpstreamConflictModal = ({
         init[item.model_name] = new Set();
       });
       setSelections(init);
+      setCurrentPage(1);
+      setSearchKeyword('');
     } else {
       setSelections({});
     }
   }, [visible, conflicts]);
 
-  const toggleField = (modelName, field, checked) => {
+  const toggleField = useCallback((modelName, field, checked) => {
     setSelections((prev) => {
       const next = { ...prev };
       const set = new Set(next[modelName] || []);
@@ -84,7 +91,67 @@ const UpstreamConflictModal = ({
       next[modelName] = set;
       return next;
     });
-  };
+  }, []);
+
+  // 构造数据源与过滤后的数据源
+  const dataSource = useMemo(
+    () =>
+      (conflicts || []).map((c) => ({
+        key: c.model_name,
+        model_name: c.model_name,
+        fields: c.fields || [],
+      })),
+    [conflicts],
+  );
+
+  const filteredDataSource = useMemo(() => {
+    const kw = (searchKeyword || '').toLowerCase();
+    if (!kw) return dataSource;
+    return dataSource.filter((item) =>
+      (item.model_name || '').toLowerCase().includes(kw),
+    );
+  }, [dataSource, searchKeyword]);
+
+  // 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置
+  const getPresentRowsForField = useCallback(
+    (fieldKey) =>
+      (filteredDataSource || []).filter((row) =>
+        (row.fields || []).some((f) => f.field === fieldKey),
+      ),
+    [filteredDataSource],
+  );
+
+  const getHeaderState = useCallback(
+    (fieldKey) => {
+      const presentRows = getPresentRowsForField(fieldKey);
+      const selectedCount = presentRows.filter((row) =>
+        selections[row.model_name]?.has(fieldKey),
+      ).length;
+      const allCount = presentRows.length;
+      return {
+        headerChecked: allCount > 0 && selectedCount === allCount,
+        headerIndeterminate: selectedCount > 0 && selectedCount < allCount,
+        hasAny: allCount > 0,
+      };
+    },
+    [getPresentRowsForField, selections],
+  );
+
+  const applyHeaderChange = useCallback(
+    (fieldKey, checked) => {
+      setSelections((prev) => {
+        const next = { ...prev };
+        getPresentRowsForField(fieldKey).forEach((row) => {
+          const set = new Set(next[row.model_name] || []);
+          if (checked) set.add(fieldKey);
+          else set.delete(fieldKey);
+          next[row.model_name] = set;
+        });
+        return next;
+      });
+    },
+    [getPresentRowsForField],
+  );
 
   const columns = useMemo(() => {
     const base = [
@@ -100,37 +167,11 @@ const UpstreamConflictModal = ({
       const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
       const label = t(rawLabel);
 
-      // 统计列头复选框状态(仅统计存在该字段冲突的行)
-      const presentRows = (conflicts || []).filter((row) =>
-        (row.fields || []).some((f) => f.field === fieldKey),
-      );
-      const selectedCount = presentRows.filter((row) =>
-        selections[row.model_name]?.has(fieldKey),
-      ).length;
-      const allCount = presentRows.length;
-      if (allCount === 0) {
-        return null; // 若此字段在所有行中都不存在,则不展示该列
-      }
-      const headerChecked = allCount > 0 && selectedCount === allCount;
-      const headerIndeterminate = selectedCount > 0 && selectedCount < allCount;
-
-      const onHeaderChange = (e) => {
-        const checked = e?.target?.checked;
-        setSelections((prev) => {
-          const next = { ...prev };
-          (conflicts || []).forEach((row) => {
-            const hasField = (row.fields || []).some(
-              (f) => f.field === fieldKey,
-            );
-            if (!hasField) return;
-            const set = new Set(next[row.model_name] || []);
-            if (checked) set.add(fieldKey);
-            else set.delete(fieldKey);
-            next[row.model_name] = set;
-          });
-          return next;
-        });
-      };
+      const { headerChecked, headerIndeterminate, hasAny } =
+        getHeaderState(fieldKey);
+      if (!hasAny) return null;
+      const onHeaderChange = (e) =>
+        applyHeaderChange(fieldKey, e?.target?.checked);
 
       return {
         title: (
@@ -194,13 +235,20 @@ const UpstreamConflictModal = ({
     });
 
     return [...base, ...cols.filter(Boolean)];
-  }, [t, selections, conflicts]);
+  }, [
+    t,
+    selections,
+    filteredDataSource,
+    getHeaderState,
+    applyHeaderChange,
+    toggleField,
+  ]);
 
-  const dataSource = conflicts.map((c) => ({
-    key: c.model_name,
-    model_name: c.model_name,
-    fields: c.fields || [],
-  }));
+  const pagedDataSource = useMemo(() => {
+    const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
+    const end = start + MODEL_TABLE_PAGE_SIZE;
+    return filteredDataSource.slice(start, end);
+  }, [filteredDataSource, currentPage]);
 
   const handleOk = async () => {
     const payload = Object.entries(selections)
@@ -236,12 +284,41 @@ const UpstreamConflictModal = ({
           <div className='mb-3 text-[var(--semi-color-text-2)]'>
             {t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
           </div>
-          <Table
-            columns={columns}
-            dataSource={dataSource}
-            pagination={false}
-            scroll={{ x: 'max-content' }}
-          />
+          {/* 搜索框 */}
+          <div className='flex items-center justify-end gap-2 w-full mb-4'>
+            <Input
+              placeholder={t('搜索模型...')}
+              value={searchKeyword}
+              onChange={(v) => {
+                setSearchKeyword(v);
+                setCurrentPage(1);
+              }}
+              className='!w-full'
+              prefix={<IconSearch />}
+              showClear
+            />
+          </div>
+          {filteredDataSource.length > 0 ? (
+            <Table
+              columns={columns}
+              dataSource={pagedDataSource}
+              pagination={{
+                currentPage: currentPage,
+                pageSize: MODEL_TABLE_PAGE_SIZE,
+                total: filteredDataSource.length,
+                showSizeChanger: false,
+                onPageChange: (page) => setCurrentPage(page),
+              }}
+              scroll={{ x: 'max-content' }}
+            />
+          ) : (
+            <Empty
+              description={
+                searchKeyword ? t('未找到匹配的模型') : t('无冲突项')
+              }
+              className='p-6'
+            />
+          )}
         </>
       )}
     </Modal>

+ 16 - 6
web/src/hooks/models/useModelsData.jsx

@@ -166,10 +166,13 @@ export const useModelsData = () => {
   };
 
   // Sync upstream models/vendors for missing models only
-  const syncUpstream = async () => {
+  const syncUpstream = async (opts = {}) => {
+    const locale = opts?.locale;
     setSyncing(true);
     try {
-      const res = await API.post('/api/models/sync_upstream');
+      const body = {};
+      if (locale) body.locale = locale;
+      const res = await API.post('/api/models/sync_upstream', body);
       const { success, message, data } = res.data || {};
       if (success) {
         const createdModels = data?.created_models || 0;
@@ -192,10 +195,12 @@ export const useModelsData = () => {
   };
 
   // Preview upstream differences
-  const previewUpstreamDiff = async () => {
+  const previewUpstreamDiff = async (opts = {}) => {
+    const locale = opts?.locale;
     setPreviewing(true);
     try {
-      const res = await API.get('/api/models/sync_upstream/preview');
+      const url = `/api/models/sync_upstream/preview${locale ? `?locale=${locale}` : ''}`;
+      const res = await API.get(url);
       const { success, message, data } = res.data || {};
       if (success) {
         return data || { missing: [], conflicts: [] };
@@ -211,10 +216,15 @@ export const useModelsData = () => {
   };
 
   // Apply selected overwrite
-  const applyUpstreamOverwrite = async (overwrite = []) => {
+  const applyUpstreamOverwrite = async (payloadOrArray = []) => {
+    const isArray = Array.isArray(payloadOrArray);
+    const overwrite = isArray ? payloadOrArray : payloadOrArray.overwrite || [];
+    const locale = isArray ? undefined : payloadOrArray.locale;
     setSyncing(true);
     try {
-      const res = await API.post('/api/models/sync_upstream', { overwrite });
+      const body = { overwrite };
+      if (locale) body.locale = locale;
+      const res = await API.post('/api/models/sync_upstream', body);
       const { success, message, data } = res.data || {};
       if (success) {
         const createdModels = data?.created_models || 0;

+ 13 - 2
web/src/i18n/locales/en.json

@@ -1801,7 +1801,7 @@
   "已绑定渠道": "Bound channels",
   "更新时间": "Update time",
   "未配置模型": "No model configured",
-  "预填组管理": "Pre-filled group management",
+  "预填组管理": "Pre-filled group",
   "搜索供应商": "Search vendor",
   "新增供应商": "Add vendor",
   "创建新的模型": "Create new model",
@@ -2057,9 +2057,20 @@
   "侧边栏设置保存成功": "Sidebar settings saved successfully",
   "需要登录访问": "Require Login",
   "开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
-  "同步官方": "Sync official",
   "参与官方同步": "Participate in official sync",
   "关闭后,此模型将不会被“同步官方”自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
+  "同步": "Sync",
+  "同步向导": "Sync Wizard",
+  "选择方式": "Select method",
+  "选择同步来源": "Select sync source",
+  "选择语言": "Select language",
+  "选择同步语言": "Select sync language",
+  "请选择同步语言": "Please select sync language",
+  "从官方模型库同步": "Sync from official model library",
+  "官方模型同步": "Official models sync",
+  "从配置文件同步": "Sync from config file",
+  "配置文件同步": "Config file sync",
+  "开始同步": "Start sync",
   "选择要覆盖的冲突项": "Select conflict items to overwrite",
   "点击查看差异": "Click to view differences",
   "无冲突项": "No conflict items",