Unverified Commit ff8b1b4a authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #467 from slovx2/main

Antigravity 相关BUG修复及调度优化
parents c0c9c984 4cce21b1
......@@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
......@@ -476,9 +477,13 @@ func (s *OpsService) executeClientRetry(ctx context.Context, reqType opsRetryReq
continue
}
attemptCtx := ctx
if switches > 0 {
attemptCtx = context.WithValue(attemptCtx, ctxkey.AccountSwitchCount, switches)
}
exec := func() *opsRetryExecution {
defer selection.ReleaseFunc()
return s.executeWithAccount(ctx, reqType, errorLog, body, account)
return s.executeWithAccount(attemptCtx, reqType, errorLog, body, account)
}()
if exec != nil {
......
......@@ -6,6 +6,7 @@ type OpsThroughputTrendPoint struct {
BucketStart time.Time `json:"bucket_start"`
RequestCount int64 `json:"request_count"`
TokenConsumed int64 `json:"token_consumed"`
SwitchCount int64 `json:"switch_count"`
QPS float64 `json:"qps"`
TPS float64 `json:"tps"`
}
......
......@@ -39,7 +39,7 @@ type UserRepository interface {
ExistsByEmail(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// TOTP 相关方法
// TOTP 双因素认证
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
EnableTotp(ctx context.Context, userID int64) error
DisableTotp(ctx context.Context, userID int64) error
......
-- ops_system_metrics 增加账号切换次数统计(按分钟窗口)
ALTER TABLE ops_system_metrics
ADD COLUMN IF NOT EXISTS account_switch_count BIGINT NOT NULL DEFAULT 0;
-- 043_add_group_invalid_request_fallback.sql
-- 添加无效请求兜底分组配置
-- 添加 fallback_group_id_on_invalid_request 字段:无效请求兜底使用的分组
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS fallback_group_id_on_invalid_request BIGINT REFERENCES groups(id) ON DELETE SET NULL;
-- 添加索引优化查询
CREATE INDEX IF NOT EXISTS idx_groups_fallback_group_id_on_invalid_request
ON groups(fallback_group_id_on_invalid_request) WHERE deleted_at IS NULL AND fallback_group_id_on_invalid_request IS NOT NULL;
-- 添加字段注释
COMMENT ON COLUMN groups.fallback_group_id_on_invalid_request IS '无效请求兜底使用的分组 ID';
-- Add mcp_xml_inject field to groups table (for antigravity platform)
ALTER TABLE groups ADD COLUMN mcp_xml_inject BOOLEAN NOT NULL DEFAULT true;
-- 添加分组支持的模型系列字段
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS supported_model_scopes JSONB NOT NULL
DEFAULT '["claude", "gemini_text", "gemini_image"]'::jsonb;
COMMENT ON COLUMN groups.supported_model_scopes IS '支持的模型系列:claude, gemini_text, gemini_image';
-- 修正 schema_migrations 中“本地改名”的迁移文件名
-- 适用场景:你已执行过旧文件名的迁移,合并后仅改了自己这边的文件名
BEGIN;
UPDATE schema_migrations
SET filename = '042b_add_ops_system_metrics_switch_count.sql'
WHERE filename = '042_add_ops_system_metrics_switch_count.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '042b_add_ops_system_metrics_switch_count.sql'
);
UPDATE schema_migrations
SET filename = '043b_add_group_invalid_request_fallback.sql'
WHERE filename = '043_add_group_invalid_request_fallback.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '043b_add_group_invalid_request_fallback.sql'
);
UPDATE schema_migrations
SET filename = '044b_add_group_mcp_xml_inject.sql'
WHERE filename = '044_add_group_mcp_xml_inject.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '044b_add_group_mcp_xml_inject.sql'
);
UPDATE schema_migrations
SET filename = '046b_add_group_supported_model_scopes.sql'
WHERE filename = '046_add_group_supported_model_scopes.sql'
AND NOT EXISTS (
SELECT 1 FROM schema_migrations WHERE filename = '046b_add_group_supported_model_scopes.sql'
);
COMMIT;
......@@ -136,6 +136,7 @@ export interface OpsThroughputTrendPoint {
bucket_start: string
request_count: number
token_consumed: number
switch_count?: number
qps: number
tps: number
}
......@@ -284,6 +285,7 @@ export interface OpsSystemMetricsSnapshot {
goroutine_count?: number | null
concurrency_queue_depth?: number | null
account_switch_count?: number | null
}
export interface OpsJobHeartbeat {
......
......@@ -56,7 +56,6 @@
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="group relative">
<span
......
......@@ -614,21 +614,87 @@
</div>
</div>
<!-- Account Type Selection (Antigravity - OAuth only) -->
<!-- Account Type Selection (Antigravity - OAuth or Upstream) -->
<div v-if="form.platform === 'antigravity'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2">
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
@click="antigravityAccountType = 'oauth'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
antigravityAccountType === 'oauth'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
antigravityAccountType === 'oauth'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-purple-500 text-white">
<Icon name="key" size="sm" />
</div>
<div>
<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">{{ t('admin.accounts.types.antigravityOauth') }}</span>
</div>
</button>
<button
type="button"
@click="antigravityAccountType = 'upstream'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
antigravityAccountType === 'upstream'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
antigravityAccountType === 'upstream'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.types.upstream') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.upstreamDesc') }}</span>
</div>
</button>
</div>
</div>
<!-- Upstream config (only for Antigravity upstream type) -->
<div v-if="form.platform === 'antigravity' && antigravityAccountType === 'upstream'" class="space-y-4">
<div>
<label class="input-label">{{ t('admin.accounts.upstream.baseUrl') }}</label>
<input
v-model="upstreamBaseUrl"
type="text"
required
class="input"
placeholder="https://s.konstants.xyz"
/>
<p class="input-hint">{{ t('admin.accounts.upstream.baseUrlHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.upstream.apiKey') }}</label>
<input
v-model="upstreamApiKey"
type="password"
required
class="input font-mono"
placeholder="sk-..."
/>
<p class="input-hint">{{ t('admin.accounts.upstream.apiKeyHint') }}</p>
</div>
</div>
......@@ -1953,6 +2019,9 @@ const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
const upstreamBaseUrl = ref('') // For upstream type: base URL
const upstreamApiKey = ref('') // For upstream type: API key
const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
......@@ -2055,7 +2124,13 @@ const form = reactive({
})
// Helper to check if current type needs OAuth flow
const isOAuthFlow = computed(() => accountCategory.value === 'oauth-based')
const isOAuthFlow = computed(() => {
// Antigravity upstream 类型不需要 OAuth 流程
if (form.platform === 'antigravity' && antigravityAccountType.value === 'upstream') {
return false
}
return accountCategory.value === 'oauth-based'
})
const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual'
......@@ -2095,10 +2170,15 @@ watch(
}
)
// Sync form.type based on accountCategory and addMethod
// Sync form.type based on accountCategory, addMethod, and antigravityAccountType
watch(
[accountCategory, addMethod],
([category, method]) => {
[accountCategory, addMethod, antigravityAccountType],
([category, method, agType]) => {
// Antigravity upstream 类型
if (form.platform === 'antigravity' && agType === 'upstream') {
form.type = 'upstream'
return
}
if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
......@@ -2126,9 +2206,10 @@ watch(
if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false
}
// Antigravity only supports OAuth
// Antigravity: reset to OAuth by default, but allow upstream selection
if (newPlatform === 'antigravity') {
accountCategory.value = 'oauth-based'
antigravityAccountType.value = 'oauth'
}
// Reset OAuth states
oauth.resetState()
......@@ -2361,6 +2442,9 @@ const resetForm = () => {
sessionIdleTimeout.value = null
tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false
antigravityAccountType.value = 'oauth'
upstreamBaseUrl.value = ''
upstreamApiKey.value = ''
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
......@@ -2442,6 +2526,36 @@ const handleSubmit = async () => {
return
}
// For Antigravity upstream type, create directly
if (form.platform === 'antigravity' && antigravityAccountType.value === 'upstream') {
if (!form.name.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
return
}
if (!upstreamBaseUrl.value.trim()) {
appStore.showError(t('admin.accounts.upstream.pleaseEnterBaseUrl'))
return
}
if (!upstreamApiKey.value.trim()) {
appStore.showError(t('admin.accounts.upstream.pleaseEnterApiKey'))
return
}
submitting.value = true
try {
const credentials: Record<string, unknown> = {
base_url: upstreamBaseUrl.value.trim(),
api_key: upstreamApiKey.value.trim()
}
await createAccountAndFinish(form.platform, 'upstream', credentials)
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate'))
} finally {
submitting.value = false
}
return
}
// For apikey type, create directly
if (!apiKeyValue.value.trim()) {
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
......
......@@ -238,14 +238,14 @@
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types'
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading'])
const { t } = useI18n()
......
......@@ -1054,6 +1054,11 @@ export default {
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)'
},
invalidRequestFallback: {
title: 'Invalid Request Fallback Group',
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
noFallback: 'No Fallback'
},
copyAccounts: {
title: 'Copy Accounts from Groups',
tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).',
......@@ -1081,6 +1086,20 @@ export default {
noRulesHint: 'Add routing rules to route specific model requests to designated accounts',
searchAccountPlaceholder: 'Search accounts...',
accountsHint: 'Select accounts to prioritize for this model pattern'
},
mcpXml: {
title: 'MCP XML Protocol Injection',
tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.',
enabled: 'Enabled',
disabled: 'Disabled'
},
supportedScopes: {
title: 'Supported Model Families',
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
claude: 'Claude',
geminiText: 'Gemini Text',
geminiImage: 'Gemini Image',
hint: 'Select at least one model family'
}
},
......@@ -1220,7 +1239,9 @@ export default {
responsesApi: 'Responses API',
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth'
antigravityOauth: 'Antigravity OAuth',
upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key'
},
status: {
active: 'Active',
......@@ -1480,6 +1501,15 @@ export default {
pleaseEnterApiKey: 'Please enter API Key',
apiKeyIsRequired: 'API Key is required',
leaveEmptyToKeep: 'Leave empty to keep current key',
// Upstream type
upstream: {
baseUrl: 'Upstream Base URL',
baseUrlHint: 'The address of the upstream Antigravity service, e.g., https://s.konstants.xyz',
apiKey: 'Upstream API Key',
apiKeyHint: 'API Key for the upstream service',
pleaseEnterBaseUrl: 'Please enter upstream Base URL',
pleaseEnterApiKey: 'Please enter upstream API Key'
},
// OAuth flow
oauth: {
title: 'Claude Account Authorization',
......@@ -2230,6 +2260,7 @@ export default {
waiting: 'waiting',
conns: 'conns',
queue: 'queue',
accountSwitches: 'Account switches',
ok: 'ok',
lastRun: 'last_run:',
lastSuccess: 'last_success:',
......@@ -2278,6 +2309,7 @@ export default {
failedToLoadData: 'Failed to load ops data.',
failedToLoadOverview: 'Failed to load overview',
failedToLoadThroughputTrend: 'Failed to load throughput trend',
failedToLoadSwitchTrend: 'Failed to load avg account switches trend',
failedToLoadLatencyHistogram: 'Failed to load request duration histogram',
failedToLoadErrorTrend: 'Failed to load error trend',
failedToLoadErrorDistribution: 'Failed to load error distribution',
......@@ -2286,9 +2318,11 @@ export default {
tpsK: 'TPS (K)',
top: 'Top:',
throughputTrend: 'Throughput Trend',
switchRateTrend: 'Avg Account Switches',
latencyHistogram: 'Request Duration Histogram',
errorTrend: 'Error Trend',
errorDistribution: 'Error Distribution',
switchRate: 'Avg switches',
// Health Score & Diagnosis
health: 'Health',
healthCondition: 'Health Condition',
......@@ -2911,6 +2945,7 @@ export default {
tooltips: {
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
switchRateTrend: 'Trend of account switches / total requests over the last 5 hours (avg switches).',
latencyHistogram: 'Request duration distribution (ms) for successful requests.',
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
errorDistribution: 'Error distribution by status code.',
......
......@@ -1139,6 +1139,11 @@ export default {
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)'
},
invalidRequestFallback: {
title: '无效请求兜底分组',
hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底',
noFallback: '不兜底'
},
copyAccounts: {
title: '从分组复制账号',
tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。',
......@@ -1167,6 +1172,20 @@ export default {
noRulesHint: '添加路由规则以将特定模型请求优先路由到指定账号',
searchAccountPlaceholder: '搜索账号...',
accountsHint: '选择此模型模式优先使用的账号'
},
mcpXml: {
title: 'MCP XML 协议注入',
tooltip: '启用后,当请求包含 MCP 工具时,会在 system prompt 中注入 XML 格式调用协议提示词。关闭此选项可避免对某些客户端造成干扰。',
enabled: '已启用',
disabled: '已禁用'
},
supportedScopes: {
title: '支持的模型系列',
tooltip: '选择此分组支持的模型系列。未勾选的系列将不会被路由到此分组。',
claude: 'Claude',
geminiText: 'Gemini Text',
geminiImage: 'Gemini Image',
hint: '至少选择一个模型系列'
}
},
......@@ -1352,6 +1371,8 @@ export default {
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
upstream: '对接上游',
upstreamDesc: '通过 Base URL + API Key 连接上游',
api_key: 'API Key',
cookie: 'Cookie'
},
......@@ -1623,6 +1644,15 @@ export default {
pleaseEnterApiKey: '请输入 API Key',
apiKeyIsRequired: 'API Key 是必需的',
leaveEmptyToKeep: '留空以保持当前密钥',
// Upstream type
upstream: {
baseUrl: '上游 Base URL',
baseUrlHint: '上游 Antigravity 服务的地址,例如:https://s.konstants.xyz',
apiKey: '上游 API Key',
apiKeyHint: '上游服务的 API Key',
pleaseEnterBaseUrl: '请输入上游 Base URL',
pleaseEnterApiKey: '请输入上游 API Key'
},
// OAuth flow
oauth: {
title: 'Claude 账号授权',
......@@ -2393,6 +2423,7 @@ export default {
waiting: '等待',
conns: '连接',
queue: '队列',
accountSwitches: '账号切换',
ok: '正常',
lastRun: '最近运行',
lastSuccess: '最近成功',
......@@ -2442,6 +2473,7 @@ export default {
failedToLoadData: '加载运维数据失败',
failedToLoadOverview: '加载概览数据失败',
failedToLoadThroughputTrend: '加载吞吐趋势失败',
failedToLoadSwitchTrend: '加载平均账号切换趋势失败',
failedToLoadLatencyHistogram: '加载请求时长分布失败',
failedToLoadErrorTrend: '加载错误趋势失败',
failedToLoadErrorDistribution: '加载错误分布失败',
......@@ -2450,9 +2482,11 @@ export default {
tpsK: 'TPS(千)',
top: '最高:',
throughputTrend: '吞吐趋势',
switchRateTrend: '平均账号切换趋势',
latencyHistogram: '请求时长分布',
errorTrend: '错误趋势',
errorDistribution: '错误分布',
switchRate: '平均账号切换',
// Health Score & Diagnosis
health: '健康',
healthCondition: '健康状况',
......@@ -3081,6 +3115,7 @@ export default {
tooltips: {
totalRequests: '当前时间窗口内的总请求数和Token消耗量。',
throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。',
switchRateTrend: '近5小时内账号切换次数 / 请求总数的趋势(平均切换次数)。',
latencyHistogram: '成功请求的请求时长分布(毫秒)。',
errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。',
errorDistribution: '按状态码统计的错误分布。',
......
......@@ -355,6 +355,7 @@ export interface Group {
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
fallback_group_id_on_invalid_request: number | null
created_at: string
updated_at: string
}
......@@ -364,6 +365,12 @@ export interface AdminGroup extends Group {
model_routing: Record<string, number[]> | null
model_routing_enabled: boolean
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[]
// 分组下账号数量(仅管理员可见)
account_count?: number
}
......@@ -421,6 +428,9 @@ export interface CreateGroupRequest {
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
supported_model_scopes?: string[]
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
}
......@@ -441,13 +451,16 @@ export interface UpdateGroupRequest {
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
supported_model_scopes?: string[]
copy_accounts_from_group_ids?: number[]
}
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
......
......@@ -468,6 +468,107 @@
</div>
</div>
<!-- 支持的模型系列 antigravity 平台 -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.supportedScopes.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.supportedScopes.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('claude')"
@change="toggleCreateScope('claude')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.claude') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('gemini_text')"
@change="toggleCreateScope('gemini_text')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiText') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="createForm.supported_model_scopes.includes('gemini_image')"
@change="toggleCreateScope('gemini_image')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiImage') }}</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.groups.supportedScopes.hint') }}</p>
</div>
<!-- MCP XML 协议注入 antigravity 平台 -->
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.mcpXml.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.mcpXml.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.mcp_xml_inject = !createForm.mcp_xml_inject"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.mcp_xml_inject ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.mcp_xml_inject ? t('admin.groups.mcpXml.enabled') : t('admin.groups.mcpXml.disabled') }}
</span>
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
......@@ -524,6 +625,20 @@
</div>
</div>
<!-- 无效请求兜底 anthropic/antigravity 平台且非订阅分组 -->
<div
v-if="['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'"
class="border-t pt-4"
>
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label>
<Select
v-model="createForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptions"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
......@@ -975,6 +1090,107 @@
</div>
</div>
<!-- 支持的模型系列 antigravity 平台 -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.supportedScopes.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.supportedScopes.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('claude')"
@change="toggleEditScope('claude')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.claude') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('gemini_text')"
@change="toggleEditScope('gemini_text')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiText') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="editForm.supported_model_scopes.includes('gemini_image')"
@change="toggleEditScope('gemini_image')"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.groups.supportedScopes.geminiImage') }}</span>
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.groups.supportedScopes.hint') }}</p>
</div>
<!-- MCP XML 协议注入 antigravity 平台 -->
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.mcpXml.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.mcpXml.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.mcp_xml_inject = !editForm.mcp_xml_inject"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.mcp_xml_inject ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.mcp_xml_inject ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.mcp_xml_inject ? t('admin.groups.mcpXml.enabled') : t('admin.groups.mcpXml.disabled') }}
</span>
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
......@@ -1031,6 +1247,20 @@
</div>
</div>
<!-- 无效请求兜底 anthropic/antigravity 平台且非订阅分组 -->
<div
v-if="['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'"
class="border-t pt-4"
>
<label class="input-label">{{ t('admin.groups.invalidRequestFallback.title') }}</label>
<Select
v-model="editForm.fallback_group_id_on_invalid_request"
:options="invalidRequestFallbackOptionsForEdit"
:placeholder="t('admin.groups.invalidRequestFallback.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.invalidRequestFallback.hint') }}</p>
</div>
<!-- 模型路由配置 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
......@@ -1329,6 +1559,44 @@ const fallbackGroupOptionsForEdit = computed(() => {
return options
})
// 无效请求兜底分组选项(创建时)- 仅包含 anthropic 平台、非订阅且未配置兜底的分组
const invalidRequestFallbackOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') }
]
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === 'anthropic' &&
g.status === 'active' &&
g.subscription_type !== 'subscription' &&
g.fallback_group_id_on_invalid_request === null
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 无效请求兜底分组选项(编辑时)- 排除自身
const invalidRequestFallbackOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.invalidRequestFallback.noFallback') }
]
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) =>
g.platform === 'anthropic' &&
g.status === 'active' &&
g.subscription_type !== 'subscription' &&
g.fallback_group_id_on_invalid_request === null &&
g.id !== currentId
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组
const copyAccountsGroupOptions = computed(() => {
const eligibleGroups = groups.value.filter(
......@@ -1393,8 +1661,13 @@ const createForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
})
......@@ -1466,6 +1739,26 @@ const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boo
rule.accounts = rule.accounts.filter(a => a.id !== accountId)
}
// 切换创建表单的模型系列选择
const toggleCreateScope = (scope: string) => {
const idx = createForm.supported_model_scopes.indexOf(scope)
if (idx === -1) {
createForm.supported_model_scopes.push(scope)
} else {
createForm.supported_model_scopes.splice(idx, 1)
}
}
// 切换编辑表单的模型系列选择
const toggleEditScope = (scope: string) => {
const idx = editForm.supported_model_scopes.indexOf(scope)
if (idx === -1) {
editForm.supported_model_scopes.push(scope)
} else {
editForm.supported_model_scopes.splice(idx, 1)
}
}
// 处理账号搜索输入框聚焦
const onAccountSearchFocus = (ruleIndex: number, isEdit: boolean = false) => {
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
......@@ -1566,8 +1859,13 @@ const editForm = reactive({
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// 模型路由开关
model_routing_enabled: false,
// 支持的模型系列(仅 antigravity 平台)
supported_model_scopes: ['claude', 'gemini_text', 'gemini_image'] as string[],
// MCP XML 协议注入开关(仅 antigravity 平台)
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[]
})
......@@ -1651,6 +1949,9 @@ const closeCreateModal = () => {
createForm.image_price_4k = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
createForm.supported_model_scopes = ['claude', 'gemini_text', 'gemini_image']
createForm.mcp_xml_inject = true
createForm.copy_accounts_from_group_ids = []
createModelRoutingRules.value = []
}
......@@ -1701,7 +2002,10 @@ const handleEdit = async (group: AdminGroup) => {
editForm.image_price_4k = group.image_price_4k
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
editForm.model_routing_enabled = group.model_routing_enabled || false
editForm.supported_model_scopes = group.supported_model_scopes || ['claude', 'gemini_text', 'gemini_image']
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true
editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing)
......@@ -1728,6 +2032,10 @@ const handleUpdateGroup = async () => {
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null
? 0
: editForm.fallback_group_id_on_invalid_request,
model_routing: convertRoutingRulesToApiFormat(editModelRoutingRules.value)
}
await adminAPI.groups.update(editingGroup.value.id, payload)
......@@ -1768,6 +2076,16 @@ watch(
(newVal) => {
if (newVal === 'subscription') {
createForm.is_exclusive = true
createForm.fallback_group_id_on_invalid_request = null
}
}
)
watch(
() => createForm.platform,
(newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) {
createForm.fallback_group_id_on_invalid_request = null
}
}
)
......
......@@ -353,7 +353,6 @@
</div>
<Toggle v-model="form.invitation_code_enabled" />
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if="form.email_verify_enabled"
......
......@@ -35,13 +35,13 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import { formatReasoningEffort } from '@/utils/format'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
......
......@@ -40,10 +40,18 @@
/>
<!-- Row: Concurrency + Throughput -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div class="lg:col-span-1 min-h-[360px]">
<OpsConcurrencyCard :platform-filter="platform" :group-id-filter="groupId" :refresh-token="dashboardRefreshToken" />
</div>
<div class="lg:col-span-1 min-h-[360px]">
<OpsSwitchRateTrendChart
:points="switchTrend?.points ?? []"
:loading="loadingSwitchTrend"
:time-range="switchTrendTimeRange"
:fullscreen="isFullscreen"
/>
</div>
<div class="lg:col-span-2 min-h-[360px]">
<OpsThroughputTrendChart
:points="throughputTrend?.points ?? []"
......@@ -138,6 +146,7 @@ import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
import OpsErrorTrendChart from './components/OpsErrorTrendChart.vue'
import OpsLatencyChart from './components/OpsLatencyChart.vue'
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
......@@ -168,6 +177,9 @@ const groupId = ref<number | null>(null)
const queryMode = ref<QueryMode>('auto')
const customStartTime = ref<string | null>(null)
const customEndTime = ref<string | null>(null)
const switchTrendWindowHours = 5
const switchTrendTimeRange = `${switchTrendWindowHours}h`
const switchTrendWindowMs = switchTrendWindowHours * 60 * 60 * 1000
const QUERY_KEYS = {
timeRange: 'tr',
......@@ -322,6 +334,9 @@ const metricThresholds = ref<OpsMetricThresholds | null>(null)
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
const loadingTrend = ref(false)
const switchTrend = ref<OpsThroughputTrendResponse | null>(null)
const loadingSwitchTrend = ref(false)
const latencyHistogram = ref<OpsLatencyHistogramResponse | null>(null)
const loadingLatency = ref(false)
......@@ -491,6 +506,19 @@ function buildApiParams() {
return params
}
function buildSwitchTrendParams() {
const params: any = {
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
}
const endTime = new Date()
const startTime = new Date(endTime.getTime() - switchTrendWindowMs)
params.start_time = startTime.toISOString()
params.end_time = endTime.toISOString()
return params
}
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
try {
......@@ -504,6 +532,24 @@ async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal)
}
}
async function refreshSwitchTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingSwitchTrend.value = true
try {
const data = await opsAPI.getThroughputTrend(buildSwitchTrendParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
switchTrend.value = data
} catch (err: any) {
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
switchTrend.value = null
appStore.showError(err?.message || t('admin.ops.failedToLoadSwitchTrend'))
} finally {
if (fetchSeq === dashboardFetchSeq) {
loadingSwitchTrend.value = false
}
}
}
async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
loadingTrend.value = true
......@@ -600,6 +646,7 @@ async function fetchData() {
await Promise.all([
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshSwitchTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
......
......@@ -50,7 +50,11 @@ const props = withDefaults(defineProps<Props>(), {
</div>
<!-- Row: Concurrency + Throughput (matches OpsDashboard.vue) -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
</div>
<div :class="['min-h-[360px] rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 lg:col-span-1', props.fullscreen ? 'p-8' : 'p-6']">
<div class="h-4 w-44 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
<div class="mt-6 h-72 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
......@@ -96,4 +100,3 @@ const props = withDefaults(defineProps<Props>(), {
</div>
</div>
</template>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment