Merge upstream v0.1.112

Merge upstream v0.1.112 into release/custom-0.1.112.
Resolved conflicts preserving our customizations (allow_user_refund,
balance/quota notify, web search emulation) while adopting upstream
improvements (BySortOrder for plans, CSS sidebar collapse animation,
mobile lazy-load optimization, Anthropic empty output fix).
This commit is contained in:
erio
2026-04-14 02:00:02 +08:00
35 changed files with 1002 additions and 81 deletions
+7
View File
@@ -28,3 +28,10 @@ exceptions:
mitigation: "No user-controlled template strings; plan to migrate to native JS alternatives"
expires_on: "2026-07-02"
owner: "security@your-domain"
- package: axios
advisory: "GHSA-3p68-rc4w-qgx5"
severity: critical
reason: "NO_PROXY bypass not exploitable; all API calls go to known endpoints via server-side proxy"
mitigation: "Proxy configuration not user-controlled; upgrade when axios releases fix"
expires_on: "2026-07-10"
owner: "security@your-domain"
+10
View File
@@ -81,6 +81,16 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
<td>Thanks to YLS Code for sponsoring this project! <a href="https://ylscode.com/">YLS Code</a> is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
</table>
## Ecosystem
+11 -1
View File
@@ -76,10 +76,20 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
</tr>
<tr>
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="silkapi" width="150"></a></td>
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
<td>感谢 伊莉思Code 赞助了本项目! <a href="https://ylscode.com/">伊莉思Code</a> 致力于构建安全的企业级Coding Agent生产力服务,提供稳定快速的 Codex / Claude / Gemini 订阅服务与即用即付API多种方案灵活选择,限时注册赠送 3 天 Codex 试用福利!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
</table>
## 生态项目
+10
View File
@@ -80,6 +80,16 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
<td>YLS Code のご支援に感謝します!<a href="https://ylscode.com/">YLS Code</a> は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
</tr>
</table>
## エコシステム
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.110.56
0.1.112.1
+7
View File
@@ -43,6 +43,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Server layer ProviderSet
server.ProviderSet,
// Payment providers
payment.ProvideRegistry,
payment.ProvideEncryptionKey,
payment.ProvideDefaultLoadBalancer,
service.ProvidePaymentConfigService,
service.ProvidePaymentOrderExpiryService,
// Privacy client factory for OpenAI training opt-out
providePrivacyClientFactory,
+2
View File
@@ -103,6 +103,7 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
@@ -174,6 +175,7 @@ require (
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
+2
View File
@@ -162,6 +162,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -124,6 +124,14 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
"top_p",
"frequency_penalty",
"presence_penalty",
// prompt_cache_retention is a newer Responses API parameter (cache TTL).
// The ChatGPT internal Codex endpoint rejects it with
// "Unsupported parameter: prompt_cache_retention". Defense-in-depth
// for any OAuth path that reaches this transform — the Cursor
// Responses-shape short-circuit in ForwardAsChatCompletions strips
// it earlier too, but we keep this line so other OAuth callers are
// equally protected.
"prompt_cache_retention",
} {
if _, ok := reqBody[key]; ok {
delete(reqBody, key)
@@ -481,6 +481,26 @@ func TestExtractSystemMessagesFromInput(t *testing.T) {
})
}
// TestApplyCodexOAuthTransform_StripsPromptCacheRetention is a regression
// test: some clients (e.g. Cursor cloud via the Responses-shape compat path)
// send prompt_cache_retention, but the ChatGPT internal Codex endpoint
// rejects it with "Unsupported parameter: prompt_cache_retention".
func TestApplyCodexOAuthTransform_StripsPromptCacheRetention(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
"prompt_cache_retention": "24h",
"input": []any{
map[string]any{"role": "user", "content": "hi"},
},
}
applyCodexOAuthTransform(reqBody, false, false)
_, stillThere := reqBody["prompt_cache_retention"]
require.False(t, stillThere,
"prompt_cache_retention must be stripped before forwarding to Codex upstream")
}
func TestApplyCodexOAuthTransform_ExtractsSystemMessages(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.1",
@@ -0,0 +1,199 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// TestCursorMixedShapeDetection covers the core invariant of the Cursor
// compatibility fix in ForwardAsChatCompletions: when a client POSTs a
// Responses-shaped body (has `input`, no `messages`) to /v1/chat/completions,
// the request must be forwarded as-is with only the `model` field rewritten.
// The raw `input` array (including Cursor's 80KB system prompt) must not be
// discarded or reshaped.
//
// Context:
//
// Before the fix, the handler unmarshaled the body into ChatCompletionsRequest,
// which has no Input field, silently dropping Cursor's input. The subsequent
// conversion produced `input: null`, which Codex upstreams reject with
// "Invalid type for 'input': expected a string, but got an object".
func TestCursorMixedShapeDetection(t *testing.T) {
// Representative Cursor cloud body — shape is what matters, content is
// abridged. Notice: `input` is a Responses-API array, there is no
// `messages` field at all, and `user`/`stream` are at the top level.
cursorBody := []byte(`{
"user": "85df22e7463ab6c2",
"model": "gpt-5.4",
"stream": true,
"input": [
{"role":"system","content":"You are GPT-5.4 running as a coding agent."},
{"role":"user","content":"hello"}
],
"service_tier": "auto",
"reasoning": {"effort": "high"}
}`)
// --- Step 1: Shape detection (mirrors ForwardAsChatCompletions) ---
hasMessages := gjson.GetBytes(cursorBody, "messages").Exists()
hasInput := gjson.GetBytes(cursorBody, "input").Exists()
isResponsesShape := !hasMessages && hasInput
require.True(t, isResponsesShape,
"Cursor body must be detected as Responses-shape (has input, no messages)")
// --- Step 2: Model rewrite (mirrors the sjson.SetBytes branch) ---
const upstreamModel = "gpt-5.1-codex"
rewritten, err := sjson.SetBytes(cursorBody, "model", upstreamModel)
require.NoError(t, err)
// --- Step 3: Invariants of the rewritten body ---
// 3a. model must be rewritten to the upstream target.
assert.Equal(t, upstreamModel, gjson.GetBytes(rewritten, "model").String())
// 3b. input array must be preserved verbatim — no reshaping, no nulling.
inputResult := gjson.GetBytes(rewritten, "input")
require.True(t, inputResult.Exists(), "input field must still exist after rewrite")
require.True(t, inputResult.IsArray(), "input must still be an array (not null, not object)")
items := inputResult.Array()
require.Len(t, items, 2, "both input items must be preserved")
assert.Equal(t, "system", items[0].Get("role").String())
assert.Equal(t, "You are GPT-5.4 running as a coding agent.",
items[0].Get("content").String())
assert.Equal(t, "user", items[1].Get("role").String())
assert.Equal(t, "hello", items[1].Get("content").String())
// 3c. ALL other top-level fields must survive intact.
assert.Equal(t, "85df22e7463ab6c2", gjson.GetBytes(rewritten, "user").String())
assert.Equal(t, true, gjson.GetBytes(rewritten, "stream").Bool())
assert.Equal(t, "auto", gjson.GetBytes(rewritten, "service_tier").String())
assert.Equal(t, "high", gjson.GetBytes(rewritten, "reasoning.effort").String())
// 3d. Final upstream body must NOT contain the old "input":null pattern.
assert.NotContains(t, string(rewritten), `"input":null`,
"rewritten body must not collapse input to null")
}
// TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected guards that
// the shape detection does NOT misfire on a standard Chat Completions request
// (one that has a `messages` array). Such requests must fall through to the
// existing ChatCompletionsToResponses conversion path.
func TestCursorMixedShapeDetection_NormalChatCompletionsUnaffected(t *testing.T) {
body := []byte(`{
"model": "gpt-4o",
"messages": [{"role":"user","content":"hi"}],
"stream": true
}`)
hasMessages := gjson.GetBytes(body, "messages").Exists()
hasInput := gjson.GetBytes(body, "input").Exists()
isResponsesShape := !hasMessages && hasInput
assert.False(t, isResponsesShape,
"standard Chat Completions body must NOT be detected as Responses-shape")
}
// TestCursorMixedShapeDetection_BothFieldsPrefersMessages guards the
// ambiguous case where a client sends both `messages` and `input`. We fall
// through to the normal conversion path (messages wins), since mixing the
// two is almost certainly a client bug and messages is the documented
// Chat Completions contract.
func TestCursorMixedShapeDetection_BothFieldsPrefersMessages(t *testing.T) {
body := []byte(`{
"model": "gpt-4o",
"messages": [{"role":"user","content":"hi"}],
"input": [{"role":"user","content":"other"}]
}`)
hasMessages := gjson.GetBytes(body, "messages").Exists()
hasInput := gjson.GetBytes(body, "input").Exists()
isResponsesShape := !hasMessages && hasInput
assert.False(t, isResponsesShape,
"when both messages and input are present, must not take the Cursor shortcut")
}
// TestCursorMixedShapeDetection_EmptyBody ensures a body with neither
// messages nor input is NOT taken as Cursor-shape (would hit the normal
// conversion and fail on its own with a clearer error).
func TestCursorMixedShapeDetection_EmptyBody(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","stream":true}`)
hasMessages := gjson.GetBytes(body, "messages").Exists()
hasInput := gjson.GetBytes(body, "input").Exists()
isResponsesShape := !hasMessages && hasInput
assert.False(t, isResponsesShape,
"body with neither messages nor input must not be taken as Cursor shape")
}
// TestCursorMixedShape_JSONRoundtrip ensures the rewritten body is still
// valid JSON and parseable back into a map without surprises — catches
// any encoding drift from sjson.
func TestCursorMixedShape_JSONRoundtrip(t *testing.T) {
cursorBody := []byte(`{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"hi"}]}`)
rewritten, err := sjson.SetBytes(cursorBody, "model", "gpt-5.1-codex")
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(rewritten, &parsed))
assert.Equal(t, "gpt-5.1-codex", parsed["model"])
assert.Equal(t, true, parsed["stream"])
inputArr, ok := parsed["input"].([]any)
require.True(t, ok, "input must decode to a Go []any after round-trip")
require.Len(t, inputArr, 1)
}
// TestCursorMixedShape_StripsUnsupportedFields mirrors the strip loop in
// ForwardAsChatCompletions (isResponsesShape branch). Cursor cloud sends
// prompt_cache_retention, safety_identifier, metadata and stream_options
// as top-level Responses API parameters, which Codex upstreams reject with
// "Unsupported parameter: ...". The fix must remove them from the raw body
// before it is forwarded, for BOTH OAuth and API Key account types.
func TestCursorMixedShape_StripsUnsupportedFields(t *testing.T) {
cursorBody := []byte(`{
"model": "gpt-5.4",
"stream": true,
"prompt_cache_retention": "24h",
"safety_identifier": "cursor-user-xyz",
"metadata": {"trace_id":"abc","caller":"cursor"},
"stream_options": {"include_usage": true},
"input": [{"role":"user","content":"hi"}]
}`)
// Sanity: the test fixture contains every field the production code strips.
for _, field := range cursorResponsesUnsupportedFields {
require.True(t, gjson.GetBytes(cursorBody, field).Exists(),
"test fixture must contain %s", field)
}
// Run the exact same loop as the production code.
result := cursorBody
for _, field := range cursorResponsesUnsupportedFields {
if stripped, err := sjson.DeleteBytes(result, field); err == nil {
result = stripped
}
}
// All unsupported fields must be gone.
for _, field := range cursorResponsesUnsupportedFields {
assert.False(t, gjson.GetBytes(result, field).Exists(),
"%s must be stripped", field)
}
// Everything else must survive intact.
assert.Equal(t, "gpt-5.4", gjson.GetBytes(result, "model").String())
assert.Equal(t, true, gjson.GetBytes(result, "stream").Bool())
assert.True(t, gjson.GetBytes(result, "input").IsArray())
assert.Equal(t, "user", gjson.GetBytes(result, "input.0.role").String())
}
@@ -16,9 +16,27 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.uber.org/zap"
)
// cursorResponsesUnsupportedFields are top-level Responses API parameters that
// Codex upstreams reject with "Unsupported parameter: ...". They must be
// stripped when forwarding a raw client body through the Responses-shape
// short-circuit in ForwardAsChatCompletions (see isResponsesShape branch).
// The normal Chat Completions → Responses conversion path is unaffected
// because ChatCompletionsRequest has no fields for these parameters — unknown
// fields are dropped naturally by json.Unmarshal. Kept semantically in sync
// with the list in openai_gateway_service.go:2034 used by the /v1/responses
// passthrough path.
var cursorResponsesUnsupportedFields = []string{
"prompt_cache_retention",
"safety_identifier",
"metadata",
"stream_options",
}
// ForwardAsChatCompletions accepts a Chat Completions request body, converts it
// to OpenAI Responses API format, forwards to the OpenAI upstream, and converts
// the response back to Chat Completions format. All account types (OAuth and API
@@ -55,13 +73,62 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
compatPromptCacheInjected = promptCacheKey != ""
}
// 3. Convert to Responses and forward
// ChatCompletionsToResponses always sets Stream=true (upstream always streams).
responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq)
if err != nil {
return nil, fmt.Errorf("convert chat completions to responses: %w", err)
// 3. Build the upstream (Responses API) body.
//
// Cursor compatibility: some clients (notably Cursor cloud) send Responses
// API shaped bodies — `input: [...]` with no `messages` field — to the
// /v1/chat/completions URL. Running those through ChatCompletionsToResponses
// would silently drop Cursor's `input` array (the struct has no Input field)
// and produce `input: null`, which Codex upstreams reject with
// "Invalid type for 'input': expected a string, but got an object".
//
// Detect that shape and forward the raw body as-is, only rewriting `model`
// to the resolved upstream model. The downstream codex OAuth transform will
// still normalize store/stream/instructions/etc.
isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
var (
responsesReq *apicompat.ResponsesRequest
responsesBody []byte
err error
)
if isResponsesShape {
responsesBody, err = sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return nil, fmt.Errorf("rewrite model in responses-shape body: %w", err)
}
// Strip Responses API parameters that no Codex upstream accepts.
// Because this branch forwards the raw body (the normal path rebuilds
// it from ChatCompletionsRequest and drops unknown fields naturally),
// we must filter these fields explicitly here — otherwise the upstream
// rejects the request with "Unsupported parameter: ...".
for _, field := range cursorResponsesUnsupportedFields {
if stripped, derr := sjson.DeleteBytes(responsesBody, field); derr == nil {
responsesBody = stripped
}
}
// Minimal stub populated from the raw body so downstream billing
// propagation (ServiceTier, ReasoningEffort) keeps working.
responsesReq = &apicompat.ResponsesRequest{
Model: upstreamModel,
ServiceTier: gjson.GetBytes(responsesBody, "service_tier").String(),
}
if effort := gjson.GetBytes(responsesBody, "reasoning.effort").String(); effort != "" {
responsesReq.Reasoning = &apicompat.ResponsesReasoning{Effort: effort}
}
} else {
// Normal path: convert Chat Completions → Responses.
// ChatCompletionsToResponses always sets Stream=true (upstream always streams).
responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq)
if err != nil {
return nil, fmt.Errorf("convert chat completions to responses: %w", err)
}
responsesReq.Model = upstreamModel
responsesBody, err = json.Marshal(responsesReq)
if err != nil {
return nil, fmt.Errorf("marshal responses request: %w", err)
}
}
responsesReq.Model = upstreamModel
logFields := []zap.Field{
zap.Int64("account_id", account.ID),
@@ -69,6 +136,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
zap.String("billing_model", billingModel),
zap.String("upstream_model", upstreamModel),
zap.Bool("stream", clientStream),
zap.Bool("responses_shape", isResponsesShape),
}
if compatPromptCacheInjected {
logFields = append(logFields,
@@ -78,12 +146,6 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
logger.L().Debug("openai chat_completions: model mapping applied", logFields...)
// 4. Marshal Responses request body, then apply OAuth codex transform
responsesBody, err := json.Marshal(responsesReq)
if err != nil {
return nil, fmt.Errorf("marshal responses request: %w", err)
}
{
var reqBody map[string]any
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
@@ -270,6 +270,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
var finalResponse *apicompat.ResponsesResponse
var usage OpenAIUsage
acc := apicompat.NewBufferedResponseAccumulator()
for scanner.Scan() {
line := scanner.Text()
@@ -288,8 +289,12 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
continue
}
// Accumulate delta content for fallback when terminal output is empty.
acc.ProcessEvent(&event)
// Terminal events carry the complete ResponsesResponse with output + usage.
if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") &&
if (event.Type == "response.completed" || event.Type == "response.done" ||
event.Type == "response.incomplete" || event.Type == "response.failed") &&
event.Response != nil {
finalResponse = event.Response
if event.Response.Usage != nil {
@@ -318,6 +323,10 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
// When the terminal event has an empty output array, reconstruct from
// accumulated delta events so the client receives the full content.
acc.SupplementResponseOutput(finalResponse)
anthropicResp := apicompat.ResponsesToAnthropic(finalResponse, originalModel)
if s.responseHeaderFilter != nil {
@@ -57,6 +57,7 @@ func validatePlanPatch(req UpdatePlanRequest) error {
return nil
}
// --- Plan CRUD ---
// PlanGroupInfo holds the group details needed for subscription plan display.
@@ -113,11 +114,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
}
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx)
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
}
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx)
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
}
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
@@ -142,11 +142,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
switch statusCode {
case 400:
// 只有当错误信息包含 "organization has been disabled" 时才禁用
// "organization has been disabled" → 永久禁用
if strings.Contains(strings.ToLower(upstreamMsg), "organization has been disabled") {
msg := "Organization disabled (400): " + upstreamMsg
s.handleAuthError(ctx, account, msg)
shouldDisable = true
} else if account.Platform == PlatformAnthropic && strings.Contains(strings.ToLower(upstreamMsg), "credit balance") {
// Anthropic API key 余额不足(语义等同 402),停止调度
msg := "Credit balance exhausted (400): " + upstreamMsg
s.handleAuthError(ctx, account, msg)
shouldDisable = true
}
// 其他 400 错误(如参数问题)不处理,不禁用账号
case 401:
+47
View File
@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS payment_orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
user_email VARCHAR(255) NOT NULL DEFAULT '',
user_name VARCHAR(100) NOT NULL DEFAULT '',
user_notes TEXT,
amount DECIMAL(20,2) NOT NULL,
pay_amount DECIMAL(20,2) NOT NULL,
fee_rate DECIMAL(10,4) NOT NULL DEFAULT 0,
recharge_code VARCHAR(64) NOT NULL DEFAULT '',
payment_type VARCHAR(30) NOT NULL DEFAULT '',
payment_trade_no VARCHAR(128) NOT NULL DEFAULT '',
pay_url TEXT,
qr_code TEXT,
qr_code_img TEXT,
order_type VARCHAR(20) NOT NULL DEFAULT 'balance',
plan_id BIGINT,
subscription_group_id BIGINT,
subscription_days INT,
provider_instance_id VARCHAR(64),
status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
refund_amount DECIMAL(20,2) NOT NULL DEFAULT 0,
refund_reason TEXT,
refund_at TIMESTAMPTZ,
force_refund BOOLEAN NOT NULL DEFAULT FALSE,
refund_requested_at TIMESTAMPTZ,
refund_request_reason TEXT,
refund_requested_by VARCHAR(20),
expires_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failed_reason TEXT,
client_ip VARCHAR(50) NOT NULL DEFAULT '',
src_host VARCHAR(255) NOT NULL DEFAULT '',
src_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_payment_orders_user_id ON payment_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON payment_orders(status);
CREATE INDEX IF NOT EXISTS idx_payment_orders_expires_at ON payment_orders(expires_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_created_at ON payment_orders(created_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_paid_at ON payment_orders(paid_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_type_paid ON payment_orders(payment_type, paid_at);
CREATE INDEX IF NOT EXISTS idx_payment_orders_order_type ON payment_orders(order_type);
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS payment_audit_logs (
id BIGSERIAL PRIMARY KEY,
order_id VARCHAR(64) NOT NULL,
action VARCHAR(50) NOT NULL,
detail TEXT NOT NULL DEFAULT '',
operator VARCHAR(100) NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_audit_logs_order_id ON payment_audit_logs(order_id);
@@ -0,0 +1,4 @@
-- Migration 092: payment_channels table was removed before release.
-- This file is a no-op placeholder to maintain migration numbering continuity.
-- The payment system now uses the existing channels table (migration 081).
SELECT 1;
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS subscription_plans (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT NOT NULL DEFAULT '',
price DECIMAL(20,2) NOT NULL,
original_price DECIMAL(20,2),
validity_days INT NOT NULL DEFAULT 30,
validity_unit VARCHAR(10) NOT NULL DEFAULT 'day',
features TEXT NOT NULL DEFAULT '',
product_name VARCHAR(100) NOT NULL DEFAULT '',
for_sale BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_subscription_plans_group_id ON subscription_plans(group_id);
CREATE INDEX IF NOT EXISTS idx_subscription_plans_for_sale ON subscription_plans(for_sale);
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS payment_provider_instances (
id BIGSERIAL PRIMARY KEY,
provider_key VARCHAR(30) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
config TEXT NOT NULL,
supported_types VARCHAR(200) NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
limits TEXT NOT NULL DEFAULT '',
refund_enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_provider_key ON payment_provider_instances(provider_key);
CREATE INDEX IF NOT EXISTS idx_payment_provider_instances_enabled ON payment_provider_instances(enabled);
@@ -0,0 +1,27 @@
-- 097_fix_settings_updated_at_default.sql
--
-- 修复 settings.updated_at 列在历史实例上可能缺失 SQL DEFAULT 的问题。
--
-- 背景:
-- 早期版本曾依赖 ent 自动迁移建表(ent 的 Default(time.Now) 仅是 Go 层默认值,
-- 不会在 SQL 层落地为 DEFAULT),随后引入的 005_schema_parity.sql 使用了
-- CREATE TABLE IF NOT EXISTS,对已存在的 settings 表不会重建,导致这部分实例
-- 的 updated_at 列虽然是 NOT NULL,但缺少 SQL DEFAULT。
--
-- 后续 098_migrate_purchase_subscription_to_custom_menu.sql 是项目中唯一使用
-- 原生 SQL INSERT INTO settings 的迁移(其余 settings 写入都走 ent / Go 层),
-- 因此该 schema 缺陷直到 098 才会触发:
-- "null value in column \"updated_at\" of relation \"settings\" violates not-null constraint"
--
-- 幂等性:
-- - ALTER COLUMN ... SET DEFAULT NOW() 在已经具备相同默认值的实例上是无操作,
-- 不会报错(PostgreSQL 允许重复设置相同的默认值)。
-- - UPDATE 子句的 WHERE updated_at IS NULL 在健康实例上匹配 0 行,不影响数据。
--
-- 这样可以同时兼容:
-- 1. 从未运行过旧版迁移的全新部署(005 已经把列建对,本迁移变成 no-op)。
-- 2. 历史损坏实例(本迁移修复缺失的默认值,使后续 098 能够正常 INSERT)。
ALTER TABLE settings ALTER COLUMN updated_at SET DEFAULT NOW();
UPDATE settings SET updated_at = NOW() WHERE updated_at IS NULL;
@@ -0,0 +1,70 @@
-- 096_migrate_purchase_subscription_to_custom_menu.sql
--
-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
-- After migration, purchase_subscription_enabled is set to "false" and
-- purchase_subscription_url is cleared.
--
-- Idempotent: skips if custom_menu_items already contains
-- "migrated_purchase_subscription".
DO $$
DECLARE
v_enabled text;
v_url text;
v_raw text;
v_items jsonb;
v_new_item jsonb;
BEGIN
-- Read legacy settings
SELECT value INTO v_enabled
FROM settings WHERE key = 'purchase_subscription_enabled';
SELECT value INTO v_url
FROM settings WHERE key = 'purchase_subscription_url';
-- Skip if not enabled or URL is empty
IF COALESCE(v_enabled, '') <> 'true' OR COALESCE(TRIM(v_url), '') = '' THEN
RETURN;
END IF;
-- Read current custom_menu_items
SELECT value INTO v_raw
FROM settings WHERE key = 'custom_menu_items';
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
v_items := '[]'::jsonb;
ELSE
v_items := v_raw::jsonb;
END IF;
-- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(v_items) elem
WHERE elem ->> 'id' = 'migrated_purchase_subscription'
) THEN
RETURN;
END IF;
-- Build the new menu item
v_new_item := jsonb_build_object(
'id', 'migrated_purchase_subscription',
'label', 'Purchase',
'icon_svg', '',
'url', TRIM(v_url),
'visibility', 'user',
'sort_order', 100
);
-- Append to array
v_items := v_items || jsonb_build_array(v_new_item);
-- Upsert custom_menu_items
INSERT INTO settings (key, value)
VALUES ('custom_menu_items', v_items::text)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- Clear legacy settings
UPDATE settings SET value = 'false' WHERE key = 'purchase_subscription_enabled';
UPDATE settings SET value = '' WHERE key = 'purchase_subscription_url';
RAISE NOTICE '[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items', v_url;
END $$;
@@ -0,0 +1,51 @@
-- 097_fix_migrated_purchase_menu_label_icon.sql
--
-- Fixes the custom menu item created by migration 096: updates the label
-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg
-- to a credit-card SVG matching the sidebar CreditCardIcon.
--
-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'.
DO $$
DECLARE
v_raw text;
v_items jsonb;
v_idx int;
v_icon text;
v_elem jsonb;
v_i int := 0;
BEGIN
SELECT value INTO v_raw
FROM settings WHERE key = 'custom_menu_items';
IF COALESCE(v_raw, '') = '' OR v_raw = 'null' THEN
RETURN;
END IF;
v_items := v_raw::jsonb;
-- Find the index of the migrated item by iterating the array
v_idx := NULL;
FOR v_elem IN SELECT jsonb_array_elements(v_items) LOOP
IF v_elem ->> 'id' = 'migrated_purchase_subscription' THEN
v_idx := v_i;
EXIT;
END IF;
v_i := v_i + 1;
END LOOP;
IF v_idx IS NULL THEN
RETURN; -- item not found, nothing to fix
END IF;
-- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar)
v_icon := '<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"/></svg>';
-- Update label and icon_svg
v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'label'], '"充值/订阅"'::jsonb);
v_items := jsonb_set(v_items, ARRAY[v_idx::text, 'icon_svg'], to_jsonb(v_icon));
UPDATE settings SET value = v_items::text WHERE key = 'custom_menu_items';
RAISE NOTICE '[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG';
END $$;
@@ -0,0 +1,17 @@
-- 098_remove_easypay_from_enabled_payment_types.sql
--
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
-- "easypay" is a provider key, not a payment type. Valid payment types
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
--
-- Idempotent: safe to run multiple times.
UPDATE settings
SET value = array_to_string(
array_remove(
string_to_array(value, ','),
'easypay'
), ','
)
WHERE key = 'ENABLED_PAYMENT_TYPES'
AND value LIKE '%easypay%';
@@ -0,0 +1,16 @@
-- Add payment_mode field to payment_provider_instances
-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
ALTER TABLE payment_provider_instances ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20) NOT NULL DEFAULT '';
-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
UPDATE payment_provider_instances
SET payment_mode = 'redirect',
supported_types = TRIM(BOTH ',' FROM REPLACE(REPLACE(REPLACE(
supported_types, 'easypay,', ''), ',easypay', ''), 'easypay', ''))
WHERE provider_key = 'easypay' AND supported_types LIKE '%easypay%';
-- EasyPay instances without 'easypay' in supported_types → api mode
UPDATE payment_provider_instances
SET payment_mode = 'api'
WHERE provider_key = 'easypay' AND payment_mode = '';
@@ -0,0 +1,6 @@
-- 100_add_out_trade_no_to_payment_orders.sql
-- Adds out_trade_no column for external order ID used with payment providers.
-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS out_trade_no VARCHAR(64) NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS paymentorder_out_trade_no ON payment_orders (out_trade_no);
@@ -1,5 +1,5 @@
<template>
<div v-if="showUsageWindows">
<div ref="rootRef" v-if="showUsageWindows">
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
<template
v-if="
@@ -401,7 +401,7 @@
</div>
<!-- Non-OAuth/Setup-Token accounts -->
<div v-else>
<div ref="rootRef" v-else>
<!-- Gemini API Key accounts: show quota info -->
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
@@ -472,7 +472,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
@@ -502,6 +502,7 @@ const props = withDefaults(
)
const { t } = useI18n()
const desktopViewportQuery = '(min-width: 768px)'
const unmounted = ref(false)
onBeforeUnmount(() => { unmounted.value = true })
@@ -510,6 +511,17 @@ const loading = ref(false)
const activeQueryLoading = ref(false)
const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
const rootRef = ref<HTMLElement | null>(null)
const isDesktopViewport = ref(
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
)
const hasEnteredViewport = ref(false)
const pendingAutoLoad = ref(false)
const pendingAutoLoadSource = ref<'passive' | 'active' | undefined>(undefined)
let desktopViewportMediaQuery: MediaQueryList | null = null
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
let visibilityObserver: IntersectionObserver | null = null
// Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed(() => {
@@ -576,6 +588,10 @@ const shouldAutoLoadUsageOnMount = computed(() => {
return shouldFetchUsage.value
})
const shouldLazyLoadOnMobile = computed(() => {
return shouldFetchUsage.value && !isDesktopViewport.value
})
// Antigravity quota types (用于 API 返回的数据)
interface AntigravityUsageResult {
utilization: number
@@ -1020,6 +1036,56 @@ const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?
}
}
const flushPendingAutoLoad = () => {
if (!pendingAutoLoad.value) return
const source = pendingAutoLoadSource.value
pendingAutoLoad.value = false
pendingAutoLoadSource.value = undefined
loadUsage(source).catch((e) => {
console.error('Failed to load deferred usage:', e)
})
}
const requestAutoLoad = (source?: 'passive' | 'active') => {
if (!shouldFetchUsage.value) return
if (shouldLazyLoadOnMobile.value && !hasEnteredViewport.value) {
pendingAutoLoad.value = true
pendingAutoLoadSource.value = source
return
}
loadUsage(source).catch((e) => {
console.error('Failed to auto load usage:', e)
})
}
const detachVisibilityObserver = () => {
visibilityObserver?.disconnect()
visibilityObserver = null
}
const attachVisibilityObserver = () => {
detachVisibilityObserver()
if (!shouldLazyLoadOnMobile.value || hasEnteredViewport.value) return
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
hasEnteredViewport.value = true
flushPendingAutoLoad()
return
}
if (!rootRef.value) return
visibilityObserver = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return
hasEnteredViewport.value = true
detachVisibilityObserver()
flushPendingAutoLoad()
}, {
root: null,
rootMargin: '200px 0px',
threshold: 0.01
})
visibilityObserver.observe(rootRef.value)
}
const loadActiveUsage = async () => {
activeQueryLoading.value = true
try {
@@ -1123,18 +1189,29 @@ const formatKeyUserCost = computed(() => {
})
onMounted(() => {
if (typeof window !== 'undefined') {
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
isDesktopViewport.value = desktopViewportMediaQuery.matches
desktopViewportListener = (event: MediaQueryListEvent) => {
isDesktopViewport.value = event.matches
}
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
} else {
desktopViewportMediaQuery.addListener(desktopViewportListener)
}
}
if (!shouldAutoLoadUsageOnMount.value) return
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
loadUsage({ source })
requestAutoLoad(source)
})
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
loadUsage().catch((e) => {
console.error('Failed to refresh OpenAI usage:', e)
})
requestAutoLoad()
})
watch(
@@ -1150,4 +1227,40 @@ watch(
})
}
)
watch(
[rootRef, shouldLazyLoadOnMobile],
() => {
if (shouldLazyLoadOnMobile.value) {
attachVisibilityObserver()
return
}
detachVisibilityObserver()
},
{ immediate: true, flush: 'post' }
)
watch(isDesktopViewport, (isDesktop) => {
if (isDesktop) {
detachVisibilityObserver()
hasEnteredViewport.value = true
flushPendingAutoLoad()
return
}
hasEnteredViewport.value = false
attachVisibilityObserver()
})
onUnmounted(() => {
detachVisibilityObserver()
if (desktopViewportMediaQuery && desktopViewportListener) {
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
} else {
desktopViewportMediaQuery.removeListener(desktopViewportListener)
}
}
desktopViewportListener = null
desktopViewportMediaQuery = null
})
</script>
+56 -8
View File
@@ -1,5 +1,5 @@
<template>
<div class="md:hidden space-y-3">
<div v-if="!isDesktopViewport" class="space-y-3">
<template v-if="loading">
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
<div class="space-y-3">
@@ -61,8 +61,9 @@
</div>
<div
v-else
ref="tableWrapperRef"
class="table-wrapper hidden md:block"
class="table-wrapper"
:class="{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
@@ -203,6 +204,11 @@ import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const desktopViewportQuery = '(min-width: 768px)'
const isDesktopViewport = ref(
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
)
const emit = defineEmits<{
sort: [key: string, order: 'asc' | 'desc']
}>()
@@ -268,8 +274,19 @@ const checkActionsColumnWidth = () => {
// 监听尺寸变化
let resizeObserver: ResizeObserver | null = null
let resizeHandler: (() => void) | null = null
let desktopViewportMediaQuery: MediaQueryList | null = null
let desktopViewportListener: ((event: MediaQueryListEvent) => void) | null = null
onMounted(() => {
const detachDesktopTableTracking = () => {
resizeObserver?.disconnect()
resizeObserver = null
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
}
const attachDesktopTableTracking = () => {
checkScrollable()
checkActionsColumnWidth()
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
@@ -286,14 +303,34 @@ onMounted(() => {
}
window.addEventListener('resize', resizeHandler)
}
}
onMounted(() => {
if (typeof window !== 'undefined') {
desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery)
isDesktopViewport.value = desktopViewportMediaQuery.matches
desktopViewportListener = (event: MediaQueryListEvent) => {
isDesktopViewport.value = event.matches
}
if (typeof desktopViewportMediaQuery.addEventListener === 'function') {
desktopViewportMediaQuery.addEventListener('change', desktopViewportListener)
} else {
desktopViewportMediaQuery.addListener(desktopViewportListener)
}
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
detachDesktopTableTracking()
if (desktopViewportMediaQuery && desktopViewportListener) {
if (typeof desktopViewportMediaQuery.removeEventListener === 'function') {
desktopViewportMediaQuery.removeEventListener('change', desktopViewportListener)
} else {
desktopViewportMediaQuery.removeListener(desktopViewportListener)
}
desktopViewportListener = null
}
desktopViewportMediaQuery = null
})
interface Props {
@@ -470,6 +507,17 @@ const columnsSignature = computed(() =>
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
)
watch(
isDesktopViewport,
async (isDesktop) => {
detachDesktopTableTracking()
if (!isDesktop) return
await nextTick()
attachDesktopTableTracking()
},
{ immediate: true, flush: 'post' }
)
// 数据/列变化时重新检查滚动状态
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
watch(
@@ -526,7 +574,7 @@ const sortedData = computed(() => {
// --- Virtual scrolling ---
const rowVirtualizer = useVirtualizer(computed(() => ({
count: sortedData.value?.length ?? 0,
count: isDesktopViewport.value ? (sortedData.value?.length ?? 0) : 0,
getScrollElement: () => tableWrapperRef.value,
estimateSize: () => props.estimateRowHeight ?? 56,
overscan: props.overscan ?? 5,
+151 -47
View File
@@ -7,20 +7,18 @@
]"
>
<!-- Logo/Brand -->
<div class="sidebar-header">
<div class="sidebar-header" :class="{ 'sidebar-header-collapsed': sidebarCollapsed }">
<!-- Custom Logo or Default Logo -->
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
<div class="sidebar-logo flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
<img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<transition name="fade">
<div v-if="!sidebarCollapsed" class="flex flex-col">
<span class="text-lg font-bold text-gray-900 dark:text-white">
{{ siteName }}
</span>
<!-- Version Badge -->
<VersionBadge :version="siteVersion" />
</div>
</transition>
<div class="sidebar-brand" :class="{ 'sidebar-brand-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">
<span class="sidebar-brand-title text-lg font-bold text-gray-900 dark:text-white">
{{ siteName }}
</span>
<!-- Version Badge -->
<VersionBadge :version="siteVersion" />
</div>
</div>
<!-- Navigation -->
@@ -35,17 +33,25 @@
<button
type="button"
class="sidebar-link mb-1 w-full"
:class="{ 'sidebar-link-active': isGroupActive(item) && !isGroupExpanded(item) }"
:class="{
'sidebar-link-active': isGroupActive(item) && !isGroupExpanded(item),
'sidebar-link-collapsed': sidebarCollapsed
}"
:title="sidebarCollapsed ? item.label : undefined"
@click="sidebarCollapsed ? undefined : toggleGroup(item)"
>
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed" class="flex flex-1 items-center justify-between">
<span>{{ item.label }}</span>
<ChevronDownIcon class="h-4 w-4 flex-shrink-0 transition-transform duration-200" :class="isGroupExpanded(item) ? 'rotate-180' : ''" />
</span>
</transition>
<span
class="sidebar-label sidebar-label-flex"
:class="{ 'sidebar-label-collapsed': sidebarCollapsed }"
:aria-hidden="sidebarCollapsed ? 'true' : 'false'"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
<ChevronDownIcon
class="h-4 w-4 flex-shrink-0 transition-transform duration-200"
:class="isGroupExpanded(item) ? 'rotate-180' : ''"
/>
</span>
</button>
<!-- Children -->
<div v-if="!sidebarCollapsed && isGroupExpanded(item)" class="mb-1 ml-4 border-l border-gray-200 pl-2 dark:border-dark-600">
@@ -67,7 +73,7 @@
v-else
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:id="
item.path === '/admin/accounts'
@@ -82,35 +88,32 @@
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</template>
</div>
<!-- Personal Section for Admin (hidden in simple mode) -->
<div v-if="!authStore.isSimpleMode" class="sidebar-section">
<div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }}
<div class="sidebar-section-title" :class="{ 'sidebar-section-title-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">
<span class="sidebar-section-title-text" :class="{ 'sidebar-section-title-text-collapsed': sidebarCollapsed }">
{{ t('nav.myAccount') }}
</span>
</div>
<div v-else class="mx-3 my-3 h-px bg-gray-200 dark:bg-dark-700"></div>
<router-link
v-for="item in personalNavItems"
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</div>
</template>
@@ -123,16 +126,14 @@
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path) }"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</transition>
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</div>
</template>
@@ -144,28 +145,26 @@
<button
@click="toggleTheme"
class="sidebar-link mb-2 w-full"
:class="{ 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? (isDark ? t('nav.lightMode') : t('nav.darkMode')) : undefined"
>
<SunIcon v-if="isDark" class="h-5 w-5 flex-shrink-0 text-amber-500" />
<MoonIcon v-else class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{
isDark ? t('nav.lightMode') : t('nav.darkMode')
}}</span>
</transition>
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{
isDark ? t('nav.lightMode') : t('nav.darkMode')
}}</span>
</button>
<!-- Collapse Button -->
<button
@click="toggleSidebar"
class="sidebar-link w-full"
:class="{ 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? t('nav.expand') : t('nav.collapse')"
>
<ChevronDoubleLeftIcon v-if="!sidebarCollapsed" class="h-5 w-5 flex-shrink-0" />
<ChevronDoubleRightIcon v-else class="h-5 w-5 flex-shrink-0" />
<transition name="fade">
<span v-if="!sidebarCollapsed">{{ t('nav.collapse') }}</span>
</transition>
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ t('nav.collapse') }}</span>
</button>
</div>
</aside>
@@ -505,7 +504,6 @@ const ChevronDoubleLeftIcon = {
)
}
const OrderIcon = {
render: () =>
h(
@@ -795,14 +793,120 @@ onMounted(() => {
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
.sidebar-logo {
flex: 0 0 2.25rem;
min-width: 2.25rem;
}
.fade-enter-from,
.fade-leave-to {
.sidebar-header-collapsed {
gap: 0;
padding-left: 1.125rem;
padding-right: 1.125rem;
}
.sidebar-brand {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
transition:
max-width 0.22s ease,
opacity 0.14s ease,
transform 0.14s ease;
max-width: 12rem;
}
.sidebar-brand-collapsed {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
}
.sidebar-brand-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-link-collapsed {
gap: 0;
padding-left: 0.875rem;
padding-right: 0.875rem;
}
.sidebar-section-title {
position: relative;
display: flex;
align-items: center;
min-height: 1.25rem;
overflow: hidden;
white-space: nowrap;
}
.sidebar-section-title-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.sidebar-section-title::after {
content: '';
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 50%;
height: 1px;
background: rgb(229 231 235);
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.18s ease;
}
.dark .sidebar-section-title::after {
background: rgb(55 65 81);
}
.sidebar-section-title-text-collapsed {
opacity: 0;
transform: translateX(-4px);
}
.sidebar-section-title-collapsed::after {
opacity: 1;
transition-delay: 0.08s;
}
.sidebar-label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
max-width 0.2s ease,
opacity 0.12s ease,
transform 0.12s ease;
max-width: 12rem;
}
.sidebar-label-flex {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.sidebar-label-collapsed {
max-width: 0;
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
}
/* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
@@ -6,6 +6,8 @@ import { describe, expect, it } from 'vitest'
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AppSidebar.vue')
const componentSource = readFileSync(componentPath, 'utf8')
const stylePath = resolve(dirname(fileURLToPath(import.meta.url)), '../../../style.css')
const styleSource = readFileSync(stylePath, 'utf8')
describe('AppSidebar custom SVG styles', () => {
it('does not override uploaded SVG fill or stroke colors', () => {
@@ -16,3 +18,12 @@ describe('AppSidebar custom SVG styles', () => {
expect(componentSource).not.toContain('fill: none;')
})
})
describe('AppSidebar header styles', () => {
it('does not clip the version badge dropdown', () => {
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
expect(sidebarHeaderBlockMatch).not.toBeNull()
expect(sidebarHeaderBlockMatch?.[0]).not.toContain('@apply overflow-hidden;')
})
})
+8 -1
View File
@@ -523,12 +523,16 @@
@apply border-r border-gray-200 dark:border-dark-800;
@apply flex flex-col;
@apply transition-transform duration-300;
transition-property: width, transform;
}
.sidebar-header {
@apply h-16 px-6;
@apply flex items-center gap-3;
@apply border-b border-gray-100 dark:border-dark-800;
transition:
padding 0.2s ease,
gap 0.2s ease;
}
.sidebar-nav {
@@ -536,12 +540,15 @@
}
.sidebar-link {
@apply flex items-center gap-3 rounded-xl px-3 py-2.5;
@apply flex items-center gap-3 rounded-xl py-2.5;
@apply overflow-hidden;
@apply text-sm font-medium;
@apply text-gray-600 dark:text-dark-300;
@apply transition-all duration-200;
@apply hover:bg-gray-100 dark:hover:bg-dark-800;
@apply hover:text-gray-900 dark:hover:text-white;
padding-left: 1.0625rem;
padding-right: 0.875rem;
}
.sidebar-link-active {
+3
View File
@@ -1644,3 +1644,6 @@ export interface UpdateScheduledTestPlanRequest {
max_results?: number
auto_recover?: boolean
}
// Payment types
export type { SubscriptionPlan, PaymentOrder, CheckoutInfoResponse } from './payment'
@@ -117,6 +117,7 @@ function getPlanNameClass(groupId: number): string {
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
}
// ==================== Plans ====================
const plansLoading = ref(false)
@@ -133,6 +134,7 @@ const planColumns = computed((): Column[] => [
{ key: 'price', label: t('payment.admin.price') },
{ key: 'validity_days', label: t('payment.admin.validityDays') },
{ key: 'for_sale', label: t('payment.admin.forSale') },
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
{ key: 'actions', label: t('common.actions') },
])
@@ -157,6 +159,7 @@ function openPlanEdit(plan: SubscriptionPlan | null) {
showPlanDialog.value = true
}
/** Quick toggle for_sale from the list */
async function toggleForSale(plan: SubscriptionPlan) {
try {