mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
## 生态项目
|
||||
|
||||
@@ -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 @@
|
||||
0.1.110.56
|
||||
0.1.112.1
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user