feat(channel): 对齐 vivid 最新渠道/模型管理设计

渠道管理(对齐 9db9971/9f6eeca/b607a8d/268a8f7/7f8a87e):
- 列表新增「运行中」(Redis 并发槽位实时计数,与调度同源)和「失败数」
  (event_logs 按渠道聚合)两列
- 行内「同步上游模型」恢复弹窗:默认全开、右侧开关、彩点+单行别名,
  底部「同步模型」批量导入;编辑表单去掉获取模型列表按钮(保存已自动
  全量同步);去掉勾选即解绑语义与 unbind-model 端点
- 导入加固:别名与上游同名报错、同批别名复用拒绝、本地类型冲突拒绝、
  单次上限 50

模型管理(对齐 5545195):
- 模型表单/CreateModel 补齐 max_reference_videos/audios 与
  max_ref_video/audio_seconds 透传

产物(对齐 2253e58):
- 下载结果仅信任 image/*/video/* 响应头,text/plain 等乱报一律按魔数
  嗅探定 MIME

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-19 19:14:25 +08:00
co-authored by Devin
parent 72d4eec203
commit 4632b1ab04
9 changed files with 129 additions and 159 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ func NewApp(ctx context.Context) (*App, error) {
// rows when CHANNEL_SECRET isn't set. // rows when CHANNEL_SECRET isn't set.
channelSecret = cfg.PostgresDSN channelSecret = cfg.PostgresDSN
} }
channelSvc := service.NewChannelService(channelRepo, modelRepo, upClient, channelSecret) channelSvc := service.NewChannelService(channelRepo, modelRepo, eventRepo, concSvc, upClient, channelSecret)
// Sweep provider-era model rows: anything not bound to a channel can never // Sweep provider-era model rows: anything not bound to a channel can never
// be routed under the channel scheduler. // be routed under the channel scheduler.
if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil { if n, err := channelSvc.PurgeOrphanModels(ctx); err != nil {
@@ -125,24 +125,6 @@ func (h *ChannelAdminHandler) ImportModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
// UnbindModel detaches one upstream model from a channel; the local model row
// is deleted too when no other channel still binds it.
func (h *ChannelAdminHandler) UnbindModel(c *gin.Context) {
var body struct {
ID string `json:"id"`
Model string `json:"model"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.ID) == "" || strings.TrimSpace(body.Model) == "" {
c.JSON(http.StatusBadRequest, gin.H{"detail": "id and model required"})
return
}
if err := h.channels.UnbindModel(c.Request.Context(), body.ID, body.Model); err != nil {
writeUpstreamErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// writeUpstreamErr translates upstream-protocol errors into sensible admin // writeUpstreamErr translates upstream-protocol errors into sensible admin
// responses (auth → 400 with the upstream's message, temporary → 502). // responses (auth → 400 with the upstream's message, temporary → 502).
func writeUpstreamErr(c *gin.Context, err error) { func writeUpstreamErr(c *gin.Context, err error) {
-1
View File
@@ -148,7 +148,6 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels) authed.POST("/channels/fetch-models", handlers.ChannelAdmin.FetchModels)
authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels) authed.POST("/channels/sync-models", handlers.ChannelAdmin.SyncModels)
authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels) authed.POST("/channels/import-models", handlers.ChannelAdmin.ImportModels)
authed.POST("/channels/unbind-model", handlers.ChannelAdmin.UnbindModel)
authed.GET("/providers", handlers.AdminRead.Providers) authed.GET("/providers", handlers.AdminRead.Providers)
authed.GET("/images", handlers.AdminRead.Images) authed.GET("/images", handlers.AdminRead.Images)
authed.DELETE("/images", handlers.AdminRead.DeleteImage) authed.DELETE("/images", handlers.AdminRead.DeleteImage)
+23
View File
@@ -668,6 +668,29 @@ func (r *EventRepository) SetAccount(ctx context.Context, eventID, accountID, ac
Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error Updates(map[string]any{"account_id": accountID, "account_email": accountEmail}).Error
} }
// FailedByAccount counts failed events grouped by account_id (channel id) —
// the channel list's 失败数 column.
func (r *EventRepository) FailedByAccount(ctx context.Context) (map[string]int64, error) {
type row struct {
AccountID string `gorm:"column:account_id"`
Count int64 `gorm:"column:count"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.EventLog{}).
Select("account_id, COUNT(*) AS count").
Where("status = ? AND account_id <> ''", "failed").
Group("account_id").
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, item := range rows {
out[item.AccountID] = item.Count
}
return out, nil
}
// InFlightByAccount counts pending (in-flight) events grouped by account_id, for // InFlightByAccount counts pending (in-flight) events grouped by account_id, for
// the accounts view's live "in-flight" column. // the accounts view's live "in-flight" column.
func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) { func (r *EventRepository) InFlightByAccount(ctx context.Context) (map[string]int64, error) {
+2
View File
@@ -402,6 +402,8 @@ func (s *AdminWriteService) CreateModel(ctx context.Context, body map[string]any
MaxReferenceImages: intValue(body["max_reference_images"]), MaxReferenceImages: intValue(body["max_reference_images"]),
MaxReferenceVideos: intValue(body["max_reference_videos"]), MaxReferenceVideos: intValue(body["max_reference_videos"]),
MaxReferenceAudios: intValue(body["max_reference_audios"]), MaxReferenceAudios: intValue(body["max_reference_audios"]),
MaxRefVideoSeconds: intValue(body["max_ref_video_seconds"]),
MaxRefAudioSeconds: intValue(body["max_ref_audio_seconds"]),
ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"), ReferenceMode: defaultString(strings.TrimSpace(stringValue(body["reference_mode"])), "none"),
Weight: intValue(body["weight"]), Weight: intValue(body["weight"]),
CreatedAt: time.Now(), CreatedAt: time.Now(),
+22 -33
View File
@@ -30,13 +30,15 @@ import (
type ChannelService struct { type ChannelService struct {
channels *repo.ChannelRepository channels *repo.ChannelRepository
models *repo.ModelRepository models *repo.ModelRepository
events *repo.EventRepository
conc *ConcurrencyService
up *upstream.Client up *upstream.Client
secret []byte secret []byte
} }
func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, up *upstream.Client, secret string) *ChannelService { func NewChannelService(channels *repo.ChannelRepository, models *repo.ModelRepository, events *repo.EventRepository, conc *ConcurrencyService, up *upstream.Client, secret string) *ChannelService {
sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret)) sum := sha256.Sum256([]byte("image2api-channel-keys|" + secret))
return &ChannelService{channels: channels, models: models, up: up, secret: sum[:]} return &ChannelService{channels: channels, models: models, events: events, conc: conc, up: up, secret: sum[:]}
} }
// ChannelView is the admin list row: local stats + bound model names + a live // ChannelView is the admin list row: local stats + bound model names + a live
@@ -50,6 +52,10 @@ type ChannelView struct {
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
Weight int `json:"weight"` Weight int `json:"weight"`
Models []string `json:"models"` Models []string `json:"models"`
// RunningCount 运行中 = Redis 并发槽位实时计数(与调度器同源);
// FailCount 失败数 = event_logs 实时聚合(channel 数少,无需预计算)。
RunningCount int64 `json:"running_count"`
FailCount int64 `json:"fail_count"`
SuccessTotal int64 `json:"success_total"` SuccessTotal int64 `json:"success_total"`
UsedCredits float64 `json:"used_credits"` UsedCredits float64 `json:"used_credits"`
UpstreamBalance *int64 `json:"upstream_balance"` UpstreamBalance *int64 `json:"upstream_balance"`
@@ -63,6 +69,7 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
fails, _ := s.events.FailedByAccount(ctx)
out := make([]ChannelView, len(items)) out := make([]ChannelView, len(items))
var wg sync.WaitGroup var wg sync.WaitGroup
for i, ch := range items { for i, ch := range items {
@@ -73,6 +80,8 @@ func (s *ChannelService) List(ctx context.Context) ([]ChannelView, error) {
Enabled: ch.Enabled, Enabled: ch.Enabled,
Concurrency: ch.Concurrency, Concurrency: ch.Concurrency,
Weight: ch.Weight, Weight: ch.Weight,
RunningCount: int64(s.conc.Count(ctx, "conc:c:"+ch.ID)),
FailCount: fails[ch.ID],
SuccessTotal: ch.SuccessTotal, SuccessTotal: ch.SuccessTotal,
UsedCredits: ch.UsedCredits, UsedCredits: ch.UsedCredits,
CreatedAt: ch.CreatedAt.Unix(), CreatedAt: ch.CreatedAt.Unix(),
@@ -354,37 +363,6 @@ func (s *ChannelService) FetchModels(ctx context.Context, d FetchModelsDto) ([]u
return items, nil return items, nil
} }
// UnbindModel detaches one upstream model from a channel; when the local model
// row ends up bound to no channel at all it is deleted too (same orphan
// semantics as deleting a whole channel).
func (s *ChannelService) UnbindModel(ctx context.Context, channelID, upstreamModelID string) error {
upID := strings.TrimSpace(upstreamModelID)
if upID == "" {
return errors.New("model id required")
}
m, err := s.channels.UpstreamModelMap(ctx, channelID)
if err != nil {
return err
}
var localID string
for mid, up := range m {
if up == upID {
localID = mid
break
}
}
if localID == "" {
return nil // not bound — idempotent
}
if err := s.channels.Unbind(ctx, channelID, localID); err != nil {
return err
}
if rest, _ := s.channels.ChannelIDsByModel(ctx, localID); len(rest) == 0 {
_, _ = s.models.Delete(ctx, localID)
}
return nil
}
// PurgeOrphanModels sweeps model_configs rows left over from the provider era: // PurgeOrphanModels sweeps model_configs rows left over from the provider era:
// an unbound model can never be routed, so any row that didn't come through a // an unbound model can never be routed, so any row that didn't come through a
// channel import (provider != "channel") is deleted at startup. Channel-typed // channel import (provider != "channel") is deleted at startup. Channel-typed
@@ -445,6 +423,10 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if err != nil { if err != nil {
return err return err
} }
if len(specs) > 50 {
return errors.New("单次最多导入 50 个模型")
}
seenAliases := make(map[string]string, len(specs)) // localID → 上游 id,别名冲突当场拒绝
for _, spec := range specs { for _, spec := range specs {
upID := strings.TrimSpace(spec.ID) upID := strings.TrimSpace(spec.ID)
if upID == "" { if upID == "" {
@@ -458,10 +440,17 @@ func (s *ChannelService) ImportModels(ctx context.Context, channelID string, spe
if localID == "" { if localID == "" {
localID = upID localID = upID
} }
if prev, dup := seenAliases[localID]; dup && prev != upID {
return fmt.Errorf("别名 %s 被多个上游模型复用", localID)
}
seenAliases[localID] = upID
existing, gerr := s.models.Get(ctx, localID) existing, gerr := s.models.Get(ctx, localID)
if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) { if gerr != nil && !errors.Is(gerr, gorm.ErrRecordNotFound) {
existing = nil existing = nil
} }
if existing != nil && existing.Type != "" && spec.Kind != "" && existing.Type != spec.Kind {
return fmt.Errorf("模型 %s 已存在且类型不同(%s)", localID, existing.Type)
}
var local *model.ModelConfig var local *model.ModelConfig
if existing != nil && existing.ID == localID { if existing != nil && existing.ID == localID {
local = existing local = existing
+6 -1
View File
@@ -422,8 +422,13 @@ func (c *Client) Download(ctx context.Context, url string) ([]byte, string, erro
if len(data) == 0 { if len(data) == 0 {
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable) return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
} }
// Content-Type fallback chain: only trust image/* video/* headers (upstreams
// often mislabel results text/plain or octet-stream), else sniff magic bytes.
ct := strings.TrimSpace(resp.Header.Get("Content-Type")) ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
if ct == "" || ct == "application/octet-stream" { if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
if !strings.HasPrefix(ct, "image/") && !strings.HasPrefix(ct, "video/") {
ct = http.DetectContentType(data) ct = http.DetectContentType(data)
} }
return data, ct, nil return data, ct, nil
@@ -167,6 +167,10 @@ async function save() {
duration_prices_agent, duration_prices_agent,
alias: alias.value.trim(), alias: alias.value.trim(),
max_reference_images: e.max_reference_images || 0, max_reference_images: e.max_reference_images || 0,
max_reference_videos: e.max_reference_videos || 0,
max_reference_audios: e.max_reference_audios || 0,
max_ref_video_seconds: e.max_ref_video_seconds || 0,
max_ref_audio_seconds: e.max_ref_audio_seconds || 0,
reference_mode: e.reference_mode || 'none', reference_mode: e.reference_mode || 'none',
weight: Number(weight.value) || 0, weight: Number(weight.value) || 0,
} }
+71 -105
View File
@@ -10,19 +10,20 @@ const channels = ref([])
const loading = ref(false) const loading = ref(false)
const err = ref('') const err = ref('')
const warn = ref('') // 保存后自动同步的非致命告警 const warn = ref('') // 保存后自动同步的非致命告警
const okMsg = ref('') // 手动同步成功提示 const okMsg = ref('') // 同步模型成功提示
const syncing = ref(false)
// create/edit form // create/edit form
const form = ref(null) const form = ref(null)
const saving = ref(false) const saving = ref(false)
const formErr = ref('') const formErr = ref('')
// fetch-models → 勾选即绑定(选中自动导入到模型列表,取消即解绑) // 同步上游模型弹窗:开关选择 + 别名,批量导入(保存渠道已自动全量同步)
const fetchTarget = ref(null) // channel snapshot being imported into const fetchTarget = ref(null) // saved channel being synced
const fetching = ref(false) const fetching = ref(false)
const fetchErr = ref('') const fetchErr = ref('')
const specs = ref([]) const specs = ref([])
const picked = ref(new Set())
const importing = ref(false)
async function load() { async function load() {
loading.value = true loading.value = true
@@ -80,84 +81,44 @@ async function del(c) {
channels.value = channels.value.filter((x) => x.id !== c.id) channels.value = channels.value.filter((x) => x.id !== c.id)
} }
// 已保存渠道手动「同步上游模型」:直接全量同步(新模型入库绑定,上游消失的 // 行内「同步上游模型」走开关 + 别名弹窗(渠道已保存,用库里凭据拉取)。
// 解绑/清孤儿),拉取失败直接报错;新渠道则只用表单地址+key 预览不落库。 // 默认全开:点「同步模型」把勾选项批量导入绑定;上游消失的模型由保存时的
async function syncModels(c) { // 全量同步负责解绑。
if (syncing.value) return
syncing.value = true
err.value = ''
warn.value = ''
okMsg.value = ''
try {
const r = await api('/channels/sync-models', jsonBody('POST', { id: c.id }))
if (!r.ok) { err.value = r.data?.detail || '同步失败'; return }
const msg = r.data?.message || '已同步上游模型'
if (msg === '已同步上游模型') okMsg.value = msg
else warn.value = msg
load()
} finally {
syncing.value = false
}
}
// 表单内拉模型:编辑态直接全量同步;新渠道用表单里的地址+key 预览(导入时自动保存)。
function fetchFromForm() {
const f = form.value
if (!f) return
if (f.id) { syncModels(f); return }
if (!/^https?:\/\/\S+$/.test((f.base_url || '').trim())) { formErr.value = '上游地址需以 http(s):// 开头'; return }
if (!f.api_key) { formErr.value = '请填写 API Key'; return }
openFetch({ id: '', name: f.name || f.base_url, base_url: f.base_url, api_key: f.api_key, concurrency: f.concurrency, weight: f.weight, enabled: !!f.enabled })
}
async function openFetch(c) { async function openFetch(c) {
fetchTarget.value = c fetchTarget.value = c
specs.value = [] specs.value = []
picked.value = new Set()
fetchErr.value = '' fetchErr.value = ''
fetching.value = true fetching.value = true
const r = await api('/channels/fetch-models', jsonBody('POST', { const r = await api('/channels/fetch-models', jsonBody('POST', { id: c.id }))
id: c.id || '', base_url: c.base_url || '', api_key: c.api_key || '',
}))
fetching.value = false fetching.value = false
if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return } if (!r.ok) { fetchErr.value = r.data?.detail || '拉取失败'; return }
specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: s.alias || '', busy: false })) specs.value = (r.data?.data || []).map((s) => ({ ...s, alias: '' }))
picked.value = new Set(specs.value.map((s) => s.id))
} }
// 勾选 = 立即导入绑定;取消勾选 = 解绑(模型没有其他渠道接入时本地行一并删除)。 function togglePick(id) {
// 新渠道尚未入库时,第一次勾选会先自动保存渠道再绑定。 const next = new Set(picked.value)
async function toggleBind(s) { next.has(id) ? next.delete(id) : next.add(id)
if (s.busy) return picked.value = next
if (!s.bound && s.alias && s.alias === s.id) { }
fetchErr.value = `「${s.id}」别名不能与上游模型同名,留空即可`
return async function doImport() {
} if (importing.value || !picked.value.size) return
s.busy = true const dup = specs.value.find((s) => picked.value.has(s.id) && s.alias && s.alias === s.id)
if (dup) { fetchErr.value = `${dup.id}的别名与上游模型名相同,别名只在需要改名时设置,同名请留空`; return }
const models = specs.value.filter((s) => picked.value.has(s.id))
.map((s) => { const { bound, ...rest } = s; return rest })
importing.value = true
fetchErr.value = '' fetchErr.value = ''
try { try {
const target = fetchTarget.value const r = await api('/channels/import-models', jsonBody('POST', { id: fetchTarget.value.id, models }))
if (!s.bound) { if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
if (!target.id) { okMsg.value = `已导入 ${models.length} 个模型`
const sr = await api('/channels', jsonBody('POST', { fetchTarget.value = null
id: '', name: target.name || target.base_url, base_url: target.base_url, api_key: target.api_key,
enabled: target.enabled !== false, concurrency: target.concurrency || 0, weight: target.weight || 1,
}))
if (!sr.ok) { fetchErr.value = sr.data?.detail || '渠道保存失败'; return }
if (sr.data?.warning) warn.value = sr.data.warning
target.id = sr.data?.id
form.value = null
}
const { bound, busy, ...rest } = s
const r = await api('/channels/import-models', jsonBody('POST', { id: target.id, models: [rest] }))
if (!r.ok) { fetchErr.value = r.data?.detail || '导入失败'; return }
s.bound = true
} else {
const r = await api('/channels/unbind-model', jsonBody('POST', { id: target.id, model: s.id }))
if (!r.ok) { fetchErr.value = r.data?.detail || '解绑失败'; return }
s.bound = false
}
load() load()
} finally { } finally {
s.busy = false importing.value = false
} }
} }
@@ -204,7 +165,9 @@ onMounted(load)
<th>渠道</th> <th>渠道</th>
<th>并发</th> <th>并发</th>
<th>配比</th> <th>配比</th>
<th class="text-right">成功调用</th> <th class="text-right">运行中</th>
<th class="text-right">成功数</th>
<th class="text-right">失败数</th>
<th class="text-right">累计消耗</th> <th class="text-right">累计消耗</th>
<th class="text-right">上游余额</th> <th class="text-right">上游余额</th>
<th>状态</th> <th>状态</th>
@@ -220,7 +183,9 @@ onMounted(load)
</td> </td>
<td class="tabular-nums">{{ c.concurrency || '不限' }}</td> <td class="tabular-nums">{{ c.concurrency || '不限' }}</td>
<td class="tabular-nums">{{ c.weight || 1 }}</td> <td class="tabular-nums">{{ c.weight || 1 }}</td>
<td class="text-right tabular-nums" :class="c.running_count > 0 ? 'text-emerald-300' : ''">{{ c.running_count || 0 }}</td>
<td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td> <td class="text-right tabular-nums">{{ (c.success_total || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums" :class="c.fail_count > 0 ? 'text-rose-300' : ''">{{ (c.fail_count || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td> <td class="text-right tabular-nums">{{ (c.used_credits || 0).toLocaleString() }}</td>
<td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td> <td class="text-right tabular-nums">{{ c.upstream_balance == null ? '-' : c.upstream_balance.toLocaleString() }}</td>
<td> <td>
@@ -232,13 +197,13 @@ onMounted(load)
</td> </td>
<td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td> <td class="text-xs text-white/45 whitespace-nowrap">{{ c.last_used_at ? fmtTs(c.last_used_at) : '-' }}</td>
<td class="text-right whitespace-nowrap"> <td class="text-right whitespace-nowrap">
<button @click="syncModels(c)" :disabled="syncing" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button> <button @click="openFetch(c)" class="op" title="同步上游模型"><Icon name="spark" class="w-3.5 h-3.5" /></button>
<button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button> <button @click="openEdit(c)" class="op" title="编辑"><Icon name="config" class="w-3.5 h-3.5" /></button>
<button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button> <button @click="del(c)" class="op danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</td> </td>
</tr> </tr>
<tr v-if="loading"><td colspan="9" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr> <tr v-if="loading"><td colspan="11" class="py-10 text-center text-white/40 text-sm">正在加载渠道…</td></tr>
<tr v-else-if="!channels.length"><td colspan="9" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr> <tr v-else-if="!channels.length"><td colspan="11" class="py-10 text-center text-white/40 text-sm">没有渠道,点右上角「添加渠道」接入上游实例</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -286,12 +251,6 @@ onMounted(load)
{{ form.enabled ? '已启用' : '已停用' }} {{ form.enabled ? '已启用' : '已停用' }}
</button> </button>
</div> </div>
<div class="flex items-center gap-3">
<button type="button" @click="fetchFromForm" :disabled="fetching" class="btn-ghost text-xs">
{{ fetching ? '拉取中…' : '获取模型列表' }}
</button>
<span class="text-[11px] text-white/35">{{ form.id ? '直接全量同步上游模型到模型列表' : '用上方的地址与 API Key 预览;导入时会先自动保存渠道' }}</span>
</div>
<p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p> <p v-if="formErr" class="text-xs text-rose-300">{{ formErr }}</p>
</div> </div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2"> <div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
@@ -301,42 +260,40 @@ onMounted(load)
</div> </div>
</div> </div>
<!-- 拉取上游模型 → 勾选即导入绑定 --> <!-- 同步上游模型:开关选择 + 别名,批量导入 -->
<div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null"> <div v-if="fetchTarget" class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4" @click.self="fetchTarget = null">
<div class="card !shadow-2xl my-12 w-full max-w-2xl"> <div class="card !shadow-2xl my-12 w-full max-w-2xl">
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between"> <div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
<h2 class="text-sm font-semibold">上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2> <h2 class="text-sm font-semibold">同步上游模型 · {{ fetchTarget.name || fetchTarget.base_url }}</h2>
<button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button> <button @click="fetchTarget = null" class="text-white/40 hover:text-white transition-colors"><Icon name="close" class="w-5 h-5" /></button>
</div> </div>
<div class="p-5"> <div class="p-5">
<div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div> <div v-if="fetching" class="py-10 text-center text-white/40 text-sm">正在从上游拉取模型列表…</div>
<template v-else> <template v-else>
<p class="text-xs text-white/45 mb-3">勾选即导入并绑定到本渠道,取消勾选即解绑(模型没有其他渠道接入时本地行一并删除)。能力参数随模型带入,导入后到「模型管理」配价上架。别名即本站模型标识(留空=与上游同名,绑定时生效)。注意:保存渠道会按上游清单全量重同步,手动解绑的模型若仍在上游列表中会被重新接入。</p> <p class="text-xs text-white/45 mb-3 leading-relaxed">开启的模型会同步到本站模型列表,参数随上游带入;到「模型管理」配价后上架。别名即本站模型标识,留空 = 与上游同名。</p>
<p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p> <p v-if="fetchErr" class="text-xs text-rose-300 mb-3">{{ fetchErr }}</p>
<div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div> <div v-if="!specs.length" class="py-10 text-center text-white/40 text-sm">上游没有可售模型(模型需在上游已配价)</div>
<div v-else class="space-y-1.5 max-h-[380px] overflow-y-auto"> <div v-else class="space-y-2 max-h-[400px] overflow-y-auto p-0.5">
<div v-for="s in specs" :key="s.id" @click="toggleBind(s)" <div v-for="s in specs" :key="s.id" @click="togglePick(s.id)"
class="flex items-start gap-2.5 p-2.5 rounded-lg ring-1 ring-white/10 hover:bg-white/[0.04] cursor-pointer transition-colors" class="p-3 rounded-xl ring-1 ring-white/10 bg-white/[0.02] hover:bg-white/[0.04] hover:ring-white/20 cursor-pointer transition-all space-y-2"
:class="[!s.bound && 'opacity-50', s.busy && 'pointer-events-none']"> :class="!picked.has(s.id) && 'opacity-45'">
<span class="mt-1 inline-block h-3.5 w-3.5 shrink-0 rounded-full ring-1 transition-all" <div class="flex items-center gap-2.5 min-w-0">
:class="s.bound ? 'bg-sky-400 ring-sky-300' : 'bg-transparent ring-white/25'"></span> <span class="w-2 h-2 rounded-full shrink-0"
<div class="flex-1 min-w-0 space-y-1.5"> :class="s.kind === 'video' ? 'bg-sky-400' : s.kind === 'text' ? 'bg-amber-400' : 'bg-indigo-400'"></span>
<div class="flex items-center gap-2 flex-wrap"> <code class="font-mono text-[13px] font-semibold text-white/90 truncate">{{ s.id }}</code>
<code class="font-mono text-xs text-white/85">{{ s.id }}</code> <span class="text-[11px] font-semibold text-white/40 shrink-0">{{ KIND_LABEL[s.kind] || s.kind }}</span>
<span class="px-1.5 py-0.5 rounded text-[10px] font-semibold" <button type="button" class="ml-auto sw" :class="picked.has(s.id) && 'sw-on'"
:class="s.kind === 'video' ? 'bg-rose-400/15 text-rose-300' : 'bg-sky-400/15 text-sky-300'"> :aria-pressed="picked.has(s.id)" @click.stop="togglePick(s.id)">
{{ KIND_LABEL[s.kind] || s.kind }} <span class="sw-thumb"></span>
</span> </button>
<span class="text-[11px] text-white/40">{{ specText(s) }}</span> </div>
<span v-if="s.busy" class="ml-auto text-[10px] text-white/40">处理中…</span> <div class="pl-[18px] text-[11px] text-white/50">{{ specText(s) }}</div>
<span v-else-if="s.bound" class="ml-auto px-1.5 py-0.5 rounded bg-emerald-400/15 text-emerald-300 text-[10px] font-semibold">已接入</span> <div class="pl-[18px]" @click.stop>
</div> <input v-model.trim="s.alias" type="text" maxlength="64" :disabled="!picked.has(s.id)"
<div class="flex items-center gap-2" @click.stop> class="w-full px-2.5 py-1.5 rounded-lg bg-white/[0.03] border border-dashed border-white/15 text-xs text-white/80
<span class="text-[11px] text-white/35 shrink-0">别名</span> hover:border-solid focus:border-solid focus:border-white/30 focus:bg-white/[0.05] focus:outline-none
<input v-model.trim="s.alias" type="text" maxlength="64" :disabled="s.bound" disabled:opacity-40 disabled:cursor-not-allowed transition-all"
class="flex-1 min-w-0 px-2.5 py-1 rounded-lg bg-white/[0.04] ring-1 ring-white/10 text-xs text-white/80 disabled:opacity-40" placeholder="别名(留空 = 与上游同名)" />
:placeholder="s.bound ? '已绑定,到模型管理改别名' : '留空 = 与上游同名'" />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -344,6 +301,9 @@ onMounted(load)
</div> </div>
<div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2"> <div class="px-5 py-4 border-t border-white/[0.06] flex justify-end gap-2">
<button @click="fetchTarget = null" class="btn-ghost">关闭</button> <button @click="fetchTarget = null" class="btn-ghost">关闭</button>
<button @click="doImport" :disabled="importing || !picked.size" class="btn-primary">
{{ importing ? '同步中…' : `同步模型(${picked.size})` }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -362,4 +322,10 @@ onMounted(load)
.op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); } .op { display: inline-grid; place-items: center; min-width: 28px; height: 26px; padding: 0 7px; margin-left: 4px; border-radius: 7px; background: rgb(255 255 255 / 0.05); color: rgb(255 255 255 / 0.6); }
.op:hover { background: rgb(255 255 255 / 0.1); color: white; } .op:hover { background: rgb(255 255 255 / 0.1); color: white; }
.op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); } .op.danger:hover { background: rgb(244 63 94 / 0.15); color: rgb(251 113 133); }
/* iOS-style switch for 模型开关 in the sync modal */
.sw { position: relative; width: 2rem; height: 1.15rem; border-radius: 9999px; background: rgb(255 255 255 / 0.12); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.18s ease; flex-shrink: 0; }
.sw-thumb { position: absolute; top: 2px; left: 2px; width: calc(1.15rem - 4px); height: calc(1.15rem - 4px); border-radius: 9999px; background: white; box-shadow: 0 1px 2px rgb(15 23 42 / 0.3); transition: transform 0.18s ease; }
.sw-on { background: rgb(16 185 129 / 0.7); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.5); }
.sw-on .sw-thumb { transform: translateX(calc(2rem - 1.15rem)); }
</style> </style>