fix: round-2 audit fixes — security, code quality, and UI improvements
Security (HIGH): - Normalize all Redis cache keys to lowercase (verifyCode, passwordReset) - Fix verify code TTL renewal on failed attempts: use remaining TTL via ExpiresAt field instead of resetting to full 15-minute window - Add 3 missing fields to diffSettings audit log (promo_code, invitation_code, custom_endpoints) Code quality (MEDIUM): - Extract filterVerifiedEmails shared helper (balance_notify_service.go) - Add Pricing array non-empty validation for channel pricing rules - Add platform token semantics comment in gateway_service.go - Complete validatePlanPatch test coverage (+10 test cases) - Replace string types with QuotaThresholdType/QuotaResetMode across frontend - Remove duplicate getPlatformTextColor/getRateBadgeClass in ChannelsView - Return EMAIL_NOT_FOUND error on RemoveNotifyEmail miss UI improvements: - Reorder cost tooltip: user billing above separator, account billing below - Add NaN guard to accountBilled function - Move timezone selector inline into reset-mode row (no longer standalone)
This commit is contained in:
@@ -357,6 +357,11 @@ func (h *ChannelHandler) Create(c *gin.Context) {
|
|||||||
fmt.Sprintf("pricing rule #%d must have at least one group or account", i+1)))
|
fmt.Sprintf("pricing rule #%d must have at least one group or account", i+1)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(r.Pricing) == 0 {
|
||||||
|
response.ErrorFrom(c, infraerrors.BadRequest("PRICING_RULE_EMPTY_PRICING",
|
||||||
|
fmt.Sprintf("pricing rule #%d must have at least one pricing entry", i+1)))
|
||||||
|
return
|
||||||
|
}
|
||||||
rule := accountStatsPricingRuleRequestToService(r)
|
rule := accountStatsPricingRuleRequestToService(r)
|
||||||
rule.SortOrder = i
|
rule.SortOrder = i
|
||||||
statsRules = append(statsRules, rule)
|
statsRules = append(statsRules, rule)
|
||||||
@@ -420,6 +425,11 @@ func (h *ChannelHandler) Update(c *gin.Context) {
|
|||||||
fmt.Sprintf("pricing rule #%d must have at least one group or account", i+1)))
|
fmt.Sprintf("pricing rule #%d must have at least one group or account", i+1)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(r.Pricing) == 0 {
|
||||||
|
response.ErrorFrom(c, infraerrors.BadRequest("PRICING_RULE_EMPTY_PRICING",
|
||||||
|
fmt.Sprintf("pricing rule #%d must have at least one pricing entry", i+1)))
|
||||||
|
return
|
||||||
|
}
|
||||||
rule := accountStatsPricingRuleRequestToService(r)
|
rule := accountStatsPricingRuleRequestToService(r)
|
||||||
rule.SortOrder = i
|
rule.SortOrder = i
|
||||||
statsRules = append(statsRules, rule)
|
statsRules = append(statsRules, rule)
|
||||||
|
|||||||
@@ -1138,6 +1138,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
|
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
|
||||||
changed = append(changed, "registration_email_suffix_whitelist")
|
changed = append(changed, "registration_email_suffix_whitelist")
|
||||||
}
|
}
|
||||||
|
if before.PromoCodeEnabled != after.PromoCodeEnabled {
|
||||||
|
changed = append(changed, "promo_code_enabled")
|
||||||
|
}
|
||||||
|
if before.InvitationCodeEnabled != after.InvitationCodeEnabled {
|
||||||
|
changed = append(changed, "invitation_code_enabled")
|
||||||
|
}
|
||||||
if before.PasswordResetEnabled != after.PasswordResetEnabled {
|
if before.PasswordResetEnabled != after.PasswordResetEnabled {
|
||||||
changed = append(changed, "password_reset_enabled")
|
changed = append(changed, "password_reset_enabled")
|
||||||
}
|
}
|
||||||
@@ -1348,6 +1354,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.CustomMenuItems != after.CustomMenuItems {
|
if before.CustomMenuItems != after.CustomMenuItems {
|
||||||
changed = append(changed, "custom_menu_items")
|
changed = append(changed, "custom_menu_items")
|
||||||
}
|
}
|
||||||
|
if before.CustomEndpoints != after.CustomEndpoints {
|
||||||
|
changed = append(changed, "custom_endpoints")
|
||||||
|
}
|
||||||
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
|
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
|
||||||
changed = append(changed, "enable_fingerprint_unification")
|
changed = append(changed, "enable_fingerprint_unification")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// verifyCodeKey generates the Redis key for email verification code.
|
// verifyCodeKey generates the Redis key for email verification code.
|
||||||
|
// Email is lowercased for case-insensitive consistency.
|
||||||
func verifyCodeKey(email string) string {
|
func verifyCodeKey(email string) string {
|
||||||
return verifyCodeKeyPrefix + email
|
return verifyCodeKeyPrefix + strings.ToLower(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyVerifyKey generates the Redis key for notify email verification code.
|
// notifyVerifyKey generates the Redis key for notify email verification code.
|
||||||
@@ -33,12 +34,12 @@ func notifyVerifyKey(email string) string {
|
|||||||
|
|
||||||
// passwordResetKey generates the Redis key for password reset token.
|
// passwordResetKey generates the Redis key for password reset token.
|
||||||
func passwordResetKey(email string) string {
|
func passwordResetKey(email string) string {
|
||||||
return passwordResetKeyPrefix + email
|
return passwordResetKeyPrefix + strings.ToLower(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// passwordResetSentAtKey generates the Redis key for password reset email sent timestamp.
|
// passwordResetSentAtKey generates the Redis key for password reset email sent timestamp.
|
||||||
func passwordResetSentAtKey(email string) string {
|
func passwordResetSentAtKey(email string) string {
|
||||||
return passwordResetSentAtKeyPrefix + email
|
return passwordResetSentAtKeyPrefix + strings.ToLower(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
type emailCache struct {
|
type emailCache struct {
|
||||||
|
|||||||
@@ -283,6 +283,20 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return filterVerifiedEmails(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSiteName reads site name from settings with fallback.
|
||||||
|
func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
|
||||||
|
name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
||||||
|
if err != nil || name == "" {
|
||||||
|
return defaultSiteName
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterVerifiedEmails returns deduplicated, non-disabled, verified emails.
|
||||||
|
func filterVerifiedEmails(entries []NotifyEmailEntry) []string {
|
||||||
var recipients []string
|
var recipients []string
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -303,38 +317,10 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context)
|
|||||||
return recipients
|
return recipients
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSiteName reads site name from settings with fallback.
|
|
||||||
func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
|
|
||||||
name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
|
||||||
if err != nil || name == "" {
|
|
||||||
return defaultSiteName
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectBalanceNotifyRecipients returns verified, non-disabled email recipients.
|
// collectBalanceNotifyRecipients returns verified, non-disabled email recipients.
|
||||||
// Only emails with verified=true and disabled=false are included.
|
// Only emails with verified=true and disabled=false are included.
|
||||||
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
||||||
var recipients []string
|
return filterVerifiedEmails(user.BalanceNotifyExtraEmails)
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, entry := range user.BalanceNotifyExtraEmails {
|
|
||||||
if entry.Disabled || !entry.Verified {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
email := strings.TrimSpace(entry.Email)
|
|
||||||
if email == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(email)
|
|
||||||
if seen[lower] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[lower] = true
|
|
||||||
recipients = append(recipients, email)
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipients
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendEmails sends an email to all recipients with shared timeout and error logging.
|
// sendEmails sends an email to all recipients with shared timeout and error logging.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type VerificationCodeData struct {
|
|||||||
Code string
|
Code string
|
||||||
Attempts int
|
Attempts int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time // absolute expiry; used to preserve remaining TTL when updating attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordResetTokenData represents password reset token data
|
// PasswordResetTokenData represents password reset token data
|
||||||
@@ -263,6 +264,7 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName strin
|
|||||||
Code: code,
|
Code: code,
|
||||||
Attempts: 0,
|
Attempts: 0,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(verifyCodeTTL),
|
||||||
}
|
}
|
||||||
if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil {
|
if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil {
|
||||||
return fmt.Errorf("save verify code: %w", err)
|
return fmt.Errorf("save verify code: %w", err)
|
||||||
@@ -295,7 +297,11 @@ func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error
|
|||||||
// 验证码不匹配 (constant-time comparison to prevent timing attacks)
|
// 验证码不匹配 (constant-time comparison to prevent timing attacks)
|
||||||
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
|
||||||
data.Attempts++
|
data.Attempts++
|
||||||
if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil {
|
remaining := time.Until(data.ExpiresAt)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return ErrInvalidVerifyCode
|
||||||
|
}
|
||||||
|
if err := s.cache.SetVerificationCode(ctx, email, data, remaining); err != nil {
|
||||||
slog.Error("failed to update verification attempt count", "email", email, "error", err)
|
slog.Error("failed to update verification attempt count", "email", email, "error", err)
|
||||||
}
|
}
|
||||||
if data.Attempts >= maxVerifyCodeAttempts {
|
if data.Attempts >= maxVerifyCodeAttempts {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -128,3 +128,66 @@ func TestValidatePlanPatch_NilOriginalPrice(t *testing.T) {
|
|||||||
err := validatePlanPatch(UpdatePlanRequest{OriginalPrice: nil})
|
err := validatePlanPatch(UpdatePlanRequest{OriginalPrice: nil})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- validatePlanPatch: other fields ---
|
||||||
|
|
||||||
|
func ptrStr(s string) *string { return &s }
|
||||||
|
func ptrInt(i int) *int { return &i }
|
||||||
|
func ptrInt64(i int64) *int64 { return &i }
|
||||||
|
func ptrFloat(f float64) *float64 { return &f }
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_EmptyName(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{Name: ptrStr("")})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "plan name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_ValidName(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{Name: ptrStr("Basic")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_ZeroGroupID(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{GroupID: ptrInt64(0)})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "group")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_NegativePrice(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{Price: ptrFloat(-1)})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "price")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_ZeroPrice(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{Price: ptrFloat(0)})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "price")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_ValidPrice(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{Price: ptrFloat(9.99)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_ZeroValidityDays(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{ValidityDays: ptrInt(0)})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "validity days")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_EmptyValidityUnit(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{ValidityUnit: ptrStr("")})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "validity unit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_ValidValidityUnit(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{ValidityUnit: ptrStr("days")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePlanPatch_AllNil(t *testing.T) {
|
||||||
|
err := validatePlanPatch(UpdatePlanRequest{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code str
|
|||||||
Code: code,
|
Code: code,
|
||||||
Attempts: 0,
|
Attempts: 0,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(verifyCodeTTL),
|
||||||
}
|
}
|
||||||
if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
|
if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
|
||||||
return fmt.Errorf("save verify code: %w", err)
|
return fmt.Errorf("save verify code: %w", err)
|
||||||
@@ -370,7 +371,11 @@ func verifyNotifyCode(ctx context.Context, cache EmailCache, email, code string)
|
|||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
|
||||||
data.Attempts++
|
data.Attempts++
|
||||||
if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
|
remaining := time.Until(data.ExpiresAt)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return ErrInvalidVerifyCode
|
||||||
|
}
|
||||||
|
if err := cache.SetNotifyVerifyCode(ctx, email, data, remaining); err != nil {
|
||||||
slog.Error("failed to update notify verify code attempts", "email", email, "error", err)
|
slog.Error("failed to update notify verify code attempts", "email", email, "error", err)
|
||||||
}
|
}
|
||||||
if data.Attempts >= maxVerifyCodeAttempts {
|
if data.Attempts >= maxVerifyCodeAttempts {
|
||||||
@@ -418,11 +423,17 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
|
|||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
|
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
|
||||||
|
found := false
|
||||||
for _, e := range user.BalanceNotifyExtraEmails {
|
for _, e := range user.BalanceNotifyExtraEmails {
|
||||||
if !strings.EqualFold(e.Email, email) {
|
if strings.EqualFold(e.Email, email) {
|
||||||
|
found = true
|
||||||
|
} else {
|
||||||
filtered = append(filtered, e)
|
filtered = append(filtered, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
|
||||||
|
}
|
||||||
user.BalanceNotifyExtraEmails = filtered
|
user.BalanceNotifyExtraEmails = filtered
|
||||||
return s.userRepo.Update(ctx, user)
|
return s.userRepo.Update(ctx, user)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
|
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
|
||||||
|
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -11,9 +12,9 @@ const props = defineProps<{
|
|||||||
quotaNotifyGlobalEnabled: boolean
|
quotaNotifyGlobalEnabled: boolean
|
||||||
notifyEnabled: boolean | null
|
notifyEnabled: boolean | null
|
||||||
notifyThreshold: number | null
|
notifyThreshold: number | null
|
||||||
notifyThresholdType: string | null
|
notifyThresholdType: QuotaThresholdType | null
|
||||||
// Reset mode (only for daily/weekly, null for total)
|
// Reset mode (only for daily/weekly, null for total)
|
||||||
resetMode: 'rolling' | 'fixed' | null
|
resetMode: QuotaResetMode | null
|
||||||
resetHour: number | null
|
resetHour: number | null
|
||||||
resetDay: number | null // weekly only
|
resetDay: number | null // weekly only
|
||||||
resetTimezone: string | null
|
resetTimezone: string | null
|
||||||
@@ -22,14 +23,15 @@ const props = defineProps<{
|
|||||||
// Shared options passed from parent
|
// Shared options passed from parent
|
||||||
hourOptions: number[]
|
hourOptions: number[]
|
||||||
dayOptions: { value: number; key: string }[]
|
dayOptions: { value: number; key: string }[]
|
||||||
|
timezoneOptions?: string[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:limit': [value: number | null]
|
'update:limit': [value: number | null]
|
||||||
'update:notifyEnabled': [value: boolean | null]
|
'update:notifyEnabled': [value: boolean | null]
|
||||||
'update:notifyThreshold': [value: number | null]
|
'update:notifyThreshold': [value: number | null]
|
||||||
'update:notifyThresholdType': [value: string | null]
|
'update:notifyThresholdType': [value: QuotaThresholdType | null]
|
||||||
'update:resetMode': [value: 'rolling' | 'fixed' | null]
|
'update:resetMode': [value: QuotaResetMode | null]
|
||||||
'update:resetHour': [value: number | null]
|
'update:resetHour': [value: number | null]
|
||||||
'update:resetDay': [value: number | null]
|
'update:resetDay': [value: number | null]
|
||||||
'update:resetTimezone': [value: string | null]
|
'update:resetTimezone': [value: string | null]
|
||||||
@@ -43,7 +45,7 @@ const onLimitInput = (e: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onModeChange = (e: Event) => {
|
const onModeChange = (e: Event) => {
|
||||||
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
|
const val = (e.target as HTMLSelectElement).value as QuotaResetMode
|
||||||
emit('update:resetMode', val)
|
emit('update:resetMode', val)
|
||||||
if (val === 'fixed') {
|
if (val === 'fixed') {
|
||||||
if (props.resetHour == null) emit('update:resetHour', 0)
|
if (props.resetHour == null) emit('update:resetHour', 0)
|
||||||
@@ -51,6 +53,17 @@ const onModeChange = (e: Event) => {
|
|||||||
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -95,6 +108,11 @@ const onModeChange = (e: Event) => {
|
|||||||
<select :value="resetHour ?? 0" @change="emit('update:resetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
|
<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>
|
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
|
||||||
</select>
|
</select>
|
||||||
|
<template v-if="timezoneOptions && timezoneOptions.length > 0">
|
||||||
|
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input py-1 text-xs w-auto">
|
||||||
|
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>
|
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import QuotaDimensionRow from './QuotaDimensionRow.vue'
|
import QuotaDimensionRow from './QuotaDimensionRow.vue'
|
||||||
|
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -9,22 +10,22 @@ const props = withDefaults(defineProps<{
|
|||||||
totalLimit: number | null
|
totalLimit: number | null
|
||||||
dailyLimit: number | null
|
dailyLimit: number | null
|
||||||
weeklyLimit: number | null
|
weeklyLimit: number | null
|
||||||
dailyResetMode: 'rolling' | 'fixed' | null
|
dailyResetMode: QuotaResetMode | null
|
||||||
dailyResetHour: number | null
|
dailyResetHour: number | null
|
||||||
weeklyResetMode: 'rolling' | 'fixed' | null
|
weeklyResetMode: QuotaResetMode | null
|
||||||
weeklyResetDay: number | null
|
weeklyResetDay: number | null
|
||||||
weeklyResetHour: number | null
|
weeklyResetHour: number | null
|
||||||
resetTimezone: string | null
|
resetTimezone: string | null
|
||||||
quotaNotifyGlobalEnabled?: boolean
|
quotaNotifyGlobalEnabled?: boolean
|
||||||
quotaNotifyDailyEnabled?: boolean | null
|
quotaNotifyDailyEnabled?: boolean | null
|
||||||
quotaNotifyDailyThreshold?: number | null
|
quotaNotifyDailyThreshold?: number | null
|
||||||
quotaNotifyDailyThresholdType?: string | null
|
quotaNotifyDailyThresholdType?: QuotaThresholdType | null
|
||||||
quotaNotifyWeeklyEnabled?: boolean | null
|
quotaNotifyWeeklyEnabled?: boolean | null
|
||||||
quotaNotifyWeeklyThreshold?: number | null
|
quotaNotifyWeeklyThreshold?: number | null
|
||||||
quotaNotifyWeeklyThresholdType?: string | null
|
quotaNotifyWeeklyThresholdType?: QuotaThresholdType | null
|
||||||
quotaNotifyTotalEnabled?: boolean | null
|
quotaNotifyTotalEnabled?: boolean | null
|
||||||
quotaNotifyTotalThreshold?: number | null
|
quotaNotifyTotalThreshold?: number | null
|
||||||
quotaNotifyTotalThresholdType?: string | null
|
quotaNotifyTotalThresholdType?: QuotaThresholdType | null
|
||||||
}>(), {
|
}>(), {
|
||||||
quotaNotifyGlobalEnabled: false,
|
quotaNotifyGlobalEnabled: false,
|
||||||
quotaNotifyDailyEnabled: null,
|
quotaNotifyDailyEnabled: null,
|
||||||
@@ -42,21 +43,21 @@ const emit = defineEmits<{
|
|||||||
'update:totalLimit': [value: number | null]
|
'update:totalLimit': [value: number | null]
|
||||||
'update:dailyLimit': [value: number | null]
|
'update:dailyLimit': [value: number | null]
|
||||||
'update:weeklyLimit': [value: number | null]
|
'update:weeklyLimit': [value: number | null]
|
||||||
'update:dailyResetMode': [value: 'rolling' | 'fixed' | null]
|
'update:dailyResetMode': [value: QuotaResetMode | null]
|
||||||
'update:dailyResetHour': [value: number | null]
|
'update:dailyResetHour': [value: number | null]
|
||||||
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null]
|
'update:weeklyResetMode': [value: QuotaResetMode | null]
|
||||||
'update:weeklyResetDay': [value: number | null]
|
'update:weeklyResetDay': [value: number | null]
|
||||||
'update:weeklyResetHour': [value: number | null]
|
'update:weeklyResetHour': [value: number | null]
|
||||||
'update:resetTimezone': [value: string | null]
|
'update:resetTimezone': [value: string | null]
|
||||||
'update:quotaNotifyDailyEnabled': [value: boolean | null]
|
'update:quotaNotifyDailyEnabled': [value: boolean | null]
|
||||||
'update:quotaNotifyDailyThreshold': [value: number | null]
|
'update:quotaNotifyDailyThreshold': [value: number | null]
|
||||||
'update:quotaNotifyDailyThresholdType': [value: string | null]
|
'update:quotaNotifyDailyThresholdType': [value: QuotaThresholdType | null]
|
||||||
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
|
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
|
||||||
'update:quotaNotifyWeeklyThreshold': [value: number | null]
|
'update:quotaNotifyWeeklyThreshold': [value: number | null]
|
||||||
'update:quotaNotifyWeeklyThresholdType': [value: string | null]
|
'update:quotaNotifyWeeklyThresholdType': [value: QuotaThresholdType | null]
|
||||||
'update:quotaNotifyTotalEnabled': [value: boolean | null]
|
'update:quotaNotifyTotalEnabled': [value: boolean | null]
|
||||||
'update:quotaNotifyTotalThreshold': [value: number | null]
|
'update:quotaNotifyTotalThreshold': [value: number | null]
|
||||||
'update:quotaNotifyTotalThresholdType': [value: string | null]
|
'update:quotaNotifyTotalThresholdType': [value: QuotaThresholdType | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const enabled = computed(() =>
|
const enabled = computed(() =>
|
||||||
@@ -89,11 +90,6 @@ watch(localEnabled, (val) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Whether any fixed mode is active (to show timezone selector)
|
|
||||||
const hasFixedMode = computed(() =>
|
|
||||||
props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Common timezone options
|
// Common timezone options
|
||||||
const timezoneOptions = [
|
const timezoneOptions = [
|
||||||
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
|
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
|
||||||
@@ -102,18 +98,6 @@ const timezoneOptions = [
|
|||||||
'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
|
'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)
|
// Hours for dropdown (0-23)
|
||||||
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
|
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
@@ -197,6 +181,7 @@ const dailyFixedHint = computed(() =>
|
|||||||
:hint-fixed="dailyFixedHint"
|
:hint-fixed="dailyFixedHint"
|
||||||
:hour-options="hourOptions"
|
:hour-options="hourOptions"
|
||||||
:day-options="dayOptions"
|
:day-options="dayOptions"
|
||||||
|
:timezone-options="timezoneOptions"
|
||||||
@update:limit="emit('update:dailyLimit', $event)"
|
@update:limit="emit('update:dailyLimit', $event)"
|
||||||
@update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
|
@update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
|
||||||
@update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
|
@update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
|
||||||
@@ -223,6 +208,7 @@ const dailyFixedHint = computed(() =>
|
|||||||
:hint-fixed="weeklyFixedHint"
|
:hint-fixed="weeklyFixedHint"
|
||||||
:hour-options="hourOptions"
|
:hour-options="hourOptions"
|
||||||
:day-options="dayOptions"
|
:day-options="dayOptions"
|
||||||
|
:timezone-options="timezoneOptions"
|
||||||
@update:limit="emit('update:weeklyLimit', $event)"
|
@update:limit="emit('update:weeklyLimit', $event)"
|
||||||
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
|
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
|
||||||
@update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
|
@update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
|
||||||
@@ -233,14 +219,6 @@ const dailyFixedHint = computed(() =>
|
|||||||
@update:reset-timezone="emit('update:resetTimezone', $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 }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total quota -->
|
<!-- Total quota -->
|
||||||
<QuotaDimensionRow
|
<QuotaDimensionRow
|
||||||
dim="total"
|
dim="total"
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE } from '@/constants/account'
|
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE, type QuotaThresholdType } from '@/constants/account'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
enabled: boolean | null
|
enabled: boolean | null
|
||||||
threshold: number | null
|
threshold: number | null
|
||||||
thresholdType: string | null // "fixed" (default) or "percentage"
|
thresholdType: QuotaThresholdType | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:enabled': [value: boolean | null]
|
'update:enabled': [value: boolean | null]
|
||||||
'update:threshold': [value: number | null]
|
'update:threshold': [value: number | null]
|
||||||
'update:thresholdType': [value: string | null]
|
'update:thresholdType': [value: QuotaThresholdType | null]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ const emit = defineEmits<{
|
|||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
:value="thresholdType || QUOTA_THRESHOLD_TYPE_FIXED"
|
:value="thresholdType || QUOTA_THRESHOLD_TYPE_FIXED"
|
||||||
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value)"
|
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value as QuotaThresholdType)"
|
||||||
class="input py-1 text-xs w-[4.5rem] flex-shrink-0 text-center"
|
class="input py-1 text-xs w-[4.5rem] flex-shrink-0 text-center"
|
||||||
>
|
>
|
||||||
<option :value="QUOTA_THRESHOLD_TYPE_FIXED">$</option>
|
<option :value="QUOTA_THRESHOLD_TYPE_FIXED">$</option>
|
||||||
|
|||||||
@@ -313,10 +313,6 @@
|
|||||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
|
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-6">
|
|
||||||
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
|
||||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-6">
|
<div class="flex items-center justify-between gap-6">
|
||||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
@@ -325,7 +321,12 @@
|
|||||||
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Account billing (separated from user billing) -->
|
||||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||||
|
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
||||||
|
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
|
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||||
<span class="font-semibold text-green-400">
|
<span class="font-semibold text-green-400">
|
||||||
${{ accountBilled({
|
${{ accountBilled({
|
||||||
@@ -355,7 +356,8 @@ import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILL
|
|||||||
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
||||||
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
|
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
|
||||||
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
|
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
|
||||||
return base * (row.account_rate_multiplier ?? 1)
|
const result = base * (row.account_rate_multiplier ?? 1)
|
||||||
|
return Number.isNaN(result) ? 0 : result
|
||||||
}
|
}
|
||||||
|
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import { QUOTA_THRESHOLD_TYPE_FIXED } from '@/constants/account'
|
import { QUOTA_THRESHOLD_TYPE_FIXED, type QuotaThresholdType } from '@/constants/account'
|
||||||
|
|
||||||
export const QUOTA_NOTIFY_DIMS = ['daily', 'weekly', 'total'] as const
|
export const QUOTA_NOTIFY_DIMS = ['daily', 'weekly', 'total'] as const
|
||||||
export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
|
export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
|
||||||
@@ -8,7 +8,7 @@ export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
|
|||||||
interface DimState {
|
interface DimState {
|
||||||
enabled: boolean | null
|
enabled: boolean | null
|
||||||
threshold: number | null
|
threshold: number | null
|
||||||
thresholdType: string | null
|
thresholdType: QuotaThresholdType | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useQuotaNotifyState() {
|
export function useQuotaNotifyState() {
|
||||||
@@ -34,7 +34,7 @@ export function useQuotaNotifyState() {
|
|||||||
for (const d of QUOTA_NOTIFY_DIMS) {
|
for (const d of QUOTA_NOTIFY_DIMS) {
|
||||||
state[d].enabled = (extra?.[`quota_notify_${d}_enabled`] as boolean) ?? null
|
state[d].enabled = (extra?.[`quota_notify_${d}_enabled`] as boolean) ?? null
|
||||||
state[d].threshold = (extra?.[`quota_notify_${d}_threshold`] as number) ?? null
|
state[d].threshold = (extra?.[`quota_notify_${d}_threshold`] as number) ?? null
|
||||||
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as string) ?? null
|
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as QuotaThresholdType) ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,8 @@ export type WebSearchMode = typeof WEB_SEARCH_MODE_DEFAULT | typeof WEB_SEARCH_M
|
|||||||
export const QUOTA_THRESHOLD_TYPE_FIXED = 'fixed' as const
|
export const QUOTA_THRESHOLD_TYPE_FIXED = 'fixed' as const
|
||||||
export const QUOTA_THRESHOLD_TYPE_PERCENTAGE = 'percentage' as const
|
export const QUOTA_THRESHOLD_TYPE_PERCENTAGE = 'percentage' as const
|
||||||
export type QuotaThresholdType = typeof QUOTA_THRESHOLD_TYPE_FIXED | typeof QUOTA_THRESHOLD_TYPE_PERCENTAGE
|
export type QuotaThresholdType = typeof QUOTA_THRESHOLD_TYPE_FIXED | typeof QUOTA_THRESHOLD_TYPE_PERCENTAGE
|
||||||
|
|
||||||
|
/** Quota reset mode values */
|
||||||
|
export const QUOTA_RESET_MODE_ROLLING = 'rolling' as const
|
||||||
|
export const QUOTA_RESET_MODE_FIXED = 'fixed' as const
|
||||||
|
export type QuotaResetMode = typeof QUOTA_RESET_MODE_ROLLING | typeof QUOTA_RESET_MODE_FIXED
|
||||||
|
|||||||
@@ -166,8 +166,8 @@
|
|||||||
class="channel-tab group"
|
class="channel-tab group"
|
||||||
:class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
|
:class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
|
||||||
>
|
>
|
||||||
<PlatformIcon :platform="section.platform" size="xs" :class="getPlatformTextColor(section.platform)" />
|
<PlatformIcon :platform="section.platform" size="xs" :class="platformTextClass(section.platform)" />
|
||||||
<span :class="getPlatformTextColor(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
|
<span :class="platformTextClass(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,8 +246,8 @@
|
|||||||
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
@change="togglePlatform(p)"
|
@change="togglePlatform(p)"
|
||||||
/>
|
/>
|
||||||
<PlatformIcon :platform="p" size="xs" :class="getPlatformTextColor(p)" />
|
<PlatformIcon :platform="p" size="xs" :class="platformTextClass(p)" />
|
||||||
<span :class="getPlatformTextColor(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
|
<span :class="platformTextClass(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,9 +310,9 @@
|
|||||||
class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
@change="toggleGroupInSection(sIdx, group.id)"
|
@change="toggleGroupInSection(sIdx, group.id)"
|
||||||
/>
|
/>
|
||||||
<span :class="['font-medium', getPlatformTextColor(group.platform)]">{{ group.name }}</span>
|
<span :class="['font-medium', platformTextClass(group.platform)]">{{ group.name }}</span>
|
||||||
<span
|
<span
|
||||||
:class="['rounded-full px-1 py-0 text-[10px]', getRateBadgeClass(group.platform)]"
|
:class="['rounded-full px-1 py-0 text-[10px]', platformBadgeLightClass(group.platform)]"
|
||||||
>{{ group.rate_multiplier }}x</span>
|
>{{ group.rate_multiplier }}x</span>
|
||||||
<span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span>
|
<span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span>
|
||||||
<span
|
<span
|
||||||
@@ -363,7 +363,7 @@
|
|||||||
:value="srcModel"
|
:value="srcModel"
|
||||||
type="text"
|
type="text"
|
||||||
class="input flex-1 text-xs"
|
class="input flex-1 text-xs"
|
||||||
:class="getPlatformTextColor(section.platform)"
|
:class="platformTextClass(section.platform)"
|
||||||
:placeholder="t('admin.channels.form.mappingSource', 'Source model')"
|
:placeholder="t('admin.channels.form.mappingSource', 'Source model')"
|
||||||
@change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
|
@change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
:value="section.model_mapping[srcModel]"
|
:value="section.model_mapping[srcModel]"
|
||||||
type="text"
|
type="text"
|
||||||
class="input flex-1 text-xs"
|
class="input flex-1 text-xs"
|
||||||
:class="getPlatformTextColor(section.platform)"
|
:class="platformTextClass(section.platform)"
|
||||||
:placeholder="t('admin.channels.form.mappingTarget', 'Target model')"
|
:placeholder="t('admin.channels.form.mappingTarget', 'Target model')"
|
||||||
@input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
|
@input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
|
||||||
/>
|
/>
|
||||||
@@ -464,7 +464,7 @@
|
|||||||
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
|
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
|
||||||
>
|
>
|
||||||
<input type="checkbox" :checked="rule.group_ids.includes(gid)" class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500" @change="rule.group_ids.includes(gid) ? rule.group_ids.splice(rule.group_ids.indexOf(gid), 1) : rule.group_ids.push(gid)" />
|
<input type="checkbox" :checked="rule.group_ids.includes(gid)" class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500" @change="rule.group_ids.includes(gid) ? rule.group_ids.splice(rule.group_ids.indexOf(gid), 1) : rule.group_ids.push(gid)" />
|
||||||
<span>{{ getGroupNameById(gid) }}</span>
|
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getGroupNameById(gid) }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="section.group_ids.length === 0" class="mt-1 text-xs text-gray-400">
|
<p v-if="section.group_ids.length === 0" class="mt-1 text-xs text-gray-400">
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
:key="accountId"
|
:key="accountId"
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
|
class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
|
||||||
>
|
>
|
||||||
<span>{{ getRuleAccountLabel(accountId) }}</span>
|
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getRuleAccountLabel(accountId) }}</span>
|
||||||
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
|
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
|
||||||
<Icon name="x" size="xs" />
|
<Icon name="x" size="xs" />
|
||||||
</button>
|
</button>
|
||||||
@@ -595,7 +595,7 @@ import type { PricingFormEntry } from '@/components/admin/channel/types'
|
|||||||
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
|
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
|
||||||
import type { AdminGroup, GroupPlatform } from '@/types'
|
import type { AdminGroup, GroupPlatform } from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import { platformTextClass } from '@/utils/platformColors'
|
import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
import DataTable from '@/components/common/DataTable.vue'
|
import DataTable from '@/components/common/DataTable.vue'
|
||||||
@@ -720,26 +720,6 @@ let abortController: AbortController | null = null
|
|||||||
// ── Platform config ──
|
// ── Platform config ──
|
||||||
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
|
||||||
|
|
||||||
function getPlatformTextColor(platform: string): string {
|
|
||||||
switch (platform) {
|
|
||||||
case 'anthropic': return 'text-orange-600 dark:text-orange-400'
|
|
||||||
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
case 'gemini': return 'text-blue-600 dark:text-blue-400'
|
|
||||||
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
|
|
||||||
default: return 'text-gray-600 dark:text-gray-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRateBadgeClass(platform: string): string {
|
|
||||||
switch (platform) {
|
|
||||||
case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
|
||||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
|
||||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
|
||||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
|
||||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
function formatDate(value: string): string {
|
function formatDate(value: string): string {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
|
|||||||
Reference in New Issue
Block a user