mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-04 21:20:51 +08:00
refactor: M5 useQuotaNotifyState composable + H14 Vue file splits
M5: New composable frontend/src/composables/useQuotaNotifyState.ts - Replaces 9 individual refs in both Create/Edit modals with reactive state - Provides loadFromExtra/writeToExtra/reset helpers - Eliminates ~120 lines of duplicated code across the two modals H14: Vue file length violations fixed - AdminPaymentPlansView.vue: 325 → 183 lines (extracted PlanEditDialog.vue) - QuotaLimitCard.vue: 327 → 268 lines (extracted QuotaDimensionRow.vue) - PlanEditDialog.vue: 181 lines (new, plan create/edit form) - QuotaDimensionRow.vue: 108 lines (new, single quota dimension row)
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.110.49
|
||||
0.1.110.51
|
||||
|
||||
@@ -1493,15 +1493,15 @@
|
||||
:dailyLimit="editQuotaDailyLimit"
|
||||
:weeklyLimit="editQuotaWeeklyLimit"
|
||||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||||
:dailyResetMode="editDailyResetMode"
|
||||
:dailyResetHour="editDailyResetHour"
|
||||
:weeklyResetMode="editWeeklyResetMode"
|
||||
@@ -1511,15 +1511,15 @@
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||||
@update:dailyResetMode="editDailyResetMode = $event"
|
||||
@update:dailyResetHour="editDailyResetHour = $event"
|
||||
@update:weeklyResetMode="editWeeklyResetMode = $event"
|
||||
@@ -1545,15 +1545,15 @@
|
||||
:dailyLimit="editQuotaDailyLimit"
|
||||
:weeklyLimit="editQuotaWeeklyLimit"
|
||||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||||
:dailyResetMode="editDailyResetMode"
|
||||
:dailyResetHour="editDailyResetHour"
|
||||
:weeklyResetMode="editWeeklyResetMode"
|
||||
@@ -1563,15 +1563,15 @@
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyDailyThresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyWeeklyThresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyTotalThreshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyTotalThresholdType = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||||
@update:dailyResetMode="editDailyResetMode = $event"
|
||||
@update:dailyResetHour="editDailyResetHour = $event"
|
||||
@update:weeklyResetMode="editWeeklyResetMode = $event"
|
||||
@@ -2903,6 +2903,7 @@ import {
|
||||
} from '@/composables/useModelWhitelist'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
|
||||
import {
|
||||
useAccountOAuth,
|
||||
type AddMethod,
|
||||
@@ -3076,25 +3077,19 @@ const codexCLIOnlyEnabled = ref(false)
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const webSearchEmulationMode = ref('default')
|
||||
const webSearchGlobalEnabled = ref(false)
|
||||
const quotaNotifyGlobalEnabled = ref(false)
|
||||
const quotaNotifyDailyEnabled = ref<boolean | null>(null)
|
||||
const quotaNotifyDailyThreshold = ref<number | null>(null)
|
||||
const quotaNotifyDailyThresholdType = ref<string | null>(null)
|
||||
const quotaNotifyWeeklyEnabled = ref<boolean | null>(null)
|
||||
const quotaNotifyWeeklyThreshold = ref<number | null>(null)
|
||||
const quotaNotifyWeeklyThresholdType = ref<string | null>(null)
|
||||
const quotaNotifyTotalEnabled = ref<boolean | null>(null)
|
||||
const quotaNotifyTotalThreshold = ref<number | null>(null)
|
||||
const quotaNotifyTotalThresholdType = ref<string | null>(null)
|
||||
const {
|
||||
globalEnabled: quotaNotifyGlobalEnabled,
|
||||
state: quotaNotifyState,
|
||||
loadGlobalState: loadQuotaNotifyGlobal,
|
||||
writeToExtra: writeQuotaNotifyToExtra,
|
||||
} = useQuotaNotifyState()
|
||||
|
||||
// Load global feature states once
|
||||
adminAPI.settings.getWebSearchEmulationConfig().then(cfg => {
|
||||
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
|
||||
}).catch(() => { webSearchGlobalEnabled.value = false })
|
||||
|
||||
adminAPI.settings.getSettings().then(settings => {
|
||||
quotaNotifyGlobalEnabled.value = settings.account_quota_notify_enabled === true
|
||||
}).catch(() => { quotaNotifyGlobalEnabled.value = false })
|
||||
loadQuotaNotifyGlobal()
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
||||
@@ -4199,21 +4194,7 @@ const createAccountAndFinish = async (
|
||||
quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC'
|
||||
}
|
||||
// Quota notify config
|
||||
if (quotaNotifyDailyEnabled.value) {
|
||||
quotaExtra.quota_notify_daily_enabled = true
|
||||
if (quotaNotifyDailyThreshold.value != null) quotaExtra.quota_notify_daily_threshold = quotaNotifyDailyThreshold.value
|
||||
quotaExtra.quota_notify_daily_threshold_type = quotaNotifyDailyThresholdType.value || 'fixed'
|
||||
}
|
||||
if (quotaNotifyWeeklyEnabled.value) {
|
||||
quotaExtra.quota_notify_weekly_enabled = true
|
||||
if (quotaNotifyWeeklyThreshold.value != null) quotaExtra.quota_notify_weekly_threshold = quotaNotifyWeeklyThreshold.value
|
||||
quotaExtra.quota_notify_weekly_threshold_type = quotaNotifyWeeklyThresholdType.value || 'fixed'
|
||||
}
|
||||
if (quotaNotifyTotalEnabled.value) {
|
||||
quotaExtra.quota_notify_total_enabled = true
|
||||
if (quotaNotifyTotalThreshold.value != null) quotaExtra.quota_notify_total_threshold = quotaNotifyTotalThreshold.value
|
||||
quotaExtra.quota_notify_total_threshold_type = quotaNotifyTotalThresholdType.value || 'fixed'
|
||||
}
|
||||
writeQuotaNotifyToExtra(quotaExtra, 'create')
|
||||
if (Object.keys(quotaExtra).length > 0) {
|
||||
finalExtra = quotaExtra
|
||||
}
|
||||
|
||||
@@ -1191,15 +1191,15 @@
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||||
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
|
||||
:quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType"
|
||||
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType"
|
||||
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
|
||||
:quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@@ -1209,15 +1209,15 @@
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||||
/>
|
||||
</div>
|
||||
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
|
||||
@@ -1242,15 +1242,15 @@
|
||||
:weeklyResetHour="editWeeklyResetHour"
|
||||
:resetTimezone="editResetTimezone"
|
||||
:quotaNotifyGlobalEnabled="quotaNotifyGlobalEnabled"
|
||||
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
|
||||
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
|
||||
:quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType"
|
||||
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
|
||||
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
|
||||
:quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType"
|
||||
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
|
||||
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
|
||||
:quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType"
|
||||
:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled"
|
||||
:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold"
|
||||
:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType"
|
||||
:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled"
|
||||
:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold"
|
||||
:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType"
|
||||
:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled"
|
||||
:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold"
|
||||
:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
@@ -1260,15 +1260,15 @@
|
||||
@update:weeklyResetDay="editWeeklyResetDay = $event"
|
||||
@update:weeklyResetHour="editWeeklyResetHour = $event"
|
||||
@update:resetTimezone="editResetTimezone = $event"
|
||||
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType = $event"
|
||||
@update:quotaNotifyDailyEnabled="quotaNotifyState.daily.enabled = $event"
|
||||
@update:quotaNotifyDailyThreshold="quotaNotifyState.daily.threshold = $event"
|
||||
@update:quotaNotifyDailyThresholdType="quotaNotifyState.daily.thresholdType = $event"
|
||||
@update:quotaNotifyWeeklyEnabled="quotaNotifyState.weekly.enabled = $event"
|
||||
@update:quotaNotifyWeeklyThreshold="quotaNotifyState.weekly.threshold = $event"
|
||||
@update:quotaNotifyWeeklyThresholdType="quotaNotifyState.weekly.thresholdType = $event"
|
||||
@update:quotaNotifyTotalEnabled="quotaNotifyState.total.enabled = $event"
|
||||
@update:quotaNotifyTotalThreshold="quotaNotifyState.total.threshold = $event"
|
||||
@update:quotaNotifyTotalThresholdType="quotaNotifyState.total.thresholdType = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1844,6 +1844,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
|
||||
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
@@ -1993,16 +1994,21 @@ const codexCLIOnlyEnabled = ref(false)
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const webSearchEmulationMode = ref('default')
|
||||
const webSearchGlobalEnabled = ref(false)
|
||||
const quotaNotifyGlobalEnabled = ref(false)
|
||||
const {
|
||||
globalEnabled: quotaNotifyGlobalEnabled,
|
||||
state: quotaNotifyState,
|
||||
loadGlobalState: loadQuotaNotifyGlobal,
|
||||
loadFromExtra: loadQuotaNotifyFromExtra,
|
||||
writeToExtra: writeQuotaNotifyToExtra,
|
||||
reset: resetQuotaNotify,
|
||||
} = useQuotaNotifyState()
|
||||
|
||||
// Load global feature states once
|
||||
adminAPI.settings.getWebSearchEmulationConfig().then(cfg => {
|
||||
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
|
||||
}).catch(() => { webSearchGlobalEnabled.value = false })
|
||||
|
||||
adminAPI.settings.getSettings().then(settings => {
|
||||
quotaNotifyGlobalEnabled.value = settings.account_quota_notify_enabled === true
|
||||
}).catch(() => { quotaNotifyGlobalEnabled.value = false })
|
||||
loadQuotaNotifyGlobal()
|
||||
const editQuotaLimit = ref<number | null>(null)
|
||||
const editQuotaDailyLimit = ref<number | null>(null)
|
||||
const editQuotaWeeklyLimit = ref<number | null>(null)
|
||||
@@ -2012,15 +2018,6 @@ const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
|
||||
const editWeeklyResetDay = ref<number | null>(null)
|
||||
const editWeeklyResetHour = ref<number | null>(null)
|
||||
const editResetTimezone = ref<string | null>(null)
|
||||
const editQuotaNotifyDailyEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyDailyThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyDailyThresholdType = ref<string | null>(null)
|
||||
const editQuotaNotifyWeeklyEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyWeeklyThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyWeeklyThresholdType = ref<string | null>(null)
|
||||
const editQuotaNotifyTotalEnabled = ref<boolean | null>(null)
|
||||
const editQuotaNotifyTotalThreshold = ref<number | null>(null)
|
||||
const editQuotaNotifyTotalThresholdType = ref<string | null>(null)
|
||||
const openAIWSModeOptions = computed(() => [
|
||||
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
|
||||
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
|
||||
@@ -2229,15 +2226,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
|
||||
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
|
||||
// Load quota notify config
|
||||
editQuotaNotifyDailyEnabled.value = (extra?.quota_notify_daily_enabled as boolean) ?? null
|
||||
editQuotaNotifyDailyThreshold.value = (extra?.quota_notify_daily_threshold as number) ?? null
|
||||
editQuotaNotifyDailyThresholdType.value = (extra?.quota_notify_daily_threshold_type as string) ?? null
|
||||
editQuotaNotifyWeeklyEnabled.value = (extra?.quota_notify_weekly_enabled as boolean) ?? null
|
||||
editQuotaNotifyWeeklyThreshold.value = (extra?.quota_notify_weekly_threshold as number) ?? null
|
||||
editQuotaNotifyWeeklyThresholdType.value = (extra?.quota_notify_weekly_threshold_type as string) ?? null
|
||||
editQuotaNotifyTotalEnabled.value = (extra?.quota_notify_total_enabled as boolean) ?? null
|
||||
editQuotaNotifyTotalThreshold.value = (extra?.quota_notify_total_threshold as number) ?? null
|
||||
editQuotaNotifyTotalThresholdType.value = (extra?.quota_notify_total_threshold_type as string) ?? null
|
||||
loadQuotaNotifyFromExtra(extra)
|
||||
} else {
|
||||
editQuotaLimit.value = null
|
||||
editQuotaDailyLimit.value = null
|
||||
@@ -2248,12 +2237,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editWeeklyResetDay.value = null
|
||||
editWeeklyResetHour.value = null
|
||||
editResetTimezone.value = null
|
||||
editQuotaNotifyDailyEnabled.value = null
|
||||
editQuotaNotifyDailyThreshold.value = null
|
||||
editQuotaNotifyWeeklyEnabled.value = null
|
||||
editQuotaNotifyWeeklyThreshold.value = null
|
||||
editQuotaNotifyTotalEnabled.value = null
|
||||
editQuotaNotifyTotalThreshold.value = null
|
||||
resetQuotaNotify()
|
||||
}
|
||||
|
||||
// Load antigravity model mapping (Antigravity 只支持映射模式)
|
||||
@@ -2369,12 +2353,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
|
||||
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
|
||||
// Load quota notify for bedrock
|
||||
editQuotaNotifyDailyEnabled.value = (bedrockExtra.quota_notify_daily_enabled as boolean) ?? null
|
||||
editQuotaNotifyDailyThreshold.value = (bedrockExtra.quota_notify_daily_threshold as number) ?? null
|
||||
editQuotaNotifyWeeklyEnabled.value = (bedrockExtra.quota_notify_weekly_enabled as boolean) ?? null
|
||||
editQuotaNotifyWeeklyThreshold.value = (bedrockExtra.quota_notify_weekly_threshold as number) ?? null
|
||||
editQuotaNotifyTotalEnabled.value = (bedrockExtra.quota_notify_total_enabled as boolean) ?? null
|
||||
editQuotaNotifyTotalThreshold.value = (bedrockExtra.quota_notify_total_threshold as number) ?? null
|
||||
loadQuotaNotifyFromExtra(bedrockExtra)
|
||||
|
||||
// Load model mappings for bedrock
|
||||
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
|
||||
@@ -3291,45 +3270,7 @@ const handleSubmit = async () => {
|
||||
delete newExtra.quota_reset_timezone
|
||||
}
|
||||
// Quota notify config
|
||||
if (editQuotaNotifyDailyEnabled.value) {
|
||||
newExtra.quota_notify_daily_enabled = true
|
||||
if (editQuotaNotifyDailyThreshold.value != null) {
|
||||
newExtra.quota_notify_daily_threshold = editQuotaNotifyDailyThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_daily_threshold
|
||||
}
|
||||
newExtra.quota_notify_daily_threshold_type = editQuotaNotifyDailyThresholdType.value || 'fixed'
|
||||
} else {
|
||||
delete newExtra.quota_notify_daily_enabled
|
||||
delete newExtra.quota_notify_daily_threshold
|
||||
delete newExtra.quota_notify_daily_threshold_type
|
||||
}
|
||||
if (editQuotaNotifyWeeklyEnabled.value) {
|
||||
newExtra.quota_notify_weekly_enabled = true
|
||||
if (editQuotaNotifyWeeklyThreshold.value != null) {
|
||||
newExtra.quota_notify_weekly_threshold = editQuotaNotifyWeeklyThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_weekly_threshold
|
||||
}
|
||||
newExtra.quota_notify_weekly_threshold_type = editQuotaNotifyWeeklyThresholdType.value || 'fixed'
|
||||
} else {
|
||||
delete newExtra.quota_notify_weekly_enabled
|
||||
delete newExtra.quota_notify_weekly_threshold
|
||||
delete newExtra.quota_notify_weekly_threshold_type
|
||||
}
|
||||
if (editQuotaNotifyTotalEnabled.value) {
|
||||
newExtra.quota_notify_total_enabled = true
|
||||
if (editQuotaNotifyTotalThreshold.value != null) {
|
||||
newExtra.quota_notify_total_threshold = editQuotaNotifyTotalThreshold.value
|
||||
} else {
|
||||
delete newExtra.quota_notify_total_threshold
|
||||
}
|
||||
newExtra.quota_notify_total_threshold_type = editQuotaNotifyTotalThresholdType.value || 'fixed'
|
||||
} else {
|
||||
delete newExtra.quota_notify_total_enabled
|
||||
delete newExtra.quota_notify_total_threshold
|
||||
delete newExtra.quota_notify_total_threshold_type
|
||||
}
|
||||
writeQuotaNotifyToExtra(newExtra, 'update')
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
108
frontend/src/components/account/QuotaDimensionRow.vue
Normal file
108
frontend/src/components/account/QuotaDimensionRow.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
dim: 'daily' | 'weekly' | 'total'
|
||||
label: string
|
||||
limit: number | null
|
||||
quotaNotifyGlobalEnabled: boolean
|
||||
notifyEnabled: boolean | null
|
||||
notifyThreshold: number | null
|
||||
notifyThresholdType: string | null
|
||||
// Reset mode (only for daily/weekly, null for total)
|
||||
resetMode: 'rolling' | 'fixed' | null
|
||||
resetHour: number | null
|
||||
resetDay: number | null // weekly only
|
||||
resetTimezone: string | null
|
||||
hintRolling: string
|
||||
hintFixed: string
|
||||
// Shared options passed from parent
|
||||
hourOptions: number[]
|
||||
dayOptions: { value: number; key: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:limit': [value: number | null]
|
||||
'update:notifyEnabled': [value: boolean | null]
|
||||
'update:notifyThreshold': [value: number | null]
|
||||
'update:notifyThresholdType': [value: string | null]
|
||||
'update:resetMode': [value: 'rolling' | 'fixed' | null]
|
||||
'update:resetHour': [value: number | null]
|
||||
'update:resetDay': [value: number | null]
|
||||
'update:resetTimezone': [value: string | null]
|
||||
}>()
|
||||
|
||||
const hasResetMode = props.dim !== 'total'
|
||||
|
||||
const onLimitInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:limit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
|
||||
const onModeChange = (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
|
||||
emit('update:resetMode', val)
|
||||
if (val === 'fixed') {
|
||||
if (props.resetHour == null) emit('update:resetHour', 0)
|
||||
if (props.dim === 'weekly' && props.resetDay == null) emit('update:resetDay', 1)
|
||||
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Title row (only when global notify is enabled) -->
|
||||
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ label }}</span>
|
||||
<span v-if="limit && limit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
|
||||
</div>
|
||||
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ label }}</label>
|
||||
|
||||
<!-- Input row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['relative', quotaNotifyGlobalEnabled ? 'flex-1 min-w-0' : 'flex-1']">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
|
||||
<input :value="limit" @input="onLimitInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
|
||||
</div>
|
||||
<QuotaNotifyToggle
|
||||
v-if="quotaNotifyGlobalEnabled && limit && limit > 0"
|
||||
class="flex-1 min-w-0"
|
||||
:enabled="notifyEnabled" :threshold="notifyThreshold" :threshold-type="notifyThresholdType"
|
||||
@update:enabled="emit('update:notifyEnabled', $event)" @update:threshold="emit('update:notifyThreshold', $event)" @update:threshold-type="emit('update:notifyThresholdType', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Reset mode row (daily/weekly only) -->
|
||||
<div v-if="hasResetMode" class="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
|
||||
<select :value="resetMode || 'rolling'" @change="onModeChange" class="input py-1 text-xs w-auto">
|
||||
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
|
||||
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
|
||||
</select>
|
||||
<template v-if="resetMode === 'fixed'">
|
||||
<!-- Weekly: day of week selector -->
|
||||
<template v-if="dim === 'weekly'">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaWeeklyResetDay') }}</label>
|
||||
<select :value="resetDay ?? 1" @change="emit('update:resetDay', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-28">
|
||||
<option v-for="d in dayOptions" :key="d.value" :value="d.value">{{ t('admin.accounts.dayOfWeek.' + d.key) }}</option>
|
||||
</select>
|
||||
</template>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
|
||||
<select :value="resetHour ?? 0" @change="emit('update:resetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
|
||||
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
|
||||
</select>
|
||||
</template>
|
||||
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>
|
||||
<template v-else>{{ hintRolling }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Total dimension hint (no reset mode) -->
|
||||
<p v-if="!hasResetMode" class="input-hint mb-0 text-[11px]">{{ hintRolling }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
|
||||
import QuotaDimensionRow from './QuotaDimensionRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -96,26 +96,24 @@ const hasFixedMode = computed(() =>
|
||||
|
||||
// Common timezone options
|
||||
const timezoneOptions = [
|
||||
'UTC',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Seoul',
|
||||
'Asia/Singapore',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Dubai',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Sao_Paulo',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
|
||||
'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',
|
||||
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
||||
'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
|
||||
]
|
||||
|
||||
// Compute GMT offset label (e.g. "GMT+8", "GMT-5") for a given IANA timezone.
|
||||
function getTimezoneOffsetLabel(tz: string): string {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
|
||||
const parts = dtf.formatToParts(new Date())
|
||||
const tzPart = parts.find(p => p.type === 'timeZoneName')
|
||||
return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Hours for dropdown (0-23)
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
@@ -130,37 +128,22 @@ const dayOptions = [
|
||||
{ value: 0, key: 'sunday' },
|
||||
]
|
||||
|
||||
const onTotalInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:totalLimit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
const onDailyInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:dailyLimit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
const onWeeklyInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
// Precomputed hint strings for the weekly fixed mode
|
||||
const weeklyFixedHint = computed(() => {
|
||||
const dayKey = dayOptions.find(d => d.value === (props.weeklyResetDay ?? 1))?.key || 'monday'
|
||||
return t('admin.accounts.quotaWeeklyLimitHintFixed', {
|
||||
day: t('admin.accounts.dayOfWeek.' + dayKey),
|
||||
hour: String(props.weeklyResetHour ?? 0).padStart(2, '0'),
|
||||
timezone: props.resetTimezone || 'UTC',
|
||||
})
|
||||
})
|
||||
|
||||
const onDailyModeChange = (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
|
||||
emit('update:dailyResetMode', val)
|
||||
if (val === 'fixed') {
|
||||
if (props.dailyResetHour == null) emit('update:dailyResetHour', 0)
|
||||
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
||||
}
|
||||
}
|
||||
|
||||
const onWeeklyModeChange = (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
|
||||
emit('update:weeklyResetMode', val)
|
||||
if (val === 'fixed') {
|
||||
if (props.weeklyResetDay == null) emit('update:weeklyResetDay', 1)
|
||||
if (props.weeklyResetHour == null) emit('update:weeklyResetHour', 0)
|
||||
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
||||
}
|
||||
}
|
||||
const dailyFixedHint = computed(() =>
|
||||
t('admin.accounts.quotaDailyLimitHintFixed', {
|
||||
hour: String(props.dailyResetHour ?? 0).padStart(2, '0'),
|
||||
timezone: props.resetTimezone || 'UTC',
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -197,117 +180,89 @@ const onWeeklyModeChange = (e: Event) => {
|
||||
|
||||
<!-- Collapsible content -->
|
||||
<div v-if="localEnabled && !collapsed" class="space-y-2 p-4 pt-3">
|
||||
<!-- 日配额 -->
|
||||
<div>
|
||||
<!-- 标题行(仅全局通知开启时显示) -->
|
||||
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaDailyLimit') }}</span>
|
||||
<span v-if="dailyLimit && dailyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
|
||||
</div>
|
||||
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaDailyLimit') }}</label>
|
||||
<!-- 输入行 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['relative', quotaNotifyGlobalEnabled ? 'w-28 flex-shrink-0' : 'flex-1']">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
|
||||
<input :value="dailyLimit" @input="onDailyInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
|
||||
</div>
|
||||
<QuotaNotifyToggle
|
||||
v-if="quotaNotifyGlobalEnabled && dailyLimit && dailyLimit > 0"
|
||||
class="flex-1 min-w-0"
|
||||
:enabled="props.quotaNotifyDailyEnabled" :threshold="props.quotaNotifyDailyThreshold" :threshold-type="props.quotaNotifyDailyThresholdType"
|
||||
@update:enabled="emit('update:quotaNotifyDailyEnabled', $event)" @update:threshold="emit('update:quotaNotifyDailyThreshold', $event)" @update:threshold-type="emit('update:quotaNotifyDailyThresholdType', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
|
||||
<select :value="dailyResetMode || 'rolling'" @change="onDailyModeChange" class="input py-1 text-xs w-auto">
|
||||
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
|
||||
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
|
||||
</select>
|
||||
<template v-if="dailyResetMode === 'fixed'">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
|
||||
<select :value="dailyResetHour ?? 0" @change="emit('update:dailyResetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
|
||||
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
|
||||
</select>
|
||||
</template>
|
||||
</div>
|
||||
<p class="input-hint mb-0 text-[11px]">
|
||||
<template v-if="dailyResetMode === 'fixed'">{{ t('admin.accounts.quotaDailyLimitHintFixed', { hour: String(dailyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}</template>
|
||||
<template v-else>{{ t('admin.accounts.quotaDailyLimitHint') }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Daily quota -->
|
||||
<QuotaDimensionRow
|
||||
dim="daily"
|
||||
:label="t('admin.accounts.quotaDailyLimit')"
|
||||
:limit="dailyLimit"
|
||||
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
|
||||
:notify-enabled="props.quotaNotifyDailyEnabled"
|
||||
:notify-threshold="props.quotaNotifyDailyThreshold"
|
||||
:notify-threshold-type="props.quotaNotifyDailyThresholdType"
|
||||
:reset-mode="dailyResetMode"
|
||||
:reset-hour="dailyResetHour"
|
||||
:reset-day="null"
|
||||
:reset-timezone="resetTimezone"
|
||||
:hint-rolling="t('admin.accounts.quotaDailyLimitHint')"
|
||||
:hint-fixed="dailyFixedHint"
|
||||
:hour-options="hourOptions"
|
||||
:day-options="dayOptions"
|
||||
@update:limit="emit('update:dailyLimit', $event)"
|
||||
@update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
|
||||
@update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
|
||||
@update:notify-threshold-type="emit('update:quotaNotifyDailyThresholdType', $event)"
|
||||
@update:reset-mode="emit('update:dailyResetMode', $event)"
|
||||
@update:reset-hour="emit('update:dailyResetHour', $event)"
|
||||
@update:reset-timezone="emit('update:resetTimezone', $event)"
|
||||
/>
|
||||
|
||||
<!-- 周配额 -->
|
||||
<div>
|
||||
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaWeeklyLimit') }}</span>
|
||||
<span v-if="weeklyLimit && weeklyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
|
||||
</div>
|
||||
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaWeeklyLimit') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['relative', quotaNotifyGlobalEnabled ? 'w-28 flex-shrink-0' : 'flex-1']">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
|
||||
<input :value="weeklyLimit" @input="onWeeklyInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
|
||||
</div>
|
||||
<QuotaNotifyToggle
|
||||
v-if="quotaNotifyGlobalEnabled && weeklyLimit && weeklyLimit > 0"
|
||||
class="flex-1 min-w-0"
|
||||
:enabled="props.quotaNotifyWeeklyEnabled" :threshold="props.quotaNotifyWeeklyThreshold" :threshold-type="props.quotaNotifyWeeklyThresholdType"
|
||||
@update:enabled="emit('update:quotaNotifyWeeklyEnabled', $event)" @update:threshold="emit('update:quotaNotifyWeeklyThreshold', $event)" @update:threshold-type="emit('update:quotaNotifyWeeklyThresholdType', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetMode') }}</label>
|
||||
<select :value="weeklyResetMode || 'rolling'" @change="onWeeklyModeChange" class="input py-1 text-xs w-auto">
|
||||
<option value="rolling">{{ t('admin.accounts.quotaResetModeRolling') }}</option>
|
||||
<option value="fixed">{{ t('admin.accounts.quotaResetModeFixed') }}</option>
|
||||
</select>
|
||||
<template v-if="weeklyResetMode === 'fixed'">
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaWeeklyResetDay') }}</label>
|
||||
<select :value="weeklyResetDay ?? 1" @change="emit('update:weeklyResetDay', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-28">
|
||||
<option v-for="d in dayOptions" :key="d.value" :value="d.value">{{ t('admin.accounts.dayOfWeek.' + d.key) }}</option>
|
||||
</select>
|
||||
<label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ t('admin.accounts.quotaResetHour') }}</label>
|
||||
<select :value="weeklyResetHour ?? 0" @change="emit('update:weeklyResetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
|
||||
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
|
||||
</select>
|
||||
</template>
|
||||
</div>
|
||||
<p class="input-hint mb-0 text-[11px]">
|
||||
<template v-if="weeklyResetMode === 'fixed'">{{ t('admin.accounts.quotaWeeklyLimitHintFixed', { day: t('admin.accounts.dayOfWeek.' + (dayOptions.find(d => d.value === (weeklyResetDay ?? 1))?.key || 'monday')), hour: String(weeklyResetHour ?? 0).padStart(2, '0'), timezone: resetTimezone || 'UTC' }) }}</template>
|
||||
<template v-else>{{ t('admin.accounts.quotaWeeklyLimitHint') }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Weekly quota -->
|
||||
<QuotaDimensionRow
|
||||
dim="weekly"
|
||||
:label="t('admin.accounts.quotaWeeklyLimit')"
|
||||
:limit="weeklyLimit"
|
||||
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
|
||||
:notify-enabled="props.quotaNotifyWeeklyEnabled"
|
||||
:notify-threshold="props.quotaNotifyWeeklyThreshold"
|
||||
:notify-threshold-type="props.quotaNotifyWeeklyThresholdType"
|
||||
:reset-mode="weeklyResetMode"
|
||||
:reset-hour="weeklyResetHour"
|
||||
:reset-day="weeklyResetDay"
|
||||
:reset-timezone="resetTimezone"
|
||||
:hint-rolling="t('admin.accounts.quotaWeeklyLimitHint')"
|
||||
:hint-fixed="weeklyFixedHint"
|
||||
:hour-options="hourOptions"
|
||||
:day-options="dayOptions"
|
||||
@update:limit="emit('update:weeklyLimit', $event)"
|
||||
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
|
||||
@update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
|
||||
@update:notify-threshold-type="emit('update:quotaNotifyWeeklyThresholdType', $event)"
|
||||
@update:reset-mode="emit('update:weeklyResetMode', $event)"
|
||||
@update:reset-hour="emit('update:weeklyResetHour', $event)"
|
||||
@update:reset-day="emit('update:weeklyResetDay', $event)"
|
||||
@update:reset-timezone="emit('update:resetTimezone', $event)"
|
||||
/>
|
||||
|
||||
<!-- 时区选择 -->
|
||||
<!-- Timezone selector (shared by daily/weekly when fixed mode is active) -->
|
||||
<div v-if="hasFixedMode">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
|
||||
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input text-sm">
|
||||
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }}</option>
|
||||
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 总配额 -->
|
||||
<div>
|
||||
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaTotalLimit') }}</span>
|
||||
<span v-if="totalLimit && totalLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
|
||||
</div>
|
||||
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaTotalLimit') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['relative', quotaNotifyGlobalEnabled ? 'w-28 flex-shrink-0' : 'flex-1']">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
|
||||
<input :value="totalLimit" @input="onTotalInput" type="number" min="0" step="0.01" class="input pl-6 py-1.5 text-sm" :placeholder="t('admin.accounts.quotaLimitPlaceholder')" />
|
||||
</div>
|
||||
<QuotaNotifyToggle
|
||||
v-if="quotaNotifyGlobalEnabled && totalLimit && totalLimit > 0"
|
||||
class="flex-1 min-w-0"
|
||||
:enabled="props.quotaNotifyTotalEnabled" :threshold="props.quotaNotifyTotalThreshold" :threshold-type="props.quotaNotifyTotalThresholdType"
|
||||
@update:enabled="emit('update:quotaNotifyTotalEnabled', $event)" @update:threshold="emit('update:quotaNotifyTotalThreshold', $event)" @update:threshold-type="emit('update:quotaNotifyTotalThresholdType', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint mb-0 text-[11px]">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
|
||||
</div>
|
||||
<!-- Total quota -->
|
||||
<QuotaDimensionRow
|
||||
dim="total"
|
||||
:label="t('admin.accounts.quotaTotalLimit')"
|
||||
:limit="totalLimit"
|
||||
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
|
||||
:notify-enabled="props.quotaNotifyTotalEnabled"
|
||||
:notify-threshold="props.quotaNotifyTotalThreshold"
|
||||
:notify-threshold-type="props.quotaNotifyTotalThresholdType"
|
||||
:reset-mode="null"
|
||||
:reset-hour="null"
|
||||
:reset-day="null"
|
||||
:reset-timezone="null"
|
||||
:hint-rolling="t('admin.accounts.quotaTotalLimitHint')"
|
||||
hint-fixed=""
|
||||
:hour-options="hourOptions"
|
||||
:day-options="dayOptions"
|
||||
@update:limit="emit('update:totalLimit', $event)"
|
||||
@update:notify-enabled="emit('update:quotaNotifyTotalEnabled', $event)"
|
||||
@update:notify-threshold="emit('update:quotaNotifyTotalThreshold', $event)"
|
||||
@update:notify-threshold-type="emit('update:quotaNotifyTotalThresholdType', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
69
frontend/src/composables/useQuotaNotifyState.ts
Normal file
69
frontend/src/composables/useQuotaNotifyState.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED } from '@/constants/account'
|
||||
|
||||
export const QUOTA_NOTIFY_DIMS = ['daily', 'weekly', 'total'] as const
|
||||
export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
|
||||
|
||||
interface DimState {
|
||||
enabled: boolean | null
|
||||
threshold: number | null
|
||||
thresholdType: string | null
|
||||
}
|
||||
|
||||
export function useQuotaNotifyState() {
|
||||
const globalEnabled = ref(false)
|
||||
const state = reactive<Record<QuotaNotifyDim, DimState>>({
|
||||
daily: { enabled: null, threshold: null, thresholdType: null },
|
||||
weekly: { enabled: null, threshold: null, thresholdType: null },
|
||||
total: { enabled: null, threshold: null, thresholdType: null },
|
||||
})
|
||||
|
||||
function loadGlobalState() {
|
||||
adminAPI.settings
|
||||
.getSettings()
|
||||
.then((settings) => {
|
||||
globalEnabled.value = settings.account_quota_notify_enabled === true
|
||||
})
|
||||
.catch(() => {
|
||||
globalEnabled.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function loadFromExtra(extra: Record<string, unknown> | null | undefined) {
|
||||
for (const d of QUOTA_NOTIFY_DIMS) {
|
||||
state[d].enabled = (extra?.[`quota_notify_${d}_enabled`] as boolean) ?? null
|
||||
state[d].threshold = (extra?.[`quota_notify_${d}_threshold`] as number) ?? null
|
||||
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as string) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function writeToExtra(extra: Record<string, unknown>, mode: 'create' | 'update') {
|
||||
for (const d of QUOTA_NOTIFY_DIMS) {
|
||||
const s = state[d]
|
||||
if (s.enabled) {
|
||||
extra[`quota_notify_${d}_enabled`] = true
|
||||
if (s.threshold != null) {
|
||||
extra[`quota_notify_${d}_threshold`] = s.threshold
|
||||
} else if (mode === 'update') {
|
||||
delete extra[`quota_notify_${d}_threshold`]
|
||||
}
|
||||
extra[`quota_notify_${d}_threshold_type`] = s.thresholdType || QUOTA_THRESHOLD_TYPE_FIXED
|
||||
} else if (mode === 'update') {
|
||||
delete extra[`quota_notify_${d}_enabled`]
|
||||
delete extra[`quota_notify_${d}_threshold`]
|
||||
delete extra[`quota_notify_${d}_threshold_type`]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
for (const d of QUOTA_NOTIFY_DIMS) {
|
||||
state[d].enabled = null
|
||||
state[d].threshold = null
|
||||
state[d].thresholdType = null
|
||||
}
|
||||
}
|
||||
|
||||
return { globalEnabled, state, loadGlobalState, loadFromExtra, writeToExtra, reset }
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
<template #cell-price="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
|
||||
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,86 +67,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Plan Edit Dialog -->
|
||||
<BaseDialog :show="showPlanDialog" :title="editingPlan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="showPlanDialog = false">
|
||||
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.planName') }}</label>
|
||||
<input v-model="planForm.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.group') }}</label>
|
||||
<Select v-model="planForm.group_id" :options="groupOptions" class="w-full">
|
||||
<template #selected="{ option }">
|
||||
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
|
||||
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
|
||||
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Info Preview -->
|
||||
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div><label class="input-label">{{ t('payment.admin.planDescription') }}</label><textarea v-model="planForm.description" rows="2" class="input"></textarea></div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.price') }}</label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.validityDays') }}</label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.validityUnit') }}</label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.features') }}</label>
|
||||
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="planForm.for_sale = !planForm.for_sale"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="showPlanDialog = false" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="plan-form" :disabled="planSaving" class="btn btn-primary">{{ planSaving ? t('common.saving') : t('common.save') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
<PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" @close="showPlanDialog = false" @saved="loadPlans" />
|
||||
|
||||
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
@@ -157,11 +85,10 @@ import type { AdminGroup } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import PlanEditDialog from './PlanEditDialog.vue'
|
||||
import { platformTextClass } from '@/utils/platformColors'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -190,39 +117,14 @@ function getPlanNameClass(groupId: number): string {
|
||||
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
|
||||
}
|
||||
|
||||
const groupOptions = computed(() => [
|
||||
{ value: 0, label: t('payment.admin.selectGroup'), platform: '' },
|
||||
...groups.value
|
||||
.filter(g => g.subscription_type === 'subscription')
|
||||
.map(g => ({
|
||||
value: g.id,
|
||||
label: `${g.name} — ${g.platform} (${g.rate_multiplier}x)`,
|
||||
platform: g.platform,
|
||||
})),
|
||||
])
|
||||
|
||||
const selectedGroupInfo = computed(() => {
|
||||
if (!planForm.group_id) return null
|
||||
return groups.value.find(g => g.id === planForm.group_id) || null
|
||||
})
|
||||
|
||||
// ==================== Plans ====================
|
||||
|
||||
const plansLoading = ref(false)
|
||||
const plans = ref<SubscriptionPlan[]>([])
|
||||
const showPlanDialog = ref(false)
|
||||
const showDeletePlanDialog = ref(false)
|
||||
const planSaving = ref(false)
|
||||
const editingPlan = ref<SubscriptionPlan | null>(null)
|
||||
const deletingPlanId = ref<number | null>(null)
|
||||
const planForm = reactive({ name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
|
||||
const planFeaturesText = ref('')
|
||||
|
||||
const validityUnitOptions = computed(() => [
|
||||
{ value: 'days', label: t('payment.admin.days') },
|
||||
{ value: 'weeks', label: t('payment.admin.weeks') },
|
||||
{ value: 'months', label: t('payment.admin.months') },
|
||||
])
|
||||
|
||||
const planColumns = computed((): Column[] => [
|
||||
{ key: 'id', label: 'ID' },
|
||||
@@ -231,7 +133,6 @@ const planColumns = computed((): Column[] => [
|
||||
{ key: 'price', label: t('payment.admin.price') },
|
||||
{ key: 'validity_days', label: t('payment.admin.validityDays') },
|
||||
{ key: 'for_sale', label: t('payment.admin.forSale') },
|
||||
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
|
||||
{ key: 'actions', label: t('common.actions') },
|
||||
])
|
||||
|
||||
@@ -253,44 +154,9 @@ async function loadPlans() {
|
||||
|
||||
function openPlanEdit(plan: SubscriptionPlan | null) {
|
||||
editingPlan.value = plan
|
||||
if (plan) {
|
||||
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale, sort_order: plan.sort_order })
|
||||
planFeaturesText.value = (plan.features || []).join('\n')
|
||||
} else {
|
||||
Object.assign(planForm, { name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
|
||||
planFeaturesText.value = ''
|
||||
}
|
||||
showPlanDialog.value = true
|
||||
}
|
||||
|
||||
/** Build request payload with snake_case keys matching backend JSON tags */
|
||||
function buildPlanPayload() {
|
||||
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
|
||||
return {
|
||||
name: planForm.name,
|
||||
group_id: planForm.group_id,
|
||||
description: planForm.description,
|
||||
price: planForm.price,
|
||||
original_price: planForm.original_price || 0,
|
||||
validity_days: planForm.validity_days,
|
||||
validity_unit: planForm.validity_unit,
|
||||
for_sale: planForm.for_sale,
|
||||
sort_order: planForm.sort_order,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePlan() {
|
||||
planSaving.value = true
|
||||
try {
|
||||
const data = buildPlanPayload()
|
||||
if (editingPlan.value) { await adminPaymentAPI.updatePlan(editingPlan.value.id, data) }
|
||||
else { await adminPaymentAPI.createPlan(data) }
|
||||
appStore.showSuccess(t('common.saved')); showPlanDialog.value = false; loadPlans()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { planSaving.value = false }
|
||||
}
|
||||
|
||||
/** Quick toggle for_sale from the list */
|
||||
async function toggleForSale(plan: SubscriptionPlan) {
|
||||
try {
|
||||
|
||||
181
frontend/src/views/admin/orders/PlanEditDialog.vue
Normal file
181
frontend/src/views/admin/orders/PlanEditDialog.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="plan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="emit('close')">
|
||||
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.planName') }} <span class="text-red-500">*</span></label>
|
||||
<input v-model="planForm.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.group') }} <span class="text-red-500">*</span></label>
|
||||
<Select v-model="planForm.group_id" :options="groupOptions" :placeholder="t('payment.admin.selectGroup')" class="w-full">
|
||||
<template #selected="{ option }">
|
||||
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
|
||||
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
|
||||
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Info Preview -->
|
||||
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div><label class="input-label">{{ t('payment.admin.planDescription') }} <span class="text-red-500">*</span></label><textarea v-model="planForm.description" rows="2" class="input" required></textarea></div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.features') }}</label>
|
||||
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="planForm.for_sale = !planForm.for_sale"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="plan-form" :disabled="saving" class="btn btn-primary">{{ saving ? t('common.saving') : t('common.save') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import { platformTextClass } from '@/utils/platformColors'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
plan: SubscriptionPlan | null
|
||||
groups: AdminGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true })
|
||||
const planFeaturesText = ref('')
|
||||
|
||||
const validityUnitOptions = computed(() => [
|
||||
{ value: 'days', label: t('payment.admin.days') },
|
||||
{ value: 'weeks', label: t('payment.admin.weeks') },
|
||||
{ value: 'months', label: t('payment.admin.months') },
|
||||
])
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups
|
||||
.filter(g => g.subscription_type === 'subscription')
|
||||
.map(g => ({
|
||||
value: g.id,
|
||||
label: `${g.name} — ${g.platform} (${g.rate_multiplier}x)`,
|
||||
platform: g.platform,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedGroupInfo = computed(() => {
|
||||
if (!planForm.group_id) return null
|
||||
return props.groups.find(g => g.id === planForm.group_id) || null
|
||||
})
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(() => props.show, (visible) => {
|
||||
if (!visible) return
|
||||
if (props.plan) {
|
||||
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', for_sale: props.plan.for_sale })
|
||||
planFeaturesText.value = (props.plan.features || []).join('\n')
|
||||
} else {
|
||||
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true })
|
||||
planFeaturesText.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
/** Build request payload with snake_case keys matching backend JSON tags */
|
||||
function buildPlanPayload() {
|
||||
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
|
||||
return {
|
||||
name: planForm.name,
|
||||
group_id: planForm.group_id,
|
||||
description: planForm.description,
|
||||
price: planForm.price,
|
||||
original_price: planForm.original_price || 0,
|
||||
validity_days: planForm.validity_days,
|
||||
validity_unit: planForm.validity_unit,
|
||||
for_sale: planForm.for_sale,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePlan() {
|
||||
if (!planForm.group_id) {
|
||||
appStore.showError(t('payment.admin.groupRequired'))
|
||||
return
|
||||
}
|
||||
if (!planForm.price || planForm.price <= 0) {
|
||||
appStore.showError(t('payment.admin.priceRequired'))
|
||||
return
|
||||
}
|
||||
if (!planForm.validity_days || planForm.validity_days < 1) {
|
||||
appStore.showError(t('payment.admin.validityDaysRequired'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data = buildPlanPayload()
|
||||
if (props.plan) { await adminPaymentAPI.updatePlan(props.plan.id, data) }
|
||||
else { await adminPaymentAPI.createPlan(data) }
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
emit('close')
|
||||
emit('saved')
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user