Compare commits

...
4 Commits
Author SHA1 Message Date
SakurasanandClaude 967c54ae32 代理: 鉴权支持 Anthropic x-api-key 头
Claude Code / Anthropic SDK 用 x-api-key 头而不是 Authorization:
Bearer,缺失 Authorization 时回退读取 x-api-key,错误文案同步提示两种方式

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 00:51:53 +08:00
SakurasanandClaude eac6938baa 服务: SPA 缓存策略修复重建后首页黑屏
- index.html 设 Cache-Control: no-cache,避免浏览器启发式缓存保留引用已删除 chunk 的旧 HTML
- /assets/*(文件名含内容 hash,不可变)设 immutable 长缓存,减少回源

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:30:17 +08:00
SakurasanandClaude fcaafdc807 前端: Base URL 输入限长 + 列表截断防撑破
- Input 组件支持 maxlength 透传
- 渠道表单 4 个 Base URL 输入框限 255 字符
- 渠道列表 Base URL 单元格截断显示(max-w-220px)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:30:09 +08:00
SakurasanandClaude 74424d980c 前端: 仪表盘快速开始卡片 + 移动端适配 + 主页 curl 用浏览器 host
- 仪表盘底部新增「快速开始」:Base URL(浏览器 host)+ 协议徽标 + curl 示例(可复制)
- 最近请求改为响应式两行(模型+状态 / token·成本·时间),窄屏不再挤压
- 主页调用演示改用 window.location.origin,去掉写死的 api.openteam.dev

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:30:03 +08:00
6 changed files with 97 additions and 17 deletions
+9 -1
View File
@@ -34,7 +34,13 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
// 静态资源(前端构建产物,存在时托管) // 静态资源(前端构建产物,存在时托管)
const dist = "web/dist" const dist = "web/dist"
if _, err := os.Stat(dist); err == nil { if _, err := os.Stat(dist); err == nil {
r.Static("/assets", dist+"/assets") // /assets 文件名含内容 hash,不可变:长缓存 + immutable,避免每次回源
assets := r.Group("/assets")
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
})
assets.Static("", dist+"/assets")
r.StaticFile("/favicon.svg", dist+"/favicon.svg") r.StaticFile("/favicon.svg", dist+"/favicon.svg")
} }
@@ -48,6 +54,8 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
return return
} }
if _, err := os.Stat(dist); err == nil { if _, err := os.Stat(dist); err == nil {
// SPA 入口:必须每次回源校验,否则浏览器启发式缓存会保留引用已删除 chunk 的旧 HTML → 黑屏
c.Header("Cache-Control", "no-cache")
c.File(dist + "/index.html") c.File(dist + "/index.html")
return return
} }
+5 -1
View File
@@ -135,8 +135,12 @@ func (g *Gateway) Auth(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
key := strings.TrimPrefix(auth, "Bearer ") key := strings.TrimPrefix(auth, "Bearer ")
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
if key == "" {
// Anthropic 客户端(Claude Code / SDK)用 x-api-key 头而不是 Authorization
key = strings.TrimSpace(c.GetHeader("x-api-key"))
}
if !apikey.Valid(key) { if !apikey.Valid(key) {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...") apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-... or x-api-key: sk-...")
c.Abort() c.Abort()
return return
} }
+2
View File
@@ -9,6 +9,7 @@ withDefaults(
error?: string error?: string
autocomplete?: string autocomplete?: string
disabled?: boolean disabled?: boolean
maxlength?: number
}>(), }>(),
{ type: 'text', modelValue: '', disabled: false }, { type: 'text', modelValue: '', disabled: false },
) )
@@ -24,6 +25,7 @@ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
:placeholder="placeholder" :placeholder="placeholder"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:disabled="disabled" :disabled="disabled"
:maxlength="maxlength"
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink placeholder-muted outline-none transition focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink placeholder-muted outline-none transition focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50"
:class="error && 'border-err focus:border-err focus:ring-err'" :class="error && 'border-err focus:border-err focus:ring-err'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
+6 -2
View File
@@ -2,6 +2,10 @@
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ThemeToggle from '@/components/ui/ThemeToggle.vue' import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const auth = useAuthStore() const auth = useAuthStore()
// 调用演示用浏览器当前 host(兼容端点),避免写死域名
const host = window.location.host
const baseURL = `${window.location.origin}/v1`
</script> </script>
<template> <template>
@@ -70,11 +74,11 @@ const auth = useAuthStore()
<div class="flex items-center gap-1.5 border-b border-edge px-4 py-2.5"> <div class="flex items-center gap-1.5 border-b border-edge px-4 py-2.5">
<span class="size-2.5 rounded-full bg-surface2" /> <span class="size-2.5 rounded-full bg-surface2" />
<span class="size-2.5 rounded-full bg-surface2" /> <span class="size-2.5 rounded-full bg-surface2" />
<span class="ml-2 text-muted">curl api.openteam.dev</span> <span class="ml-2 text-muted">curl {{ host }}</span>
</div> </div>
<div class="space-y-3 p-4 leading-relaxed"> <div class="space-y-3 p-4 leading-relaxed">
<div> <div>
<p class="text-muted"><span class="text-accent">$</span> curl https://api.openteam.dev/v1/chat/completions</p> <p class="text-muted"><span class="text-accent">$</span> curl {{ baseURL }}/chat/completions</p>
<p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-..."</span> \</p> <p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-..."</span> \</p>
<p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p> <p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
</div> </div>
+5 -4
View File
@@ -222,7 +222,7 @@ onMounted(load)
>{{ protocolShort(f) }}</code> >{{ protocolShort(f) }}</code>
</div> </div>
</td> </td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td> <td class="max-w-[220px] truncate 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">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'"> <Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
@@ -288,13 +288,14 @@ onMounted(load)
v-model="form.base_url" v-model="form.base_url"
label="Base URL(可选)" label="Base URL(可选)"
placeholder="https://api.openai.com/v1" placeholder="https://api.openai.com/v1"
:maxlength="255"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认" hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
/> />
<div class="space-y-3 rounded-md border border-edge p-3"> <div class="space-y-3 rounded-md border border-edge p-3">
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p> <p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" /> <Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" /> <Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" /> <Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" :maxlength="255" />
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p> <p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
</div> </div>
<Input <Input
+70 -9
View File
@@ -3,7 +3,9 @@ import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
import { copyText } from '@/lib/clipboard'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue' import Skeleton from '@/components/ui/Skeleton.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
@@ -44,6 +46,24 @@ function daysAgo(n: number): string {
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
} }
// 快速开始:Base URL 用浏览器当前 host(兼容端点),curl 示例
const baseURL = `${window.location.origin}/v1`
const curlCmd = `curl ${baseURL}/chat/completions \\
-H "Authorization: Bearer sk-xxxx" \\
-H "Content-Type: application/json" \\
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
const copied = ref('')
async function copy(text: string, key: string) {
const ok = await copyText(text)
if (ok) {
copied.value = key
setTimeout(() => (copied.value = ''), 2000)
} else {
toast.err('复制失败,请手动复制')
}
}
onMounted(load) onMounted(load)
</script> </script>
@@ -102,16 +122,18 @@ onMounted(load)
</li> </li>
</ul> </ul>
<ul v-else class="divide-y divide-edge"> <ul v-else class="divide-y divide-edge">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2"> <li v-for="l in logs" :key="l.id" class="py-2">
<div class="min-w-0"> <div class="flex items-center justify-between gap-2">
<p class="truncate font-mono text-xs text-ink">{{ l.model }}</p> <p class="min-w-0 truncate font-mono text-xs text-ink">{{ l.model }}</p>
<p class="text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p> <Badge class="shrink-0" :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</div>
<div class="flex items-center gap-2">
<span class="mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="mono-num w-16 text-right text-xs text-muted">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</div> </div>
<p class="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="opacity-40">·</span>
<span class="mono-num">{{ fmtCost(l.cost) }}</span>
<span class="opacity-40">·</span>
<span>{{ fmtTime(l.created_at) }}</span>
</p>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted"> <li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">
还没有请求记录,去 还没有请求记录,去
@@ -121,5 +143,44 @@ onMounted(load)
</ul> </ul>
</div> </div>
</div> </div>
<!-- 快速开始 -->
<div class="card p-5">
<div class="mb-4">
<h2 class="text-sm font-semibold">快速开始</h2>
<p class="text-xs text-muted">通过兼容端点接入,OpenAI / Anthropic 协议自动转换</p>
</div>
<div class="space-y-4">
<div>
<p class="mb-1.5 text-[11px] font-medium text-muted">Base URL</p>
<div class="flex items-center gap-2">
<code class="min-w-0 flex-1 overflow-x-auto rounded-md border border-edge bg-surface px-3 py-2 font-mono text-xs whitespace-nowrap text-ink">{{ baseURL }}</code>
<Button size="sm" variant="ghost" class="shrink-0" @click="copy(baseURL, 'base')">
{{ copied === 'base' ? '已复制' : '复制' }}
</Button>
</div>
</div>
<div>
<p class="mb-1.5 text-[11px] font-medium text-muted">兼容</p>
<div class="flex flex-wrap gap-1.5">
<Badge variant="neutral">messages</Badge>
<Badge variant="neutral">chat/completions</Badge>
<Badge variant="neutral">responses</Badge>
</div>
</div>
<div>
<p class="mb-1.5 text-[11px] font-medium text-muted">curl 示例</p>
<div class="relative">
<pre class="overflow-x-auto rounded-md border border-edge bg-surface px-3 py-2.5 pr-20 font-mono text-[11px] leading-relaxed text-ink">{{ curlCmd }}</pre>
<Button size="sm" variant="ghost" class="absolute top-2 right-2" @click="copy(curlCmd, 'curl')">
{{ copied === 'curl' ? '已复制' : '复制' }}
</Button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>