Commit 2bd288a6 authored by song's avatar song
Browse files

Merge branch 'main' into feature/antigravity_auth

parents 234e98f1 c01db6b1
...@@ -164,6 +164,14 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl ...@@ -164,6 +164,14 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl
return nil return nil
} }
// UpdateConcurrency 更新用户并发数(管理员功能)
func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concurrency int) error {
if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil {
return fmt.Errorf("update concurrency: %w", err)
}
return nil
}
// UpdateStatus 更新用户状态(管理员功能) // UpdateStatus 更新用户状态(管理员功能)
func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error { func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error {
user, err := s.userRepo.GetByID(ctx, userID) user, err := s.userRepo.GetByID(ctx, userID)
......
...@@ -20,6 +20,10 @@ SERVER_PORT=8080 ...@@ -20,6 +20,10 @@ SERVER_PORT=8080
# Server mode: release or debug # Server mode: release or debug
SERVER_MODE=release SERVER_MODE=release
# 运行模式: standard (默认) 或 simple (内部自用)
# standard: 完整 SaaS 功能,包含计费/余额校验;simple: 隐藏 SaaS 功能并跳过计费/余额校验
RUN_MODE=standard
# Timezone # Timezone
TZ=Asia/Shanghai TZ=Asia/Shanghai
......
...@@ -13,6 +13,14 @@ server: ...@@ -13,6 +13,14 @@ server:
# Mode: "debug" for development, "release" for production # Mode: "debug" for development, "release" for production
mode: "release" mode: "release"
# =============================================================================
# Run Mode Configuration
# =============================================================================
# Run mode: "standard" (default) or "simple" (for internal use)
# - standard: Full SaaS features with billing/balance checks
# - simple: Hides SaaS features and skips billing/balance checks
run_mode: "standard"
# ============================================================================= # =============================================================================
# Database Configuration (PostgreSQL) # Database Configuration (PostgreSQL)
# ============================================================================= # =============================================================================
......
...@@ -36,6 +36,7 @@ services: ...@@ -36,6 +36,7 @@ services:
- SERVER_HOST=0.0.0.0 - SERVER_HOST=0.0.0.0
- SERVER_PORT=8080 - SERVER_PORT=8080
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}
- RUN_MODE=${RUN_MODE:-standard}
# ======================================================================= # =======================================================================
# Database Configuration (PostgreSQL) # Database Configuration (PostgreSQL)
......
...@@ -8,7 +8,7 @@ import type { ...@@ -8,7 +8,7 @@ import type {
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
AuthResponse, AuthResponse,
User, CurrentUserResponse,
SendVerifyCodeRequest, SendVerifyCodeRequest,
SendVerifyCodeResponse, SendVerifyCodeResponse,
PublicSettings PublicSettings
...@@ -70,9 +70,8 @@ export async function register(userData: RegisterRequest): Promise<AuthResponse> ...@@ -70,9 +70,8 @@ export async function register(userData: RegisterRequest): Promise<AuthResponse>
* Get current authenticated user * Get current authenticated user
* @returns User profile data * @returns User profile data
*/ */
export async function getCurrentUser(): Promise<User> { export async function getCurrentUser() {
const { data } = await apiClient.get<User>('/auth/me') return apiClient.get<CurrentUserResponse>('/auth/me')
return data
} }
/** /**
......
...@@ -585,7 +585,7 @@ ...@@ -585,7 +585,7 @@
: 'https://api.anthropic.com' : 'https://api.anthropic.com'
" "
/> />
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p> <p class="input-hint">{{ baseUrlHint }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label> <label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
...@@ -602,13 +602,7 @@ ...@@ -602,13 +602,7 @@
: 'sk-ant-...' : 'sk-ant-...'
" "
/> />
<p class="input-hint"> <p class="input-hint">{{ apiKeyHint }}</p>
{{
form.platform === 'gemini'
? t('admin.accounts.gemini.apiKeyHint')
: t('admin.accounts.apiKeyHint')
}}
</p>
</div> </div>
<!-- Model Restriction Section (不适用于 Gemini) --> <!-- Model Restriction Section (不适用于 Gemini) -->
...@@ -1055,8 +1049,9 @@ ...@@ -1055,8 +1049,9 @@
</div> </div>
</div> </div>
<!-- Group Selection --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector <GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids" v-model="form.group_ids"
:groups="groups" :groups="groups"
:platform="form.platform" :platform="form.platform"
...@@ -1172,6 +1167,7 @@ ...@@ -1172,6 +1167,7 @@
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { import {
useAccountOAuth, useAccountOAuth,
...@@ -1199,6 +1195,7 @@ interface OAuthFlowExposed { ...@@ -1199,6 +1195,7 @@ interface OAuthFlowExposed {
} }
const { t } = useI18n() const { t } = useI18n()
const authStore = useAuthStore()
const oauthStepTitle = computed(() => { const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
...@@ -1207,6 +1204,19 @@ const oauthStepTitle = computed(() => { ...@@ -1207,6 +1204,19 @@ const oauthStepTitle = computed(() => {
return t('admin.accounts.oauth.title') return t('admin.accounts.oauth.title')
}) })
// Platform-specific hints for API Key type
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
return t('admin.accounts.apiKeyHint')
})
interface Props { interface Props {
show: boolean show: boolean
proxies: Proxy[] proxies: Proxy[]
......
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
: 'https://api.anthropic.com' : 'https://api.anthropic.com'
" "
/> />
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p> <p class="input-hint">{{ baseUrlHint }}</p>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label> <label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
...@@ -497,8 +497,9 @@ ...@@ -497,8 +497,9 @@
</div> </div>
</div> </div>
<!-- Group Selection --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector <GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids" v-model="form.group_ids"
:groups="groups" :groups="groups"
:platform="account?.platform" :platform="account?.platform"
...@@ -549,6 +550,7 @@ ...@@ -549,6 +550,7 @@
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
...@@ -571,6 +573,15 @@ const emit = defineEmits<{ ...@@ -571,6 +573,15 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
// Model mapping type // Model mapping type
interface ModelMapping { interface ModelMapping {
......
...@@ -297,7 +297,7 @@ onUnmounted(() => { ...@@ -297,7 +297,7 @@ onUnmounted(() => {
} }
.select-dropdown { .select-dropdown {
@apply absolute z-[100] mt-2 w-full; @apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px];
@apply bg-white dark:bg-dark-800; @apply bg-white dark:bg-dark-800;
@apply rounded-xl; @apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700; @apply border border-gray-200 dark:border-dark-700;
...@@ -339,7 +339,7 @@ onUnmounted(() => { ...@@ -339,7 +339,7 @@ onUnmounted(() => {
} }
.select-option-label { .select-option-label {
@apply truncate; @apply flex-1 min-w-0 truncate text-left;
} }
.select-empty { .select-empty {
......
...@@ -45,8 +45,8 @@ ...@@ -45,8 +45,8 @@
</router-link> </router-link>
</div> </div>
<!-- Personal Section for Admin --> <!-- Personal Section for Admin (hidden in simple mode) -->
<div class="sidebar-section"> <div v-if="!authStore.isSimpleMode" class="sidebar-section">
<div v-if="!sidebarCollapsed" class="sidebar-section-title"> <div v-if="!sidebarCollapsed" class="sidebar-section-title">
{{ t('nav.myAccount') }} {{ t('nav.myAccount') }}
</div> </div>
...@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = { ...@@ -402,36 +402,54 @@ const ChevronDoubleRightIcon = {
} }
// User navigation items (for regular users) // User navigation items (for regular users)
const userNavItems = computed(() => [ const userNavItems = computed(() => {
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const items = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
]) { path: '/profile', label: t('nav.profile'), icon: UserIcon }
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Personal navigation items (for admin's "My Account" section, without Dashboard) // Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => [ const personalNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, const items = [
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
]) { path: '/profile', label: t('nav.profile'), icon: UserIcon }
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
// Admin navigation items // Admin navigation items
const adminNavItems = computed(() => [ const adminNavItems = computed(() => {
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, const baseItems = [
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon }, { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon }, { path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon }, { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon } { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon },
]) ]
// 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
return filtered
}
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
return baseItems
})
function toggleSidebar() { function toggleSidebar() {
appStore.toggleSidebar() appStore.toggleSidebar()
......
...@@ -676,14 +676,21 @@ export default { ...@@ -676,14 +676,21 @@ export default {
description: 'Description', description: 'Description',
platform: 'Platform', platform: 'Platform',
rateMultiplier: 'Rate Multiplier', rateMultiplier: 'Rate Multiplier',
status: 'Status' status: 'Status',
exclusive: 'Exclusive Group'
}, },
enterGroupName: 'Enter group name', enterGroupName: 'Enter group name',
optionalDescription: 'Optional description', optionalDescription: 'Optional description',
platformHint: 'Select the platform this group is associated with', platformHint: 'Select the platform this group is associated with',
platformNotEditable: 'Platform cannot be changed after creation', platformNotEditable: 'Platform cannot be changed after creation',
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)', rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
exclusiveHint: 'Exclusive (requires explicit user access)', exclusiveHint: 'Exclusive group, manually assign to specific users',
exclusiveTooltip: {
title: 'What is an exclusive group?',
description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.',
example: 'Use case:',
exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.'
},
noGroupsYet: 'No groups yet', noGroupsYet: 'No groups yet',
createFirstGroup: 'Create your first group to organize API keys.', createFirstGroup: 'Create your first group to organize API keys.',
creating: 'Creating...', creating: 'Creating...',
...@@ -910,6 +917,11 @@ export default { ...@@ -910,6 +917,11 @@ export default {
apiKeyRequired: 'API Key *', apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Your Claude Console API Key', apiKeyHint: 'Your Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: 'Leave default for official OpenAI API',
apiKeyHint: 'Your OpenAI API Key'
},
modelRestriction: 'Model Restriction (Optional)', modelRestriction: 'Model Restriction (Optional)',
modelWhitelist: 'Model Whitelist', modelWhitelist: 'Model Whitelist',
modelMapping: 'Model Mapping', modelMapping: 'Model Mapping',
...@@ -1096,6 +1108,7 @@ export default { ...@@ -1096,6 +1108,7 @@ export default {
modelPassthrough: 'Gemini Model Passthrough', modelPassthrough: 'Gemini Model Passthrough',
modelPassthroughDesc: modelPassthroughDesc:
'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.',
baseUrlHint: 'Leave default for official Gemini API',
apiKeyHint: 'Your Gemini API Key (starts with AIza)' apiKeyHint: 'Your Gemini API Key (starts with AIza)'
}, },
// Re-Auth Modal // Re-Auth Modal
...@@ -1215,9 +1228,9 @@ export default { ...@@ -1215,9 +1228,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',
......
...@@ -733,14 +733,15 @@ export default { ...@@ -733,14 +733,15 @@ export default {
platform: '平台', platform: '平台',
rateMultiplier: '费率倍数', rateMultiplier: '费率倍数',
status: '状态', status: '状态',
exclusive: '专属分组',
nameLabel: '分组名称', nameLabel: '分组名称',
namePlaceholder: '请输入分组名称', namePlaceholder: '请输入分组名称',
descriptionLabel: '描述', descriptionLabel: '描述',
descriptionPlaceholder: '请输入描述(可选)', descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数', rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
exclusiveLabel: '独占模式', exclusiveLabel: '专属分组',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', exclusiveHint: '专属分组,可以手动指定给用户',
platformLabel: '平台限制', platformLabel: '平台限制',
platformPlaceholder: '选择平台(留空则不限制)', platformPlaceholder: '选择平台(留空则不限制)',
accountsLabel: '指定账号', accountsLabel: '指定账号',
...@@ -753,8 +754,14 @@ export default { ...@@ -753,8 +754,14 @@ export default {
yes: '', yes: '',
no: '' no: ''
}, },
exclusive: '独占', exclusive: '专属',
exclusiveHint: '启用后,此分组的用户将独占使用分配的账号', exclusiveHint: '专属分组,可以手动指定给特定用户',
exclusiveTooltip: {
title: '什么是专属分组?',
description: '开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。',
example: '使用场景:',
exampleContent: '公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。'
},
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
platforms: { platforms: {
all: '全部平台', all: '全部平台',
...@@ -773,8 +780,8 @@ export default { ...@@ -773,8 +780,8 @@ export default {
allPlatforms: '全部平台', allPlatforms: '全部平台',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组', allGroups: '全部分组',
exclusiveFilter: '独占', exclusiveFilter: '专属',
nonExclusive: '非独占', nonExclusive: '公开',
public: '公开', public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号', rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号', accountsCount: '{count} 个账号',
...@@ -1058,6 +1065,11 @@ export default { ...@@ -1058,6 +1065,11 @@ export default {
apiKeyRequired: 'API Key *', apiKeyRequired: 'API Key *',
apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: '您的 Claude Console API Key', apiKeyHint: '您的 Claude Console API Key',
// OpenAI specific hints
openai: {
baseUrlHint: '留空使用官方 OpenAI API',
apiKeyHint: '您的 OpenAI API Key'
},
modelRestriction: '模型限制(可选)', modelRestriction: '模型限制(可选)',
modelWhitelist: '模型白名单', modelWhitelist: '模型白名单',
modelMapping: '模型映射', modelMapping: '模型映射',
...@@ -1226,7 +1238,8 @@ export default { ...@@ -1226,7 +1238,8 @@ export default {
gemini: { gemini: {
modelPassthrough: 'Gemini 直接转发模型', modelPassthrough: 'Gemini 直接转发模型',
modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。',
apiKeyHint: 'Your Gemini API Key(以 AIza 开头)' baseUrlHint: '留空使用官方 Gemini API',
apiKeyHint: '您的 Gemini API Key(以 AIza 开头)'
}, },
// Re-Auth Modal // Re-Auth Modal
reAuthorizeAccount: '重新授权账号', reAuthorizeAccount: '重新授权账号',
...@@ -1364,8 +1377,8 @@ export default { ...@@ -1364,8 +1377,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} 个',
......
...@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => { ...@@ -341,6 +341,23 @@ router.beforeEach((to, _from, next) => {
return return
} }
// 简易模式下限制访问某些页面
if (authStore.isSimpleMode) {
const restrictedPaths = [
'/admin/groups',
'/admin/subscriptions',
'/admin/redeem',
'/subscriptions',
'/redeem'
]
if (restrictedPaths.some((path) => to.path.startsWith(path))) {
// 简易模式下访问受限页面,重定向到仪表板
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
}
// All checks passed, allow navigation // All checks passed, allow navigation
next() next()
}) })
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed, readonly } from 'vue'
import { authAPI } from '@/api' import { authAPI } from '@/api'
import type { User, LoginRequest, RegisterRequest } from '@/types' import type { User, LoginRequest, RegisterRequest } from '@/types'
...@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -17,6 +17,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const token = ref<string | null>(null) const token = ref<string | null>(null)
const runMode = ref<'standard' | 'simple'>('standard')
let refreshIntervalId: ReturnType<typeof setInterval> | null = null let refreshIntervalId: ReturnType<typeof setInterval> | null = null
// ==================== Computed ==================== // ==================== Computed ====================
...@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -29,6 +30,8 @@ export const useAuthStore = defineStore('auth', () => {
return user.value?.role === 'admin' return user.value?.role === 'admin'
}) })
const isSimpleMode = computed(() => runMode.value === 'simple')
// ==================== Actions ==================== // ==================== Actions ====================
/** /**
...@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -98,16 +101,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user // Store token and user
token.value = response.access_token token.value = response.access_token
user.value = response.user
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode, ...userData } = response.user
user.value = userData
// Persist to localStorage // Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
// Start auto-refresh interval // Start auto-refresh interval
startAutoRefresh() startAutoRefresh()
return response.user return userData
} catch (error) { } catch (error) {
// Clear any partial state on error // Clear any partial state on error
clearAuth() clearAuth()
...@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -127,16 +136,22 @@ export const useAuthStore = defineStore('auth', () => {
// Store token and user // Store token and user
token.value = response.access_token token.value = response.access_token
user.value = response.user
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode, ...userDataWithoutRunMode } = response.user
user.value = userDataWithoutRunMode
// Persist to localStorage // Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(response.user)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userDataWithoutRunMode))
// Start auto-refresh interval // Start auto-refresh interval
startAutoRefresh() startAutoRefresh()
return response.user return userDataWithoutRunMode
} catch (error) { } catch (error) {
// Clear any partial state on error // Clear any partial state on error
clearAuth() clearAuth()
...@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -168,13 +183,17 @@ export const useAuthStore = defineStore('auth', () => {
} }
try { try {
const updatedUser = await authAPI.getCurrentUser() const response = await authAPI.getCurrentUser()
user.value = updatedUser if (response.data.run_mode) {
runMode.value = response.data.run_mode
}
const { run_mode, ...userData } = response.data
user.value = userData
// Update localStorage // Update localStorage
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(updatedUser)) localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
return updatedUser return userData
} catch (error) { } catch (error) {
// If refresh fails with 401, clear auth state // If refresh fails with 401, clear auth state
if ((error as { status?: number }).status === 401) { if ((error as { status?: number }).status === 401) {
...@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => { ...@@ -204,10 +223,12 @@ export const useAuthStore = defineStore('auth', () => {
// State // State
user, user,
token, token,
runMode: readonly(runMode),
// Computed // Computed
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isSimpleMode,
// Actions // Actions
login, login,
......
...@@ -60,7 +60,11 @@ export interface PublicSettings { ...@@ -60,7 +60,11 @@ export interface PublicSettings {
export interface AuthResponse { export interface AuthResponse {
access_token: string access_token: string
token_type: string token_type: string
user: User user: User & { run_mode?: 'standard' | 'simple' }
}
export interface CurrentUserResponse extends User {
run_mode?: 'standard' | 'simple'
} }
// ==================== Subscription Types ==================== // ==================== Subscription Types ====================
......
...@@ -494,6 +494,7 @@ ...@@ -494,6 +494,7 @@
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue' import { ref, reactive, 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 { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' 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'
...@@ -522,22 +523,34 @@ import { formatRelativeTime } from '@/utils/format' ...@@ -522,22 +523,34 @@ import { formatRelativeTime } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore()
// Table columns // Table columns
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => {
{ key: 'select', label: '', sortable: false }, const cols: Column[] = [
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true }, { key: 'select', label: '', sortable: false },
{ key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false }, { key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
{ key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false }, { key: 'platform_type', label: t('admin.accounts.columns.platformType'), sortable: false },
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true }, { key: 'concurrency', label: t('admin.accounts.columns.concurrencyStatus'), sortable: false },
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true }, { key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }, { key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
{ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false }, { key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false }
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, ]
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, // 简易模式下不显示分组列
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } if (!authStore.isSimpleMode) {
]) cols.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
}
cols.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
)
return cols
})
// Filter options // Filter options
const platformOptions = computed(() => [ const platformOptions = computed(() => [
......
...@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([]) ...@@ -407,10 +407,20 @@ const trendData = ref<TrendDataPoint[]>([])
const modelStats = ref<ModelStat[]>([]) const modelStats = ref<ModelStat[]>([])
const userTrend = ref<UserUsageTrendPoint[]>([]) const userTrend = ref<UserUsageTrendPoint[]>([])
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range // Date range
const granularity = ref<'day' | 'hour'>('day') const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
// Granularity options for Select component // Granularity options for Select component
const granularityOptions = computed(() => [ const granularityOptions = computed(() => [
...@@ -597,18 +607,6 @@ const onDateRangeChange = (range: { ...@@ -597,18 +607,6 @@ const onDateRangeChange = (range: {
loadChartData() loadChartData()
} }
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data // Load data
const loadDashboardStats = async () => { const loadDashboardStats = async () => {
loading.value = true loading.value = true
...@@ -649,7 +647,6 @@ const loadChartData = async () => { ...@@ -649,7 +647,6 @@ const loadChartData = async () => {
onMounted(() => { onMounted(() => {
loadDashboardStats() loadDashboardStats()
initializeDateRange()
loadChartData() loadChartData()
}) })
</script> </script>
......
...@@ -282,34 +282,66 @@ ...@@ -282,34 +282,66 @@
/> />
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p> <p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
</div> </div>
<div v-if="createForm.subscription_type !== 'subscription'" class="flex items-center gap-3"> <div v-if="createForm.subscription_type !== 'subscription'">
<button <div class="mb-1.5 flex items-center gap-1">
type="button" <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@click="createForm.is_exclusive = !createForm.is_exclusive" {{ t('admin.groups.form.exclusive') }}
:class="[ </label>
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', <!-- Help Tooltip -->
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' <div class="group relative inline-flex">
]" <svg
> class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
<span fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Tooltip Popover -->
<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="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
<!-- Arrow -->
<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.is_exclusive = !createForm.is_exclusive"
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]" ]"
/> >
</button> <span
<label class="text-sm text-gray-700 dark:text-gray-300"> :class="[
{{ t('admin.groups.exclusiveHint') }} 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
</label> createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</div>
</div> </div>
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white"> <div>
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" /> <Select v-model="createForm.subscription_type" :options="subscriptionTypeOptions" />
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p> <p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
...@@ -432,25 +464,61 @@ ...@@ -432,25 +464,61 @@
class="input" class="input"
/> />
</div> </div>
<div v-if="editForm.subscription_type !== 'subscription'" class="flex items-center gap-3"> <div v-if="editForm.subscription_type !== 'subscription'">
<button <div class="mb-1.5 flex items-center gap-1">
type="button" <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@click="editForm.is_exclusive = !editForm.is_exclusive" {{ t('admin.groups.form.exclusive') }}
:class="[ </label>
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', <!-- Help Tooltip -->
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' <div class="group relative inline-flex">
]" <svg
> class="h-3.5 w-3.5 cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
<span fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<!-- Tooltip Popover -->
<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="mb-2 text-xs font-medium">{{ t('admin.groups.exclusiveTooltip.title') }}</p>
<p class="mb-2 text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.exclusiveTooltip.description') }}
</p>
<div class="rounded bg-gray-800 p-2 dark:bg-gray-700">
<p class="text-xs leading-relaxed text-gray-300">
<span class="text-primary-400">💡 {{ t('admin.groups.exclusiveTooltip.example') }}</span>
{{ t('admin.groups.exclusiveTooltip.exampleContent') }}
</p>
</div>
<!-- Arrow -->
<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.is_exclusive = !editForm.is_exclusive"
:class="[ :class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform', 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1' editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]" ]"
/> >
</button> <span
<label class="text-sm text-gray-700 dark:text-gray-300"> :class="[
{{ t('admin.groups.exclusiveHint') }} 'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
</label> editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }}
</span>
</div>
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.groups.form.status') }}</label> <label class="input-label">{{ t('admin.groups.form.status') }}</label>
...@@ -459,11 +527,7 @@ ...@@ -459,11 +527,7 @@
<!-- Subscription Configuration --> <!-- Subscription Configuration -->
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
<h4 class="mb-4 text-sm font-medium text-gray-900 dark:text-white"> <div>
{{ t('admin.groups.subscription.title') }}
</h4>
<div class="mb-4">
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label> <label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
<Select <Select
v-model="editForm.subscription_type" v-model="editForm.subscription_type"
......
...@@ -736,9 +736,19 @@ const groupOptions = computed(() => { ...@@ -736,9 +736,19 @@ const groupOptions = computed(() => {
] ]
}) })
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range state // Date range state
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
const filters = ref<AdminUsageQueryParams>({ const filters = ref<AdminUsageQueryParams>({
user_id: undefined, user_id: undefined,
...@@ -752,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({ ...@@ -752,18 +762,9 @@ const filters = ref<AdminUsageQueryParams>({
end_date: undefined end_date: undefined
}) })
// Initialize default date range (last 7 days) // Initialize filters with date range
const initializeDateRange = () => { filters.value.start_date = startDate.value
const now = new Date() filters.value.end_date = endDate.value
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// User search with debounce // User search with debounce
const debounceSearchUsers = () => { const debounceSearchUsers = () => {
...@@ -988,9 +989,12 @@ const loadModelOptions = async () => { ...@@ -988,9 +989,12 @@ const loadModelOptions = async () => {
const endDate = new Date() const endDate = new Date()
const startDateRange = new Date(endDate) const startDateRange = new Date(endDate)
startDateRange.setDate(startDateRange.getDate() - 29) startDateRange.setDate(startDateRange.getDate() - 29)
// Use local timezone instead of UTC
const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`
const startDateStr = `${startDateRange.getFullYear()}-${String(startDateRange.getMonth() + 1).padStart(2, '0')}-${String(startDateRange.getDate()).padStart(2, '0')}`
const response = await adminAPI.dashboard.getModelStats({ const response = await adminAPI.dashboard.getModelStats({
start_date: startDateRange.toISOString().split('T')[0], start_date: startDateStr,
end_date: endDate.toISOString().split('T')[0] end_date: endDateStr
}) })
const uniqueModels = new Set<string>() const uniqueModels = new Set<string>()
response.models?.forEach((stat) => { response.models?.forEach((stat) => {
...@@ -1022,7 +1026,13 @@ const resetFilters = () => { ...@@ -1022,7 +1026,13 @@ const resetFilters = () => {
} }
granularity.value = 'day' granularity.value = 'day'
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = formatLocalDate(weekAgo)
endDate.value = formatLocalDate(now)
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
pagination.value.page = 1 pagination.value.page = 1
loadApiKeys() loadApiKeys()
loadUsageLogs() loadUsageLogs()
...@@ -1114,7 +1124,6 @@ const hideTooltip = () => { ...@@ -1114,7 +1124,6 @@ const hideTooltip = () => {
} }
onMounted(() => { onMounted(() => {
initializeDateRange()
loadFilterOptions() loadFilterOptions()
loadApiKeys() loadApiKeys()
loadUsageLogs() loadUsageLogs()
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<!-- Row 1: Core Stats --> <!-- Row 1: Core Stats -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Balance --> <!-- Balance -->
<div class="card p-4"> <div v-if="!authStore.isSimpleMode" class="card p-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30"> <div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<svg <svg
...@@ -727,10 +727,20 @@ const trendChartRef = ref<ChartComponentRef | null>(null) ...@@ -727,10 +727,20 @@ const trendChartRef = ref<ChartComponentRef | null>(null)
// Recent usage // Recent usage
const recentUsage = ref<UsageLog[]>([]) const recentUsage = ref<UsageLog[]>([])
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately (not in onMounted)
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range // Date range
const granularity = ref<'day' | 'hour'>('day') const granularity = ref<'day' | 'hour'>('day')
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
// Granularity options for Select component // Granularity options for Select component
const granularityOptions = computed(() => [ const granularityOptions = computed(() => [
...@@ -963,18 +973,6 @@ const onDateRangeChange = (range: { ...@@ -963,18 +973,6 @@ const onDateRangeChange = (range: {
loadChartData() loadChartData()
} }
// Initialize default date range
const initializeDateRange = () => {
const now = new Date()
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
granularity.value = 'day'
}
// Load data // Load data
const loadDashboardStats = async () => { const loadDashboardStats = async () => {
loading.value = true loading.value = true
...@@ -1015,8 +1013,11 @@ const loadChartData = async () => { ...@@ -1015,8 +1013,11 @@ const loadChartData = async () => {
const loadRecentUsage = async () => { const loadRecentUsage = async () => {
loadingUsage.value = true loadingUsage.value = true
try { try {
const endDate = new Date().toISOString().split('T')[0] // Use local timezone instead of UTC
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const now = new Date()
const endDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const startDate = `${weekAgo.getFullYear()}-${String(weekAgo.getMonth() + 1).padStart(2, '0')}-${String(weekAgo.getDate()).padStart(2, '0')}`
const usageResponse = await usageAPI.getByDateRange(startDate, endDate) const usageResponse = await usageAPI.getByDateRange(startDate, endDate)
recentUsage.value = usageResponse.items.slice(0, 5) recentUsage.value = usageResponse.items.slice(0, 5)
} catch (error) { } catch (error) {
...@@ -1035,9 +1036,6 @@ onMounted(async () => { ...@@ -1035,9 +1036,6 @@ onMounted(async () => {
console.error('Failed to refresh subscription status:', error) console.error('Failed to refresh subscription status:', error)
}) })
// Initialize date range (synchronous)
initializeDateRange()
// Load chart data and recent usage in parallel (non-critical) // Load chart data and recent usage in parallel (non-critical)
Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => { Promise.all([loadChartData(), loadRecentUsage()]).catch((error) => {
console.error('Error loading secondary data:', error) console.error('Error loading secondary data:', error)
......
...@@ -488,9 +488,19 @@ const apiKeyOptions = computed(() => { ...@@ -488,9 +488,19 @@ const apiKeyOptions = computed(() => {
] ]
}) })
// Helper function to format date in local timezone
const formatLocalDate = (date: Date): string => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// Initialize date range immediately
const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
// Date range state // Date range state
const startDate = ref('') const startDate = ref(formatLocalDate(weekAgo))
const endDate = ref('') const endDate = ref(formatLocalDate(now))
const filters = ref<UsageQueryParams>({ const filters = ref<UsageQueryParams>({
api_key_id: undefined, api_key_id: undefined,
...@@ -498,18 +508,9 @@ const filters = ref<UsageQueryParams>({ ...@@ -498,18 +508,9 @@ const filters = ref<UsageQueryParams>({
end_date: undefined end_date: undefined
}) })
// Initialize default date range (last 7 days) // Initialize filters with date range
const initializeDateRange = () => { filters.value.start_date = startDate.value
const now = new Date() filters.value.end_date = endDate.value
const today = now.toISOString().split('T')[0]
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = weekAgo.toISOString().split('T')[0]
endDate.value = today
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
}
// Handle date range change from DateRangePicker // Handle date range change from DateRangePicker
const onDateRangeChange = (range: { const onDateRangeChange = (range: {
...@@ -629,7 +630,13 @@ const resetFilters = () => { ...@@ -629,7 +630,13 @@ const resetFilters = () => {
end_date: undefined end_date: undefined
} }
// Reset date range to default (last 7 days) // Reset date range to default (last 7 days)
initializeDateRange() const now = new Date()
const weekAgo = new Date(now)
weekAgo.setDate(weekAgo.getDate() - 6)
startDate.value = formatLocalDate(weekAgo)
endDate.value = formatLocalDate(now)
filters.value.start_date = startDate.value
filters.value.end_date = endDate.value
pagination.page = 1 pagination.page = 1
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
...@@ -772,7 +779,6 @@ const hideTooltip = () => { ...@@ -772,7 +779,6 @@ const hideTooltip = () => {
} }
onMounted(() => { onMounted(() => {
initializeDateRange()
loadApiKeys() loadApiKeys()
loadUsageLogs() loadUsageLogs()
loadUsageStats() loadUsageStats()
......
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