mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdff4f34f | ||
|
|
0aa480283f | ||
|
|
cd9d31f5f2 | ||
|
|
cbfce49aa1 | ||
|
|
d3e73f1260 | ||
|
|
b5ca6a654c | ||
|
|
94749b12ac | ||
|
|
523fa9f71e | ||
|
|
54636781ea | ||
|
|
5187db5ee5 | ||
|
|
0b9c4ae69e | ||
|
|
0d5a8a95c8 | ||
|
|
9cd97c9e1d | ||
|
|
d521191e87 | ||
|
|
fd78993b91 | ||
|
|
80cce858cb | ||
|
|
0743652d92 | ||
|
|
96bec5c9b1 | ||
|
|
cfeb6b8b14 | ||
|
|
481310dea0 | ||
|
|
ea2821d11d | ||
|
|
7a0de1765f | ||
|
|
35b1bc3753 | ||
|
|
8d38788672 | ||
|
|
c615a4264d | ||
|
|
227d506c53 | ||
|
|
36a86e9ab4 | ||
|
|
f133b051dc | ||
|
|
7af1bdbf4c | ||
|
|
016d7ef645 | ||
|
|
f1e47291cd | ||
|
|
d7e9ae38e4 | ||
|
|
88be981afc | ||
|
|
3f92a43170 | ||
|
|
2101f1d1c8 | ||
|
|
f0f920e49f | ||
|
|
95583fce83 | ||
|
|
254f12543c | ||
|
|
cf8a64528c |
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
@@ -143,3 +143,61 @@ jobs:
|
|||||||
repository: ${{ secrets.DOCKERHUB_USERNAME }}/sub2api
|
repository: ${{ secrets.DOCKERHUB_USERNAME }}/sub2api
|
||||||
short-description: "Sub2API - AI API Gateway Platform"
|
short-description: "Sub2API - AI API Gateway Platform"
|
||||||
readme-filepath: ./deploy/DOCKER.md
|
readme-filepath: ./deploy/DOCKER.md
|
||||||
|
|
||||||
|
# Send Telegram notification
|
||||||
|
- name: Send Telegram Notification
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
# 检查必要的环境变量
|
||||||
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
|
echo "Telegram credentials not configured, skipping notification"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||||
|
VERSION=${TAG_NAME#v}
|
||||||
|
REPO="${{ github.repository }}"
|
||||||
|
DOCKER_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/sub2api"
|
||||||
|
|
||||||
|
# 获取 tag message 内容
|
||||||
|
TAG_MESSAGE='${{ steps.tag_message.outputs.message }}'
|
||||||
|
|
||||||
|
# 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容)
|
||||||
|
if [ ${#TAG_MESSAGE} -gt 3500 ]; then
|
||||||
|
TAG_MESSAGE="${TAG_MESSAGE:0:3500}..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建消息内容
|
||||||
|
MESSAGE="🚀 *Sub2API 新版本发布!*"$'\n'$'\n'
|
||||||
|
MESSAGE+="📦 版本号: \`${VERSION}\`"$'\n'$'\n'
|
||||||
|
|
||||||
|
# 添加更新内容
|
||||||
|
if [ -n "$TAG_MESSAGE" ]; then
|
||||||
|
MESSAGE+="${TAG_MESSAGE}"$'\n'$'\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
MESSAGE+="🐳 *Docker 部署:*"$'\n'
|
||||||
|
MESSAGE+="\`\`\`bash"$'\n'
|
||||||
|
MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG_NAME}"$'\n'
|
||||||
|
MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n'
|
||||||
|
MESSAGE+="\`\`\`"$'\n'$'\n'
|
||||||
|
MESSAGE+="🔗 *相关链接:*"$'\n'
|
||||||
|
MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG_NAME})"$'\n'
|
||||||
|
MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE})"$'\n'$'\n'
|
||||||
|
MESSAGE+="#Sub2API #Release #${TAG_NAME//./_}"
|
||||||
|
|
||||||
|
# 发送消息
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg chat_id "${TELEGRAM_CHAT_ID}" \
|
||||||
|
--arg text "${MESSAGE}" \
|
||||||
|
'{
|
||||||
|
chat_id: $chat_id,
|
||||||
|
text: $text,
|
||||||
|
parse_mode: "Markdown",
|
||||||
|
disable_web_page_preview: true
|
||||||
|
}')"
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ frontend/node_modules/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
*.local
|
*.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
vite.config.d.ts
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Stage 1: Frontend Builder
|
# Stage 1: Frontend Builder
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:24-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ RUN npm run build
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Stage 2: Backend Builder
|
# Stage 2: Backend Builder
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
|
||||||
# Build arguments for version info (set by CI)
|
# Build arguments for version info (set by CI)
|
||||||
ARG VERSION=docker
|
ARG VERSION=docker
|
||||||
|
|||||||
@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
billingService := service.NewBillingService(configConfig, pricingService)
|
billingService := service.NewBillingService(configConfig, pricingService)
|
||||||
identityCache := repository.NewIdentityCache(client)
|
identityCache := repository.NewIdentityCache(client)
|
||||||
identityService := service.NewIdentityService(identityCache)
|
identityService := service.NewIdentityService(identityCache)
|
||||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
timingWheelService := service.ProvideTimingWheelService()
|
||||||
|
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||||
|
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
||||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream)
|
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService)
|
||||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ require (
|
|||||||
github.com/google/wire v0.7.0
|
github.com/google/wire v0.7.0
|
||||||
github.com/imroc/req/v3 v3.56.0
|
github.com/imroc/req/v3 v3.56.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/redis/go-redis/v9 v9.7.3
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/redis v0.40.0
|
github.com/testcontainers/testcontainers-go/modules/redis v0.40.0
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
|
github.com/zeromicro/go-zero v1.9.4
|
||||||
golang.org/x/crypto v0.44.0
|
golang.org/x/crypto v0.44.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/net v0.47.0
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/term v0.37.0
|
||||||
@@ -49,6 +50,7 @@ require (
|
|||||||
github.com/docker/go-connections v0.6.0 // indirect
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/ebitengine/purego v0.8.4 // indirect
|
github.com/ebitengine/purego v0.8.4 // indirect
|
||||||
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
@@ -59,7 +61,7 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/subcommands v1.2.0 // indirect
|
github.com/google/subcommands v1.2.0 // indirect
|
||||||
@@ -67,9 +69,9 @@ require (
|
|||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/icholy/digest v1.1.0 // indirect
|
github.com/icholy/digest v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@@ -78,7 +80,8 @@ require (
|
|||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
@@ -93,7 +96,7 @@ require (
|
|||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
@@ -105,6 +108,7 @@ require (
|
|||||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
@@ -123,7 +127,8 @@ require (
|
|||||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.10.0 // indirect
|
||||||
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
|||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
@@ -80,8 +82,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
|
|||||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||||
@@ -113,12 +115,12 @@ github.com/imroc/req/v3 v3.56.0 h1:t6YdqqerYBXhZ9+VjqsQs5wlKxdUNEvsgBhxWc1AEEo=
|
|||||||
github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk=
|
github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -142,8 +144,11 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
|||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
@@ -179,8 +184,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -188,12 +193,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||||
|
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
|
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
|
||||||
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
|
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
|
||||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
||||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
@@ -208,6 +215,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
|||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
@@ -228,6 +237,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
@@ -259,26 +269,30 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
|
|||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
|
||||||
|
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
@@ -301,6 +315,7 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type GenerateRedeemCodesRequest struct {
|
|||||||
Count int `json:"count" binding:"required,min=1,max=100"`
|
Count int `json:"count" binding:"required,min=1,max=100"`
|
||||||
Type string `json:"type" binding:"required,oneof=balance concurrency subscription"`
|
Type string `json:"type" binding:"required,oneof=balance concurrency subscription"`
|
||||||
Value float64 `json:"value" binding:"min=0"`
|
Value float64 `json:"value" binding:"min=0"`
|
||||||
GroupID *int64 `json:"group_id"` // 订阅类型必填
|
GroupID *int64 `json:"group_id"` // 订阅类型必填
|
||||||
ValidityDays int `json:"validity_days"` // 订阅类型使用,默认30天
|
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年
|
||||||
}
|
}
|
||||||
|
|
||||||
// List handles listing all redeem codes with pagination
|
// List handles listing all redeem codes with pagination
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func NewSubscriptionHandler(subscriptionService *service.SubscriptionService) *S
|
|||||||
type AssignSubscriptionRequest struct {
|
type AssignSubscriptionRequest struct {
|
||||||
UserID int64 `json:"user_id" binding:"required"`
|
UserID int64 `json:"user_id" binding:"required"`
|
||||||
GroupID int64 `json:"group_id" binding:"required"`
|
GroupID int64 `json:"group_id" binding:"required"`
|
||||||
ValidityDays int `json:"validity_days"`
|
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +49,13 @@ type AssignSubscriptionRequest struct {
|
|||||||
type BulkAssignSubscriptionRequest struct {
|
type BulkAssignSubscriptionRequest struct {
|
||||||
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
|
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
|
||||||
GroupID int64 `json:"group_id" binding:"required"`
|
GroupID int64 `json:"group_id" binding:"required"`
|
||||||
ValidityDays int `json:"validity_days"`
|
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtendSubscriptionRequest represents extend subscription request
|
// ExtendSubscriptionRequest represents extend subscription request
|
||||||
type ExtendSubscriptionRequest struct {
|
type ExtendSubscriptionRequest struct {
|
||||||
Days int `json:"days" binding:"required,min=1"`
|
Days int `json:"days" binding:"required,min=1,max=36500"` // max 100 years
|
||||||
}
|
}
|
||||||
|
|
||||||
// List handles listing all subscriptions with pagination and filters
|
// List handles listing all subscriptions with pagination and filters
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
page, pageSize := response.ParsePagination(c)
|
page, pageSize := response.ParsePagination(c)
|
||||||
|
|
||||||
// Parse filters
|
// Parse filters
|
||||||
var userID, apiKeyID int64
|
var userID, apiKeyID, accountID, groupID int64
|
||||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||||
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
id, err := strconv.ParseInt(userIDStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,6 +59,47 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
apiKeyID = id
|
apiKeyID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
|
||||||
|
id, err := strconv.ParseInt(accountIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid account_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
|
||||||
|
id, err := strconv.ParseInt(groupIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid group_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
model := c.Query("model")
|
||||||
|
|
||||||
|
var stream *bool
|
||||||
|
if streamStr := c.Query("stream"); streamStr != "" {
|
||||||
|
val, err := strconv.ParseBool(streamStr)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid stream value, use true or false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream = &val
|
||||||
|
}
|
||||||
|
|
||||||
|
var billingType *int8
|
||||||
|
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||||
|
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid billing_type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bt := int8(val)
|
||||||
|
billingType = &bt
|
||||||
|
}
|
||||||
|
|
||||||
// Parse date range
|
// Parse date range
|
||||||
var startTime, endTime *time.Time
|
var startTime, endTime *time.Time
|
||||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
@@ -83,10 +124,15 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||||
filters := usagestats.UsageLogFilters{
|
filters := usagestats.UsageLogFilters{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ApiKeyID: apiKeyID,
|
ApiKeyID: apiKeyID,
|
||||||
StartTime: startTime,
|
AccountID: accountID,
|
||||||
EndTime: endTime,
|
GroupID: groupID,
|
||||||
|
Model: model,
|
||||||
|
Stream: stream,
|
||||||
|
BillingType: billingType,
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: endTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -127,66 +128,158 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
platform = apiKey.Group.Platform
|
platform = apiKey.Group.Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择支持该模型的账号
|
|
||||||
var account *service.Account
|
|
||||||
if platform == service.PlatformGemini {
|
if platform == service.PlatformGemini {
|
||||||
account, err = h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model)
|
const maxAccountSwitches = 3
|
||||||
} else {
|
switchCount := 0
|
||||||
account, err = h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model)
|
failedAccountIDs := make(map[int64]struct{})
|
||||||
}
|
lastFailoverStatus := 0
|
||||||
if err != nil {
|
|
||||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查预热请求拦截(在账号选择后、转发前检查)
|
for {
|
||||||
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
account, err := h.geminiCompatService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model, failedAccountIDs)
|
||||||
if req.Stream {
|
if err != nil {
|
||||||
sendMockWarmupStream(c, req.Model)
|
if len(failedAccountIDs) == 0 {
|
||||||
} else {
|
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||||
sendMockWarmupResponse(c, req.Model)
|
return
|
||||||
|
}
|
||||||
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查预热请求拦截(在账号选择后、转发前检查)
|
||||||
|
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
||||||
|
if req.Stream {
|
||||||
|
sendMockWarmupStream(c, req.Model)
|
||||||
|
} else {
|
||||||
|
sendMockWarmupResponse(c, req.Model)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取账号并发槽位
|
||||||
|
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, req.Stream, &streamStarted)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Account concurrency acquire failed: %v", err)
|
||||||
|
h.handleConcurrencyError(c, err, "account", streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发请求
|
||||||
|
result, err := h.geminiCompatService.Forward(c.Request.Context(), c, account, body)
|
||||||
|
if accountReleaseFunc != nil {
|
||||||
|
accountReleaseFunc()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var failoverErr *service.UpstreamFailoverError
|
||||||
|
if errors.As(err, &failoverErr) {
|
||||||
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
if switchCount >= maxAccountSwitches {
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
switchCount++
|
||||||
|
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 错误响应已在Forward中处理,这里只记录日志
|
||||||
|
log.Printf("Forward request failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步记录使用量(subscription已在函数开头获取)
|
||||||
|
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
|
Result: result,
|
||||||
|
ApiKey: apiKey,
|
||||||
|
User: apiKey.User,
|
||||||
|
Account: usedAccount,
|
||||||
|
Subscription: subscription,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("Record usage failed: %v", err)
|
||||||
|
}
|
||||||
|
}(result, account)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 获取账号并发槽位
|
const maxAccountSwitches = 3
|
||||||
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, req.Stream, &streamStarted)
|
switchCount := 0
|
||||||
if err != nil {
|
failedAccountIDs := make(map[int64]struct{})
|
||||||
log.Printf("Account concurrency acquire failed: %v", err)
|
lastFailoverStatus := 0
|
||||||
h.handleConcurrencyError(c, err, "account", streamStarted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if accountReleaseFunc != nil {
|
|
||||||
defer accountReleaseFunc()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转发请求
|
for {
|
||||||
var result *service.ForwardResult
|
// 选择支持该模型的账号
|
||||||
if platform == service.PlatformGemini {
|
account, err := h.gatewayService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model, failedAccountIDs)
|
||||||
result, err = h.geminiCompatService.Forward(c.Request.Context(), c, account, body)
|
if err != nil {
|
||||||
} else {
|
if len(failedAccountIDs) == 0 {
|
||||||
result, err = h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||||
}
|
return
|
||||||
if err != nil {
|
}
|
||||||
// 错误响应已在Forward中处理,这里只记录日志
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
log.Printf("Forward request failed: %v", err)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 异步记录使用量(subscription已在函数开头获取)
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
|
||||||
Result: result,
|
|
||||||
ApiKey: apiKey,
|
|
||||||
User: apiKey.User,
|
|
||||||
Account: account,
|
|
||||||
Subscription: subscription,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("Record usage failed: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
// 检查预热请求拦截(在账号选择后、转发前检查)
|
||||||
|
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
||||||
|
if req.Stream {
|
||||||
|
sendMockWarmupStream(c, req.Model)
|
||||||
|
} else {
|
||||||
|
sendMockWarmupResponse(c, req.Model)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取账号并发槽位
|
||||||
|
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, req.Stream, &streamStarted)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Account concurrency acquire failed: %v", err)
|
||||||
|
h.handleConcurrencyError(c, err, "account", streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发请求
|
||||||
|
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
||||||
|
if accountReleaseFunc != nil {
|
||||||
|
accountReleaseFunc()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var failoverErr *service.UpstreamFailoverError
|
||||||
|
if errors.As(err, &failoverErr) {
|
||||||
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
if switchCount >= maxAccountSwitches {
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
switchCount++
|
||||||
|
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 错误响应已在Forward中处理,这里只记录日志
|
||||||
|
log.Printf("Forward request failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步记录使用量(subscription已在函数开头获取)
|
||||||
|
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
|
Result: result,
|
||||||
|
ApiKey: apiKey,
|
||||||
|
User: apiKey.User,
|
||||||
|
Account: usedAccount,
|
||||||
|
Subscription: subscription,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("Record usage failed: %v", err)
|
||||||
|
}
|
||||||
|
}(result, account)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Models handles listing available models
|
// Models handles listing available models
|
||||||
@@ -314,6 +407,28 @@ func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotT
|
|||||||
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
|
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int, streamStarted bool) {
|
||||||
|
status, errType, errMsg := h.mapUpstreamError(statusCode)
|
||||||
|
h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) {
|
||||||
|
switch statusCode {
|
||||||
|
case 401:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"
|
||||||
|
case 403:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"
|
||||||
|
case 429:
|
||||||
|
return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"
|
||||||
|
case 529:
|
||||||
|
return http.StatusServiceUnavailable, "overloaded_error", "Upstream service overloaded, please retry later"
|
||||||
|
case 500, 502, 503, 504:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable"
|
||||||
|
default:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream request failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleStreamingAwareError handles errors that may occur after streaming has started
|
// handleStreamingAwareError handles errors that may occur after streaming has started
|
||||||
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
||||||
if streamStarted {
|
if streamStarted {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -158,44 +159,69 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
|||||||
|
|
||||||
// 3) select account (sticky session based on request body)
|
// 3) select account (sticky session based on request body)
|
||||||
sessionHash := h.gatewayService.GenerateSessionHash(body)
|
sessionHash := h.gatewayService.GenerateSessionHash(body)
|
||||||
account, err := h.geminiCompatService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, modelName)
|
const maxAccountSwitches = 3
|
||||||
if err != nil {
|
switchCount := 0
|
||||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
failedAccountIDs := make(map[int64]struct{})
|
||||||
return
|
lastFailoverStatus := 0
|
||||||
}
|
|
||||||
|
|
||||||
// 4) account concurrency slot
|
for {
|
||||||
accountReleaseFunc, err := geminiConcurrency.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, stream, &streamStarted)
|
account, err := h.geminiCompatService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, modelName, failedAccountIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
googleError(c, http.StatusTooManyRequests, err.Error())
|
if len(failedAccountIDs) == 0 {
|
||||||
return
|
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||||
}
|
return
|
||||||
if accountReleaseFunc != nil {
|
}
|
||||||
defer accountReleaseFunc()
|
handleGeminiFailoverExhausted(c, lastFailoverStatus)
|
||||||
}
|
return
|
||||||
|
|
||||||
// 5) forward (writes response to client)
|
|
||||||
result, err := h.geminiCompatService.ForwardNative(c.Request.Context(), c, account, modelName, action, stream, body)
|
|
||||||
if err != nil {
|
|
||||||
// ForwardNative already wrote the response
|
|
||||||
log.Printf("Gemini native forward failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6) record usage async
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
|
||||||
Result: result,
|
|
||||||
ApiKey: apiKey,
|
|
||||||
User: apiKey.User,
|
|
||||||
Account: account,
|
|
||||||
Subscription: subscription,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("Record usage failed: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
// 4) account concurrency slot
|
||||||
|
accountReleaseFunc, err := geminiConcurrency.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, stream, &streamStarted)
|
||||||
|
if err != nil {
|
||||||
|
googleError(c, http.StatusTooManyRequests, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) forward (writes response to client)
|
||||||
|
result, err := h.geminiCompatService.ForwardNative(c.Request.Context(), c, account, modelName, action, stream, body)
|
||||||
|
if accountReleaseFunc != nil {
|
||||||
|
accountReleaseFunc()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var failoverErr *service.UpstreamFailoverError
|
||||||
|
if errors.As(err, &failoverErr) {
|
||||||
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
if switchCount >= maxAccountSwitches {
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
handleGeminiFailoverExhausted(c, lastFailoverStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
switchCount++
|
||||||
|
log.Printf("Gemini account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// ForwardNative already wrote the response
|
||||||
|
log.Printf("Gemini native forward failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) record usage async
|
||||||
|
go func(result *service.ForwardResult, usedAccount *service.Account) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
|
Result: result,
|
||||||
|
ApiKey: apiKey,
|
||||||
|
User: apiKey.User,
|
||||||
|
Account: usedAccount,
|
||||||
|
Subscription: subscription,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("Record usage failed: %v", err)
|
||||||
|
}
|
||||||
|
}(result, account)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGeminiModelAction(rest string) (model string, action string, err error) {
|
func parseGeminiModelAction(rest string) (model string, action string, err error) {
|
||||||
@@ -217,6 +243,28 @@ func parseGeminiModelAction(rest string) (model string, action string, err error
|
|||||||
return "", "", &pathParseError{"invalid model action path"}
|
return "", "", &pathParseError{"invalid model action path"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleGeminiFailoverExhausted(c *gin.Context, statusCode int) {
|
||||||
|
status, message := mapGeminiUpstreamError(statusCode)
|
||||||
|
googleError(c, status, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapGeminiUpstreamError(statusCode int) (int, string) {
|
||||||
|
switch statusCode {
|
||||||
|
case 401:
|
||||||
|
return http.StatusBadGateway, "Upstream authentication failed, please contact administrator"
|
||||||
|
case 403:
|
||||||
|
return http.StatusBadGateway, "Upstream access forbidden, please contact administrator"
|
||||||
|
case 429:
|
||||||
|
return http.StatusTooManyRequests, "Upstream rate limit exceeded, please retry later"
|
||||||
|
case 529:
|
||||||
|
return http.StatusServiceUnavailable, "Upstream service overloaded, please retry later"
|
||||||
|
case 500, 502, 503, 504:
|
||||||
|
return http.StatusBadGateway, "Upstream service temporarily unavailable"
|
||||||
|
default:
|
||||||
|
return http.StatusBadGateway, "Upstream request failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type pathParseError struct{ msg string }
|
type pathParseError struct{ msg string }
|
||||||
|
|
||||||
func (e *pathParseError) Error() string { return e.msg }
|
func (e *pathParseError) Error() string { return e.msg }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -127,49 +128,74 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
// Generate session hash (from header for OpenAI)
|
// Generate session hash (from header for OpenAI)
|
||||||
sessionHash := h.gatewayService.GenerateSessionHash(c)
|
sessionHash := h.gatewayService.GenerateSessionHash(c)
|
||||||
|
|
||||||
// Select account supporting the requested model
|
const maxAccountSwitches = 3
|
||||||
log.Printf("[OpenAI Handler] Selecting account: groupID=%v model=%s", apiKey.GroupID, reqModel)
|
switchCount := 0
|
||||||
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel)
|
failedAccountIDs := make(map[int64]struct{})
|
||||||
if err != nil {
|
lastFailoverStatus := 0
|
||||||
log.Printf("[OpenAI Handler] SelectAccount failed: %v", err)
|
|
||||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[OpenAI Handler] Selected account: id=%d name=%s", account.ID, account.Name)
|
|
||||||
|
|
||||||
// 3. Acquire account concurrency slot
|
for {
|
||||||
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, reqStream, &streamStarted)
|
// Select account supporting the requested model
|
||||||
if err != nil {
|
log.Printf("[OpenAI Handler] Selecting account: groupID=%v model=%s", apiKey.GroupID, reqModel)
|
||||||
log.Printf("Account concurrency acquire failed: %v", err)
|
account, err := h.gatewayService.SelectAccountForModelWithExclusions(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, failedAccountIDs)
|
||||||
h.handleConcurrencyError(c, err, "account", streamStarted)
|
if err != nil {
|
||||||
return
|
log.Printf("[OpenAI Handler] SelectAccount failed: %v", err)
|
||||||
}
|
if len(failedAccountIDs) == 0 {
|
||||||
if accountReleaseFunc != nil {
|
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||||
defer accountReleaseFunc()
|
return
|
||||||
}
|
}
|
||||||
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
// Forward request
|
return
|
||||||
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
|
||||||
if err != nil {
|
|
||||||
// Error response already handled in Forward, just log
|
|
||||||
log.Printf("Forward request failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async record usage
|
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
|
||||||
Result: result,
|
|
||||||
ApiKey: apiKey,
|
|
||||||
User: apiKey.User,
|
|
||||||
Account: account,
|
|
||||||
Subscription: subscription,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("Record usage failed: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
log.Printf("[OpenAI Handler] Selected account: id=%d name=%s", account.ID, account.Name)
|
||||||
|
|
||||||
|
// 3. Acquire account concurrency slot
|
||||||
|
accountReleaseFunc, err := h.concurrencyHelper.AcquireAccountSlotWithWait(c, account.ID, account.Concurrency, reqStream, &streamStarted)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Account concurrency acquire failed: %v", err)
|
||||||
|
h.handleConcurrencyError(c, err, "account", streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward request
|
||||||
|
result, err := h.gatewayService.Forward(c.Request.Context(), c, account, body)
|
||||||
|
if accountReleaseFunc != nil {
|
||||||
|
accountReleaseFunc()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var failoverErr *service.UpstreamFailoverError
|
||||||
|
if errors.As(err, &failoverErr) {
|
||||||
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
if switchCount >= maxAccountSwitches {
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFailoverStatus = failoverErr.StatusCode
|
||||||
|
switchCount++
|
||||||
|
log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Error response already handled in Forward, just log
|
||||||
|
log.Printf("Forward request failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async record usage
|
||||||
|
go func(result *service.OpenAIForwardResult, usedAccount *service.Account) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
|
||||||
|
Result: result,
|
||||||
|
ApiKey: apiKey,
|
||||||
|
User: apiKey.User,
|
||||||
|
Account: usedAccount,
|
||||||
|
Subscription: subscription,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("Record usage failed: %v", err)
|
||||||
|
}
|
||||||
|
}(result, account)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConcurrencyError handles concurrency-related errors with proper 429 response
|
// handleConcurrencyError handles concurrency-related errors with proper 429 response
|
||||||
@@ -178,6 +204,28 @@ func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error,
|
|||||||
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
|
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType), streamStarted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, statusCode int, streamStarted bool) {
|
||||||
|
status, errType, errMsg := h.mapUpstreamError(statusCode)
|
||||||
|
h.handleStreamingAwareError(c, status, errType, errMsg, streamStarted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OpenAIGatewayHandler) mapUpstreamError(statusCode int) (int, string, string) {
|
||||||
|
switch statusCode {
|
||||||
|
case 401:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"
|
||||||
|
case 403:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"
|
||||||
|
case 429:
|
||||||
|
return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"
|
||||||
|
case 529:
|
||||||
|
return http.StatusServiceUnavailable, "upstream_error", "Upstream service overloaded, please retry later"
|
||||||
|
case 500, 502, 503, 504:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable"
|
||||||
|
default:
|
||||||
|
return http.StatusBadGateway, "upstream_error", "Upstream request failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleStreamingAwareError handles errors that may occur after streaming has started
|
// handleStreamingAwareError handles errors that may occur after streaming has started
|
||||||
func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
||||||
if streamStarted {
|
if streamStarted {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
@@ -61,16 +62,64 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
apiKeyID = id
|
apiKeyID = id
|
||||||
}
|
}
|
||||||
|
|
||||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
// Parse additional filters
|
||||||
var records []service.UsageLog
|
model := c.Query("model")
|
||||||
var result *pagination.PaginationResult
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if apiKeyID > 0 {
|
var stream *bool
|
||||||
records, result, err = h.usageService.ListByApiKey(c.Request.Context(), apiKeyID, params)
|
if streamStr := c.Query("stream"); streamStr != "" {
|
||||||
} else {
|
val, err := strconv.ParseBool(streamStr)
|
||||||
records, result, err = h.usageService.ListByUser(c.Request.Context(), subject.UserID, params)
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid stream value, use true or false")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream = &val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var billingType *int8
|
||||||
|
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||||
|
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid billing_type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bt := int8(val)
|
||||||
|
billingType = &bt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date range
|
||||||
|
var startTime, endTime *time.Time
|
||||||
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
|
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTime = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
||||||
|
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Set end time to end of day
|
||||||
|
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||||
|
endTime = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||||
|
filters := usagestats.UsageLogFilters{
|
||||||
|
UserID: subject.UserID, // Always filter by current user for security
|
||||||
|
ApiKeyID: apiKeyID,
|
||||||
|
Model: model,
|
||||||
|
Stream: stream,
|
||||||
|
BillingType: billingType,
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: endTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -322,24 +371,16 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify ownership of all requested API keys
|
// Limit the number of API key IDs to prevent SQL parameter overflow
|
||||||
userApiKeys, _, err := h.apiKeyService.List(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: 1, PageSize: 1000})
|
if len(req.ApiKeyIDs) > 100 {
|
||||||
if err != nil {
|
response.BadRequest(c, "Too many API key IDs (maximum 100 allowed)")
|
||||||
response.ErrorFrom(c, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userApiKeyIDs := make(map[int64]bool)
|
validApiKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.ApiKeyIDs)
|
||||||
for _, key := range userApiKeys {
|
if err != nil {
|
||||||
userApiKeyIDs[key.ID] = true
|
response.ErrorFrom(c, err)
|
||||||
}
|
return
|
||||||
|
|
||||||
// Filter to only include user's own API keys
|
|
||||||
validApiKeyIDs := make([]int64, 0)
|
|
||||||
for _, id := range req.ApiKeyIDs {
|
|
||||||
if userApiKeyIDs[id] {
|
|
||||||
validApiKeyIDs = append(validApiKeyIDs, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(validApiKeyIDs) == 0 {
|
if len(validApiKeyIDs) == 0 {
|
||||||
|
|||||||
@@ -127,10 +127,15 @@ type UserDashboardStats struct {
|
|||||||
|
|
||||||
// UsageLogFilters represents filters for usage log queries
|
// UsageLogFilters represents filters for usage log queries
|
||||||
type UsageLogFilters struct {
|
type UsageLogFilters struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
ApiKeyID int64
|
ApiKeyID int64
|
||||||
StartTime *time.Time
|
AccountID int64
|
||||||
EndTime *time.Time
|
GroupID int64
|
||||||
|
Model string
|
||||||
|
Stream *bool
|
||||||
|
BillingType *int8
|
||||||
|
StartTime *time.Time
|
||||||
|
EndTime *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageStats represents usage statistics
|
// UsageStats represents usage statistics
|
||||||
|
|||||||
@@ -171,6 +171,27 @@ func (r *accountRepository) UpdateLastUsed(ctx context.Context, id int64) error
|
|||||||
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Update("last_used_at", now).Error
|
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Update("last_used_at", now).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error {
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var caseSql = "UPDATE accounts SET last_used_at = CASE id"
|
||||||
|
var args []any
|
||||||
|
var ids []int64
|
||||||
|
|
||||||
|
for id, ts := range updates {
|
||||||
|
caseSql += " WHEN ? THEN CAST(? AS TIMESTAMP)"
|
||||||
|
args = append(args, id, ts)
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
caseSql += " END WHERE id IN ?"
|
||||||
|
args = append(args, ids)
|
||||||
|
|
||||||
|
return r.db.WithContext(ctx).Exec(caseSql, args...).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg string) error {
|
func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg string) error {
|
||||||
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).
|
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
|
|||||||
@@ -81,6 +81,22 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
|
|||||||
return outKeys, paginationResultFromTotal(total, params), nil
|
return outKeys, paginationResultFromTotal(total, params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
if len(apiKeyIDs) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int64, 0, len(apiKeyIDs))
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Model(&apiKeyModel{}).
|
||||||
|
Where("user_id = ? AND id IN ?", userID, apiKeyIDs).
|
||||||
|
Pluck("id", &ids).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error
|
err := r.db.WithContext(ctx).Model(&apiKeyModel{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxExpiresAt is the maximum allowed expiration date for subscriptions (year 2099)
|
||||||
|
// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
|
||||||
|
var maxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
// AutoMigrate runs schema migrations for all repository persistence models.
|
// AutoMigrate runs schema migrations for all repository persistence models.
|
||||||
// Persistence models are defined within individual `*_repo.go` files.
|
// Persistence models are defined within individual `*_repo.go` files.
|
||||||
func AutoMigrate(db *gorm.DB) error {
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(
|
err := db.AutoMigrate(
|
||||||
&userModel{},
|
&userModel{},
|
||||||
&apiKeyModel{},
|
&apiKeyModel{},
|
||||||
&groupModel{},
|
&groupModel{},
|
||||||
@@ -17,4 +26,24 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&settingModel{},
|
&settingModel{},
|
||||||
&userSubscriptionModel{},
|
&userSubscriptionModel{},
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败)
|
||||||
|
return fixInvalidExpiresAt(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixInvalidExpiresAt 修复 user_subscriptions 表中无效的过期时间
|
||||||
|
func fixInvalidExpiresAt(db *gorm.DB) error {
|
||||||
|
result := db.Model(&userSubscriptionModel{}).
|
||||||
|
Where("expires_at > ?", maxExpiresAt).
|
||||||
|
Update("expires_at", maxExpiresAt)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
log.Printf("[AutoMigrate] Fixed %d subscriptions with invalid expires_at (year > 2099)", result.RowsAffected)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
|
|||||||
return fullCode, nil
|
return fullCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) {
|
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||||
client := s.clientFactory(proxyURL)
|
client := s.clientFactory(proxyURL)
|
||||||
|
|
||||||
// Parse code which may contain state in format "authCode#state"
|
// Parse code which may contain state in format "authCode#state"
|
||||||
@@ -168,6 +168,11 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
|
|||||||
reqBody["state"] = codeState
|
reqBody["state"] = codeState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup token requires longer expiration (1 year)
|
||||||
|
if isSetupToken {
|
||||||
|
reqBody["expires_in"] = 31536000 // 365 * 24 * 60 * 60 seconds
|
||||||
|
}
|
||||||
|
|
||||||
reqBodyJSON, _ := json.Marshal(reqBody)
|
reqBodyJSON, _ := json.Marshal(reqBody)
|
||||||
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
|
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
|
||||||
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))
|
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))
|
||||||
@@ -199,16 +204,20 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
|
|||||||
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||||
client := s.clientFactory(proxyURL)
|
client := s.clientFactory(proxyURL)
|
||||||
|
|
||||||
formData := url.Values{}
|
// 使用 JSON 格式(与 ExchangeCodeForToken 保持一致)
|
||||||
formData.Set("grant_type", "refresh_token")
|
// Anthropic OAuth API 期望 JSON 格式的请求体
|
||||||
formData.Set("refresh_token", refreshToken)
|
reqBody := map[string]any{
|
||||||
formData.Set("client_id", oauth.ClientID)
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"client_id": oauth.ClientID,
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp oauth.TokenResponse
|
var tokenResp oauth.TokenResponse
|
||||||
|
|
||||||
resp, err := client.R().
|
resp, err := client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
SetFormDataFromValues(formData).
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetBody(reqBody).
|
||||||
SetSuccessResult(&tokenResp).
|
SetSuccessResult(&tokenResp).
|
||||||
Post(s.tokenURL)
|
Post(s.tokenURL)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ type requestCapture struct {
|
|||||||
method string
|
method string
|
||||||
cookies []*http.Cookie
|
cookies []*http.Cookie
|
||||||
body []byte
|
body []byte
|
||||||
formValues url.Values
|
|
||||||
bodyJSON map[string]any
|
bodyJSON map[string]any
|
||||||
contentType string
|
contentType string
|
||||||
}
|
}
|
||||||
@@ -193,12 +191,13 @@ func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() {
|
|||||||
|
|
||||||
func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
handler http.HandlerFunc
|
handler http.HandlerFunc
|
||||||
code string
|
code string
|
||||||
wantErr bool
|
isSetupToken bool
|
||||||
wantResp *oauth.TokenResponse
|
wantErr bool
|
||||||
validate func(captured requestCapture)
|
wantResp *oauth.TokenResponse
|
||||||
|
validate func(captured requestCapture)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "sends_state_when_embedded",
|
name: "sends_state_when_embedded",
|
||||||
@@ -212,7 +211,8 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
|||||||
Scope: "s",
|
Scope: "s",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
code: "AUTH#STATE2",
|
code: "AUTH#STATE2",
|
||||||
|
isSetupToken: false,
|
||||||
wantResp: &oauth.TokenResponse{
|
wantResp: &oauth.TokenResponse{
|
||||||
AccessToken: "at",
|
AccessToken: "at",
|
||||||
RefreshToken: "rt",
|
RefreshToken: "rt",
|
||||||
@@ -225,6 +225,29 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
|||||||
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
|
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
|
||||||
require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
|
require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
|
||||||
require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"])
|
require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"])
|
||||||
|
// Regular OAuth should not include expires_in
|
||||||
|
require.Nil(s.T(), captured.bodyJSON["expires_in"], "regular OAuth should not include expires_in")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "setup_token_includes_expires_in",
|
||||||
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
|
||||||
|
AccessToken: "at",
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 31536000,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
code: "AUTH",
|
||||||
|
isSetupToken: true,
|
||||||
|
wantResp: &oauth.TokenResponse{
|
||||||
|
AccessToken: "at",
|
||||||
|
},
|
||||||
|
validate: func(captured requestCapture) {
|
||||||
|
// Setup token should include expires_in with 1 year value
|
||||||
|
require.Equal(s.T(), float64(31536000), captured.bodyJSON["expires_in"],
|
||||||
|
"setup token should include expires_in: 31536000")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -233,8 +256,9 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
|||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
_, _ = w.Write([]byte("bad request"))
|
_, _ = w.Write([]byte("bad request"))
|
||||||
},
|
},
|
||||||
code: "AUTH",
|
code: "AUTH",
|
||||||
wantErr: true,
|
isSetupToken: false,
|
||||||
|
wantErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +280,7 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
|||||||
s.client = client
|
s.client = client
|
||||||
s.client.tokenURL = s.srv.URL
|
s.client.tokenURL = s.srv.URL
|
||||||
|
|
||||||
resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "")
|
resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "", tt.isSetupToken)
|
||||||
|
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
require.Error(s.T(), err)
|
require.Error(s.T(), err)
|
||||||
@@ -282,24 +306,53 @@ func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
|
|||||||
validate func(captured requestCapture)
|
validate func(captured requestCapture)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "sends_form",
|
name: "sends_json_format",
|
||||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{AccessToken: "at2", TokenType: "bearer", ExpiresIn: 3600})
|
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
|
||||||
|
AccessToken: "new_access_token",
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 28800,
|
||||||
|
RefreshToken: "new_refresh_token",
|
||||||
|
Scope: "user:profile user:inference",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
wantResp: &oauth.TokenResponse{
|
||||||
|
AccessToken: "new_access_token",
|
||||||
|
RefreshToken: "new_refresh_token",
|
||||||
},
|
},
|
||||||
wantResp: &oauth.TokenResponse{AccessToken: "at2"},
|
|
||||||
validate: func(captured requestCapture) {
|
validate: func(captured requestCapture) {
|
||||||
require.Equal(s.T(), http.MethodPost, captured.method, "expected POST")
|
require.Equal(s.T(), http.MethodPost, captured.method, "expected POST")
|
||||||
require.Equal(s.T(), "refresh_token", captured.formValues.Get("grant_type"))
|
// 验证使用 JSON 格式(不是 form 格式)
|
||||||
require.Equal(s.T(), "rt", captured.formValues.Get("refresh_token"))
|
require.True(s.T(), strings.HasPrefix(captured.contentType, "application/json"),
|
||||||
require.Equal(s.T(), oauth.ClientID, captured.formValues.Get("client_id"))
|
"expected JSON content-type, got: %s", captured.contentType)
|
||||||
|
// 验证 JSON body 内容
|
||||||
|
require.Equal(s.T(), "refresh_token", captured.bodyJSON["grant_type"])
|
||||||
|
require.Equal(s.T(), "rt", captured.bodyJSON["refresh_token"])
|
||||||
|
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns_new_refresh_token",
|
||||||
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
|
||||||
|
AccessToken: "at",
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 28800,
|
||||||
|
RefreshToken: "rotated_rt", // Anthropic rotates refresh tokens
|
||||||
|
})
|
||||||
|
},
|
||||||
|
wantResp: &oauth.TokenResponse{
|
||||||
|
AccessToken: "at",
|
||||||
|
RefreshToken: "rotated_rt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non_200_returns_error",
|
name: "non_200_returns_error",
|
||||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
_, _ = w.Write([]byte("unauthorized"))
|
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@@ -311,8 +364,9 @@ func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
|
|||||||
|
|
||||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
captured.method = r.Method
|
captured.method = r.Method
|
||||||
|
captured.contentType = r.Header.Get("Content-Type")
|
||||||
captured.body, _ = io.ReadAll(r.Body)
|
captured.body, _ = io.ReadAll(r.Body)
|
||||||
captured.formValues, _ = url.ParseQuery(string(captured.body))
|
_ = json.Unmarshal(captured.body, &captured.bodyJSON)
|
||||||
tt.handler(w, r)
|
tt.handler(w, r)
|
||||||
}))
|
}))
|
||||||
defer s.srv.Close()
|
defer s.srv.Close()
|
||||||
@@ -331,6 +385,7 @@ func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
|
|||||||
|
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
require.Equal(s.T(), tt.wantResp.AccessToken, resp.AccessToken)
|
require.Equal(s.T(), tt.wantResp.AccessToken, resp.AccessToken)
|
||||||
|
require.Equal(s.T(), tt.wantResp.RefreshToken, resp.RefreshToken)
|
||||||
if tt.validate != nil {
|
if tt.validate != nil {
|
||||||
tt.validate(captured)
|
tt.validate(captured)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,51 +129,67 @@ type DashboardStats = usagestats.DashboardStats
|
|||||||
func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||||
var stats DashboardStats
|
var stats DashboardStats
|
||||||
today := timezone.Today()
|
today := timezone.Today()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
// 总用户数
|
// 合并用户统计查询
|
||||||
r.db.WithContext(ctx).Model(&userModel{}).Count(&stats.TotalUsers)
|
var userStats struct {
|
||||||
|
TotalUsers int64 `gorm:"column:total_users"`
|
||||||
|
TodayNewUsers int64 `gorm:"column:today_new_users"`
|
||||||
|
ActiveUsers int64 `gorm:"column:active_users"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN created_at >= ? THEN 1 END) as today_new_users,
|
||||||
|
(SELECT COUNT(DISTINCT user_id) FROM usage_logs WHERE created_at >= ?) as active_users
|
||||||
|
FROM users
|
||||||
|
`, today, today).Scan(&userStats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalUsers = userStats.TotalUsers
|
||||||
|
stats.TodayNewUsers = userStats.TodayNewUsers
|
||||||
|
stats.ActiveUsers = userStats.ActiveUsers
|
||||||
|
|
||||||
// 今日新增用户数
|
// 合并API Key统计查询
|
||||||
r.db.WithContext(ctx).Model(&userModel{}).
|
var apiKeyStats struct {
|
||||||
Where("created_at >= ?", today).
|
TotalApiKeys int64 `gorm:"column:total_api_keys"`
|
||||||
Count(&stats.TodayNewUsers)
|
ActiveApiKeys int64 `gorm:"column:active_api_keys"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_api_keys,
|
||||||
|
COUNT(CASE WHEN status = ? THEN 1 END) as active_api_keys
|
||||||
|
FROM api_keys
|
||||||
|
`, service.StatusActive).Scan(&apiKeyStats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.TotalApiKeys = apiKeyStats.TotalApiKeys
|
||||||
|
stats.ActiveApiKeys = apiKeyStats.ActiveApiKeys
|
||||||
|
|
||||||
// 今日活跃用户数 (今日有请求的用户)
|
// 合并账户统计查询
|
||||||
r.db.WithContext(ctx).Model(&usageLogModel{}).
|
var accountStats struct {
|
||||||
Distinct("user_id").
|
TotalAccounts int64 `gorm:"column:total_accounts"`
|
||||||
Where("created_at >= ?", today).
|
NormalAccounts int64 `gorm:"column:normal_accounts"`
|
||||||
Count(&stats.ActiveUsers)
|
ErrorAccounts int64 `gorm:"column:error_accounts"`
|
||||||
|
RateLimitAccounts int64 `gorm:"column:ratelimit_accounts"`
|
||||||
// 总 API Key 数
|
OverloadAccounts int64 `gorm:"column:overload_accounts"`
|
||||||
r.db.WithContext(ctx).Model(&apiKeyModel{}).Count(&stats.TotalApiKeys)
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(`
|
||||||
// 活跃 API Key 数
|
SELECT
|
||||||
r.db.WithContext(ctx).Model(&apiKeyModel{}).
|
COUNT(*) as total_accounts,
|
||||||
Where("status = ?", service.StatusActive).
|
COUNT(CASE WHEN status = ? AND schedulable = true THEN 1 END) as normal_accounts,
|
||||||
Count(&stats.ActiveApiKeys)
|
COUNT(CASE WHEN status = ? THEN 1 END) as error_accounts,
|
||||||
|
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > ? THEN 1 END) as ratelimit_accounts,
|
||||||
// 总账户数
|
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > ? THEN 1 END) as overload_accounts
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).Count(&stats.TotalAccounts)
|
FROM accounts
|
||||||
|
`, service.StatusActive, service.StatusError, now, now).Scan(&accountStats).Error; err != nil {
|
||||||
// 正常账户数 (schedulable=true, status=active)
|
return nil, err
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
}
|
||||||
Where("status = ? AND schedulable = ?", service.StatusActive, true).
|
stats.TotalAccounts = accountStats.TotalAccounts
|
||||||
Count(&stats.NormalAccounts)
|
stats.NormalAccounts = accountStats.NormalAccounts
|
||||||
|
stats.ErrorAccounts = accountStats.ErrorAccounts
|
||||||
// 异常账户数 (status=error)
|
stats.RateLimitAccounts = accountStats.RateLimitAccounts
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
stats.OverloadAccounts = accountStats.OverloadAccounts
|
||||||
Where("status = ?", service.StatusError).
|
|
||||||
Count(&stats.ErrorAccounts)
|
|
||||||
|
|
||||||
// 限流账户数
|
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
|
||||||
Where("rate_limited_at IS NOT NULL AND rate_limit_reset_at > ?", time.Now()).
|
|
||||||
Count(&stats.RateLimitAccounts)
|
|
||||||
|
|
||||||
// 过载账户数
|
|
||||||
r.db.WithContext(ctx).Model(&accountModel{}).
|
|
||||||
Where("overload_until IS NOT NULL AND overload_until > ?", time.Now()).
|
|
||||||
Count(&stats.OverloadAccounts)
|
|
||||||
|
|
||||||
// 累计 Token 统计
|
// 累计 Token 统计
|
||||||
var totalStats struct {
|
var totalStats struct {
|
||||||
@@ -273,6 +289,88 @@ func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID
|
|||||||
return usageLogModelsToService(logs), nil, err
|
return usageLogModelsToService(logs), nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation
|
||||||
|
func (r *usageLogRepository) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
var stats struct {
|
||||||
|
TotalRequests int64 `gorm:"column:total_requests"`
|
||||||
|
TotalInputTokens int64 `gorm:"column:total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `gorm:"column:total_output_tokens"`
|
||||||
|
TotalCacheTokens int64 `gorm:"column:total_cache_tokens"`
|
||||||
|
TotalCost float64 `gorm:"column:total_cost"`
|
||||||
|
TotalActualCost float64 `gorm:"column:total_actual_cost"`
|
||||||
|
AverageDurationMs float64 `gorm:"column:avg_duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Model(&usageLogModel{}).
|
||||||
|
Select(`
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||||
|
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
|
||||||
|
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||||
|
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||||
|
COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
|
||||||
|
`).
|
||||||
|
Where("user_id = ? AND created_at >= ? AND created_at < ?", userID, startTime, endTime).
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &usagestats.UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetApiKeyStatsAggregated returns aggregated usage statistics for an API key using database-level aggregation
|
||||||
|
func (r *usageLogRepository) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
var stats struct {
|
||||||
|
TotalRequests int64 `gorm:"column:total_requests"`
|
||||||
|
TotalInputTokens int64 `gorm:"column:total_input_tokens"`
|
||||||
|
TotalOutputTokens int64 `gorm:"column:total_output_tokens"`
|
||||||
|
TotalCacheTokens int64 `gorm:"column:total_cache_tokens"`
|
||||||
|
TotalCost float64 `gorm:"column:total_cost"`
|
||||||
|
TotalActualCost float64 `gorm:"column:total_actual_cost"`
|
||||||
|
AverageDurationMs float64 `gorm:"column:avg_duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.WithContext(ctx).Model(&usageLogModel{}).
|
||||||
|
Select(`
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||||
|
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
|
||||||
|
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||||
|
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||||
|
COALESCE(AVG(COALESCE(duration_ms, 0)), 0) as avg_duration_ms
|
||||||
|
`).
|
||||||
|
Where("api_key_id = ? AND created_at >= ? AND created_at < ?", apiKeyID, startTime, endTime).
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &usagestats.UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
func (r *usageLogRepository) ListByApiKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||||
var logs []usageLogModel
|
var logs []usageLogModel
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
@@ -631,6 +729,21 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
|||||||
if filters.ApiKeyID > 0 {
|
if filters.ApiKeyID > 0 {
|
||||||
db = db.Where("api_key_id = ?", filters.ApiKeyID)
|
db = db.Where("api_key_id = ?", filters.ApiKeyID)
|
||||||
}
|
}
|
||||||
|
if filters.AccountID > 0 {
|
||||||
|
db = db.Where("account_id = ?", filters.AccountID)
|
||||||
|
}
|
||||||
|
if filters.GroupID > 0 {
|
||||||
|
db = db.Where("group_id = ?", filters.GroupID)
|
||||||
|
}
|
||||||
|
if filters.Model != "" {
|
||||||
|
db = db.Where("model = ?", filters.Model)
|
||||||
|
}
|
||||||
|
if filters.Stream != nil {
|
||||||
|
db = db.Where("stream = ?", *filters.Stream)
|
||||||
|
}
|
||||||
|
if filters.BillingType != nil {
|
||||||
|
db = db.Where("billing_type = ?", *filters.BillingType)
|
||||||
|
}
|
||||||
if filters.StartTime != nil {
|
if filters.StartTime != nil {
|
||||||
db = db.Where("created_at >= ?", *filters.StartTime)
|
db = db.Where("created_at >= ?", *filters.StartTime)
|
||||||
}
|
}
|
||||||
@@ -642,8 +755,8 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preload user and api_key for display
|
// Preload user, api_key, account, and group for display
|
||||||
if err := db.Preload("User").Preload("ApiKey").
|
if err := db.Preload("User").Preload("ApiKey").Preload("Account").Preload("Group").
|
||||||
Offset(params.Offset()).Limit(params.Limit()).
|
Offset(params.Offset()).Limit(params.Limit()).
|
||||||
Order("id DESC").Find(&logs).Error; err != nil {
|
Order("id DESC").Find(&logs).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -788,6 +788,25 @@ func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
if len(apiKeyIDs) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
seen := make(map[int64]struct{}, len(apiKeyIDs))
|
||||||
|
out := make([]int64, 0, len(apiKeyIDs))
|
||||||
|
for _, id := range apiKeyIDs {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
key, ok := r.byID[id]
|
||||||
|
if ok && key.UserID == userID {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
for _, key := range r.byID {
|
for _, key := range r.byID {
|
||||||
@@ -903,6 +922,55 @@ func (r *stubUsageLogRepo) GetUserUsageTrend(ctx context.Context, startTime, end
|
|||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *stubUsageLogRepo) GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
logs := r.userLogs[userID]
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return &usagestats.UsageStats{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRequests int64
|
||||||
|
var totalInputTokens int64
|
||||||
|
var totalOutputTokens int64
|
||||||
|
var totalCacheTokens int64
|
||||||
|
var totalCost float64
|
||||||
|
var totalActualCost float64
|
||||||
|
var totalDuration int64
|
||||||
|
var durationCount int64
|
||||||
|
|
||||||
|
for _, log := range logs {
|
||||||
|
totalRequests++
|
||||||
|
totalInputTokens += int64(log.InputTokens)
|
||||||
|
totalOutputTokens += int64(log.OutputTokens)
|
||||||
|
totalCacheTokens += int64(log.CacheCreationTokens + log.CacheReadTokens)
|
||||||
|
totalCost += log.TotalCost
|
||||||
|
totalActualCost += log.ActualCost
|
||||||
|
if log.DurationMs != nil {
|
||||||
|
totalDuration += int64(*log.DurationMs)
|
||||||
|
durationCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var avgDuration float64
|
||||||
|
if durationCount > 0 {
|
||||||
|
avgDuration = float64(totalDuration) / float64(durationCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &usagestats.UsageStats{
|
||||||
|
TotalRequests: totalRequests,
|
||||||
|
TotalInputTokens: totalInputTokens,
|
||||||
|
TotalOutputTokens: totalOutputTokens,
|
||||||
|
TotalCacheTokens: totalCacheTokens,
|
||||||
|
TotalTokens: totalInputTokens + totalOutputTokens + totalCacheTokens,
|
||||||
|
TotalCost: totalCost,
|
||||||
|
TotalActualCost: totalActualCost,
|
||||||
|
AverageDurationMs: avgDuration,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stubUsageLogRepo) GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
func (r *stubUsageLogRepo) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
@@ -924,7 +992,40 @@ func (r *stubUsageLogRepo) GetUserModelStats(ctx context.Context, userID int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, errors.New("not implemented")
|
logs := r.userLogs[filters.UserID]
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
var filtered []service.UsageLog
|
||||||
|
for _, log := range logs {
|
||||||
|
// Apply ApiKeyID filter
|
||||||
|
if filters.ApiKeyID > 0 && log.ApiKeyID != filters.ApiKeyID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Apply Model filter
|
||||||
|
if filters.Model != "" && log.Model != filters.Model {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Apply Stream filter
|
||||||
|
if filters.Stream != nil && log.Stream != *filters.Stream {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Apply BillingType filter
|
||||||
|
if filters.BillingType != nil && log.BillingType != *filters.BillingType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Apply time range filters
|
||||||
|
if filters.StartTime != nil && log.CreatedAt.Before(*filters.StartTime) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filters.EndTime != nil && log.CreatedAt.After(*filters.EndTime) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int64(len(filtered))
|
||||||
|
out := paginateLogs(filtered, params)
|
||||||
|
return out, paginationResult(total, params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Set(string(ContextKeyApiKey), apiKey)
|
c.Set(string(ContextKeyApiKey), apiKey)
|
||||||
c.Set(string(ContextKeyUser), apiKey.User)
|
c.Set(string(ContextKeyUser), AuthSubject{
|
||||||
|
UserID: apiKey.User.ID,
|
||||||
|
Concurrency: apiKey.User.Concurrency,
|
||||||
|
})
|
||||||
|
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -82,12 +86,28 @@ func (a *Account) GetCredential(key string) string {
|
|||||||
if a.Credentials == nil {
|
if a.Credentials == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if v, ok := a.Credentials[key]; ok {
|
v, ok := a.Credentials[key]
|
||||||
if s, ok := v.(string); ok {
|
if !ok || v == nil {
|
||||||
return s
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持多种类型(兼容历史数据中 expires_at 等字段可能是数字或字符串)
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
return val
|
||||||
|
case json.Number:
|
||||||
|
// GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
|
||||||
|
return val.String()
|
||||||
|
case float64:
|
||||||
|
// JSON 解析后数字默认为 float64
|
||||||
|
return strconv.FormatInt(int64(val), 10)
|
||||||
|
case int64:
|
||||||
|
return strconv.FormatInt(val, 10)
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(val)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) GetModelMapping() map[string]string {
|
func (a *Account) GetModelMapping() map[string]string {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type AccountRepository interface {
|
|||||||
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
||||||
|
|
||||||
UpdateLastUsed(ctx context.Context, id int64) error
|
UpdateLastUsed(ctx context.Context, id int64) error
|
||||||
|
BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
|
||||||
SetError(ctx context.Context, id int64, errorMsg string) error
|
SetError(ctx context.Context, id int64, errorMsg string) error
|
||||||
SetSchedulable(ctx context.Context, id int64, schedulable bool) error
|
SetSchedulable(ctx context.Context, id int64, schedulable bool) error
|
||||||
BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
|
BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
|
||||||
@@ -208,20 +209,23 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
|
|||||||
account.Status = *req.Status
|
account.Status = *req.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.accountRepo.Update(ctx, account); err != nil {
|
// 先验证分组是否存在(在任何写操作之前)
|
||||||
return nil, fmt.Errorf("update account: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新分组绑定
|
|
||||||
if req.GroupIDs != nil {
|
if req.GroupIDs != nil {
|
||||||
// 验证分组是否存在
|
|
||||||
for _, groupID := range *req.GroupIDs {
|
for _, groupID := range *req.GroupIDs {
|
||||||
_, err := s.groupRepo.GetByID(ctx, groupID)
|
_, err := s.groupRepo.GetByID(ctx, groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get group: %w", err)
|
return nil, fmt.Errorf("get group: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||||
|
return nil, fmt.Errorf("update account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定分组
|
||||||
|
if req.GroupIDs != nil {
|
||||||
if err := s.accountRepo.BindGroups(ctx, account.ID, *req.GroupIDs); err != nil {
|
if err := s.accountRepo.BindGroups(ctx, account.ID, *req.GroupIDs); err != nil {
|
||||||
return nil, fmt.Errorf("bind groups: %w", err)
|
return nil, fmt.Errorf("bind groups: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ type UsageLogRepository interface {
|
|||||||
|
|
||||||
// Account stats
|
// Account stats
|
||||||
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
|
||||||
|
|
||||||
|
// Aggregated stats (optimized)
|
||||||
|
GetUserStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
|
GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageCache 用于缓存usage数据
|
// usageCache 用于缓存usage数据
|
||||||
|
|||||||
@@ -652,11 +652,20 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
|||||||
account.Status = input.Status
|
account.Status = input.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先验证分组是否存在(在任何写操作之前)
|
||||||
|
if input.GroupIDs != nil {
|
||||||
|
for _, groupID := range *input.GroupIDs {
|
||||||
|
if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil {
|
||||||
|
return nil, fmt.Errorf("get group: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.accountRepo.Update(ctx, account); err != nil {
|
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新分组绑定
|
// 绑定分组
|
||||||
if input.GroupIDs != nil {
|
if input.GroupIDs != nil {
|
||||||
if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil {
|
if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type ApiKeyRepository interface {
|
|||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
|
|
||||||
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
||||||
|
VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error)
|
||||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
||||||
ExistsByKey(ctx context.Context, key string) (bool, error)
|
ExistsByKey(ctx context.Context, key string) (bool, error)
|
||||||
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]ApiKey, *pagination.PaginationResult, error)
|
||||||
@@ -256,6 +257,18 @@ func (s *ApiKeyService) List(ctx context.Context, userID int64, params paginatio
|
|||||||
return keys, pagination, nil
|
return keys, pagination, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
|
if len(apiKeyIDs) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
validIDs, err := s.apiKeyRepo.VerifyOwnership(ctx, userID, apiKeyIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verify api key ownership: %w", err)
|
||||||
|
}
|
||||||
|
return validIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID 根据ID获取API Key
|
// GetByID 根据ID获取API Key
|
||||||
func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) {
|
func (s *ApiKeyService) GetByID(ctx context.Context, id int64) (*ApiKey, error) {
|
||||||
apiKey, err := s.apiKeyRepo.GetByID(ctx, id)
|
apiKey, err := s.apiKeyRepo.GetByID(ctx, id)
|
||||||
|
|||||||
76
backend/internal/service/deferred_service.go
Normal file
76
backend/internal/service/deferred_service.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeferredService provides deferred batch update functionality
|
||||||
|
type DeferredService struct {
|
||||||
|
accountRepo AccountRepository
|
||||||
|
timingWheel *TimingWheelService
|
||||||
|
interval time.Duration
|
||||||
|
|
||||||
|
lastUsedUpdates sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeferredService creates a new DeferredService instance
|
||||||
|
func NewDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService, interval time.Duration) *DeferredService {
|
||||||
|
return &DeferredService{
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
timingWheel: timingWheel,
|
||||||
|
interval: interval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the deferred service
|
||||||
|
func (s *DeferredService) Start() {
|
||||||
|
s.timingWheel.ScheduleRecurring("deferred:last_used", s.interval, s.flushLastUsed)
|
||||||
|
log.Printf("[DeferredService] Started (interval: %v)", s.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the deferred service
|
||||||
|
func (s *DeferredService) Stop() {
|
||||||
|
s.timingWheel.Cancel("deferred:last_used")
|
||||||
|
s.flushLastUsed()
|
||||||
|
log.Printf("[DeferredService] Service stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeferredService) ScheduleLastUsedUpdate(accountID int64) {
|
||||||
|
s.lastUsedUpdates.Store(accountID, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DeferredService) flushLastUsed() {
|
||||||
|
updates := make(map[int64]time.Time)
|
||||||
|
s.lastUsedUpdates.Range(func(key, value any) bool {
|
||||||
|
id, ok := key.(int64)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ts, ok := value.(time.Time)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
updates[id] = ts
|
||||||
|
s.lastUsedUpdates.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := s.accountRepo.BatchUpdateLastUsed(ctx, updates); err != nil {
|
||||||
|
log.Printf("[DeferredService] BatchUpdateLastUsed failed (%d accounts): %v", len(updates), err)
|
||||||
|
for id, ts := range updates {
|
||||||
|
s.lastUsedUpdates.Store(id, ts)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[DeferredService] BatchUpdateLastUsed flushed %d accounts", len(updates))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,15 @@ type ForwardResult struct {
|
|||||||
FirstTokenMs *int // 首字时间(流式请求)
|
FirstTokenMs *int // 首字时间(流式请求)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpstreamFailoverError indicates an upstream error that should trigger account failover.
|
||||||
|
type UpstreamFailoverError struct {
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UpstreamFailoverError) Error() string {
|
||||||
|
return fmt.Sprintf("upstream error: %d (failover)", e.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// GatewayService handles API gateway operations
|
// GatewayService handles API gateway operations
|
||||||
type GatewayService struct {
|
type GatewayService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
@@ -94,6 +103,7 @@ type GatewayService struct {
|
|||||||
billingCacheService *BillingCacheService
|
billingCacheService *BillingCacheService
|
||||||
identityService *IdentityService
|
identityService *IdentityService
|
||||||
httpUpstream HTTPUpstream
|
httpUpstream HTTPUpstream
|
||||||
|
deferredService *DeferredService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGatewayService creates a new GatewayService
|
// NewGatewayService creates a new GatewayService
|
||||||
@@ -109,6 +119,7 @@ func NewGatewayService(
|
|||||||
billingCacheService *BillingCacheService,
|
billingCacheService *BillingCacheService,
|
||||||
identityService *IdentityService,
|
identityService *IdentityService,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
|
deferredService *DeferredService,
|
||||||
) *GatewayService {
|
) *GatewayService {
|
||||||
return &GatewayService{
|
return &GatewayService{
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
@@ -122,6 +133,7 @@ func NewGatewayService(
|
|||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
identityService: identityService,
|
identityService: identityService,
|
||||||
httpUpstream: httpUpstream,
|
httpUpstream: httpUpstream,
|
||||||
|
deferredService: deferredService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,19 +286,26 @@ func (s *GatewayService) SelectAccount(ctx context.Context, groupID *int64, sess
|
|||||||
|
|
||||||
// SelectAccountForModel 选择支持指定模型的账号(粘性会话+优先级+模型映射)
|
// SelectAccountForModel 选择支持指定模型的账号(粘性会话+优先级+模型映射)
|
||||||
func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
||||||
|
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
||||||
|
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||||
// 1. 查询粘性会话
|
// 1. 查询粘性会话
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
// 使用IsSchedulable代替IsActive,确保限流/过载账号不会被选中
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
// 同时检查模型支持
|
// 使用IsSchedulable代替IsActive,确保限流/过载账号不会被选中
|
||||||
if err == nil && account.IsSchedulable() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
// 同时检查模型支持
|
||||||
// 续期粘性会话
|
if err == nil && account.IsSchedulable() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
||||||
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
// 续期粘性会话
|
||||||
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
||||||
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
}
|
}
|
||||||
return account, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +326,9 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
|
|||||||
var selected *Account
|
var selected *Account
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
acc := &accounts[i]
|
acc := &accounts[i]
|
||||||
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// 检查模型支持
|
// 检查模型支持
|
||||||
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
||||||
continue
|
continue
|
||||||
@@ -394,6 +416,16 @@ func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode i
|
|||||||
return !account.ShouldHandleErrorCode(statusCode)
|
return !account.ShouldHandleErrorCode(statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldFailoverUpstreamError determines whether an upstream error should trigger account failover.
|
||||||
|
func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
|
||||||
|
switch statusCode {
|
||||||
|
case 401, 403, 429, 529:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return statusCode >= 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Forward 转发请求到Claude API
|
// Forward 转发请求到Claude API
|
||||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -478,9 +510,19 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
|||||||
|
|
||||||
// 处理重试耗尽的情况
|
// 处理重试耗尽的情况
|
||||||
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
||||||
|
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||||
|
s.handleRetryExhaustedSideEffects(ctx, resp, account)
|
||||||
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
return s.handleRetryExhaustedError(ctx, resp, c, account)
|
return s.handleRetryExhaustedError(ctx, resp, c, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理可切换账号的错误
|
||||||
|
if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||||
|
s.handleFailoverSideEffects(ctx, resp, account)
|
||||||
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理错误响应(不可重试的错误)
|
// 处理错误响应(不可重试的错误)
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return s.handleErrorResponse(ctx, resp, c, account)
|
return s.handleErrorResponse(ctx, resp, c, account)
|
||||||
@@ -692,10 +734,7 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
|
|||||||
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRetryExhaustedError 处理重试耗尽后的错误
|
func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) {
|
||||||
// OAuth 403:标记账号异常
|
|
||||||
// API Key 未配置错误码:仅返回错误,不标记账号
|
|
||||||
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
statusCode := resp.StatusCode
|
statusCode := resp.StatusCode
|
||||||
|
|
||||||
@@ -707,6 +746,18 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
|
|||||||
// API Key 未配置错误码:不标记账号状态
|
// API Key 未配置错误码:不标记账号状态
|
||||||
log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetries)
|
log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetries)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRetryExhaustedError 处理重试耗尽后的错误
|
||||||
|
// OAuth 403:标记账号异常
|
||||||
|
// API Key 未配置错误码:仅返回错误,不标记账号
|
||||||
|
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) {
|
||||||
|
s.handleRetryExhaustedSideEffects(ctx, resp, account)
|
||||||
|
|
||||||
// 返回统一的重试耗尽错误响应
|
// 返回统一的重试耗尽错误响应
|
||||||
c.JSON(http.StatusBadGateway, gin.H{
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
@@ -717,7 +768,7 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil, fmt.Errorf("upstream error: %d (retries exhausted)", statusCode)
|
return nil, fmt.Errorf("upstream error: %d (retries exhausted)", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamingResult 流式响应结果
|
// streamingResult 流式响应结果
|
||||||
@@ -1047,10 +1098,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新账号最后使用时间
|
// Schedule batch update for account last_used_at
|
||||||
if err := s.accountRepo.UpdateLastUsed(ctx, account.ID); err != nil {
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||||
log.Printf("Update last used failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,20 @@ func (s *GeminiMessagesCompatService) GetTokenProvider() *GeminiTokenProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
||||||
|
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||||
cacheKey := "gemini:" + sessionHash
|
cacheKey := "gemini:" + sessionHash
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey)
|
accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey)
|
||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
if err == nil && account.IsSchedulable() && account.Platform == PlatformGemini && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
|
if err == nil && account.IsSchedulable() && account.Platform == PlatformGemini && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
||||||
return account, nil
|
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +94,9 @@ func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context,
|
|||||||
var selected *Account
|
var selected *Account
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
acc := &accounts[i]
|
acc := &accounts[i]
|
||||||
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -425,6 +434,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
|||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||||
|
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
|
||||||
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
return nil, s.writeGeminiMappedError(c, resp.StatusCode, respBody)
|
return nil, s.writeGeminiMappedError(c, resp.StatusCode, respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,6 +736,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) {
|
||||||
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
|
|
||||||
respBody = unwrapIfNeeded(isOAuth, respBody)
|
respBody = unwrapIfNeeded(isOAuth, respBody)
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
@@ -795,6 +811,15 @@ func (s *GeminiMessagesCompatService) shouldRetryGeminiUpstreamError(account *Ac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCode int) bool {
|
||||||
|
switch statusCode {
|
||||||
|
case 401, 403, 429, 529:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return statusCode >= 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sleepGeminiBackoff(attempt int) {
|
func sleepGeminiBackoff(attempt int) {
|
||||||
delay := geminiRetryBaseDelay * time.Duration(1<<uint(attempt-1))
|
delay := geminiRetryBaseDelay * time.Duration(1<<uint(attempt-1))
|
||||||
if delay > geminiRetryMaxDelay {
|
if delay > geminiRetryMaxDelay {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type OpenAIOAuthClient interface {
|
|||||||
type ClaudeOAuthClient interface {
|
type ClaudeOAuthClient interface {
|
||||||
GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error)
|
GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error)
|
||||||
GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
|
GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
|
||||||
ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error)
|
ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error)
|
||||||
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
|
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +142,11 @@ func (s *OAuthService) ExchangeCode(ctx context.Context, input *ExchangeCodeInpu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if this is a setup token (scope is inference only)
|
||||||
|
isSetupToken := session.Scope == oauth.ScopeInference
|
||||||
|
|
||||||
// Exchange code for token
|
// Exchange code for token
|
||||||
tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL)
|
tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL, isSetupToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -172,10 +175,12 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine scope
|
// Determine scope and if this is a setup token
|
||||||
scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference)
|
scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference)
|
||||||
|
isSetupToken := false
|
||||||
if input.Scope == "inference" {
|
if input.Scope == "inference" {
|
||||||
scope = oauth.ScopeInference
|
scope = oauth.ScopeInference
|
||||||
|
isSetupToken = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get organization info using sessionKey
|
// Step 1: Get organization info using sessionKey
|
||||||
@@ -203,7 +208,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Exchange code for token
|
// Step 4: Exchange code for token
|
||||||
tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL)
|
tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL, isSetupToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -228,8 +233,8 @@ func (s *OAuthService) getAuthorizationCode(ctx context.Context, sessionKey, org
|
|||||||
}
|
}
|
||||||
|
|
||||||
// exchangeCodeForToken exchanges authorization code for tokens
|
// exchangeCodeForToken exchanges authorization code for tokens
|
||||||
func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*TokenInfo, error) {
|
func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*TokenInfo, error) {
|
||||||
tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL)
|
tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL, isSetupToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type OpenAIGatewayService struct {
|
|||||||
rateLimitService *RateLimitService
|
rateLimitService *RateLimitService
|
||||||
billingCacheService *BillingCacheService
|
billingCacheService *BillingCacheService
|
||||||
httpUpstream HTTPUpstream
|
httpUpstream HTTPUpstream
|
||||||
|
deferredService *DeferredService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenAIGatewayService creates a new OpenAIGatewayService
|
// NewOpenAIGatewayService creates a new OpenAIGatewayService
|
||||||
@@ -97,6 +98,7 @@ func NewOpenAIGatewayService(
|
|||||||
rateLimitService *RateLimitService,
|
rateLimitService *RateLimitService,
|
||||||
billingCacheService *BillingCacheService,
|
billingCacheService *BillingCacheService,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
|
deferredService *DeferredService,
|
||||||
) *OpenAIGatewayService {
|
) *OpenAIGatewayService {
|
||||||
return &OpenAIGatewayService{
|
return &OpenAIGatewayService{
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
@@ -109,6 +111,7 @@ func NewOpenAIGatewayService(
|
|||||||
rateLimitService: rateLimitService,
|
rateLimitService: rateLimitService,
|
||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
httpUpstream: httpUpstream,
|
httpUpstream: httpUpstream,
|
||||||
|
deferredService: deferredService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,15 +132,22 @@ func (s *OpenAIGatewayService) SelectAccount(ctx context.Context, groupID *int64
|
|||||||
|
|
||||||
// SelectAccountForModel selects an account supporting the requested model
|
// SelectAccountForModel selects an account supporting the requested model
|
||||||
func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupID *int64, sessionHash string, requestedModel string) (*Account, error) {
|
||||||
|
return s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
||||||
|
func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||||
// 1. Check sticky session
|
// 1. Check sticky session
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
accountID, err := s.cache.GetSessionAccountID(ctx, "openai:"+sessionHash)
|
accountID, err := s.cache.GetSessionAccountID(ctx, "openai:"+sessionHash)
|
||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
if err == nil && account.IsSchedulable() && account.IsOpenAI() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
// Refresh sticky session TTL
|
if err == nil && account.IsSchedulable() && account.IsOpenAI() && (requestedModel == "" || account.IsModelSupported(requestedModel)) {
|
||||||
_ = s.cache.RefreshSessionTTL(ctx, "openai:"+sessionHash, openaiStickySessionTTL)
|
// Refresh sticky session TTL
|
||||||
return account, nil
|
_ = s.cache.RefreshSessionTTL(ctx, "openai:"+sessionHash, openaiStickySessionTTL)
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +168,9 @@ func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupI
|
|||||||
var selected *Account
|
var selected *Account
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
acc := &accounts[i]
|
acc := &accounts[i]
|
||||||
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Check model support
|
// Check model support
|
||||||
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
||||||
continue
|
continue
|
||||||
@@ -221,6 +234,20 @@ func (s *OpenAIGatewayService) GetAccessToken(ctx context.Context, account *Acco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
|
||||||
|
switch statusCode {
|
||||||
|
case 401, 403, 429, 529:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return statusCode >= 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
|
||||||
|
}
|
||||||
|
|
||||||
// Forward forwards request to OpenAI API
|
// Forward forwards request to OpenAI API
|
||||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -288,6 +315,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
|
|
||||||
// Handle error response
|
// Handle error response
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
|
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||||
|
s.handleFailoverSideEffects(ctx, resp, account)
|
||||||
|
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
return s.handleErrorResponse(ctx, resp, c, account)
|
return s.handleErrorResponse(ctx, resp, c, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,8 +775,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update account last used
|
// Schedule batch update for account last_used_at
|
||||||
_ = s.accountRepo.UpdateLastUsed(ctx, account.ID)
|
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxExpiresAt is the maximum allowed expiration date (year 2099)
|
||||||
|
// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
|
||||||
|
var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
|
// MaxValidityDays is the maximum allowed validity days for subscriptions (100 years)
|
||||||
|
const MaxValidityDays = 36500
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
|
ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
|
||||||
ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
|
ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
|
||||||
@@ -111,6 +118,9 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
|||||||
if validityDays <= 0 {
|
if validityDays <= 0 {
|
||||||
validityDays = 30
|
validityDays = 30
|
||||||
}
|
}
|
||||||
|
if validityDays > MaxValidityDays {
|
||||||
|
validityDays = MaxValidityDays
|
||||||
|
}
|
||||||
|
|
||||||
// 已有订阅,执行续期
|
// 已有订阅,执行续期
|
||||||
if existingSub != nil {
|
if existingSub != nil {
|
||||||
@@ -125,6 +135,11 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
|||||||
newExpiresAt = now.AddDate(0, 0, validityDays)
|
newExpiresAt = now.AddDate(0, 0, validityDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确保不超过最大过期时间
|
||||||
|
if newExpiresAt.After(MaxExpiresAt) {
|
||||||
|
newExpiresAt = MaxExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
// 更新过期时间
|
// 更新过期时间
|
||||||
if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil {
|
if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil {
|
||||||
return nil, false, fmt.Errorf("extend subscription: %w", err)
|
return nil, false, fmt.Errorf("extend subscription: %w", err)
|
||||||
@@ -189,13 +204,21 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass
|
|||||||
if validityDays <= 0 {
|
if validityDays <= 0 {
|
||||||
validityDays = 30
|
validityDays = 30
|
||||||
}
|
}
|
||||||
|
if validityDays > MaxValidityDays {
|
||||||
|
validityDays = MaxValidityDays
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
expiresAt := now.AddDate(0, 0, validityDays)
|
||||||
|
if expiresAt.After(MaxExpiresAt) {
|
||||||
|
expiresAt = MaxExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
sub := &UserSubscription{
|
sub := &UserSubscription{
|
||||||
UserID: input.UserID,
|
UserID: input.UserID,
|
||||||
GroupID: input.GroupID,
|
GroupID: input.GroupID,
|
||||||
StartsAt: now,
|
StartsAt: now,
|
||||||
ExpiresAt: now.AddDate(0, 0, validityDays),
|
ExpiresAt: expiresAt,
|
||||||
Status: SubscriptionStatusActive,
|
Status: SubscriptionStatusActive,
|
||||||
AssignedAt: now,
|
AssignedAt: now,
|
||||||
Notes: input.Notes,
|
Notes: input.Notes,
|
||||||
@@ -291,8 +314,17 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
|
|||||||
return nil, ErrSubscriptionNotFound
|
return nil, ErrSubscriptionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 限制延长天数
|
||||||
|
if days > MaxValidityDays {
|
||||||
|
days = MaxValidityDays
|
||||||
|
}
|
||||||
|
|
||||||
// 计算新的过期时间
|
// 计算新的过期时间
|
||||||
newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days)
|
newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days)
|
||||||
|
if newExpiresAt.After(MaxExpiresAt) {
|
||||||
|
newExpiresAt = MaxExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
|
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
63
backend/internal/service/timing_wheel_service.go
Normal file
63
backend/internal/service/timing_wheel_service.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/collection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimingWheelService wraps go-zero's TimingWheel for task scheduling
|
||||||
|
type TimingWheelService struct {
|
||||||
|
tw *collection.TimingWheel
|
||||||
|
stopOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTimingWheelService creates a new TimingWheelService instance
|
||||||
|
func NewTimingWheelService() *TimingWheelService {
|
||||||
|
// 1 second tick, 3600 slots = supports up to 1 hour delay
|
||||||
|
// execute function: runs func() type tasks
|
||||||
|
tw, err := collection.NewTimingWheel(1*time.Second, 3600, func(key, value any) {
|
||||||
|
if fn, ok := value.(func()); ok {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &TimingWheelService{tw: tw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the timing wheel
|
||||||
|
func (s *TimingWheelService) Start() {
|
||||||
|
log.Println("[TimingWheel] Started (auto-start by go-zero)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the timing wheel
|
||||||
|
func (s *TimingWheelService) Stop() {
|
||||||
|
s.stopOnce.Do(func() {
|
||||||
|
s.tw.Stop()
|
||||||
|
log.Println("[TimingWheel] Stopped")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule schedules a one-time task
|
||||||
|
func (s *TimingWheelService) Schedule(name string, delay time.Duration, fn func()) {
|
||||||
|
_ = s.tw.SetTimer(name, fn, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleRecurring schedules a recurring task
|
||||||
|
func (s *TimingWheelService) ScheduleRecurring(name string, interval time.Duration, fn func()) {
|
||||||
|
var schedule func()
|
||||||
|
schedule = func() {
|
||||||
|
fn()
|
||||||
|
_ = s.tw.SetTimer(name, schedule, interval)
|
||||||
|
}
|
||||||
|
_ = s.tw.SetTimer(name, schedule, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel cancels a scheduled task
|
||||||
|
func (s *TimingWheelService) Cancel(name string) {
|
||||||
|
_ = s.tw.RemoveTimer(name)
|
||||||
|
}
|
||||||
@@ -106,6 +106,9 @@ func (s *TokenRefreshService) processRefresh() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totalAccounts := len(accounts)
|
||||||
|
oauthAccounts := 0 // 可刷新的OAuth账号数
|
||||||
|
needsRefresh := 0 // 需要刷新的账号数
|
||||||
refreshed, failed := 0, 0
|
refreshed, failed := 0, 0
|
||||||
|
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
@@ -117,11 +120,15 @@ func (s *TokenRefreshService) processRefresh() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oauthAccounts++
|
||||||
|
|
||||||
// 检查是否需要刷新
|
// 检查是否需要刷新
|
||||||
if !refresher.NeedsRefresh(account, refreshWindow) {
|
if !refresher.NeedsRefresh(account, refreshWindow) {
|
||||||
continue
|
break // 不需要刷新,跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
|
needsRefresh++
|
||||||
|
|
||||||
// 执行刷新
|
// 执行刷新
|
||||||
if err := s.refreshWithRetry(ctx, account, refresher); err != nil {
|
if err := s.refreshWithRetry(ctx, account, refresher); err != nil {
|
||||||
log.Printf("[TokenRefresh] Account %d (%s) failed: %v", account.ID, account.Name, err)
|
log.Printf("[TokenRefresh] Account %d (%s) failed: %v", account.ID, account.Name, err)
|
||||||
@@ -136,9 +143,9 @@ func (s *TokenRefreshService) processRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if refreshed > 0 || failed > 0 {
|
// 始终打印周期日志,便于跟踪服务运行状态
|
||||||
log.Printf("[TokenRefresh] Cycle complete: %d refreshed, %d failed", refreshed, failed)
|
log.Printf("[TokenRefresh] Cycle complete: total=%d, oauth=%d, needs_refresh=%d, refreshed=%d, failed=%d",
|
||||||
}
|
totalAccounts, oauthAccounts, needsRefresh, refreshed, failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listActiveAccounts 获取所有active状态的账号
|
// listActiveAccounts 获取所有active状态的账号
|
||||||
|
|||||||
@@ -43,18 +43,17 @@ func (r *ClaudeTokenRefresher) CanRefresh(account *Account) bool {
|
|||||||
// NeedsRefresh 检查token是否需要刷新
|
// NeedsRefresh 检查token是否需要刷新
|
||||||
// 基于 expires_at 字段判断是否在刷新窗口内
|
// 基于 expires_at 字段判断是否在刷新窗口内
|
||||||
func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
||||||
expiresAtStr := account.GetCredential("expires_at")
|
s := account.GetCredential("expires_at")
|
||||||
if expiresAtStr == "" {
|
if s == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64)
|
expiresAt, err := strconv.ParseInt(s, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
expiryTime := time.Unix(expiresAt, 0)
|
return time.Until(time.Unix(expiresAt, 0)) < refreshWindow
|
||||||
return time.Until(expiryTime) < refreshWindow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh 执行token刷新
|
// Refresh 执行token刷新
|
||||||
|
|||||||
214
backend/internal/service/token_refresher_test.go
Normal file
214
backend/internal/service/token_refresher_test.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaudeTokenRefresher_NeedsRefresh(t *testing.T) {
|
||||||
|
refresher := &ClaudeTokenRefresher{}
|
||||||
|
refreshWindow := 30 * time.Minute
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
credentials map[string]any
|
||||||
|
wantRefresh bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "expires_at as string - expired",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": "1000", // 1970-01-01 00:16:40 UTC, 已过期
|
||||||
|
},
|
||||||
|
wantRefresh: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expires_at as float64 - expired",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": float64(1000), // 数字类型,已过期
|
||||||
|
},
|
||||||
|
wantRefresh: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expires_at as string - far future",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": "9999999999", // 远未来
|
||||||
|
},
|
||||||
|
wantRefresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expires_at as float64 - far future",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": float64(9999999999), // 远未来,数字类型
|
||||||
|
},
|
||||||
|
wantRefresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expires_at missing",
|
||||||
|
credentials: map[string]any{},
|
||||||
|
wantRefresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expires_at is nil",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": nil,
|
||||||
|
},
|
||||||
|
wantRefresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expires_at is invalid string",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": "invalid",
|
||||||
|
},
|
||||||
|
wantRefresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "credentials is nil",
|
||||||
|
credentials: nil,
|
||||||
|
wantRefresh: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformAnthropic,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: tt.credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := refresher.NeedsRefresh(account, refreshWindow)
|
||||||
|
require.Equal(t, tt.wantRefresh, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeTokenRefresher_NeedsRefresh_WithinWindow(t *testing.T) {
|
||||||
|
refresher := &ClaudeTokenRefresher{}
|
||||||
|
refreshWindow := 30 * time.Minute
|
||||||
|
|
||||||
|
// 设置一个在刷新窗口内的时间(当前时间 + 15分钟)
|
||||||
|
expiresAt := time.Now().Add(15 * time.Minute).Unix()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
credentials map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "string type - within refresh window",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": strconv.FormatInt(expiresAt, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float64 type - within refresh window",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": float64(expiresAt),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformAnthropic,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: tt.credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := refresher.NeedsRefresh(account, refreshWindow)
|
||||||
|
require.True(t, got, "should need refresh when within window")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeTokenRefresher_NeedsRefresh_OutsideWindow(t *testing.T) {
|
||||||
|
refresher := &ClaudeTokenRefresher{}
|
||||||
|
refreshWindow := 30 * time.Minute
|
||||||
|
|
||||||
|
// 设置一个在刷新窗口外的时间(当前时间 + 1小时)
|
||||||
|
expiresAt := time.Now().Add(1 * time.Hour).Unix()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
credentials map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "string type - outside refresh window",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": strconv.FormatInt(expiresAt, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float64 type - outside refresh window",
|
||||||
|
credentials: map[string]any{
|
||||||
|
"expires_at": float64(expiresAt),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformAnthropic,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: tt.credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := refresher.NeedsRefresh(account, refreshWindow)
|
||||||
|
require.False(t, got, "should not need refresh when outside window")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeTokenRefresher_CanRefresh(t *testing.T) {
|
||||||
|
refresher := &ClaudeTokenRefresher{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
platform string
|
||||||
|
accType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "anthropic oauth - can refresh",
|
||||||
|
platform: PlatformAnthropic,
|
||||||
|
accType: AccountTypeOAuth,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic api-key - cannot refresh",
|
||||||
|
platform: PlatformAnthropic,
|
||||||
|
accType: AccountTypeApiKey,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai oauth - cannot refresh",
|
||||||
|
platform: PlatformOpenAI,
|
||||||
|
accType: AccountTypeOAuth,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gemini oauth - cannot refresh",
|
||||||
|
platform: PlatformGemini,
|
||||||
|
accType: AccountTypeOAuth,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: tt.platform,
|
||||||
|
Type: tt.accType,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := refresher.CanRefresh(account)
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,22 +148,40 @@ func (s *UsageService) ListByAccount(ctx context.Context, accountID int64, param
|
|||||||
|
|
||||||
// GetStatsByUser 获取用户的使用统计
|
// GetStatsByUser 获取用户的使用统计
|
||||||
func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
func (s *UsageService) GetStatsByUser(ctx context.Context, userID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
||||||
logs, _, err := s.usageRepo.ListByUserAndTimeRange(ctx, userID, startTime, endTime)
|
stats, err := s.usageRepo.GetUserStatsAggregated(ctx, userID, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list usage logs: %w", err)
|
return nil, fmt.Errorf("get user stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.calculateStats(logs), nil
|
return &UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatsByApiKey 获取API Key的使用统计
|
// GetStatsByApiKey 获取API Key的使用统计
|
||||||
func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
func (s *UsageService) GetStatsByApiKey(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*UsageStats, error) {
|
||||||
logs, _, err := s.usageRepo.ListByApiKeyAndTimeRange(ctx, apiKeyID, startTime, endTime)
|
stats, err := s.usageRepo.GetApiKeyStatsAggregated(ctx, apiKeyID, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list usage logs: %w", err)
|
return nil, fmt.Errorf("get api key stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.calculateStats(logs), nil
|
return &UsageStats{
|
||||||
|
TotalRequests: stats.TotalRequests,
|
||||||
|
TotalInputTokens: stats.TotalInputTokens,
|
||||||
|
TotalOutputTokens: stats.TotalOutputTokens,
|
||||||
|
TotalCacheTokens: stats.TotalCacheTokens,
|
||||||
|
TotalTokens: stats.TotalTokens,
|
||||||
|
TotalCost: stats.TotalCost,
|
||||||
|
TotalActualCost: stats.TotalActualCost,
|
||||||
|
AverageDurationMs: stats.AverageDurationMs,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatsByAccount 获取账号的使用统计
|
// GetStatsByAccount 获取账号的使用统计
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
)
|
)
|
||||||
@@ -44,6 +46,20 @@ func ProvideTokenRefreshService(
|
|||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideTimingWheelService creates and starts TimingWheelService
|
||||||
|
func ProvideTimingWheelService() *TimingWheelService {
|
||||||
|
svc := NewTimingWheelService()
|
||||||
|
svc.Start()
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideDeferredService creates and starts DeferredService
|
||||||
|
func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService) *DeferredService {
|
||||||
|
svc := NewDeferredService(accountRepo, timingWheel, 10*time.Second)
|
||||||
|
svc.Start()
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
// ProviderSet is the Wire provider set for all services
|
// ProviderSet is the Wire provider set for all services
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
// Core services
|
// Core services
|
||||||
@@ -80,4 +96,6 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewCRSSyncService,
|
NewCRSSyncService,
|
||||||
ProvideUpdateService,
|
ProvideUpdateService,
|
||||||
ProvideTokenRefreshService,
|
ProvideTokenRefreshService,
|
||||||
|
ProvideTimingWheelService,
|
||||||
|
ProvideDeferredService,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
|
|||||||
strings.HasPrefix(path, "/v1/") ||
|
strings.HasPrefix(path, "/v1/") ||
|
||||||
strings.HasPrefix(path, "/v1beta/") ||
|
strings.HasPrefix(path, "/v1beta/") ||
|
||||||
strings.HasPrefix(path, "/setup/") ||
|
strings.HasPrefix(path, "/setup/") ||
|
||||||
path == "/health" {
|
path == "/health" ||
|
||||||
|
path == "/responses" {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
184
deploy/Caddyfile
Normal file
184
deploy/Caddyfile
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Sub2API Caddy Reverse Proxy Configuration (宿主机部署)
|
||||||
|
# =============================================================================
|
||||||
|
# 使用方法:
|
||||||
|
# 1. 安装 Caddy: https://caddyserver.com/docs/install
|
||||||
|
# 2. 修改下方 example.com 为你的域名
|
||||||
|
# 3. 确保域名 DNS 已指向服务器
|
||||||
|
# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile
|
||||||
|
# 5. 重载配置: sudo systemctl reload caddy
|
||||||
|
#
|
||||||
|
# Caddy 会自动申请和续期 Let's Encrypt SSL 证书
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 全局配置
|
||||||
|
{
|
||||||
|
# Let's Encrypt 邮箱通知
|
||||||
|
email admin@example.com
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
servers {
|
||||||
|
# 启用 HTTP/2 和 HTTP/3
|
||||||
|
protocols h1 h2 h3
|
||||||
|
|
||||||
|
# 超时配置
|
||||||
|
timeouts {
|
||||||
|
read_body 30s
|
||||||
|
read_header 10s
|
||||||
|
write 60s
|
||||||
|
idle 120s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 修改为你的域名
|
||||||
|
example.com {
|
||||||
|
# =========================================================================
|
||||||
|
# TLS 安全配置
|
||||||
|
# =========================================================================
|
||||||
|
tls {
|
||||||
|
# 仅使用 TLS 1.2 和 1.3
|
||||||
|
protocols tls1.2 tls1.3
|
||||||
|
|
||||||
|
# 优先使用的加密套件
|
||||||
|
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 反向代理配置
|
||||||
|
# =========================================================================
|
||||||
|
reverse_proxy localhost:8080 {
|
||||||
|
# 健康检查
|
||||||
|
health_uri /health
|
||||||
|
health_interval 30s
|
||||||
|
health_timeout 10s
|
||||||
|
health_status 200
|
||||||
|
|
||||||
|
# 负载均衡策略(单节点可忽略,多节点时有用)
|
||||||
|
lb_policy round_robin
|
||||||
|
lb_try_duration 5s
|
||||||
|
lb_try_interval 250ms
|
||||||
|
|
||||||
|
# 传递真实客户端信息
|
||||||
|
# 兼容 Cloudflare 和直连:后端应优先读取 CF-Connecting-IP,其次 X-Real-IP
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
# 保留 Cloudflare 原始头(如果存在)
|
||||||
|
# 后端获取 IP 的优先级建议: CF-Connecting-IP → X-Real-IP → X-Forwarded-For
|
||||||
|
header_up CF-Connecting-IP {http.request.header.CF-Connecting-IP}
|
||||||
|
|
||||||
|
# 连接池优化
|
||||||
|
transport http {
|
||||||
|
keepalive 120s
|
||||||
|
keepalive_idle_conns 256
|
||||||
|
read_buffer 16KB
|
||||||
|
write_buffer 16KB
|
||||||
|
compression off
|
||||||
|
}
|
||||||
|
|
||||||
|
# 故障转移
|
||||||
|
fail_duration 30s
|
||||||
|
max_fails 3
|
||||||
|
unhealthy_status 500 502 503 504
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 压缩配置
|
||||||
|
# =========================================================================
|
||||||
|
encode {
|
||||||
|
zstd
|
||||||
|
gzip 6
|
||||||
|
minimum_length 256
|
||||||
|
match {
|
||||||
|
header Content-Type text/*
|
||||||
|
header Content-Type application/json*
|
||||||
|
header Content-Type application/javascript*
|
||||||
|
header Content-Type application/xml*
|
||||||
|
header Content-Type application/rss+xml*
|
||||||
|
header Content-Type image/svg+xml*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 速率限制 (需要 caddy-ratelimit 插件)
|
||||||
|
# 如未安装插件,请注释掉此段
|
||||||
|
# =========================================================================
|
||||||
|
# rate_limit {
|
||||||
|
# zone api {
|
||||||
|
# key {remote_host}
|
||||||
|
# events 100
|
||||||
|
# window 1m
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 安全响应头
|
||||||
|
# =========================================================================
|
||||||
|
header {
|
||||||
|
# 防止点击劫持
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
|
||||||
|
# XSS 保护
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
|
||||||
|
# 防止 MIME 类型嗅探
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
|
||||||
|
# 引用策略
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
# HSTS - 强制 HTTPS (max-age=1年)
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
|
||||||
|
# 内容安全策略 (根据需要调整)
|
||||||
|
# Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;"
|
||||||
|
|
||||||
|
# 权限策略
|
||||||
|
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||||
|
|
||||||
|
# 跨域资源策略
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Cross-Origin-Embedder-Policy "require-corp"
|
||||||
|
Cross-Origin-Resource-Policy "same-origin"
|
||||||
|
|
||||||
|
# 移除敏感头
|
||||||
|
-Server
|
||||||
|
-X-Powered-By
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 请求大小限制 (防止大文件攻击)
|
||||||
|
# =========================================================================
|
||||||
|
request_body {
|
||||||
|
max_size 100MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 日志配置
|
||||||
|
# =========================================================================
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/sub2api.log {
|
||||||
|
roll_size 50mb
|
||||||
|
roll_keep 10
|
||||||
|
roll_keep_for 720h
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
level INFO
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 错误处理
|
||||||
|
# =========================================================================
|
||||||
|
handle_errors {
|
||||||
|
respond "{err.status_code} {err.status_text}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明)
|
||||||
|
# =============================================================================
|
||||||
|
; http://example.com {
|
||||||
|
; redir https://{host}{uri} permanent
|
||||||
|
; }
|
||||||
@@ -281,6 +281,30 @@ To change after installation:
|
|||||||
sudo systemctl restart sub2api
|
sudo systemctl restart sub2api
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Gemini OAuth Configuration
|
||||||
|
|
||||||
|
If you need to use AI Studio OAuth for Gemini accounts, add the OAuth client credentials to the systemd service file:
|
||||||
|
|
||||||
|
1. Edit the service file:
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/sub2api.service
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add your OAuth credentials in the `[Service]` section (after the existing `Environment=` lines):
|
||||||
|
```ini
|
||||||
|
Environment=GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Reload and restart:
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart sub2api
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Code Assist OAuth does not require any configuration - it uses the built-in Gemini CLI client.
|
||||||
|
> See the [Gemini OAuth Configuration](#gemini-oauth-configuration) section above for detailed setup instructions.
|
||||||
|
|
||||||
#### Application Configuration
|
#### Application Configuration
|
||||||
|
|
||||||
The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard).
|
The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard).
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
ports:
|
# 注意:不暴露端口到宿主机,应用通过内部网络连接
|
||||||
- 5433:5432
|
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Redis Cache
|
# Redis Cache
|
||||||
|
|||||||
@@ -226,7 +226,9 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">Tokens</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||||
|
t('admin.accounts.stats.tokens')
|
||||||
|
}}</span>
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||||
formatTokens(stats.summary.today?.tokens || 0)
|
formatTokens(stats.summary.today?.tokens || 0)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
|
import { formatTime } from '@/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
@@ -139,13 +140,4 @@ const statusText = computed(() => {
|
|||||||
return props.account.status
|
return props.account.status
|
||||||
})
|
})
|
||||||
|
|
||||||
// Format time helper
|
|
||||||
const formatTime = (dateStr: string | null | undefined) => {
|
|
||||||
if (!dateStr) return 'N/A'
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -280,10 +280,12 @@
|
|||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, ClaudeModel } from '@/types'
|
import type { Account, ClaudeModel } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
interface OutputLine {
|
interface OutputLine {
|
||||||
text: string
|
text: string
|
||||||
@@ -501,6 +503,6 @@ const handleEvent = (event: {
|
|||||||
|
|
||||||
const copyOutput = () => {
|
const copyOutput = () => {
|
||||||
const text = outputLines.value.map((l) => l.text).join('\n')
|
const text = outputLines.value.map((l) => l.text).join('\n')
|
||||||
navigator.clipboard.writeText(text)
|
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,21 +16,27 @@
|
|||||||
<div v-else-if="stats" class="space-y-0.5 text-xs">
|
<div v-else-if="stats" class="space-y-0.5 text-xs">
|
||||||
<!-- Requests -->
|
<!-- Requests -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Req:</span>
|
<span class="text-gray-500 dark:text-gray-400"
|
||||||
|
>{{ t('admin.accounts.stats.requests') }}:</span
|
||||||
|
>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
formatNumber(stats.requests)
|
formatNumber(stats.requests)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tokens -->
|
<!-- Tokens -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
|
<span class="text-gray-500 dark:text-gray-400"
|
||||||
|
>{{ t('admin.accounts.stats.tokens') }}:</span
|
||||||
|
>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
formatTokens(stats.tokens)
|
formatTokens(stats.tokens)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Cost -->
|
<!-- Cost -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
|
<span class="text-gray-500 dark:text-gray-400"
|
||||||
|
>{{ t('admin.accounts.stats.cost') }}:</span
|
||||||
|
>
|
||||||
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
|
||||||
formatCurrency(stats.cost)
|
formatCurrency(stats.cost)
|
||||||
}}</span>
|
}}</span>
|
||||||
@@ -44,6 +50,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, WindowStats } from '@/types'
|
import type { Account, WindowStats } from '@/types'
|
||||||
import { formatNumber, formatCurrency } from '@/utils/format'
|
import { formatNumber, formatCurrency } from '@/utils/format'
|
||||||
@@ -52,6 +59,8 @@ const props = defineProps<{
|
|||||||
account: Account
|
account: Account
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const stats = ref<WindowStats | null>(null)
|
const stats = ref<WindowStats | null>(null)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, AccountUsageInfo } from '@/types'
|
import type { Account, AccountUsageInfo } from '@/types'
|
||||||
import UsageProgressBar from './UsageProgressBar.vue'
|
import UsageProgressBar from './UsageProgressBar.vue'
|
||||||
@@ -113,6 +114,8 @@ const props = defineProps<{
|
|||||||
account: Account
|
account: Account
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||||
@@ -282,7 +285,7 @@ const loadUsage = async () => {
|
|||||||
try {
|
try {
|
||||||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
|
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = 'Failed'
|
error.value = t('common.error')
|
||||||
console.error('Failed to load usage:', e)
|
console.error('Failed to load usage:', e)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="lg" @close="handleClose">
|
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="xl" @close="handleClose">
|
||||||
<!-- Step Indicator for OAuth accounts -->
|
<!-- Step Indicator for OAuth accounts -->
|
||||||
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
|
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT OAuth</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.chatgptOauth') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">Responses API</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">Google OAuth</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.googleOauth') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.codeAssist') }}</span>
|
||||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
|
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!geminiAIStudioOAuthEnabled"
|
v-if="!geminiAIStudioOAuthEnabled"
|
||||||
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
|
class="pointer-events-none absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
>
|
>
|
||||||
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
|
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
value="oauth"
|
value="oauth"
|
||||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.types.oauth') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose">
|
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose">
|
||||||
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
|
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('common.name') }}</label>
|
<label class="input-label">{{ t('common.name') }}</label>
|
||||||
|
|||||||
@@ -63,7 +63,9 @@
|
|||||||
value="oauth"
|
value="oauth"
|
||||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Oauth</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||||
|
t('admin.accounts.types.oauth')
|
||||||
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
@@ -116,7 +118,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
|
||||||
|
t('admin.accounts.types.codeAssist')
|
||||||
|
}}</span>
|
||||||
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
|
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
|
||||||
t('admin.accounts.oauth.gemini.needsProjectId')
|
t('admin.accounts.oauth.gemini.needsProjectId')
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="windowStats"
|
v-if="windowStats"
|
||||||
class="mb-0.5 flex items-center justify-between"
|
class="mb-0.5 flex items-center justify-between"
|
||||||
:title="`5h 窗口用量统计`"
|
:title="t('admin.accounts.usageWindow.statsTitle')"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
|
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { WindowStats } from '@/types'
|
import type { WindowStats } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -61,6 +62,8 @@ const props = defineProps<{
|
|||||||
windowStats?: WindowStats | null
|
windowStats?: WindowStats | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Label background colors
|
// Label background colors
|
||||||
const labelClass = computed(() => {
|
const labelClass = computed(() => {
|
||||||
const colors = {
|
const colors = {
|
||||||
|
|||||||
@@ -31,8 +31,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import Modal from './Modal.vue'
|
import Modal from './Modal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
title: string
|
title: string
|
||||||
@@ -47,12 +51,13 @@ interface Emits {
|
|||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
confirmText: 'Confirm',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
danger: false
|
danger: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const confirmText = computed(() => props.confirmText || t('common.confirm'))
|
||||||
|
const cancelText = computed(() => props.cancelText || t('common.cancel'))
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
|||||||
@@ -1,18 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-x-auto">
|
<div
|
||||||
|
ref="tableWrapperRef"
|
||||||
|
class="table-wrapper"
|
||||||
|
:class="{
|
||||||
|
'actions-expanded': actionsExpanded,
|
||||||
|
'is-scrollable': isScrollable
|
||||||
|
}"
|
||||||
|
>
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||||
<thead class="bg-gray-50 dark:bg-dark-800">
|
<thead class="table-header bg-gray-50 dark:bg-dark-800">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
v-for="column in columns"
|
v-for="(column, index) in columns"
|
||||||
:key="column.key"
|
:key="column.key"
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
:class="[
|
||||||
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
|
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
|
||||||
|
getAdaptivePaddingClass(),
|
||||||
|
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
|
||||||
|
getStickyColumnClass(column, index)
|
||||||
|
]"
|
||||||
@click="column.sortable && handleSort(column.key)"
|
@click="column.sortable && handleSort(column.key)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-1">
|
<div class="flex items-center space-x-1">
|
||||||
<span>{{ column.label }}</span>
|
<span>{{ column.label }}</span>
|
||||||
|
<!-- 操作列展开/折叠按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="column.key === 'actions' && hasExpandableActions"
|
||||||
|
type="button"
|
||||||
|
@click.stop="toggleActionsExpanded"
|
||||||
|
class="ml-2 flex items-center justify-center rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300"
|
||||||
|
:title="actionsExpanded ? t('table.collapseActions') : t('table.expandActions')"
|
||||||
|
>
|
||||||
|
<!-- 展开状态:收起图标 -->
|
||||||
|
<svg
|
||||||
|
v-if="actionsExpanded"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
<!-- 折叠状态:展开图标 -->
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
|
||||||
<svg
|
<svg
|
||||||
v-if="sortKey === column.key"
|
v-if="sortKey === column.key"
|
||||||
@@ -37,10 +79,10 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||||
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
|
<td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +93,7 @@
|
|||||||
<tr v-else-if="!data || data.length === 0">
|
<tr v-else-if="!data || data.length === 0">
|
||||||
<td
|
<td
|
||||||
:colspan="columns.length"
|
:colspan="columns.length"
|
||||||
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"
|
:class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
|
||||||
>
|
>
|
||||||
<slot name="empty">
|
<slot name="empty">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
@@ -84,11 +126,15 @@
|
|||||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
v-for="column in columns"
|
v-for="(column, colIndex) in columns"
|
||||||
:key="column.key"
|
:key="column.key"
|
||||||
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
|
:class="[
|
||||||
|
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||||
|
getAdaptivePaddingClass(),
|
||||||
|
getStickyColumnClass(column, colIndex)
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||||
</slot>
|
</slot>
|
||||||
</td>
|
</td>
|
||||||
@@ -99,24 +145,135 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Column } from './types'
|
import type { Column } from './types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 表格容器引用
|
||||||
|
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||||||
|
const isScrollable = ref(false)
|
||||||
|
const actionsColumnNeedsExpanding = ref(false)
|
||||||
|
|
||||||
|
// 检查是否可滚动
|
||||||
|
const checkScrollable = () => {
|
||||||
|
if (tableWrapperRef.value) {
|
||||||
|
isScrollable.value = tableWrapperRef.value.scrollWidth > tableWrapperRef.value.clientWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查操作列是否需要展开
|
||||||
|
const checkActionsColumnWidth = () => {
|
||||||
|
if (!tableWrapperRef.value) return
|
||||||
|
|
||||||
|
// 查找第一行的操作列单元格
|
||||||
|
const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child')
|
||||||
|
if (!firstActionCell) return
|
||||||
|
|
||||||
|
// 查找操作列内容的容器div
|
||||||
|
const actionsContainer = firstActionCell.querySelector('div')
|
||||||
|
if (!actionsContainer) return
|
||||||
|
|
||||||
|
// 临时展开以测量完整宽度
|
||||||
|
const wasExpanded = actionsExpanded.value
|
||||||
|
actionsExpanded.value = true
|
||||||
|
|
||||||
|
// 等待DOM更新
|
||||||
|
nextTick(() => {
|
||||||
|
// 测量所有按钮的总宽度
|
||||||
|
const buttons = actionsContainer.querySelectorAll('button')
|
||||||
|
if (buttons.length <= 2) {
|
||||||
|
actionsColumnNeedsExpanding.value = false
|
||||||
|
actionsExpanded.value = wasExpanded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算所有按钮的总宽度(包括gap)
|
||||||
|
let totalWidth = 0
|
||||||
|
buttons.forEach((btn, index) => {
|
||||||
|
totalWidth += (btn as HTMLElement).offsetWidth
|
||||||
|
if (index < buttons.length - 1) {
|
||||||
|
totalWidth += 4 // gap-1 = 4px
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单元格可用宽度(减去padding)
|
||||||
|
const cellWidth = (firstActionCell as HTMLElement).clientWidth - 32 // 减去左右padding
|
||||||
|
|
||||||
|
// 如果总宽度超过可用宽度,需要展开功能
|
||||||
|
actionsColumnNeedsExpanding.value = totalWidth > cellWidth
|
||||||
|
|
||||||
|
// 恢复原来的展开状态
|
||||||
|
actionsExpanded.value = wasExpanded
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听尺寸变化
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
|
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(tableWrapperRef.value)
|
||||||
|
} else {
|
||||||
|
// 降级方案:不支持 ResizeObserver 时使用 window resize
|
||||||
|
const handleResize = () => {
|
||||||
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', checkScrollable)
|
||||||
|
})
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
columns: Column[]
|
columns: Column[]
|
||||||
data: any[]
|
data: any[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
stickyFirstColumn?: boolean
|
||||||
|
stickyActionsColumn?: boolean
|
||||||
|
expandableActions?: boolean
|
||||||
|
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false
|
loading: false,
|
||||||
|
stickyFirstColumn: true,
|
||||||
|
stickyActionsColumn: true,
|
||||||
|
expandableActions: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortKey = ref<string>('')
|
const sortKey = ref<string>('')
|
||||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||||
|
const actionsExpanded = ref(false)
|
||||||
|
|
||||||
|
// 数据/列变化时重新检查滚动状态
|
||||||
|
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||||
|
watch(
|
||||||
|
[() => props.data.length, () => props.columns],
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
checkScrollable()
|
||||||
|
checkActionsColumnWidth()
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 单独监听展开状态变化,只更新滚动状态
|
||||||
|
watch(actionsExpanded, async () => {
|
||||||
|
await nextTick()
|
||||||
|
checkScrollable()
|
||||||
|
})
|
||||||
|
|
||||||
const handleSort = (key: string) => {
|
const handleSort = (key: string) => {
|
||||||
if (sortKey.value === key) {
|
if (sortKey.value === key) {
|
||||||
@@ -140,4 +297,212 @@ const sortedData = computed(() => {
|
|||||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
return sortOrder.value === 'asc' ? comparison : -comparison
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 检查是否有可展开的操作列
|
||||||
|
const hasExpandableActions = computed(() => {
|
||||||
|
// 如果明确指定了actionsCount,使用它来判断
|
||||||
|
if (props.actionsCount !== undefined) {
|
||||||
|
return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则使用原来的检测逻辑
|
||||||
|
return (
|
||||||
|
props.expandableActions &&
|
||||||
|
props.columns.some((col) => col.key === 'actions') &&
|
||||||
|
actionsColumnNeedsExpanding.value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换操作列展开/折叠状态
|
||||||
|
const toggleActionsExpanded = () => {
|
||||||
|
actionsExpanded.value = !actionsExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查第一列是否为勾选列
|
||||||
|
const hasSelectColumn = computed(() => {
|
||||||
|
return props.columns.length > 0 && props.columns[0].key === 'select'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成固定列的 CSS 类
|
||||||
|
const getStickyColumnClass = (column: Column, index: number) => {
|
||||||
|
const classes: string[] = []
|
||||||
|
|
||||||
|
if (props.stickyFirstColumn) {
|
||||||
|
// 如果第一列是勾选列,固定前两列(勾选+名称)
|
||||||
|
if (hasSelectColumn.value) {
|
||||||
|
if (index === 0) {
|
||||||
|
classes.push('sticky-col sticky-col-left-first')
|
||||||
|
} else if (index === 1) {
|
||||||
|
classes.push('sticky-col sticky-col-left-second')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 否则只固定第一列
|
||||||
|
if (index === 0) {
|
||||||
|
classes.push('sticky-col sticky-col-left')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作列固定(最后一列)
|
||||||
|
if (props.stickyActionsColumn && column.key === 'actions') {
|
||||||
|
classes.push('sticky-col sticky-col-right')
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据列数自适应调整内边距
|
||||||
|
const getAdaptivePaddingClass = () => {
|
||||||
|
const columnCount = props.columns.length
|
||||||
|
|
||||||
|
// 列数越多,内边距越小
|
||||||
|
if (columnCount >= 10) {
|
||||||
|
return 'px-2' // 8px
|
||||||
|
} else if (columnCount >= 7) {
|
||||||
|
return 'px-3' // 12px
|
||||||
|
} else if (columnCount >= 5) {
|
||||||
|
return 'px-4' // 16px
|
||||||
|
} else {
|
||||||
|
return 'px-6' // 24px (原始值)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 表格横向滚动 */
|
||||||
|
.table-wrapper {
|
||||||
|
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
|
||||||
|
position: relative;
|
||||||
|
overflow-x: auto;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头容器,确保在滚动时覆盖表体内容 */
|
||||||
|
.table-wrapper .table-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background-color: rgb(249 250 251);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .table-wrapper .table-header {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表体保持在表头下方 */
|
||||||
|
.table-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 所有表头单元格固定在顶部 */
|
||||||
|
.sticky-header-cell {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 210; /* 必须高于所有表体内容 */
|
||||||
|
background-color: rgb(249 250 251);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sticky-header-cell {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky 列基础样式 */
|
||||||
|
.sticky-col {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 20; /* 表体固定列 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单列固定(无勾选列时) */
|
||||||
|
.sticky-col-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 双列固定(有勾选列时):第一列(勾选) */
|
||||||
|
.sticky-col-left-first {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 双列固定(有勾选列时):第二列(名称) */
|
||||||
|
.sticky-col-left-second {
|
||||||
|
left: var(--select-col-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作列固定 */
|
||||||
|
.sticky-col-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头 sticky 列 - 需要比普通表头单元格更高的 z-index */
|
||||||
|
.sticky-header-cell.sticky-col {
|
||||||
|
z-index: 220; /* 高于普通表头单元格和表体固定列 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表体 sticky 列背景 */
|
||||||
|
tbody .sticky-col {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark tbody .sticky-col {
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover 状态保持 */
|
||||||
|
tbody tr:hover .sticky-col {
|
||||||
|
background-color: rgb(249 250 251);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark tbody tr:hover .sticky-col {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 阴影只在可滚动时显示 */
|
||||||
|
/* 单列固定右侧阴影 */
|
||||||
|
.is-scrollable .sticky-col-left::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 双列固定:只在第二列显示阴影 */
|
||||||
|
.is-scrollable .sticky-col-left-second::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作列左侧阴影 */
|
||||||
|
.is-scrollable .sticky-col-right::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
background: linear-gradient(to left, rgba(0, 0, 0, 0.08), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式阴影 */
|
||||||
|
.dark .is-scrollable .sticky-col-left::after,
|
||||||
|
.dark .is-scrollable .sticky-col-left-second::after {
|
||||||
|
background: linear-gradient(to right, rgba(0, 0, 0, 0.2), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .is-scrollable .sticky-col-right::before {
|
||||||
|
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -135,7 +135,22 @@ const localStartDate = ref(props.startDate)
|
|||||||
const localEndDate = ref(props.endDate)
|
const localEndDate = ref(props.endDate)
|
||||||
const activePreset = ref<string | null>('7days')
|
const activePreset = ref<string | null>('7days')
|
||||||
|
|
||||||
const today = computed(() => new Date().toISOString().split('T')[0])
|
const today = computed(() => {
|
||||||
|
// Use local timezone to avoid UTC timezone issues
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper function to format date to YYYY-MM-DD using local timezone
|
||||||
|
const formatDateToString = (date: Date): string => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
const presets: DatePreset[] = [
|
const presets: DatePreset[] = [
|
||||||
{
|
{
|
||||||
@@ -152,7 +167,7 @@ const presets: DatePreset[] = [
|
|||||||
getRange: () => {
|
getRange: () => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 1)
|
d.setDate(d.getDate() - 1)
|
||||||
const yesterday = d.toISOString().split('T')[0]
|
const yesterday = formatDateToString(d)
|
||||||
return { start: yesterday, end: yesterday }
|
return { start: yesterday, end: yesterday }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -163,7 +178,7 @@ const presets: DatePreset[] = [
|
|||||||
const end = today.value
|
const end = today.value
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 6)
|
d.setDate(d.getDate() - 6)
|
||||||
const start = d.toISOString().split('T')[0]
|
const start = formatDateToString(d)
|
||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -174,7 +189,7 @@ const presets: DatePreset[] = [
|
|||||||
const end = today.value
|
const end = today.value
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 13)
|
d.setDate(d.getDate() - 13)
|
||||||
const start = d.toISOString().split('T')[0]
|
const start = formatDateToString(d)
|
||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -185,7 +200,7 @@ const presets: DatePreset[] = [
|
|||||||
const end = today.value
|
const end = today.value
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 29)
|
d.setDate(d.getDate() - 29)
|
||||||
const start = d.toISOString().split('T')[0]
|
const start = formatDateToString(d)
|
||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -194,7 +209,7 @@ const presets: DatePreset[] = [
|
|||||||
value: 'thisMonth',
|
value: 'thisMonth',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
|
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 1))
|
||||||
return { start, end: today.value }
|
return { start, end: today.value }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -203,8 +218,8 @@ const presets: DatePreset[] = [
|
|||||||
value: 'lastMonth',
|
value: 'lastMonth',
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().split('T')[0]
|
const start = formatDateToString(new Date(now.getFullYear(), now.getMonth() - 1, 1))
|
||||||
const end = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().split('T')[0]
|
const end = formatDateToString(new Date(now.getFullYear(), now.getMonth(), 0))
|
||||||
return { start, end }
|
return { start, end }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h3 class="empty-state-title">
|
<h3 class="empty-state-title">
|
||||||
{{ title }}
|
{{ displayTitle }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -61,8 +61,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: Component | string
|
icon?: Component | string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -73,11 +77,12 @@ interface Props {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: 'No data found',
|
|
||||||
description: '',
|
description: '',
|
||||||
actionIcon: true
|
actionIcon: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const displayTitle = computed(() => props.title || t('common.noData'))
|
||||||
|
|
||||||
defineEmits(['action'])
|
defineEmits(['action'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
v-for="group in filteredGroups"
|
v-for="group in filteredGroups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
|
||||||
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
|
:title="t('admin.groups.rateAndAccounts', { rate: group.rate_multiplier, count: group.account_count || 0 })"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -40,9 +40,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import GroupBadge from './GroupBadge.vue'
|
import GroupBadge from './GroupBadge.vue'
|
||||||
import type { Group, GroupPlatform } from '@/types'
|
import type { Group, GroupPlatform } from '@/types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: number[]
|
modelValue: number[]
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ const goToPage = (newPage: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageSizeChange = (value: string | number | null) => {
|
const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||||
if (value === null) return
|
if (value === null || typeof value === 'boolean') return
|
||||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||||
emit('update:pageSize', newPageSize)
|
emit('update:pageSize', newPageSize)
|
||||||
// Reset to first page when page size changes
|
// Reset to first page when page size changes
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<div class="select-options">
|
<div class="select-options">
|
||||||
<div
|
<div
|
||||||
v-for="option in filteredOptions"
|
v-for="option in filteredOptions"
|
||||||
:key="getOptionValue(option) ?? undefined"
|
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
|
||||||
@click="selectOption(option)"
|
@click="selectOption(option)"
|
||||||
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
||||||
>
|
>
|
||||||
@@ -96,14 +96,14 @@ import { useI18n } from 'vue-i18n'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string | number | null
|
value: string | number | boolean | null
|
||||||
label: string
|
label: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: string | number | null | undefined
|
modelValue: string | number | boolean | null | undefined
|
||||||
options: SelectOption[] | Array<Record<string, unknown>>
|
options: SelectOption[] | Array<Record<string, unknown>>
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -116,8 +116,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:modelValue', value: string | number | null): void
|
(e: 'update:modelValue', value: string | number | boolean | null): void
|
||||||
(e: 'change', value: string | number | null, option: SelectOption | null): void
|
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -144,11 +144,11 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
|
|||||||
|
|
||||||
const getOptionValue = (
|
const getOptionValue = (
|
||||||
option: SelectOption | Record<string, unknown>
|
option: SelectOption | Record<string, unknown>
|
||||||
): string | number | null | undefined => {
|
): string | number | boolean | null | undefined => {
|
||||||
if (typeof option === 'object' && option !== null) {
|
if (typeof option === 'object' && option !== null) {
|
||||||
return option[props.valueKey] as string | number | null | undefined
|
return option[props.valueKey] as string | number | boolean | null | undefined
|
||||||
}
|
}
|
||||||
return option as string | number | null
|
return option as string | number | boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ function formatDaysRemaining(expiresAt: string): string {
|
|||||||
const diff = expires.getTime() - now.getTime()
|
const diff = expires.getTime() - now.getTime()
|
||||||
if (diff < 0) return t('subscriptionProgress.expired')
|
if (diff < 0) return t('subscriptionProgress.expired')
|
||||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
if (days === 0) return t('subscriptionProgress.expirestoday')
|
if (days === 0) return t('subscriptionProgress.expiresToday')
|
||||||
if (days === 1) return t('subscriptionProgress.expiresTomorrow')
|
if (days === 1) return t('subscriptionProgress.expiresTomorrow')
|
||||||
return t('subscriptionProgress.daysRemaining', { days })
|
return t('subscriptionProgress.daysRemaining', { days })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
|
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-800 dark:text-dark-400 dark:hover:bg-dark-700'
|
||||||
]"
|
]"
|
||||||
:title="hasUpdate ? 'New version available' : 'Up to date'"
|
:title="hasUpdate ? t('version.updateAvailable') : t('version.upToDate')"
|
||||||
>
|
>
|
||||||
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
|
<span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
import { ref, computed, h, watch, type Component } from 'vue'
|
import { ref, computed, h, watch, type Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import type { GroupPlatform } from '@/types'
|
import type { GroupPlatform } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -150,7 +150,7 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
const copiedIndex = ref<number | null>(null)
|
const copiedIndex = ref<number | null>(null)
|
||||||
const activeTab = ref<string>('unix')
|
const activeTab = ref<string>('unix')
|
||||||
@@ -340,14 +340,12 @@ ${key('requires_openai_auth')} ${operator('=')} ${keyword('true')}`
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyContent = async (content: string, index: number) => {
|
const copyContent = async (content: string, index: number) => {
|
||||||
try {
|
const success = await clipboardCopy(content, t('keys.copied'))
|
||||||
await navigator.clipboard.writeText(content)
|
if (success) {
|
||||||
copiedIndex.value = index
|
copiedIndex.value = index
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copiedIndex.value = null
|
copiedIndex.value = null
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
|
||||||
appStore.showError(t('common.copyFailed'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
114
frontend/src/components/layout/TablePageLayout.vue
Normal file
114
frontend/src/components/layout/TablePageLayout.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-page-layout" :class="{ 'mobile-mode': isMobile }">
|
||||||
|
<!-- 固定区域:操作按钮 -->
|
||||||
|
<div v-if="$slots.actions" class="layout-section-fixed">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定区域:搜索和过滤器 -->
|
||||||
|
<div v-if="$slots.filters" class="layout-section-fixed">
|
||||||
|
<slot name="filters" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 滚动区域:表格 -->
|
||||||
|
<div class="layout-section-scrollable">
|
||||||
|
<div class="card table-scroll-container">
|
||||||
|
<slot name="table" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定区域:分页器 -->
|
||||||
|
<div v-if="$slots.pagination" class="layout-section-fixed">
|
||||||
|
<slot name="pagination" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 桌面端:Flexbox 布局 */
|
||||||
|
.table-page-layout {
|
||||||
|
@apply flex flex-col gap-6;
|
||||||
|
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-section-fixed {
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-section-scrollable {
|
||||||
|
@apply flex-1 min-h-0 flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格滚动容器 - 增强版表体滚动方案 */
|
||||||
|
.table-scroll-container {
|
||||||
|
@apply flex flex-col overflow-hidden h-full bg-white dark:bg-dark-800 rounded-2xl border border-gray-200 dark:border-dark-700 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container :deep(.table-wrapper) {
|
||||||
|
@apply flex-1 overflow-x-auto overflow-y-auto;
|
||||||
|
/* 确保横向滚动条显示在最底部 */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container :deep(table) {
|
||||||
|
@apply w-full;
|
||||||
|
min-width: max-content; /* 关键:确保表格宽度根据内容撑开,从而触发横向滚动 */
|
||||||
|
display: table; /* 使用标准 table 布局以支持 sticky 列 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container :deep(thead) {
|
||||||
|
@apply bg-gray-50/80 dark:bg-dark-800/80 backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container :deep(tbody) {
|
||||||
|
/* 保持默认 table-row-group 显示,不使用 block */
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container :deep(th) {
|
||||||
|
/* 表头高度和文字加粗优化 */
|
||||||
|
@apply px-5 py-4 text-left text-sm font-bold text-gray-900 dark:text-white border-b border-gray-200 dark:border-dark-700;
|
||||||
|
@apply uppercase tracking-wider; /* 让表头更有设计感 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-container :deep(td) {
|
||||||
|
@apply px-5 py-4 text-sm text-gray-700 dark:text-gray-300 border-b border-gray-100 dark:border-dark-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端:恢复正常滚动 */
|
||||||
|
.table-page-layout.mobile-mode .table-scroll-container {
|
||||||
|
@apply h-auto overflow-visible border-none shadow-none bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-page-layout.mobile-mode .layout-section-scrollable {
|
||||||
|
@apply flex-none min-h-fit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-page-layout.mobile-mode .table-scroll-container :deep(.table-wrapper) {
|
||||||
|
@apply overflow-visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-page-layout.mobile-mode .table-scroll-container :deep(table) {
|
||||||
|
@apply flex-none;
|
||||||
|
display: table;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,40 +1,65 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
|
||||||
|
*/
|
||||||
|
function isClipboardSupported(): boolean {
|
||||||
|
return !!(navigator.clipboard && window.isSecureContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 降级方案:使用 textarea + execCommand
|
||||||
|
* 使用 textarea 而非 input,以正确处理多行文本
|
||||||
|
*/
|
||||||
|
function fallbackCopy(text: string): boolean {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.cssText = 'position:fixed;left:-9999px;top:-9999px'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
try {
|
||||||
|
return document.execCommand('copy')
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useClipboard() {
|
export function useClipboard() {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, successMessage = 'Copied to clipboard') => {
|
const copyToClipboard = async (
|
||||||
|
text: string,
|
||||||
|
successMessage = 'Copied to clipboard'
|
||||||
|
): Promise<boolean> => {
|
||||||
if (!text) return false
|
if (!text) return false
|
||||||
|
|
||||||
try {
|
let success = false
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
copied.value = true
|
if (isClipboardSupported()) {
|
||||||
appStore.showSuccess(successMessage)
|
try {
|
||||||
setTimeout(() => {
|
await navigator.clipboard.writeText(text)
|
||||||
copied.value = false
|
success = true
|
||||||
}, 2000)
|
} catch {
|
||||||
return true
|
success = fallbackCopy(text)
|
||||||
} catch {
|
}
|
||||||
// Fallback for older browsers
|
} else {
|
||||||
const input = document.createElement('input')
|
success = fallbackCopy(text)
|
||||||
input.value = text
|
|
||||||
document.body.appendChild(input)
|
|
||||||
input.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(input)
|
|
||||||
copied.value = true
|
|
||||||
appStore.showSuccess(successMessage)
|
|
||||||
setTimeout(() => {
|
|
||||||
copied.value = false
|
|
||||||
}, 2000)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
copied.value = true
|
||||||
|
appStore.showSuccess(successMessage)
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
appStore.showError('Copy failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { copied, copyToClipboard }
|
||||||
copied,
|
|
||||||
copyToClipboard
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,61 @@ export default {
|
|||||||
title: 'Supported Providers',
|
title: 'Supported Providers',
|
||||||
description: 'Unified API interface for AI services',
|
description: 'Unified API interface for AI services',
|
||||||
supported: 'Supported',
|
supported: 'Supported',
|
||||||
soon: 'Soon'
|
soon: 'Soon',
|
||||||
|
claude: 'Claude',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
more: 'More'
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
allRightsReserved: 'All rights reserved.'
|
allRightsReserved: 'All rights reserved.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Setup Wizard
|
||||||
|
setup: {
|
||||||
|
title: 'Sub2API Setup',
|
||||||
|
description: 'Configure your Sub2API instance',
|
||||||
|
database: {
|
||||||
|
title: 'Database Configuration',
|
||||||
|
host: 'Host',
|
||||||
|
port: 'Port',
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
databaseName: 'Database Name',
|
||||||
|
sslMode: 'SSL Mode',
|
||||||
|
passwordPlaceholder: 'Password',
|
||||||
|
ssl: {
|
||||||
|
disable: 'Disable',
|
||||||
|
require: 'Require',
|
||||||
|
verifyCa: 'Verify CA',
|
||||||
|
verifyFull: 'Verify Full'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
title: 'Redis Configuration',
|
||||||
|
host: 'Host',
|
||||||
|
port: 'Port',
|
||||||
|
password: 'Password (optional)',
|
||||||
|
database: 'Database',
|
||||||
|
passwordPlaceholder: 'Password'
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
title: 'Admin Account',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
confirmPassword: 'Confirm Password',
|
||||||
|
passwordPlaceholder: 'Min 6 characters',
|
||||||
|
confirmPasswordPlaceholder: 'Confirm password',
|
||||||
|
passwordMismatch: 'Passwords do not match'
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
title: 'Ready to Install',
|
||||||
|
database: 'Database',
|
||||||
|
redis: 'Redis',
|
||||||
|
adminEmail: 'Admin Email'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
@@ -84,7 +132,14 @@ export default {
|
|||||||
searchPlaceholder: 'Search...',
|
searchPlaceholder: 'Search...',
|
||||||
noOptionsFound: 'No options found',
|
noOptionsFound: 'No options found',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
refresh: 'Refresh'
|
refresh: 'Refresh',
|
||||||
|
time: {
|
||||||
|
never: 'Never',
|
||||||
|
justNow: 'Just now',
|
||||||
|
minutesAgo: '{n}m ago',
|
||||||
|
hoursAgo: '{n}h ago',
|
||||||
|
daysAgo: '{n}d ago'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
@@ -142,7 +197,20 @@ export default {
|
|||||||
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
|
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
|
||||||
turnstileExpired: 'Verification expired, please try again',
|
turnstileExpired: 'Verification expired, please try again',
|
||||||
turnstileFailed: 'Verification failed, please try again',
|
turnstileFailed: 'Verification failed, please try again',
|
||||||
completeVerification: 'Please complete the verification'
|
completeVerification: 'Please complete the verification',
|
||||||
|
verifyYourEmail: 'Verify Your Email',
|
||||||
|
sessionExpired: 'Session expired',
|
||||||
|
sessionExpiredDesc: 'Please go back to the registration page and start again.',
|
||||||
|
verificationCode: 'Verification Code',
|
||||||
|
verificationCodeHint: 'Enter the 6-digit code sent to your email',
|
||||||
|
sendingCode: 'Sending...',
|
||||||
|
clickToResend: 'Click to resend code',
|
||||||
|
resendCode: 'Resend verification code',
|
||||||
|
oauth: {
|
||||||
|
code: 'Code',
|
||||||
|
state: 'State',
|
||||||
|
fullUrl: 'Full URL'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
@@ -207,7 +275,7 @@ export default {
|
|||||||
created: 'Created',
|
created: 'Created',
|
||||||
copyToClipboard: 'Copy to clipboard',
|
copyToClipboard: 'Copy to clipboard',
|
||||||
copied: 'Copied!',
|
copied: 'Copied!',
|
||||||
importToCcSwitch: 'Import to CC Switch',
|
importToCcSwitch: 'Import to CCS',
|
||||||
enable: 'Enable',
|
enable: 'Enable',
|
||||||
disable: 'Disable',
|
disable: 'Disable',
|
||||||
nameLabel: 'Name',
|
nameLabel: 'Name',
|
||||||
@@ -377,6 +445,12 @@ export default {
|
|||||||
noData: 'No data found'
|
noData: 'No data found'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Table
|
||||||
|
table: {
|
||||||
|
expandActions: 'Expand More Actions',
|
||||||
|
collapseActions: 'Collapse Actions'
|
||||||
|
},
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
pagination: {
|
pagination: {
|
||||||
showing: 'Showing',
|
showing: 'Showing',
|
||||||
@@ -455,6 +529,7 @@ export default {
|
|||||||
actual: 'Actual',
|
actual: 'Actual',
|
||||||
standard: 'Standard',
|
standard: 'Standard',
|
||||||
noDataAvailable: 'No data available',
|
noDataAvailable: 'No data available',
|
||||||
|
recentUsage: 'Recent Usage',
|
||||||
failedToLoad: 'Failed to load dashboard statistics'
|
failedToLoad: 'Failed to load dashboard statistics'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -507,9 +582,13 @@ export default {
|
|||||||
noSubscription: 'No subscription',
|
noSubscription: 'No subscription',
|
||||||
daysRemaining: '{days}d',
|
daysRemaining: '{days}d',
|
||||||
expired: 'Expired',
|
expired: 'Expired',
|
||||||
|
disable: 'Disable',
|
||||||
|
enable: 'Enable',
|
||||||
disableUser: 'Disable User',
|
disableUser: 'Disable User',
|
||||||
enableUser: 'Enable User',
|
enableUser: 'Enable User',
|
||||||
viewApiKeys: 'View API Keys',
|
viewApiKeys: 'View API Keys',
|
||||||
|
groups: 'Groups',
|
||||||
|
apiKeys: 'API Keys',
|
||||||
userApiKeys: 'User API Keys',
|
userApiKeys: 'User API Keys',
|
||||||
noApiKeys: 'This user has no API keys',
|
noApiKeys: 'This user has no API keys',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
@@ -584,6 +663,7 @@ export default {
|
|||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
billingType: 'Billing Type'
|
billingType: 'Billing Type'
|
||||||
},
|
},
|
||||||
|
rateAndAccounts: '{rate}x rate · {count} accounts',
|
||||||
accountsCount: '{count} accounts',
|
accountsCount: '{count} accounts',
|
||||||
form: {
|
form: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -742,6 +822,13 @@ export default {
|
|||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
gemini: 'Gemini'
|
gemini: 'Gemini'
|
||||||
},
|
},
|
||||||
|
types: {
|
||||||
|
oauth: 'OAuth',
|
||||||
|
chatgptOauth: 'ChatGPT OAuth',
|
||||||
|
responsesApi: 'Responses API',
|
||||||
|
googleOauth: 'Google OAuth',
|
||||||
|
codeAssist: 'Code Assist'
|
||||||
|
},
|
||||||
columns: {
|
columns: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
platformType: 'Platform/Type',
|
platformType: 'Platform/Type',
|
||||||
@@ -1022,6 +1109,7 @@ export default {
|
|||||||
todayOverview: 'Today Overview',
|
todayOverview: 'Today Overview',
|
||||||
cost: 'Cost',
|
cost: 'Cost',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
|
tokens: 'Tokens',
|
||||||
highestCostDay: 'Highest Cost Day',
|
highestCostDay: 'Highest Cost Day',
|
||||||
highestRequestDay: 'Highest Request Day',
|
highestRequestDay: 'Highest Request Day',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
@@ -1037,6 +1125,9 @@ export default {
|
|||||||
todayCost: 'Today Cost',
|
todayCost: 'Today Cost',
|
||||||
usageTrend: '30-Day Cost & Request Trend',
|
usageTrend: '30-Day Cost & Request Trend',
|
||||||
noData: 'No usage data available for this account'
|
noData: 'No usage data available for this account'
|
||||||
|
},
|
||||||
|
usageWindow: {
|
||||||
|
statsTitle: '5-Hour Window Usage Statistics'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1070,6 +1161,10 @@ export default {
|
|||||||
enterProxyName: 'Enter proxy name',
|
enterProxyName: 'Enter proxy name',
|
||||||
leaveEmptyToKeep: 'Leave empty to keep current',
|
leaveEmptyToKeep: 'Leave empty to keep current',
|
||||||
optionalAuth: 'Optional authentication',
|
optionalAuth: 'Optional authentication',
|
||||||
|
form: {
|
||||||
|
hostPlaceholder: 'proxy.example.com',
|
||||||
|
portPlaceholder: '8080'
|
||||||
|
},
|
||||||
noProxiesYet: 'No proxies yet',
|
noProxiesYet: 'No proxies yet',
|
||||||
createFirstProxy: 'Create your first proxy to route traffic through it.',
|
createFirstProxy: 'Create your first proxy to route traffic through it.',
|
||||||
// Batch import
|
// Batch import
|
||||||
@@ -1077,9 +1172,9 @@ export default {
|
|||||||
batchAdd: 'Quick Add',
|
batchAdd: 'Quick Add',
|
||||||
batchInput: 'Proxy List',
|
batchInput: 'Proxy List',
|
||||||
batchInputPlaceholder:
|
batchInputPlaceholder:
|
||||||
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
"Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
|
||||||
batchInputHint:
|
batchInputHint:
|
||||||
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
|
"Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port",
|
||||||
parsedCount: '{count} valid',
|
parsedCount: '{count} valid',
|
||||||
invalidCount: '{count} invalid',
|
invalidCount: '{count} invalid',
|
||||||
duplicateCount: '{count} duplicate',
|
duplicateCount: '{count} duplicate',
|
||||||
@@ -1174,6 +1269,18 @@ export default {
|
|||||||
searchUserPlaceholder: 'Search user by email...',
|
searchUserPlaceholder: 'Search user by email...',
|
||||||
selectedUser: 'Selected',
|
selectedUser: 'Selected',
|
||||||
user: 'User',
|
user: 'User',
|
||||||
|
account: 'Account',
|
||||||
|
group: 'Group',
|
||||||
|
requestId: 'Request ID',
|
||||||
|
allModels: 'All Models',
|
||||||
|
allAccounts: 'All Accounts',
|
||||||
|
allGroups: 'All Groups',
|
||||||
|
allTypes: 'All Types',
|
||||||
|
allBillingTypes: 'All Billing',
|
||||||
|
inputCost: 'Input Cost',
|
||||||
|
outputCost: 'Output Cost',
|
||||||
|
cacheCreationCost: 'Cache Creation Cost',
|
||||||
|
cacheReadCost: 'Cache Read Cost',
|
||||||
failedToLoad: 'Failed to load usage records'
|
failedToLoad: 'Failed to load usage records'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1211,16 +1318,20 @@ export default {
|
|||||||
title: 'Site Settings',
|
title: 'Site Settings',
|
||||||
description: 'Customize site branding',
|
description: 'Customize site branding',
|
||||||
siteName: 'Site Name',
|
siteName: 'Site Name',
|
||||||
|
siteNamePlaceholder: 'Sub2API',
|
||||||
siteNameHint: 'Displayed in emails and page titles',
|
siteNameHint: 'Displayed in emails and page titles',
|
||||||
siteSubtitle: 'Site Subtitle',
|
siteSubtitle: 'Site Subtitle',
|
||||||
|
siteSubtitlePlaceholder: 'Subscription to API Conversion Platform',
|
||||||
siteSubtitleHint: 'Displayed on login and register pages',
|
siteSubtitleHint: 'Displayed on login and register pages',
|
||||||
apiBaseUrl: 'API Base URL',
|
apiBaseUrl: 'API Base URL',
|
||||||
|
apiBaseUrlPlaceholder: 'https://api.example.com',
|
||||||
apiBaseUrlHint:
|
apiBaseUrlHint:
|
||||||
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
|
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
|
||||||
contactInfo: 'Contact Info',
|
contactInfo: 'Contact Info',
|
||||||
contactInfoPlaceholder: 'e.g., QQ: 123456789',
|
contactInfoPlaceholder: 'e.g., QQ: 123456789',
|
||||||
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
|
contactInfoHint: 'Customer support contact info, displayed on redeem page, profile, etc.',
|
||||||
docUrl: 'Documentation URL',
|
docUrl: 'Documentation URL',
|
||||||
|
docUrlPlaceholder: 'https://docs.example.com',
|
||||||
docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.',
|
docUrlHint: 'Link to your documentation site. Leave empty to hide the documentation link.',
|
||||||
siteLogo: 'Site Logo',
|
siteLogo: 'Site Logo',
|
||||||
uploadImage: 'Upload Image',
|
uploadImage: 'Upload Image',
|
||||||
@@ -1236,12 +1347,18 @@ export default {
|
|||||||
testConnection: 'Test Connection',
|
testConnection: 'Test Connection',
|
||||||
testing: 'Testing...',
|
testing: 'Testing...',
|
||||||
host: 'SMTP Host',
|
host: 'SMTP Host',
|
||||||
|
hostPlaceholder: 'smtp.gmail.com',
|
||||||
port: 'SMTP Port',
|
port: 'SMTP Port',
|
||||||
|
portPlaceholder: '587',
|
||||||
username: 'SMTP Username',
|
username: 'SMTP Username',
|
||||||
|
usernamePlaceholder: "your-email{'@'}gmail.com",
|
||||||
password: 'SMTP Password',
|
password: 'SMTP Password',
|
||||||
|
passwordPlaceholder: '********',
|
||||||
passwordHint: 'Leave empty to keep existing password',
|
passwordHint: 'Leave empty to keep existing password',
|
||||||
fromEmail: 'From Email',
|
fromEmail: 'From Email',
|
||||||
|
fromEmailPlaceholder: "noreply{'@'}example.com",
|
||||||
fromName: 'From Name',
|
fromName: 'From Name',
|
||||||
|
fromNamePlaceholder: 'Sub2API',
|
||||||
useTls: 'Use TLS',
|
useTls: 'Use TLS',
|
||||||
useTlsHint: 'Enable TLS encryption for SMTP connection'
|
useTlsHint: 'Enable TLS encryption for SMTP connection'
|
||||||
},
|
},
|
||||||
@@ -1249,6 +1366,7 @@ export default {
|
|||||||
title: 'Send Test Email',
|
title: 'Send Test Email',
|
||||||
description: 'Send a test email to verify your SMTP configuration',
|
description: 'Send a test email to verify your SMTP configuration',
|
||||||
recipientEmail: 'Recipient Email',
|
recipientEmail: 'Recipient Email',
|
||||||
|
recipientEmailPlaceholder: "test{'@'}example.com",
|
||||||
sendTestEmail: 'Send Test Email',
|
sendTestEmail: 'Send Test Email',
|
||||||
sending: 'Sending...',
|
sending: 'Sending...',
|
||||||
enterRecipientHint: 'Please enter a recipient email address'
|
enterRecipientHint: 'Please enter a recipient email address'
|
||||||
|
|||||||
@@ -27,13 +27,61 @@ export default {
|
|||||||
title: '支持的服务商',
|
title: '支持的服务商',
|
||||||
description: 'AI 服务的统一 API 接口',
|
description: 'AI 服务的统一 API 接口',
|
||||||
supported: '已支持',
|
supported: '已支持',
|
||||||
soon: '即将推出'
|
soon: '即将推出',
|
||||||
|
claude: 'Claude',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
more: '更多'
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
allRightsReserved: '保留所有权利。'
|
allRightsReserved: '保留所有权利。'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Setup Wizard
|
||||||
|
setup: {
|
||||||
|
title: 'Sub2API 安装向导',
|
||||||
|
description: '配置您的 Sub2API 实例',
|
||||||
|
database: {
|
||||||
|
title: '数据库配置',
|
||||||
|
host: '主机',
|
||||||
|
port: '端口',
|
||||||
|
username: '用户名',
|
||||||
|
password: '密码',
|
||||||
|
databaseName: '数据库名称',
|
||||||
|
sslMode: 'SSL 模式',
|
||||||
|
passwordPlaceholder: '密码',
|
||||||
|
ssl: {
|
||||||
|
disable: '禁用',
|
||||||
|
require: '要求',
|
||||||
|
verifyCa: '验证 CA',
|
||||||
|
verifyFull: '完全验证'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
title: 'Redis 配置',
|
||||||
|
host: '主机',
|
||||||
|
port: '端口',
|
||||||
|
password: '密码(可选)',
|
||||||
|
database: '数据库',
|
||||||
|
passwordPlaceholder: '密码'
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
title: '管理员账户',
|
||||||
|
email: '邮箱',
|
||||||
|
password: '密码',
|
||||||
|
confirmPassword: '确认密码',
|
||||||
|
passwordPlaceholder: '至少 6 个字符',
|
||||||
|
confirmPasswordPlaceholder: '确认密码',
|
||||||
|
passwordMismatch: '密码不匹配'
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
title: '准备安装',
|
||||||
|
database: '数据库',
|
||||||
|
redis: 'Redis',
|
||||||
|
adminEmail: '管理员邮箱'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
@@ -81,7 +129,14 @@ export default {
|
|||||||
searchPlaceholder: '搜索...',
|
searchPlaceholder: '搜索...',
|
||||||
noOptionsFound: '无匹配选项',
|
noOptionsFound: '无匹配选项',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
refresh: '刷新'
|
refresh: '刷新',
|
||||||
|
time: {
|
||||||
|
never: '从未',
|
||||||
|
justNow: '刚刚',
|
||||||
|
minutesAgo: '{n}分钟前',
|
||||||
|
hoursAgo: '{n}小时前',
|
||||||
|
daysAgo: '{n}天前'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
@@ -139,7 +194,20 @@ export default {
|
|||||||
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
||||||
turnstileExpired: '验证已过期,请重试',
|
turnstileExpired: '验证已过期,请重试',
|
||||||
turnstileFailed: '验证失败,请重试',
|
turnstileFailed: '验证失败,请重试',
|
||||||
completeVerification: '请完成验证'
|
completeVerification: '请完成验证',
|
||||||
|
verifyYourEmail: '验证您的邮箱',
|
||||||
|
sessionExpired: '会话已过期',
|
||||||
|
sessionExpiredDesc: '请返回注册页面重新开始。',
|
||||||
|
verificationCode: '验证码',
|
||||||
|
verificationCodeHint: '请输入发送到您邮箱的6位验证码',
|
||||||
|
sendingCode: '发送中...',
|
||||||
|
clickToResend: '点击重新发送验证码',
|
||||||
|
resendCode: '重新发送验证码',
|
||||||
|
oauth: {
|
||||||
|
code: '授权码',
|
||||||
|
state: '状态',
|
||||||
|
fullUrl: '完整URL'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
@@ -204,7 +272,7 @@ export default {
|
|||||||
created: '创建时间',
|
created: '创建时间',
|
||||||
copyToClipboard: '复制到剪贴板',
|
copyToClipboard: '复制到剪贴板',
|
||||||
copied: '已复制!',
|
copied: '已复制!',
|
||||||
importToCcSwitch: '导入到 CC Switch',
|
importToCcSwitch: '导入到 CCS',
|
||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '禁用',
|
disable: '禁用',
|
||||||
nameLabel: '名称',
|
nameLabel: '名称',
|
||||||
@@ -373,6 +441,12 @@ export default {
|
|||||||
noData: '暂无数据'
|
noData: '暂无数据'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Table
|
||||||
|
table: {
|
||||||
|
expandActions: '展开更多操作',
|
||||||
|
collapseActions: '收起操作'
|
||||||
|
},
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
pagination: {
|
pagination: {
|
||||||
showing: '显示',
|
showing: '显示',
|
||||||
@@ -527,9 +601,13 @@ export default {
|
|||||||
noSubscription: '暂无订阅',
|
noSubscription: '暂无订阅',
|
||||||
daysRemaining: '{days}天',
|
daysRemaining: '{days}天',
|
||||||
expired: '已过期',
|
expired: '已过期',
|
||||||
|
disable: '禁用',
|
||||||
|
enable: '启用',
|
||||||
disableUser: '禁用用户',
|
disableUser: '禁用用户',
|
||||||
enableUser: '启用用户',
|
enableUser: '启用用户',
|
||||||
viewApiKeys: '查看 API 密钥',
|
viewApiKeys: '查看 API 密钥',
|
||||||
|
groups: '分组',
|
||||||
|
apiKeys: 'API密钥',
|
||||||
userApiKeys: '用户 API 密钥',
|
userApiKeys: '用户 API 密钥',
|
||||||
noApiKeys: '此用户暂无 API 密钥',
|
noApiKeys: '此用户暂无 API 密钥',
|
||||||
group: '分组',
|
group: '分组',
|
||||||
@@ -665,10 +743,13 @@ export default {
|
|||||||
priorityHint: '数值越高优先级越高,用于账号调度',
|
priorityHint: '数值越高优先级越高,用于账号调度',
|
||||||
statusLabel: '状态'
|
statusLabel: '状态'
|
||||||
},
|
},
|
||||||
exclusive: {
|
exclusiveObj: {
|
||||||
yes: '是',
|
yes: '是',
|
||||||
no: '否'
|
no: '否'
|
||||||
},
|
},
|
||||||
|
exclusive: '独占',
|
||||||
|
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号',
|
||||||
|
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||||
platforms: {
|
platforms: {
|
||||||
all: '全部平台',
|
all: '全部平台',
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
@@ -689,6 +770,7 @@ export default {
|
|||||||
exclusiveFilter: '独占',
|
exclusiveFilter: '独占',
|
||||||
nonExclusive: '非独占',
|
nonExclusive: '非独占',
|
||||||
public: '公开',
|
public: '公开',
|
||||||
|
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
|
||||||
accountsCount: '{count} 个账号',
|
accountsCount: '{count} 个账号',
|
||||||
enterGroupName: '请输入分组名称',
|
enterGroupName: '请输入分组名称',
|
||||||
optionalDescription: '可选描述',
|
optionalDescription: '可选描述',
|
||||||
@@ -813,6 +895,7 @@ export default {
|
|||||||
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
|
||||||
refreshCookie: '刷新 Cookie',
|
refreshCookie: '刷新 Cookie',
|
||||||
testAccount: '测试账号',
|
testAccount: '测试账号',
|
||||||
|
searchAccounts: '搜索账号...',
|
||||||
// Filter options
|
// Filter options
|
||||||
allPlatforms: '全部平台',
|
allPlatforms: '全部平台',
|
||||||
allTypes: '全部类型',
|
allTypes: '全部类型',
|
||||||
@@ -840,6 +923,19 @@ export default {
|
|||||||
lastUsed: '最近使用',
|
lastUsed: '最近使用',
|
||||||
actions: '操作'
|
actions: '操作'
|
||||||
},
|
},
|
||||||
|
clearRateLimit: '清除速率限制',
|
||||||
|
testConnection: '测试连接',
|
||||||
|
reAuthorize: '重新授权',
|
||||||
|
refreshToken: '刷新令牌',
|
||||||
|
noAccountsYet: '暂无账号',
|
||||||
|
createFirstAccount: '添加 AI 平台账号以开始使用 API 网关。',
|
||||||
|
tokenRefreshed: 'Token 刷新成功',
|
||||||
|
accountDeleted: '账号删除成功',
|
||||||
|
rateLimitCleared: '速率限制已清除',
|
||||||
|
setupToken: 'Setup Token',
|
||||||
|
apiKey: 'API Key',
|
||||||
|
deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。",
|
||||||
|
failedToClearRateLimit: '清除速率限制失败',
|
||||||
platforms: {
|
platforms: {
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
@@ -848,6 +944,10 @@ export default {
|
|||||||
},
|
},
|
||||||
types: {
|
types: {
|
||||||
oauth: 'OAuth',
|
oauth: 'OAuth',
|
||||||
|
chatgptOauth: 'ChatGPT OAuth',
|
||||||
|
responsesApi: 'Responses API',
|
||||||
|
googleOauth: 'Google OAuth',
|
||||||
|
codeAssist: 'Code Assist',
|
||||||
api_key: 'API Key',
|
api_key: 'API Key',
|
||||||
cookie: 'Cookie'
|
cookie: 'Cookie'
|
||||||
},
|
},
|
||||||
@@ -857,6 +957,9 @@ export default {
|
|||||||
error: '错误',
|
error: '错误',
|
||||||
cooldown: '冷却中'
|
cooldown: '冷却中'
|
||||||
},
|
},
|
||||||
|
usageWindow: {
|
||||||
|
statsTitle: '5小时窗口用量统计'
|
||||||
|
},
|
||||||
form: {
|
form: {
|
||||||
nameLabel: '账号名称',
|
nameLabel: '账号名称',
|
||||||
namePlaceholder: '请输入账号名称',
|
namePlaceholder: '请输入账号名称',
|
||||||
@@ -1125,6 +1228,7 @@ export default {
|
|||||||
todayOverview: '今日概览',
|
todayOverview: '今日概览',
|
||||||
cost: '费用',
|
cost: '费用',
|
||||||
requests: '请求',
|
requests: '请求',
|
||||||
|
tokens: 'Token',
|
||||||
highestCostDay: '最高费用日',
|
highestCostDay: '最高费用日',
|
||||||
highestRequestDay: '最高请求日',
|
highestRequestDay: '最高请求日',
|
||||||
date: '日期',
|
date: '日期',
|
||||||
@@ -1217,8 +1321,8 @@ export default {
|
|||||||
batchAdd: '快捷添加',
|
batchAdd: '快捷添加',
|
||||||
batchInput: '代理列表',
|
batchInput: '代理列表',
|
||||||
batchInputPlaceholder:
|
batchInputPlaceholder:
|
||||||
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
"每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
|
||||||
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
|
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口",
|
||||||
parsedCount: '有效 {count} 个',
|
parsedCount: '有效 {count} 个',
|
||||||
invalidCount: '无效 {count} 个',
|
invalidCount: '无效 {count} 个',
|
||||||
duplicateCount: '重复 {count} 个',
|
duplicateCount: '重复 {count} 个',
|
||||||
@@ -1364,6 +1468,18 @@ export default {
|
|||||||
searchUserPlaceholder: '按邮箱搜索用户...',
|
searchUserPlaceholder: '按邮箱搜索用户...',
|
||||||
selectedUser: '已选择',
|
selectedUser: '已选择',
|
||||||
user: '用户',
|
user: '用户',
|
||||||
|
account: '账户',
|
||||||
|
group: '分组',
|
||||||
|
requestId: '请求ID',
|
||||||
|
allModels: '全部模型',
|
||||||
|
allAccounts: '全部账户',
|
||||||
|
allGroups: '全部分组',
|
||||||
|
allTypes: '全部类型',
|
||||||
|
allBillingTypes: '全部计费',
|
||||||
|
inputCost: '输入成本',
|
||||||
|
outputCost: '输出成本',
|
||||||
|
cacheCreationCost: '缓存创建成本',
|
||||||
|
cacheReadCost: '缓存读取成本',
|
||||||
failedToLoad: '加载使用记录失败'
|
failedToLoad: '加载使用记录失败'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1402,15 +1518,19 @@ export default {
|
|||||||
description: '自定义站点品牌',
|
description: '自定义站点品牌',
|
||||||
siteName: '站点名称',
|
siteName: '站点名称',
|
||||||
siteNameHint: '显示在邮件和页面标题中',
|
siteNameHint: '显示在邮件和页面标题中',
|
||||||
|
siteNamePlaceholder: 'Sub2API',
|
||||||
siteSubtitle: '站点副标题',
|
siteSubtitle: '站点副标题',
|
||||||
siteSubtitleHint: '显示在登录和注册页面',
|
siteSubtitleHint: '显示在登录和注册页面',
|
||||||
|
siteSubtitlePlaceholder: '订阅转 API 转换平台',
|
||||||
apiBaseUrl: 'API 端点地址',
|
apiBaseUrl: 'API 端点地址',
|
||||||
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
|
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
|
||||||
|
apiBaseUrlPlaceholder: 'https://api.example.com',
|
||||||
contactInfo: '客服联系方式',
|
contactInfo: '客服联系方式',
|
||||||
contactInfoPlaceholder: '例如:QQ: 123456789',
|
contactInfoPlaceholder: '例如:QQ: 123456789',
|
||||||
contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置',
|
contactInfoHint: '填写客服联系方式,将展示在兑换页面、个人资料等位置',
|
||||||
docUrl: '文档链接',
|
docUrl: '文档链接',
|
||||||
docUrlHint: '文档网站的链接。留空则隐藏文档链接。',
|
docUrlHint: '文档网站的链接。留空则隐藏文档链接。',
|
||||||
|
docUrlPlaceholder: 'https://docs.example.com',
|
||||||
siteLogo: '站点Logo',
|
siteLogo: '站点Logo',
|
||||||
uploadImage: '上传图片',
|
uploadImage: '上传图片',
|
||||||
remove: '移除',
|
remove: '移除',
|
||||||
@@ -1425,12 +1545,18 @@ export default {
|
|||||||
testConnection: '测试连接',
|
testConnection: '测试连接',
|
||||||
testing: '测试中...',
|
testing: '测试中...',
|
||||||
host: 'SMTP 主机',
|
host: 'SMTP 主机',
|
||||||
|
hostPlaceholder: 'smtp.gmail.com',
|
||||||
port: 'SMTP 端口',
|
port: 'SMTP 端口',
|
||||||
|
portPlaceholder: '587',
|
||||||
username: 'SMTP 用户名',
|
username: 'SMTP 用户名',
|
||||||
|
usernamePlaceholder: "your-email{'@'}gmail.com",
|
||||||
password: 'SMTP 密码',
|
password: 'SMTP 密码',
|
||||||
|
passwordPlaceholder: '********',
|
||||||
passwordHint: '留空以保留现有密码',
|
passwordHint: '留空以保留现有密码',
|
||||||
fromEmail: '发件人邮箱',
|
fromEmail: '发件人邮箱',
|
||||||
|
fromEmailPlaceholder: "noreply{'@'}example.com",
|
||||||
fromName: '发件人名称',
|
fromName: '发件人名称',
|
||||||
|
fromNamePlaceholder: 'Sub2API',
|
||||||
useTls: '使用 TLS',
|
useTls: '使用 TLS',
|
||||||
useTlsHint: '为 SMTP 连接启用 TLS 加密'
|
useTlsHint: '为 SMTP 连接启用 TLS 加密'
|
||||||
},
|
},
|
||||||
@@ -1438,6 +1564,7 @@ export default {
|
|||||||
title: '发送测试邮件',
|
title: '发送测试邮件',
|
||||||
description: '发送测试邮件以验证 SMTP 配置',
|
description: '发送测试邮件以验证 SMTP 配置',
|
||||||
recipientEmail: '收件人邮箱',
|
recipientEmail: '收件人邮箱',
|
||||||
|
recipientEmailPlaceholder: "test{'@'}example.com",
|
||||||
sendTestEmail: '发送测试邮件',
|
sendTestEmail: '发送测试邮件',
|
||||||
sending: '发送中...',
|
sending: '发送中...',
|
||||||
enterRecipientHint: '请输入收件人邮箱地址'
|
enterRecipientHint: '请输入收件人邮箱地址'
|
||||||
|
|||||||
@@ -488,6 +488,17 @@
|
|||||||
@apply bg-gray-900 text-gray-100;
|
@apply bg-gray-900 text-gray-100;
|
||||||
@apply overflow-x-auto rounded-xl p-4;
|
@apply overflow-x-auto rounded-xl p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ 表格页面布局优化 ============ */
|
||||||
|
/* 表格容器 - 默认仅支持水平滚动 */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头固定时添加底部阴影,增强视觉层次 */
|
||||||
|
.table-wrapper thead.sticky {
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|||||||
@@ -442,22 +442,38 @@ export interface UsageLog {
|
|||||||
user_id: number
|
user_id: number
|
||||||
api_key_id: number
|
api_key_id: number
|
||||||
account_id: number | null
|
account_id: number | null
|
||||||
|
request_id: string
|
||||||
model: string
|
model: string
|
||||||
|
|
||||||
|
group_id: number | null
|
||||||
|
subscription_id: number | null
|
||||||
|
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
cache_creation_tokens: number
|
cache_creation_tokens: number
|
||||||
cache_read_tokens: number
|
cache_read_tokens: number
|
||||||
|
cache_creation_5m_tokens: number
|
||||||
|
cache_creation_1h_tokens: number
|
||||||
|
|
||||||
|
input_cost: number
|
||||||
|
output_cost: number
|
||||||
|
cache_creation_cost: number
|
||||||
|
cache_read_cost: number
|
||||||
total_cost: number
|
total_cost: number
|
||||||
actual_cost: number
|
actual_cost: number
|
||||||
rate_multiplier: number
|
rate_multiplier: number
|
||||||
|
|
||||||
billing_type: BillingType
|
billing_type: BillingType
|
||||||
stream: boolean
|
stream: boolean
|
||||||
duration_ms: number
|
duration_ms: number
|
||||||
first_token_ms: number | null
|
first_token_ms: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|
||||||
user?: User
|
user?: User
|
||||||
api_key?: ApiKey
|
api_key?: ApiKey
|
||||||
account?: Account
|
account?: Account
|
||||||
|
group?: Group
|
||||||
|
subscription?: UserSubscription
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RedeemCode {
|
export interface RedeemCode {
|
||||||
@@ -677,6 +693,11 @@ export interface UsageQueryParams {
|
|||||||
page_size?: number
|
page_size?: number
|
||||||
api_key_id?: number
|
api_key_id?: number
|
||||||
user_id?: number
|
user_id?: number
|
||||||
|
account_id?: number
|
||||||
|
group_id?: number
|
||||||
|
model?: string
|
||||||
|
stream?: boolean
|
||||||
|
billing_type?: number
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,30 +3,32 @@
|
|||||||
* 参考 CRS 项目的 format.js 实现
|
* 参考 CRS 项目的 format.js 实现
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { i18n } from '@/i18n'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化相对时间
|
* 格式化相对时间
|
||||||
* @param date 日期字符串或 Date 对象
|
* @param date 日期字符串或 Date 对象
|
||||||
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
|
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
|
||||||
*/
|
*/
|
||||||
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
||||||
if (!date) return 'Never'
|
if (!date) return i18n.global.t('common.time.never')
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const past = new Date(date)
|
const past = new Date(date)
|
||||||
const diffMs = now.getTime() - past.getTime()
|
const diffMs = now.getTime() - past.getTime()
|
||||||
|
|
||||||
// 处理未来时间或无效日期
|
// 处理未来时间或无效日期
|
||||||
if (diffMs < 0 || isNaN(diffMs)) return 'Never'
|
if (diffMs < 0 || isNaN(diffMs)) return i18n.global.t('common.time.never')
|
||||||
|
|
||||||
const diffSecs = Math.floor(diffMs / 1000)
|
const diffSecs = Math.floor(diffMs / 1000)
|
||||||
const diffMins = Math.floor(diffSecs / 60)
|
const diffMins = Math.floor(diffSecs / 60)
|
||||||
const diffHours = Math.floor(diffMins / 60)
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
const diffDays = Math.floor(diffHours / 24)
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
if (diffDays > 0) return `${diffDays}d ago`
|
if (diffDays > 0) return i18n.global.t('common.time.daysAgo', { n: diffDays })
|
||||||
if (diffHours > 0) return `${diffHours}h ago`
|
if (diffHours > 0) return i18n.global.t('common.time.hoursAgo', { n: diffHours })
|
||||||
if (diffMins > 0) return `${diffMins}m ago`
|
if (diffMins > 0) return i18n.global.t('common.time.minutesAgo', { n: diffMins })
|
||||||
return 'Just now'
|
return i18n.global.t('common.time.justNow')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,3 +116,30 @@ export function formatDate(
|
|||||||
.replace('mm', minutes)
|
.replace('mm', minutes)
|
||||||
.replace('ss', seconds)
|
.replace('ss', seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期(只显示日期部分)
|
||||||
|
* @param date 日期字符串或 Date 对象
|
||||||
|
* @returns 格式化后的日期字符串,格式为 YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export function formatDateOnly(date: string | Date | null | undefined): string {
|
||||||
|
return formatDate(date, 'YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期时间(完整格式)
|
||||||
|
* @param date 日期字符串或 Date 对象
|
||||||
|
* @returns 格式化后的日期时间字符串,格式为 YYYY-MM-DD HH:mm:ss
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: string | Date | null | undefined): string {
|
||||||
|
return formatDate(date, 'YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间(只显示时分)
|
||||||
|
* @param date 日期字符串或 Date 对象
|
||||||
|
* @returns 格式化后的时间字符串,格式为 HH:mm
|
||||||
|
*/
|
||||||
|
export function formatTime(date: string | Date | null | undefined): string {
|
||||||
|
return formatDate(date, 'HH:mm')
|
||||||
|
}
|
||||||
|
|||||||
@@ -385,7 +385,7 @@
|
|||||||
>
|
>
|
||||||
<span class="text-xs font-bold text-white">C</span>
|
<span class="text-xs font-bold text-white">C</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Claude</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.claude') }}</span>
|
||||||
<span
|
<span
|
||||||
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
|
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
|
||||||
>{{ t('home.providers.supported') }}</span
|
>{{ t('home.providers.supported') }}</span
|
||||||
@@ -415,7 +415,7 @@
|
|||||||
>
|
>
|
||||||
<span class="text-xs font-bold text-white">G</span>
|
<span class="text-xs font-bold text-white">G</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">Gemini</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.gemini') }}</span>
|
||||||
<span
|
<span
|
||||||
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
|
class="rounded bg-primary-100 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-400"
|
||||||
>{{ t('home.providers.supported') }}</span
|
>{{ t('home.providers.supported') }}</span
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
>
|
>
|
||||||
<span class="text-xs font-bold text-white">+</span>
|
<span class="text-xs font-bold text-white">+</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">More</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-dark-200">{{ t('home.providers.more') }}</span>
|
||||||
<span
|
<span
|
||||||
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
|
class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700 dark:text-dark-400"
|
||||||
>{{ t('home.providers.soon') }}</span
|
>{{ t('home.providers.soon') }}</span
|
||||||
|
|||||||
@@ -43,7 +43,9 @@
|
|||||||
|
|
||||||
<!-- Text Content -->
|
<!-- Text Content -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">Page Not Found</h1>
|
<h1 class="mb-3 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ t('errors.pageNotFound') }}
|
||||||
|
</h1>
|
||||||
<p class="text-gray-500 dark:text-dark-400">
|
<p class="text-gray-500 dark:text-dark-400">
|
||||||
The page you are looking for doesn't exist or has been moved.
|
The page you are looking for doesn't exist or has been moved.
|
||||||
</p>
|
</p>
|
||||||
@@ -100,8 +102,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<template #actions>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadAccounts"
|
@click="loadAccounts"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="showCrsSyncModal = true" class="btn btn-secondary" title="从 CRS 同步">
|
<button @click="showCrsSyncModal = true" class="btn btn-secondary" :title="t('admin.accounts.syncFromCrs')">
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -50,11 +50,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ t('admin.accounts.createAccount') }}
|
{{ t('admin.accounts.createAccount') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
<template #filters>
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="relative max-w-md flex-1">
|
<div class="relative max-w-md flex-1">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -75,8 +76,8 @@
|
|||||||
class="input pl-10"
|
class="input pl-10"
|
||||||
@input="handleSearch"
|
@input="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.platform"
|
v-model="filters.platform"
|
||||||
:options="platformOptions"
|
:options="platformOptions"
|
||||||
@@ -98,10 +99,12 @@
|
|||||||
class="w-36"
|
class="w-36"
|
||||||
@change="loadAccounts"
|
@change="loadAccounts"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Bulk Actions Bar -->
|
<template #table>
|
||||||
|
<!-- Bulk Actions Bar -->
|
||||||
<div
|
<div
|
||||||
v-if="selectedAccountIds.length > 0"
|
v-if="selectedAccountIds.length > 0"
|
||||||
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
|
class="card border-primary-200 bg-primary-50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
|
||||||
@@ -162,9 +165,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accounts Table -->
|
<DataTable :columns="columns" :data="accounts" :loading="loading" :actions-count="6">
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
|
||||||
<template #cell-select="{ row }">
|
<template #cell-select="{ row }">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -274,134 +275,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row, expanded }">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<!-- Reset Status button for error accounts -->
|
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||||
<button
|
|
||||||
v-if="row.status === 'error'"
|
|
||||||
@click="handleResetStatus(row)"
|
|
||||||
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
|
||||||
:title="t('admin.accounts.resetStatus')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Clear Rate Limit button -->
|
|
||||||
<button
|
|
||||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
|
||||||
@click="handleClearRateLimit(row)"
|
|
||||||
class="rounded-lg p-2 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
|
||||||
:title="t('admin.accounts.clearRateLimit')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Test Connection button -->
|
|
||||||
<button
|
|
||||||
@click="handleTest(row)"
|
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
|
||||||
:title="t('admin.accounts.testConnection')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- View Stats button -->
|
|
||||||
<button
|
|
||||||
@click="handleViewStats(row)"
|
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
|
||||||
:title="t('admin.accounts.viewStats')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
|
||||||
@click="handleReAuth(row)"
|
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
|
||||||
:title="t('admin.accounts.reAuthorize')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
|
||||||
@click="handleRefreshToken(row)"
|
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
|
||||||
:title="t('admin.accounts.refreshToken')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -416,11 +295,11 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -435,7 +314,134 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 次要操作:展开时显示 -->
|
||||||
|
<template v-if="expanded">
|
||||||
|
<!-- Reset Status button for error accounts -->
|
||||||
|
<button
|
||||||
|
v-if="row.status === 'error'"
|
||||||
|
@click="handleResetStatus(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.resetStatus') }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- Clear Rate Limit button -->
|
||||||
|
<button
|
||||||
|
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||||
|
@click="handleClearRateLimit(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.clearRateLimit') }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- Test Connection button -->
|
||||||
|
<button
|
||||||
|
@click="handleTest(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.testConnection') }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- View Stats button -->
|
||||||
|
<button
|
||||||
|
@click="handleViewStats(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.viewStats') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||||
|
@click="handleReAuth(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.reAuthorize') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||||
|
@click="handleRefreshToken(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.accounts.refreshToken') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -448,17 +454,18 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
:total="pagination.total"
|
:total="pagination.total"
|
||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Create Account Modal -->
|
<!-- Create Account Modal -->
|
||||||
<CreateAccountModal
|
<CreateAccountModal
|
||||||
@@ -541,6 +548,7 @@ import { adminAPI } from '@/api/admin'
|
|||||||
import type { Account, Proxy, Group } from '@/types'
|
import type { Account, Proxy, Group } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
|
|||||||
@@ -1,69 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<template #actions>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadGroups"
|
@click="loadGroups"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
:title="t('common.refresh')"
|
:title="t('common.refresh')"
|
||||||
>
|
|
||||||
<svg
|
|
||||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
viewBox="0 0 24 24"
|
||||||
/>
|
stroke="currentColor"
|
||||||
</svg>
|
stroke-width="1.5"
|
||||||
</button>
|
>
|
||||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
<path
|
||||||
<svg
|
stroke-linecap="round"
|
||||||
class="mr-2 h-5 w-5"
|
stroke-linejoin="round"
|
||||||
fill="none"
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
stroke="currentColor"
|
</svg>
|
||||||
stroke-width="1.5"
|
</button>
|
||||||
>
|
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<svg
|
||||||
</svg>
|
class="mr-2 h-5 w-5"
|
||||||
{{ t('admin.groups.createGroup') }}
|
fill="none"
|
||||||
</button>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
{{ t('admin.groups.createGroup') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Filters -->
|
<template #filters>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.platform"
|
v-model="filters.platform"
|
||||||
:options="platformFilterOptions"
|
:options="platformFilterOptions"
|
||||||
placeholder="All Platforms"
|
:placeholder="t('admin.groups.allPlatforms')"
|
||||||
class="w-44"
|
class="w-44"
|
||||||
@change="loadGroups"
|
@change="loadGroups"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.status"
|
v-model="filters.status"
|
||||||
:options="statusOptions"
|
:options="statusOptions"
|
||||||
placeholder="All Status"
|
:placeholder="t('admin.groups.allStatus')"
|
||||||
class="w-40"
|
class="w-40"
|
||||||
@change="loadGroups"
|
@change="loadGroups"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.is_exclusive"
|
v-model="filters.is_exclusive"
|
||||||
:options="exclusiveOptions"
|
:options="exclusiveOptions"
|
||||||
placeholder="All Groups"
|
:placeholder="t('admin.groups.allGroups')"
|
||||||
class="w-44"
|
class="w-44"
|
||||||
@change="loadGroups"
|
@change="loadGroups"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Groups Table -->
|
<template #table>
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||||
<template #cell-name="{ value }">
|
<template #cell-name="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
@@ -165,8 +166,7 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -200,6 +200,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -213,17 +214,18 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
:total="pagination.total"
|
:total="pagination.total"
|
||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Create Group Modal -->
|
<!-- Create Group Modal -->
|
||||||
<Modal
|
<Modal
|
||||||
@@ -541,6 +543,7 @@ import { adminAPI } from '@/api/admin'
|
|||||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<template #actions>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadProxies"
|
@click="loadProxies"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@@ -35,11 +35,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ t('admin.proxies.createProxy') }}
|
{{ t('admin.proxies.createProxy') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
<template #filters>
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="relative max-w-md flex-1">
|
<div class="relative max-w-md flex-1">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -60,8 +61,8 @@
|
|||||||
class="input pl-10"
|
class="input pl-10"
|
||||||
@input="handleSearch"
|
@input="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.protocol"
|
v-model="filters.protocol"
|
||||||
:options="protocolOptions"
|
:options="protocolOptions"
|
||||||
@@ -76,11 +77,11 @@
|
|||||||
class="w-36"
|
class="w-36"
|
||||||
@change="loadProxies"
|
@change="loadProxies"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Proxies Table -->
|
<template #table>
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||||
<template #cell-name="{ value }">
|
<template #cell-name="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
@@ -111,8 +112,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="handleTestConnection(row)"
|
@click="handleTestConnection(row)"
|
||||||
:disabled="testingProxyIds.has(row.id)"
|
:disabled="testingProxyIds.has(row.id)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||||
:title="t('admin.proxies.testConnection')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="testingProxyIds.has(row.id)"
|
v-if="testingProxyIds.has(row.id)"
|
||||||
@@ -148,11 +148,11 @@
|
|||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.proxies.testConnection') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
:title="t('common.edit')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -167,11 +167,11 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -199,17 +200,18 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
:total="pagination.total"
|
:total="pagination.total"
|
||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Create Proxy Modal -->
|
<!-- Create Proxy Modal -->
|
||||||
<Modal
|
<Modal
|
||||||
@@ -291,7 +293,7 @@
|
|||||||
v-model="createForm.host"
|
v-model="createForm.host"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="proxy.example.com"
|
:placeholder="t('admin.proxies.form.hostPlaceholder')"
|
||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,7 +305,7 @@
|
|||||||
required
|
required
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
placeholder="8080"
|
:placeholder="t('admin.proxies.form.portPlaceholder')"
|
||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,6 +579,7 @@ import { adminAPI } from '@/api/admin'
|
|||||||
import type { Proxy, ProxyProtocol } from '@/types'
|
import type { Proxy, ProxyProtocol } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<template #actions>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadCodes"
|
@click="loadCodes"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@@ -26,11 +26,12 @@
|
|||||||
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
||||||
{{ t('admin.redeem.generateCodes') }}
|
{{ t('admin.redeem.generateCodes') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Filters and Actions -->
|
<template #filters>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="max-w-md flex-1">
|
<div class="max-w-md flex-1">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -38,8 +39,8 @@
|
|||||||
class="input"
|
class="input"
|
||||||
@input="handleSearch"
|
@input="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.type"
|
v-model="filters.type"
|
||||||
:options="filterTypeOptions"
|
:options="filterTypeOptions"
|
||||||
@@ -55,11 +56,11 @@
|
|||||||
<button @click="handleExportCodes" class="btn btn-secondary">
|
<button @click="handleExportCodes" class="btn btn-secondary">
|
||||||
{{ t('admin.redeem.exportCsv') }}
|
{{ t('admin.redeem.exportCsv') }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Redeem Codes Table -->
|
<template #table>
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||||
<template #cell-code="{ value }">
|
<template #cell-code="{ value }">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
|
|
||||||
<template #cell-used_at="{ value }">
|
<template #cell-used_at="{ value }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{
|
<span class="text-sm text-gray-500 dark:text-dark-400">{{
|
||||||
value ? formatDate(value) : '-'
|
value ? formatDateTime(value) : '-'
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -160,8 +161,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="row.status === 'unused'"
|
v-if="row.status === 'unused'"
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -171,29 +171,31 @@
|
|||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
:total="pagination.total"
|
:total="pagination.total"
|
||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Batch Actions -->
|
<!-- Batch Actions -->
|
||||||
<div v-if="filters.status === 'unused'" class="flex justify-end">
|
<div v-if="filters.status === 'unused'" class="flex justify-end">
|
||||||
<button @click="showDeleteUnusedDialog = true" class="btn btn-danger">
|
<button @click="showDeleteUnusedDialog = true" class="btn btn-danger">
|
||||||
{{ t('admin.redeem.deleteAllUnused') }}
|
{{ t('admin.redeem.deleteAllUnused') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -416,10 +418,13 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
@@ -427,6 +432,7 @@ import Select from '@/components/common/Select.vue'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
const showGenerateDialog = ref(false)
|
const showGenerateDialog = ref(false)
|
||||||
const showResultDialog = ref(false)
|
const showResultDialog = ref(false)
|
||||||
@@ -549,10 +555,6 @@ const generateForm = reactive({
|
|||||||
validity_days: 30
|
validity_days: 30
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
return new Date(dateString).toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadCodes = async () => {
|
const loadCodes = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -618,15 +620,12 @@ const handleGenerateCodes = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
try {
|
const success = await clipboardCopy(text, t('admin.redeem.copied'))
|
||||||
await navigator.clipboard.writeText(text)
|
if (success) {
|
||||||
copiedCode.value = text
|
copiedCode.value = text
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copiedCode.value = null
|
copiedCode.value = null
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
|
||||||
appStore.showError(t('admin.redeem.failedToCopy'))
|
|
||||||
console.error('Error copying to clipboard:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,12 @@
|
|||||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{{ t('admin.settings.site.siteName') }}
|
{{ t('admin.settings.site.siteName') }}
|
||||||
</label>
|
</label>
|
||||||
<input v-model="form.site_name" type="text" class="input" placeholder="Sub2API" />
|
<input
|
||||||
|
v-model="form.site_name"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.settings.site.siteNamePlaceholder')"
|
||||||
|
/>
|
||||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.settings.site.siteNameHint') }}
|
{{ t('admin.settings.site.siteNameHint') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -339,7 +344,7 @@
|
|||||||
v-model="form.site_subtitle"
|
v-model="form.site_subtitle"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Subscription to API Conversion Platform"
|
:placeholder="t('admin.settings.site.siteSubtitlePlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.settings.site.siteSubtitleHint') }}
|
{{ t('admin.settings.site.siteSubtitleHint') }}
|
||||||
@@ -356,7 +361,7 @@
|
|||||||
v-model="form.api_base_url"
|
v-model="form.api_base_url"
|
||||||
type="text"
|
type="text"
|
||||||
class="input font-mono text-sm"
|
class="input font-mono text-sm"
|
||||||
placeholder="https://api.example.com"
|
:placeholder="t('admin.settings.site.apiBaseUrlPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.settings.site.apiBaseUrlHint') }}
|
{{ t('admin.settings.site.apiBaseUrlHint') }}
|
||||||
@@ -388,7 +393,7 @@
|
|||||||
v-model="form.doc_url"
|
v-model="form.doc_url"
|
||||||
type="url"
|
type="url"
|
||||||
class="input font-mono text-sm"
|
class="input font-mono text-sm"
|
||||||
placeholder="https://docs.example.com"
|
:placeholder="t('admin.settings.site.docUrlPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.settings.site.docUrlHint') }}
|
{{ t('admin.settings.site.docUrlHint') }}
|
||||||
@@ -537,7 +542,7 @@
|
|||||||
v-model="form.smtp_host"
|
v-model="form.smtp_host"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="smtp.gmail.com"
|
:placeholder="t('admin.settings.smtp.hostPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -550,7 +555,7 @@
|
|||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="587"
|
:placeholder="t('admin.settings.smtp.portPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -561,7 +566,7 @@
|
|||||||
v-model="form.smtp_username"
|
v-model="form.smtp_username"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="your-email@gmail.com"
|
:placeholder="t('admin.settings.smtp.usernamePlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -572,7 +577,7 @@
|
|||||||
v-model="form.smtp_password"
|
v-model="form.smtp_password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="********"
|
:placeholder="t('admin.settings.smtp.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ t('admin.settings.smtp.passwordHint') }}
|
{{ t('admin.settings.smtp.passwordHint') }}
|
||||||
@@ -586,7 +591,7 @@
|
|||||||
v-model="form.smtp_from_email"
|
v-model="form.smtp_from_email"
|
||||||
type="email"
|
type="email"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="noreply@example.com"
|
:placeholder="t('admin.settings.smtp.fromEmailPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -597,7 +602,7 @@
|
|||||||
v-model="form.smtp_from_name"
|
v-model="form.smtp_from_name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Sub2API"
|
:placeholder="t('admin.settings.smtp.fromNamePlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -639,7 +644,7 @@
|
|||||||
v-model="testEmailAddress"
|
v-model="testEmailAddress"
|
||||||
type="email"
|
type="email"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="test@example.com"
|
:placeholder="t('admin.settings.testEmail.recipientEmailPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<!-- Page Header Actions -->
|
||||||
|
<template #actions>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadSubscriptions"
|
@click="loadSubscriptions"
|
||||||
@@ -36,8 +37,10 @@
|
|||||||
{{ t('admin.subscriptions.assignSubscription') }}
|
{{ t('admin.subscriptions.assignSubscription') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
|
<template #filters>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Select
|
<Select
|
||||||
v-model="filters.status"
|
v-model="filters.status"
|
||||||
@@ -54,9 +57,10 @@
|
|||||||
@change="loadSubscriptions"
|
@change="loadSubscriptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Subscriptions Table -->
|
<!-- Subscriptions Table -->
|
||||||
<div class="card overflow-hidden">
|
<template #table>
|
||||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
||||||
<template #cell-user="{ row }">
|
<template #cell-user="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -222,7 +226,7 @@
|
|||||||
: 'text-gray-700 dark:text-gray-300'
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ formatDate(value) }}
|
{{ formatDateOnly(value) }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
|
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
|
||||||
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
|
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
|
||||||
@@ -253,8 +257,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@click="handleExtend(row)"
|
@click="handleExtend(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||||
:title="t('admin.subscriptions.extend')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -269,12 +272,12 @@
|
|||||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.subscriptions.extend') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@click="handleRevoke(row)"
|
@click="handleRevoke(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
:title="t('admin.subscriptions.revoke')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -289,6 +292,7 @@
|
|||||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('admin.subscriptions.revoke') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -302,9 +306,10 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
@@ -312,7 +317,8 @@
|
|||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Assign Subscription Modal -->
|
<!-- Assign Subscription Modal -->
|
||||||
<Modal
|
<Modal
|
||||||
@@ -401,7 +407,7 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">
|
<span class="font-medium text-gray-900 dark:text-white">
|
||||||
{{
|
{{
|
||||||
extendingSubscription.expires_at
|
extendingSubscription.expires_at
|
||||||
? formatDate(extendingSubscription.expires_at)
|
? formatDateOnly(extendingSubscription.expires_at)
|
||||||
: t('admin.subscriptions.noExpiration')
|
: t('admin.subscriptions.noExpiration')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
@@ -444,7 +450,9 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { UserSubscription, Group, User } from '@/types'
|
import type { UserSubscription, Group, User } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
|
import { formatDateOnly } from '@/utils/format'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
@@ -640,14 +648,6 @@ const confirmRevoke = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDaysRemaining = (expiresAt: string): number | null => {
|
const getDaysRemaining = (expiresAt: string): number | null => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const expires = new Date(expiresAt)
|
const expires = new Date(expiresAt)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Summary Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<!-- Total Requests -->
|
<!-- Total Requests -->
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
<svg
|
<svg
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts Section -->
|
<!-- Charts Section -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Chart Controls -->
|
<!-- Chart Controls -->
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -157,9 +157,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters Section -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
<!-- User Search -->
|
<!-- User Search -->
|
||||||
<div class="min-w-[200px]">
|
<div class="min-w-[200px]">
|
||||||
@@ -229,6 +229,61 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Filter -->
|
||||||
|
<div class="min-w-[180px]">
|
||||||
|
<label class="input-label">{{ t('usage.model') }}</label>
|
||||||
|
<Select
|
||||||
|
v-model="filters.model"
|
||||||
|
:options="modelOptions"
|
||||||
|
:placeholder="t('admin.usage.allModels')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Filter -->
|
||||||
|
<div class="min-w-[180px]">
|
||||||
|
<label class="input-label">{{ t('admin.usage.account') }}</label>
|
||||||
|
<Select
|
||||||
|
v-model="filters.account_id"
|
||||||
|
:options="accountOptions"
|
||||||
|
:placeholder="t('admin.usage.allAccounts')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stream Type Filter -->
|
||||||
|
<div class="min-w-[150px]">
|
||||||
|
<label class="input-label">{{ t('usage.type') }}</label>
|
||||||
|
<Select
|
||||||
|
v-model="filters.stream"
|
||||||
|
:options="streamOptions"
|
||||||
|
:placeholder="t('admin.usage.allTypes')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Billing Type Filter -->
|
||||||
|
<div class="min-w-[150px]">
|
||||||
|
<label class="input-label">{{ t('usage.billingType') }}</label>
|
||||||
|
<Select
|
||||||
|
v-model="filters.billing_type"
|
||||||
|
:options="billingTypeOptions"
|
||||||
|
:placeholder="t('admin.usage.allBillingTypes')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Filter -->
|
||||||
|
<div class="min-w-[150px]">
|
||||||
|
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||||
|
<Select
|
||||||
|
v-model="filters.group_id"
|
||||||
|
:options="groupOptions"
|
||||||
|
:placeholder="t('admin.usage.allGroups')"
|
||||||
|
@change="applyFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Date Range Filter -->
|
<!-- Date Range Filter -->
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||||||
@@ -252,9 +307,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Usage Table -->
|
<!-- Table Section -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
<div class="overflow-auto">
|
||||||
|
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||||
<template #cell-user="{ row }">
|
<template #cell-user="{ row }">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||||
@@ -270,10 +326,26 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-account="{ row }">
|
||||||
|
<span class="text-sm text-gray-900 dark:text-white">{{
|
||||||
|
row.account?.name || '-'
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-model="{ value }">
|
<template #cell-model="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-group="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="row.group"
|
||||||
|
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200"
|
||||||
|
>
|
||||||
|
{{ row.group.name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-stream="{ row }">
|
<template #cell-stream="{ row }">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
|
||||||
@@ -383,7 +455,11 @@
|
|||||||
${{ row.actual_cost.toFixed(6) }}
|
${{ row.actual_cost.toFixed(6) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Cost Detail Tooltip -->
|
<!-- Cost Detail Tooltip -->
|
||||||
<div class="group relative">
|
<div
|
||||||
|
class="group relative"
|
||||||
|
@mouseenter="showTooltip($event, row)"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||||||
>
|
>
|
||||||
@@ -399,39 +475,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tooltip Content (right side) -->
|
|
||||||
<div
|
|
||||||
class="invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div class="flex items-center justify-between gap-6">
|
|
||||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
|
||||||
<span class="font-semibold text-blue-400"
|
|
||||||
>{{ (row.rate_multiplier || 1).toFixed(2) }}x</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-6">
|
|
||||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
|
||||||
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
|
|
||||||
>
|
|
||||||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
|
||||||
<span class="font-semibold text-green-400"
|
|
||||||
>${{ row.actual_cost.toFixed(6) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tooltip Arrow (left side) -->
|
|
||||||
<div
|
|
||||||
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -471,10 +514,17 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-request_id="{ row }">
|
||||||
|
<span class="font-mono text-xs text-gray-500 dark:text-gray-400">{{
|
||||||
|
row.request_id || '-'
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<EmptyState :message="t('usage.noRecords')" />
|
<EmptyState :message="t('usage.noRecords')" />
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
@@ -487,6 +537,66 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
<!-- Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tooltipPosition.x + 'px',
|
||||||
|
top: tooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<!-- Cost Breakdown -->
|
||||||
|
<div class="mb-2 border-b border-gray-700 pb-1.5">
|
||||||
|
<div class="text-xs font-semibold text-gray-300 mb-1">成本明细</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.input_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tooltipData && tooltipData.cache_read_cost > 0" class="flex items-center justify-between gap-4">
|
||||||
|
<span class="text-gray-400">{{ t('admin.usage.cacheReadCost') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData.cache_read_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rate and Summary -->
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400"
|
||||||
|
>{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData?.total_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||||
|
<span class="font-semibold text-green-400"
|
||||||
|
>${{ tooltipData?.actual_cost.toFixed(6) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip Arrow (left side) -->
|
||||||
|
<div
|
||||||
|
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -498,6 +608,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
|||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||||
@@ -514,6 +625,11 @@ import type {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Tooltip state
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||||
|
|
||||||
@@ -532,17 +648,23 @@ const granularityOptions = computed(() => [
|
|||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||||
|
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
||||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||||
|
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
||||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||||
{ key: 'created_at', label: t('usage.time'), sortable: true }
|
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||||
|
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
||||||
])
|
])
|
||||||
|
|
||||||
const usageLogs = ref<UsageLog[]>([])
|
const usageLogs = ref<UsageLog[]>([])
|
||||||
const apiKeys = ref<SimpleApiKey[]>([])
|
const apiKeys = ref<SimpleApiKey[]>([])
|
||||||
|
const models = ref<string[]>([])
|
||||||
|
const accounts = ref<any[]>([])
|
||||||
|
const groups = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// User search state
|
// User search state
|
||||||
@@ -564,6 +686,53 @@ const apiKeyOptions = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Model options
|
||||||
|
const modelOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ value: null, label: t('admin.usage.allModels') },
|
||||||
|
...models.value.map((model) => ({
|
||||||
|
value: model,
|
||||||
|
label: model
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Account options
|
||||||
|
const accountOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ value: null, label: t('admin.usage.allAccounts') },
|
||||||
|
...accounts.value.map((account) => ({
|
||||||
|
value: account.id,
|
||||||
|
label: account.name
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream type options
|
||||||
|
const streamOptions = computed(() => [
|
||||||
|
{ value: null, label: t('admin.usage.allTypes') },
|
||||||
|
{ value: true, label: t('usage.stream') },
|
||||||
|
{ value: false, label: t('usage.sync') }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Billing type options
|
||||||
|
const billingTypeOptions = computed(() => [
|
||||||
|
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||||
|
{ value: 0, label: t('usage.balance') },
|
||||||
|
{ value: 1, label: t('usage.subscription') }
|
||||||
|
])
|
||||||
|
|
||||||
|
// Group options
|
||||||
|
const groupOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ value: null, label: t('admin.usage.allGroups') },
|
||||||
|
...groups.value.map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
// Date range state
|
// Date range state
|
||||||
const startDate = ref('')
|
const startDate = ref('')
|
||||||
const endDate = ref('')
|
const endDate = ref('')
|
||||||
@@ -571,6 +740,11 @@ const endDate = ref('')
|
|||||||
const filters = ref<AdminUsageQueryParams>({
|
const filters = ref<AdminUsageQueryParams>({
|
||||||
user_id: undefined,
|
user_id: undefined,
|
||||||
api_key_id: undefined,
|
api_key_id: undefined,
|
||||||
|
account_id: undefined,
|
||||||
|
group_id: undefined,
|
||||||
|
model: undefined,
|
||||||
|
stream: undefined,
|
||||||
|
billing_type: undefined,
|
||||||
start_date: undefined,
|
start_date: undefined,
|
||||||
end_date: undefined
|
end_date: undefined
|
||||||
})
|
})
|
||||||
@@ -689,17 +863,6 @@ const formatCacheTokens = (value: number): string => {
|
|||||||
return value.toLocaleString()
|
return value.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDateTime = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadUsageLogs = async () => {
|
const loadUsageLogs = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -713,6 +876,9 @@ const loadUsageLogs = async () => {
|
|||||||
usageLogs.value = response.items
|
usageLogs.value = response.items
|
||||||
pagination.value.total = response.total
|
pagination.value.total = response.total
|
||||||
pagination.value.pages = response.pages
|
pagination.value.pages = response.pages
|
||||||
|
|
||||||
|
// Extract models from loaded logs for filter options
|
||||||
|
extractModelsFromLogs()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appStore.showError(t('usage.failedToLoad'))
|
appStore.showError(t('usage.failedToLoad'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -775,6 +941,32 @@ const applyFilters = () => {
|
|||||||
loadChartData()
|
loadChartData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load filter options
|
||||||
|
const loadFilterOptions = async () => {
|
||||||
|
try {
|
||||||
|
// Load accounts
|
||||||
|
const accountsResponse = await adminAPI.accounts.list(1, 1000)
|
||||||
|
accounts.value = accountsResponse.items || []
|
||||||
|
|
||||||
|
// Load groups
|
||||||
|
const groupsResponse = await adminAPI.groups.list(1, 1000)
|
||||||
|
groups.value = groupsResponse.items || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load filter options:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract unique models from usage logs
|
||||||
|
const extractModelsFromLogs = () => {
|
||||||
|
const uniqueModels = new Set<string>()
|
||||||
|
usageLogs.value.forEach(log => {
|
||||||
|
if (log.model) {
|
||||||
|
uniqueModels.add(log.model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
models.value = Array.from(uniqueModels).sort()
|
||||||
|
}
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
selectedUser.value = null
|
selectedUser.value = null
|
||||||
userSearchKeyword.value = ''
|
userSearchKeyword.value = ''
|
||||||
@@ -783,6 +975,11 @@ const resetFilters = () => {
|
|||||||
filters.value = {
|
filters.value = {
|
||||||
user_id: undefined,
|
user_id: undefined,
|
||||||
api_key_id: undefined,
|
api_key_id: undefined,
|
||||||
|
account_id: undefined,
|
||||||
|
group_id: undefined,
|
||||||
|
model: undefined,
|
||||||
|
stream: undefined,
|
||||||
|
billing_type: undefined,
|
||||||
start_date: undefined,
|
start_date: undefined,
|
||||||
end_date: undefined
|
end_date: undefined
|
||||||
}
|
}
|
||||||
@@ -856,8 +1053,25 @@ const handleClickOutside = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltip functions
|
||||||
|
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
tooltipData.value = row
|
||||||
|
tooltipPosition.value.x = rect.right + 8
|
||||||
|
tooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltipVisible.value = false
|
||||||
|
tooltipData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
|
loadFilterOptions()
|
||||||
loadUsageLogs()
|
loadUsageLogs()
|
||||||
loadUsageStats()
|
loadUsageStats()
|
||||||
loadChartData()
|
loadChartData()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<!-- Page Header Actions -->
|
||||||
<div class="flex justify-end gap-3">
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadUsers"
|
@click="loadUsers"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -36,8 +37,10 @@
|
|||||||
{{ t('admin.users.createUser') }}
|
{{ t('admin.users.createUser') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
<!-- Search and Filters -->
|
||||||
|
<template #filters>
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="relative max-w-md flex-1">
|
<div class="relative max-w-md flex-1">
|
||||||
<svg
|
<svg
|
||||||
@@ -78,10 +81,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<div class="card overflow-hidden">
|
<template #table>
|
||||||
<DataTable :columns="columns" :data="users" :loading="loading">
|
<DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7">
|
||||||
<template #cell-email="{ value }">
|
<template #cell-email="{ value }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
:subscription-type="sub.group?.subscription_type"
|
:subscription-type="sub.group?.subscription_type"
|
||||||
:rate-multiplier="sub.group?.rate_multiplier"
|
:rate-multiplier="sub.group?.rate_multiplier"
|
||||||
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
|
:days-remaining="sub.expires_at ? getDaysRemaining(sub.expires_at) : null"
|
||||||
:title="sub.expires_at ? formatExpiresAt(sub.expires_at) : ''"
|
:title="sub.expires_at ? formatDateTime(sub.expires_at) : ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -191,27 +195,65 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-created_at="{ value }">
|
<template #cell-created_at="{ value }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span>
|
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row, expanded }">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<!-- Toggle Status (hidden for admin users) -->
|
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||||
|
<button
|
||||||
|
@click="handleEdit(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="row.role !== 'admin'"
|
v-if="row.role !== 'admin'"
|
||||||
@click="handleToggleStatus(row)"
|
@click="handleDelete(row)"
|
||||||
:class="[
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
'rounded-lg p-2 transition-colors',
|
|
||||||
row.status === 'active'
|
|
||||||
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
|
||||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
|
||||||
]"
|
|
||||||
:title="
|
|
||||||
row.status === 'active'
|
|
||||||
? t('admin.users.disableUser')
|
|
||||||
: t('admin.users.enableUser')
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 次要操作:展开时显示 -->
|
||||||
|
<template v-if="expanded">
|
||||||
|
<!-- Toggle Status (hidden for admin users) -->
|
||||||
|
<button
|
||||||
|
v-if="row.role !== 'admin'"
|
||||||
|
@click="handleToggleStatus(row)"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
|
||||||
|
row.status === 'active'
|
||||||
|
? 'text-gray-500 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
||||||
|
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -240,120 +282,81 @@
|
|||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
<span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
|
||||||
<!-- Allowed Groups -->
|
</button>
|
||||||
<button
|
<!-- Allowed Groups -->
|
||||||
@click="handleAllowedGroups(row)"
|
<button
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
@click="handleAllowedGroups(row)"
|
||||||
:title="t('admin.users.setAllowedGroups')"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="h-4 w-4"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
stroke-width="1.5"
|
||||||
</button>
|
>
|
||||||
<!-- View API Keys -->
|
<path
|
||||||
<button
|
stroke-linecap="round"
|
||||||
@click="handleViewApiKeys(row)"
|
stroke-linejoin="round"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||||
:title="t('admin.users.viewApiKeys')"
|
/>
|
||||||
>
|
</svg>
|
||||||
<svg
|
<span class="text-xs">{{ t('admin.users.groups') }}</span>
|
||||||
class="h-4 w-4"
|
</button>
|
||||||
fill="none"
|
<!-- View API Keys -->
|
||||||
stroke="currentColor"
|
<button
|
||||||
viewBox="0 0 24 24"
|
@click="handleViewApiKeys(row)"
|
||||||
stroke-width="1.5"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="h-4 w-4"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
stroke-width="1.5"
|
||||||
</button>
|
>
|
||||||
<!-- Deposit -->
|
<path
|
||||||
<button
|
stroke-linecap="round"
|
||||||
@click="handleDeposit(row)"
|
stroke-linejoin="round"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||||
:title="t('admin.users.deposit')"
|
/>
|
||||||
>
|
</svg>
|
||||||
<svg
|
<span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
|
||||||
class="h-4 w-4"
|
</button>
|
||||||
fill="none"
|
<!-- Deposit -->
|
||||||
stroke="currentColor"
|
<button
|
||||||
viewBox="0 0 24 24"
|
@click="handleDeposit(row)"
|
||||||
stroke-width="1.5"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<svg
|
||||||
</svg>
|
class="h-4 w-4"
|
||||||
</button>
|
fill="none"
|
||||||
<!-- Withdraw -->
|
stroke="currentColor"
|
||||||
<button
|
viewBox="0 0 24 24"
|
||||||
@click="handleWithdraw(row)"
|
stroke-width="1.5"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
>
|
||||||
:title="t('admin.users.withdraw')"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
>
|
</svg>
|
||||||
<svg
|
<span class="text-xs">{{ t('admin.users.deposit') }}</span>
|
||||||
class="h-4 w-4"
|
</button>
|
||||||
fill="none"
|
<!-- Withdraw -->
|
||||||
stroke="currentColor"
|
<button
|
||||||
viewBox="0 0 24 24"
|
@click="handleWithdraw(row)"
|
||||||
stroke-width="1.5"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
<svg
|
||||||
</svg>
|
class="h-4 w-4"
|
||||||
</button>
|
fill="none"
|
||||||
<!-- Edit -->
|
stroke="currentColor"
|
||||||
<button
|
viewBox="0 0 24 24"
|
||||||
@click="handleEdit(row)"
|
stroke-width="1.5"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
>
|
||||||
:title="t('common.edit')"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||||
>
|
</svg>
|
||||||
<svg
|
<span class="text-xs">{{ t('admin.users.withdraw') }}</span>
|
||||||
class="h-4 w-4"
|
</button>
|
||||||
fill="none"
|
</template>
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<!-- Delete (hidden for admin users) -->
|
|
||||||
<button
|
|
||||||
v-if="row.role !== 'admin'"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
|
||||||
:title="t('common.delete')"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -366,9 +369,10 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
@@ -376,7 +380,8 @@
|
|||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Create User Modal -->
|
<!-- Create User Modal -->
|
||||||
<Modal
|
<Modal
|
||||||
@@ -808,7 +813,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
>{{ t('admin.users.columns.created') }}: {{ formatDate(key.created_at) }}</span
|
>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1164,6 +1169,8 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
@@ -1171,6 +1178,7 @@ import type { User, ApiKey, Group } from '@/types'
|
|||||||
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
@@ -1180,6 +1188,7 @@ import Select from '@/components/common/Select.vue'
|
|||||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
|
{ key: 'email', label: t('admin.users.columns.user'), sortable: true },
|
||||||
@@ -1274,15 +1283,6 @@ const editForm = reactive({
|
|||||||
})
|
})
|
||||||
const editPasswordCopied = ref(false)
|
const editPasswordCopied = ref(false)
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算剩余天数
|
// 计算剩余天数
|
||||||
const getDaysRemaining = (expiresAt: string): number => {
|
const getDaysRemaining = (expiresAt: string): number => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -1291,12 +1291,6 @@ const getDaysRemaining = (expiresAt: string): number => {
|
|||||||
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
return Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化过期时间(用于 tooltip)
|
|
||||||
const formatExpiresAt = (expiresAt: string): string => {
|
|
||||||
const date = new Date(expiresAt)
|
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateRandomPasswordStr = () => {
|
const generateRandomPasswordStr = () => {
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||||
let password = ''
|
let password = ''
|
||||||
@@ -1316,27 +1310,23 @@ const generateEditPassword = () => {
|
|||||||
|
|
||||||
const copyPassword = async () => {
|
const copyPassword = async () => {
|
||||||
if (!createForm.password) return
|
if (!createForm.password) return
|
||||||
try {
|
const success = await clipboardCopy(createForm.password, t('admin.users.passwordCopied'))
|
||||||
await navigator.clipboard.writeText(createForm.password)
|
if (success) {
|
||||||
passwordCopied.value = true
|
passwordCopied.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
passwordCopied.value = false
|
passwordCopied.value = false
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
|
||||||
appStore.showError(t('common.copyFailed'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyEditPassword = async () => {
|
const copyEditPassword = async () => {
|
||||||
if (!editForm.password) return
|
if (!editForm.password) return
|
||||||
try {
|
const success = await clipboardCopy(editForm.password, t('admin.users.passwordCopied'))
|
||||||
await navigator.clipboard.writeText(editForm.password)
|
if (success) {
|
||||||
editPasswordCopied.value = true
|
editPasswordCopied.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
editPasswordCopied.value = false
|
editPasswordCopied.value = false
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
|
||||||
appStore.showError(t('common.copyFailed'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ t('auth.verifyYourEmail') }}
|
||||||
|
</h2>
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||||
We'll send a verification code to
|
We'll send a verification code to
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
|
||||||
@@ -32,8 +34,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-amber-700 dark:text-amber-400">
|
<div class="text-sm text-amber-700 dark:text-amber-400">
|
||||||
<p class="font-medium">Session expired</p>
|
<p class="font-medium">{{ t('auth.sessionExpired') }}</p>
|
||||||
<p class="mt-1">Please go back to the registration page and start again.</p>
|
<p class="mt-1">{{ t('auth.sessionExpiredDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +44,9 @@
|
|||||||
<form v-else @submit.prevent="handleVerify" class="space-y-5">
|
<form v-else @submit.prevent="handleVerify" class="space-y-5">
|
||||||
<!-- Verification Code Input -->
|
<!-- Verification Code Input -->
|
||||||
<div>
|
<div>
|
||||||
<label for="code" class="input-label text-center"> Verification Code </label>
|
<label for="code" class="input-label text-center">
|
||||||
|
{{ t('auth.verificationCode') }}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="code"
|
id="code"
|
||||||
v-model="verifyCode"
|
v-model="verifyCode"
|
||||||
@@ -59,7 +63,7 @@
|
|||||||
<p v-if="errors.code" class="input-error-text text-center">
|
<p v-if="errors.code" class="input-error-text text-center">
|
||||||
{{ errors.code }}
|
{{ errors.code }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="input-hint text-center">Enter the 6-digit code sent to your email</p>
|
<p v-else class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Code Status -->
|
<!-- Code Status -->
|
||||||
@@ -190,9 +194,11 @@
|
|||||||
"
|
"
|
||||||
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
|
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
>
|
>
|
||||||
<span v-if="isSendingCode">Sending...</span>
|
<span v-if="isSendingCode">{{ t('auth.sendingCode') }}</span>
|
||||||
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
|
<span v-else-if="turnstileEnabled && !showResendTurnstile">
|
||||||
<span v-else>Resend verification code</span>
|
{{ t('auth.clickToResend') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ t('auth.resendCode') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -226,11 +232,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
|
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// ==================== Router & Stores ====================
|
// ==================== Router & Stores ====================
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Code</label>
|
<label class="input-label">{{ t('auth.oauth.code') }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input class="input flex-1 font-mono text-sm" :value="code" readonly />
|
<input class="input flex-1 font-mono text-sm" :value="code" readonly />
|
||||||
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
|
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">State</label>
|
<label class="input-label">{{ t('auth.oauth.state') }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input class="input flex-1 font-mono text-sm" :value="state" readonly />
|
<input class="input flex-1 font-mono text-sm" :value="state" readonly />
|
||||||
<button
|
<button
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Full URL</label>
|
<label class="input-label">{{ t('auth.oauth.fullUrl') }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<input class="input flex-1 font-mono text-xs" :value="fullUrl" readonly />
|
<input class="input flex-1 font-mono text-xs" :value="fullUrl" readonly />
|
||||||
<button
|
<button
|
||||||
@@ -63,10 +63,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
const code = computed(() => (route.query.code as string) || '')
|
const code = computed(() => (route.query.code as string) || '')
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sub2API Setup</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ t('setup.title') }}</h1>
|
||||||
<p class="mt-2 text-gray-500 dark:text-dark-400">Configure your Sub2API instance</p>
|
<p class="mt-2 text-gray-500 dark:text-dark-400">{{ t('setup.description') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress Steps -->
|
<!-- Progress Steps -->
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<div v-if="currentStep === 0" class="space-y-6">
|
<div v-if="currentStep === 0" class="space-y-6">
|
||||||
<div class="mb-6 text-center">
|
<div class="mb-6 text-center">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Database Configuration
|
{{ t('setup.database.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Connect to your PostgreSQL database
|
Connect to your PostgreSQL database
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Host</label>
|
<label class="input-label">{{ t('setup.database.host') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.database.host"
|
v-model="formData.database.host"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Port</label>
|
<label class="input-label">{{ t('setup.database.port') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="formData.database.port"
|
v-model.number="formData.database.port"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Username</label>
|
<label class="input-label">{{ t('setup.database.username') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.database.user"
|
v-model="formData.database.user"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -123,19 +123,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Password</label>
|
<label class="input-label">{{ t('setup.database.password') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.database.password"
|
v-model="formData.database.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Password"
|
:placeholder="t('setup.database.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Database Name</label>
|
<label class="input-label">{{ t('setup.database.databaseName') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.database.dbname"
|
v-model="formData.database.dbname"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -144,12 +144,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">SSL Mode</label>
|
<label class="input-label">{{ t('setup.database.sslMode') }}</label>
|
||||||
<select v-model="formData.database.sslmode" class="input">
|
<select v-model="formData.database.sslmode" class="input">
|
||||||
<option value="disable">Disable</option>
|
<option value="disable">{{ t('setup.database.ssl.disable') }}</option>
|
||||||
<option value="require">Require</option>
|
<option value="require">{{ t('setup.database.ssl.require') }}</option>
|
||||||
<option value="verify-ca">Verify CA</option>
|
<option value="verify-ca">{{ t('setup.database.ssl.verifyCa') }}</option>
|
||||||
<option value="verify-full">Verify Full</option>
|
<option value="verify-full">{{ t('setup.database.ssl.verifyFull') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +198,9 @@
|
|||||||
<!-- Step 2: Redis -->
|
<!-- Step 2: Redis -->
|
||||||
<div v-if="currentStep === 1" class="space-y-6">
|
<div v-if="currentStep === 1" class="space-y-6">
|
||||||
<div class="mb-6 text-center">
|
<div class="mb-6 text-center">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Redis Configuration</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('setup.redis.title') }}
|
||||||
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Connect to your Redis server
|
Connect to your Redis server
|
||||||
</p>
|
</p>
|
||||||
@@ -206,7 +208,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Host</label>
|
<label class="input-label">{{ t('setup.redis.host') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.redis.host"
|
v-model="formData.redis.host"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -215,7 +217,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Port</label>
|
<label class="input-label">{{ t('setup.redis.port') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="formData.redis.port"
|
v-model.number="formData.redis.port"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -227,16 +229,16 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Password (optional)</label>
|
<label class="input-label">{{ t('setup.redis.password') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.redis.password"
|
v-model="formData.redis.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Password"
|
:placeholder="t('setup.redis.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Database</label>
|
<label class="input-label">{{ t('setup.redis.database') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="formData.redis.db"
|
v-model.number="formData.redis.db"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -294,14 +296,16 @@
|
|||||||
<!-- Step 3: Admin -->
|
<!-- Step 3: Admin -->
|
||||||
<div v-if="currentStep === 2" class="space-y-6">
|
<div v-if="currentStep === 2" class="space-y-6">
|
||||||
<div class="mb-6 text-center">
|
<div class="mb-6 text-center">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Admin Account</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('setup.admin.title') }}
|
||||||
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Create your administrator account
|
Create your administrator account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Email</label>
|
<label class="input-label">{{ t('setup.admin.email') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.admin.email"
|
v-model="formData.admin.email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -311,28 +315,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Password</label>
|
<label class="input-label">{{ t('setup.admin.password') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="formData.admin.password"
|
v-model="formData.admin.password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Min 6 characters"
|
:placeholder="t('setup.admin.passwordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">Confirm Password</label>
|
<label class="input-label">{{ t('setup.admin.confirmPassword') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Confirm password"
|
:placeholder="t('setup.admin.confirmPasswordPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-if="confirmPassword && formData.admin.password !== confirmPassword"
|
v-if="confirmPassword && formData.admin.password !== confirmPassword"
|
||||||
class="input-error-text"
|
class="input-error-text"
|
||||||
>
|
>
|
||||||
Passwords do not match
|
{{ t('setup.admin.passwordMismatch') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +344,9 @@
|
|||||||
<!-- Step 4: Complete -->
|
<!-- Step 4: Complete -->
|
||||||
<div v-if="currentStep === 3" class="space-y-6">
|
<div v-if="currentStep === 3" class="space-y-6">
|
||||||
<div class="mb-6 text-center">
|
<div class="mb-6 text-center">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Ready to Install</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('setup.ready.title') }}
|
||||||
|
</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||||
Review your configuration and complete setup
|
Review your configuration and complete setup
|
||||||
</p>
|
</p>
|
||||||
@@ -348,7 +354,9 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||||
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">Database</h3>
|
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('setup.ready.database') }}
|
||||||
|
</h3>
|
||||||
<p class="text-gray-900 dark:text-white">
|
<p class="text-gray-900 dark:text-white">
|
||||||
{{ formData.database.user }}@{{ formData.database.host }}:{{
|
{{ formData.database.user }}@{{ formData.database.host }}:{{
|
||||||
formData.database.port
|
formData.database.port
|
||||||
@@ -357,14 +365,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||||
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">Redis</h3>
|
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('setup.ready.redis') }}
|
||||||
|
</h3>
|
||||||
<p class="text-gray-900 dark:text-white">
|
<p class="text-gray-900 dark:text-white">
|
||||||
{{ formData.redis.host }}:{{ formData.redis.port }}
|
{{ formData.redis.host }}:{{ formData.redis.port }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||||
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">Admin Email</h3>
|
<h3 class="mb-2 text-sm font-medium text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('setup.ready.adminEmail') }}
|
||||||
|
</h3>
|
||||||
<p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p>
|
<p class="text-gray-900 dark:text-white">{{ formData.admin.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,8 +538,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ id: 'database', title: 'Database' },
|
{ id: 'database', title: 'Database' },
|
||||||
{ id: 'redis', title: 'Redis' },
|
{ id: 'redis', title: 'Redis' },
|
||||||
|
|||||||
@@ -452,16 +452,16 @@
|
|||||||
{{ log.model }}
|
{{ log.model }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
{{ formatDate(log.created_at) }}
|
{{ formatDateTime(log.created_at) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
<span class="text-green-600 dark:text-green-400" title="实际扣除"
|
<span class="text-green-600 dark:text-green-400" :title="t('dashboard.actual')"
|
||||||
>${{ formatCost(log.actual_cost) }}</span
|
>${{ formatCost(log.actual_cost) }}</span
|
||||||
>
|
>
|
||||||
<span class="font-normal text-gray-400 dark:text-gray-500" title="标准计费">
|
<span class="font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')">
|
||||||
/ ${{ formatCost(log.total_cost) }}</span
|
/ ${{ formatCost(log.total_cost) }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
@@ -649,6 +649,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { usageAPI, type UserDashboardStats } from '@/api/usage'
|
import { usageAPI, type UserDashboardStats } from '@/api/usage'
|
||||||
@@ -914,16 +915,6 @@ const formatDuration = (ms: number): string => {
|
|||||||
return `${Math.round(ms)}ms`
|
return `${Math.round(ms)}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateTo = (path: string) => {
|
const navigateTo = (path: string) => {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Page Header Actions -->
|
<template #actions>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@click="loadApiKeys"
|
@click="loadApiKeys"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@@ -36,9 +36,9 @@
|
|||||||
{{ t('keys.createKey') }}
|
{{ t('keys.createKey') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- API Keys Table -->
|
<template #table>
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="apiKeys" :loading="loading">
|
<DataTable :columns="columns" :data="apiKeys" :loading="loading">
|
||||||
<template #cell-key="{ value, row }">
|
<template #cell-key="{ value, row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-created_at="{ value }">
|
<template #cell-created_at="{ value }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span>
|
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
@@ -154,8 +154,7 @@
|
|||||||
<!-- Use Key Button -->
|
<!-- Use Key Button -->
|
||||||
<button
|
<button
|
||||||
@click="openUseKeyModal(row)"
|
@click="openUseKeyModal(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||||
:title="t('keys.useKey')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -170,12 +169,12 @@
|
|||||||
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
|
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('keys.useKey') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Import to CC Switch Button -->
|
<!-- Import to CC Switch Button -->
|
||||||
<button
|
<button
|
||||||
@click="importToCcswitch(row.key)"
|
@click="importToCcswitch(row.key)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
:title="t('keys.importToCcSwitch')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -190,17 +189,17 @@
|
|||||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('keys.importToCcSwitch') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Toggle Status Button -->
|
<!-- Toggle Status Button -->
|
||||||
<button
|
<button
|
||||||
@click="toggleKeyStatus(row)"
|
@click="toggleKeyStatus(row)"
|
||||||
:class="[
|
:class="[
|
||||||
'rounded-lg p-2 transition-colors',
|
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
|
||||||
row.status === 'active'
|
row.status === 'active'
|
||||||
? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400'
|
? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400'
|
||||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||||
]"
|
]"
|
||||||
:title="row.status === 'active' ? t('keys.disable') : t('keys.enable')"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active'"
|
||||||
@@ -230,12 +229,12 @@
|
|||||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ row.status === 'active' ? t('keys.disable') : t('keys.enable') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Edit Button -->
|
<!-- Edit Button -->
|
||||||
<button
|
<button
|
||||||
@click="editKey(row)"
|
@click="editKey(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||||
title="Edit"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -250,12 +249,12 @@
|
|||||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Delete Button -->
|
<!-- Delete Button -->
|
||||||
<button
|
<button
|
||||||
@click="confirmDelete(row)"
|
@click="confirmDelete(row)"
|
||||||
class="rounded-lg p-2 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||||
title="Delete"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -270,6 +269,7 @@
|
|||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -283,17 +283,18 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
:total="pagination.total"
|
:total="pagination.total"
|
||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<Modal
|
<Modal
|
||||||
@@ -492,10 +493,12 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
@@ -507,6 +510,7 @@ import GroupBadge from '@/components/common/GroupBadge.vue'
|
|||||||
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
|
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
interface GroupOption {
|
interface GroupOption {
|
||||||
value: number
|
value: number
|
||||||
@@ -517,6 +521,7 @@ interface GroupOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
{ key: 'name', label: t('common.name'), sortable: true },
|
{ key: 'name', label: t('common.name'), sortable: true },
|
||||||
@@ -613,26 +618,15 @@ const maskKey = (key: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, keyId: number) => {
|
const copyToClipboard = async (text: string, keyId: number) => {
|
||||||
try {
|
const success = await clipboardCopy(text, t('keys.copied'))
|
||||||
await navigator.clipboard.writeText(text)
|
if (success) {
|
||||||
copiedKeyId.value = keyId
|
copiedKeyId.value = keyId
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copiedKeyId.value = null
|
copiedKeyId.value = null
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
|
||||||
appStore.showError(t('common.copyFailed'))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadApiKeys = async () => {
|
const loadApiKeys = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
:title="t('profile.memberSince')"
|
:title="t('profile.memberSince')"
|
||||||
:value="formatMemberSince(user?.created_at || '')"
|
:value="formatDate(user?.created_at || '', 'YYYY-MM')"
|
||||||
:icon="CalendarIcon"
|
:icon="CalendarIcon"
|
||||||
icon-variant="primary"
|
icon-variant="primary"
|
||||||
/>
|
/>
|
||||||
@@ -267,6 +267,7 @@ import { ref, computed, h, onMounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { formatDate } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { userAPI, authAPI } from '@/api'
|
import { userAPI, authAPI } from '@/api'
|
||||||
@@ -358,15 +359,6 @@ const formatCurrency = (value: number): string => {
|
|||||||
return `$${value.toFixed(2)}`
|
return `$${value.toFixed(2)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatMemberSince = (dateString: string): string => {
|
|
||||||
if (!dateString) return 'N/A'
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePassword = async () => {
|
||||||
// Validate password match
|
// Validate password match
|
||||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||||
|
|||||||
@@ -377,7 +377,7 @@
|
|||||||
{{ getHistoryItemTitle(item) }}
|
{{ getHistoryItemTitle(item) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
{{ formatDate(item.used_at) }}
|
{{ formatDateTime(item.used_at) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -447,6 +447,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
|
import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -472,18 +473,6 @@ const history = ref<RedeemHistoryItem[]>([])
|
|||||||
const loadingHistory = ref(false)
|
const loadingHistory = ref(false)
|
||||||
const contactInfo = ref('')
|
const contactInfo = ref('')
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return '-'
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for history display
|
// Helper functions for history display
|
||||||
const isBalanceType = (type: string) => {
|
const isBalanceType = (type: string) => {
|
||||||
return type === 'balance' || type === 'admin_balance'
|
return type === 'balance' || type === 'admin_balance'
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import subscriptionsAPI from '@/api/subscriptions'
|
import subscriptionsAPI from '@/api/subscriptions'
|
||||||
import type { UserSubscription } from '@/types'
|
import type { UserSubscription } from '@/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import { formatDateOnly } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -300,11 +301,7 @@ function formatExpirationDate(expiresAt: string): string {
|
|||||||
return t('userSubscriptions.status.expired')
|
return t('userSubscriptions.status.expired')
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateStr = expires.toLocaleDateString(undefined, {
|
const dateStr = formatDateOnly(expires)
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (days === 0) {
|
if (days === 0) {
|
||||||
return `${dateStr} (Today)`
|
return `${dateStr} (Today)`
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="space-y-6">
|
<TablePageLayout>
|
||||||
<!-- Summary Stats Cards -->
|
<template #actions>
|
||||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<!-- Total Requests -->
|
<!-- Total Requests -->
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||||
<svg
|
<svg
|
||||||
@@ -131,11 +131,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Filters -->
|
<template #filters>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
<!-- API Key Filter -->
|
<!-- API Key Filter -->
|
||||||
<div class="min-w-[180px]">
|
<div class="min-w-[180px]">
|
||||||
@@ -169,11 +170,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Usage Table -->
|
<template #table>
|
||||||
<div class="card overflow-hidden">
|
|
||||||
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
<DataTable :columns="columns" :data="usageLogs" :loading="loading">
|
||||||
|
<template #cell-api_key="{ row }">
|
||||||
|
<span class="text-sm text-gray-900 dark:text-white">{{
|
||||||
|
row.api_key?.name || '-'
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-model="{ value }">
|
<template #cell-model="{ value }">
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -287,7 +294,11 @@
|
|||||||
${{ row.actual_cost.toFixed(6) }}
|
${{ row.actual_cost.toFixed(6) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Cost Detail Tooltip -->
|
<!-- Cost Detail Tooltip -->
|
||||||
<div class="group relative">
|
<div
|
||||||
|
class="group relative"
|
||||||
|
@mouseenter="showTooltip($event, row)"
|
||||||
|
@mouseleave="hideTooltip"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50"
|
||||||
>
|
>
|
||||||
@@ -303,39 +314,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tooltip Content (right side) -->
|
|
||||||
<div
|
|
||||||
class="invisible absolute left-full top-1/2 z-[100] ml-2 -translate-y-1/2 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<div class="flex items-center justify-between gap-6">
|
|
||||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
|
||||||
<span class="font-semibold text-blue-400"
|
|
||||||
>{{ (row.rate_multiplier || 1).toFixed(2) }}x</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-6">
|
|
||||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
|
||||||
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
|
|
||||||
>
|
|
||||||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
|
||||||
<span class="font-semibold text-green-400"
|
|
||||||
>${{ row.actual_cost.toFixed(6) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Tooltip Arrow (left side) -->
|
|
||||||
<div
|
|
||||||
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -379,18 +357,58 @@
|
|||||||
<EmptyState :message="t('usage.noRecords')" />
|
<EmptyState :message="t('usage.noRecords')" />
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<template #pagination>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
:total="pagination.total"
|
:total="pagination.total"
|
||||||
:page-size="pagination.page_size"
|
:page-size="pagination.page_size"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</TablePageLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
||||||
|
<!-- Tooltip Portal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tooltipVisible"
|
||||||
|
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
|
||||||
|
:style="{
|
||||||
|
left: tooltipPosition.x + 'px',
|
||||||
|
top: tooltipPosition.y + 'px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400"
|
||||||
|
>{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||||
|
<span class="font-medium text-white">${{ tooltipData?.total_cost.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||||
|
<span class="font-semibold text-green-400"
|
||||||
|
>${{ tooltipData?.actual_cost.toFixed(6) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip Arrow (left side) -->
|
||||||
|
<div
|
||||||
|
class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -399,6 +417,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { usageAPI, keysAPI } from '@/api'
|
import { usageAPI, keysAPI } from '@/api'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
import Pagination from '@/components/common/Pagination.vue'
|
import Pagination from '@/components/common/Pagination.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
@@ -406,14 +425,21 @@ import Select from '@/components/common/Select.vue'
|
|||||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||||
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
// Tooltip state
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||||
|
const tooltipData = ref<UsageLog | null>(null)
|
||||||
|
|
||||||
// Usage stats from API
|
// Usage stats from API
|
||||||
const usageStats = ref<UsageStatsResponse | null>(null)
|
const usageStats = ref<UsageStatsResponse | null>(null)
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
|
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||||
@@ -505,17 +531,6 @@ const formatCacheTokens = (value: number): string => {
|
|||||||
return value.toLocaleString()
|
return value.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDateTime = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadUsageLogs = async () => {
|
const loadUsageLogs = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -629,6 +644,23 @@ const exportToCSV = () => {
|
|||||||
appStore.showSuccess(t('usage.exportSuccess'))
|
appStore.showSuccess(t('usage.exportSuccess'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltip functions
|
||||||
|
const showTooltip = (event: MouseEvent, row: UsageLog) => {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
tooltipData.value = row
|
||||||
|
// Position to the right of the icon, vertically centered
|
||||||
|
tooltipPosition.value.x = rect.right + 8
|
||||||
|
tooltipPosition.value.y = rect.top + rect.height / 2
|
||||||
|
tooltipVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
tooltipVisible.value = false
|
||||||
|
tooltipData.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeDateRange()
|
initializeDateRange()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
frontend/vite.config.d.ts
vendored
2
frontend/vite.config.d.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
declare const _default: import('vite').UserConfig
|
|
||||||
export default _default
|
|
||||||
Reference in New Issue
Block a user