渠道: 新增主页字段 + 表格展示 favicon

- Channel 新增 homepage 字段(渠道主页)
- 后端 /admin/channels/:id/favicon 代理抓取 {homepage}/favicon.ico, 内存缓存 1h;
  服务端抓取可解析 localhost/内网主页, 避免浏览器跨域
- 前端渠道表单加"渠道主页"输入; 表格新增主页列(favicon + 域名), 加载失败回退灰色地球图标
- mock 上游提供 /favicon.ico 演示

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 17:01:45 +08:00
co-authored by Claude
parent d9dcbc4fb3
commit 4f92d7e0a4
7 changed files with 122 additions and 15 deletions
+6
View File
@@ -49,6 +49,12 @@ func main() {
fmt.Fprint(w, `{"object":"list","data":[{"id":"gpt-4o-mini","object":"model"},{"id":"gpt-4o","object":"model"},{"id":"claude-sonnet-5","object":"model"}]}`) fmt.Fprint(w, `{"object":"list","data":[{"id":"gpt-4o-mini","object":"model"},{"id":"gpt-4o","object":"model"},{"id":"claude-sonnet-5","object":"model"}]}`)
}) })
// favicon(演示渠道主页图标)
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
fmt.Fprint(w, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="#34d399"/><path d="M4 4.6h8v1.4H4zM4 7.3h8v1.4H4zM4 10h5v1.4H4z" fill="#09090b"/></svg>`)
})
// Anthropic Messages 端点(provider=anthropic 的渠道走这里) // Anthropic Messages 端点(provider=anthropic 的渠道走这里)
http.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
+64 -12
View File
@@ -33,7 +33,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
masked = "****" masked = "****"
} }
out = append(out, gin.H{ out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "base_url": ch.BaseURL, "id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "homepage": ch.Homepage, "base_url": ch.BaseURL,
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority, "api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency, "timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
"health_status": ch.HealthStatus, "enabled": ch.Enabled, "health_status": ch.HealthStatus, "enabled": ch.Enabled,
@@ -47,6 +47,7 @@ type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"` Name string `json:"name" binding:"required,min=1,max=64"`
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据) Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置) Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
Homepage string `json:"homepage"`
BaseURL string `json:"base_url" binding:"required"` BaseURL string `json:"base_url" binding:"required"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Weight *int `json:"weight"` Weight *int `json:"weight"`
@@ -142,7 +143,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
return return
} }
ch := store.Channel{ ch := store.Channel{
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: strings.TrimRight(req.BaseURL, "/"), Name: req.Name, Provider: req.Provider, Formats: formats, Homepage: req.Homepage, BaseURL: strings.TrimRight(req.BaseURL, "/"),
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0), APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16), TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true), HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
@@ -162,17 +163,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
return return
} }
var body struct { var body struct {
Name *string `json:"name"` Name *string `json:"name"`
Provider *string `json:"provider"` Provider *string `json:"provider"`
Formats *[]string `json:"formats"` Formats *[]string `json:"formats"`
BaseURL *string `json:"base_url"` Homepage *string `json:"homepage"`
APIKey *string `json:"api_key"` BaseURL *string `json:"base_url"`
Weight *int `json:"weight"` APIKey *string `json:"api_key"`
Priority *int `json:"priority"` Weight *int `json:"weight"`
TimeoutMS *int `json:"timeout_ms"` Priority *int `json:"priority"`
MaxConcurrency *int `json:"max_concurrency"` TimeoutMS *int `json:"timeout_ms"`
HealthStatus *string `json:"health_status"` MaxConcurrency *int `json:"max_concurrency"`
Enabled *bool `json:"enabled"` HealthStatus *string `json:"health_status"`
Enabled *bool `json:"enabled"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input") resp.Fail(c, http.StatusBadRequest, "invalid input")
@@ -217,6 +219,9 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
if body.MaxConcurrency != nil { if body.MaxConcurrency != nil {
updates["max_concurrency"] = *body.MaxConcurrency updates["max_concurrency"] = *body.MaxConcurrency
} }
if body.Homepage != nil {
updates["homepage"] = *body.Homepage
}
if body.HealthStatus != nil { if body.HealthStatus != nil {
updates["health_status"] = *body.HealthStatus updates["health_status"] = *body.HealthStatus
} }
@@ -398,6 +403,53 @@ func (h *Handler) AdminImportChannelModels(c *gin.Context) {
resp.OK(c, gin.H{"imported": imported}) resp.OK(c, gin.H{"imported": imported})
} }
// AdminChannelFavicon GET /api/v1/admin/channels/:id/favicon — 代理获取渠道主页 favicon(内存缓存 1h)。
func (h *Handler) AdminChannelFavicon(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
var ch store.Channel
if err := h.a.DB.First(&ch, id).Error; err != nil || ch.Homepage == "" {
c.Status(http.StatusNotFound)
return
}
h.favMu.Lock()
if f, ok := h.favCache[id]; ok && time.Since(f.at) < time.Hour {
h.favMu.Unlock()
c.Data(http.StatusOK, f.ct, f.data)
return
}
h.favMu.Unlock()
url := strings.TrimRight(ch.Homepage, "/") + "/favicon.ico"
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil || resp.StatusCode != http.StatusOK {
if resp != nil {
resp.Body.Close()
}
c.Status(http.StatusNotFound)
return
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil || len(data) == 0 {
c.Status(http.StatusNotFound)
return
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "image/x-icon"
}
h.favMu.Lock()
h.favCache[id] = favEntry{data: data, ct: ct, at: time.Now()}
h.favMu.Unlock()
c.Data(http.StatusOK, ct, data)
}
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用) var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
func intOr(p *int, def int) int { func intOr(p *int, def int) int {
+13 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -17,9 +18,20 @@ import (
// Handler 聚合所有管理 API。 // Handler 聚合所有管理 API。
type Handler struct { type Handler struct {
a *app.App a *app.App
favMu sync.Mutex
favCache map[uint64]favEntry // 渠道 favicon 内存缓存
} }
func NewHandler(a *app.App) *Handler { return &Handler{a: a} } type favEntry struct {
data []byte
ct string
at time.Time
}
func NewHandler(a *app.App) *Handler {
return &Handler{a: a, favCache: map[uint64]favEntry{}}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 认证 // 认证
+1
View File
@@ -93,6 +93,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
admin.DELETE("/channels/:id", h.AdminDeleteChannel) admin.DELETE("/channels/:id", h.AdminDeleteChannel)
admin.POST("/channels/:id/test", h.AdminTestChannel) admin.POST("/channels/:id/test", h.AdminTestChannel)
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels) admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
admin.GET("/channels/:id/favicon", h.AdminChannelFavicon)
// 模型与定价 // 模型与定价
admin.GET("/models", h.AdminModels) admin.GET("/models", h.AdminModels)
admin.POST("/models", h.AdminCreateModel) admin.POST("/models", h.AdminCreateModel)
+1
View File
@@ -81,6 +81,7 @@ type Channel struct {
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式) Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
Homepage string `gorm:"size:255" json:"homepage"` // 渠道主页,用于展示 favicon
BaseURL string `gorm:"size:255;not null" json:"base_url"` BaseURL string `gorm:"size:255;not null" json:"base_url"`
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
Weight int `gorm:"not null;default:1" json:"weight"` Weight int `gorm:"not null;default:1" json:"weight"`
+1
View File
@@ -26,6 +26,7 @@ export interface Channel {
name: string name: string
provider: 'openai' | 'anthropic' | 'compatible' provider: 'openai' | 'anthropic' | 'compatible'
formats: string[] // chat | responses | messages formats: string[] // chat | responses | messages
homepage?: string
base_url: string base_url: string
api_key_masked: string api_key_masked: string
weight: number weight: number
+36 -2
View File
@@ -19,6 +19,7 @@ const busyId = ref<number | null>(null)
const form = reactive({ const form = reactive({
name: '', name: '',
formats: ['chat'] as string[], formats: ['chat'] as string[],
homepage: '',
base_url: '', base_url: '',
api_key: '', api_key: '',
weight: 1, weight: 1,
@@ -28,6 +29,24 @@ const form = reactive({
enabled: true, enabled: true,
}) })
// favicon 兜底:灰色地球
const FAV_FALLBACK =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.5" fill="none" stroke="#a1a1aa" stroke-width="1.4"/><ellipse cx="8" cy="8" rx="3" ry="6.5" fill="none" stroke="#a1a1aa" stroke-width="1.4"/><path d="M1.8 8h12.4" stroke="#a1a1aa" stroke-width="1.4"/></svg>',
)
function onFavError(e: Event) {
;(e.target as HTMLImageElement).src = FAV_FALLBACK
}
function hostOf(u?: string): string {
if (!u) return '-'
try {
return new URL(u).host
} catch {
return u
}
}
async function load() { async function load() {
try { try {
const { data } = await http.get('/admin/channels') const { data } = await http.get('/admin/channels')
@@ -40,7 +59,7 @@ async function load() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
Object.assign(form, { Object.assign(form, {
name: '', formats: ['chat'], base_url: '', api_key: '', name: '', formats: ['chat'], homepage: '', base_url: '', api_key: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true, weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
}) })
editOpen.value = true editOpen.value = true
@@ -50,6 +69,7 @@ function openEdit(ch: Channel) {
editing.value = ch editing.value = ch
Object.assign(form, { Object.assign(form, {
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])], name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
homepage: ch.homepage || '',
base_url: ch.base_url, api_key: '', base_url: ch.base_url, api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms, weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled, max_concurrency: ch.max_concurrency, enabled: ch.enabled,
@@ -143,6 +163,7 @@ onMounted(load)
<tr class="border-b border-edge text-left text-xs text-muted"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th> <th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">主页</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th> <th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th> <th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th> <th scope="col" class="px-4 py-2.5 font-medium">健康</th>
@@ -162,6 +183,18 @@ onMounted(load)
>{{ protocolName(f) }}</span> >{{ protocolName(f) }}</span>
</div> </div>
</td> </td>
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
<img
:src="`/api/v1/admin/channels/${ch.id}/favicon`"
:alt="ch.name"
class="size-5 shrink-0 rounded-sm"
loading="lazy"
@error="onFavError"
/>
<span class="text-xs text-muted">{{ hostOf(ch.homepage) }}</span>
</div>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
@@ -182,7 +215,7 @@ onMounted(load)
</td> </td>
</tr> </tr>
<tr v-if="channels.length === 0"> <tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td> <td colspan="8" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -213,6 +246,7 @@ onMounted(load)
<p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p> <p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div> </div>
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" /> <Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" />
<Input v-model="form.homepage" label="渠道主页" placeholder="https://openai.com(用于展示 favicon)" />
<Input <Input
v-model="form.api_key" v-model="form.api_key"
label="上游 API Key" label="上游 API Key"