refactor(frontend): extract centralized platform color config

Create utils/platformColors.ts as single source of truth for all
platform color classes (badge, border, accent, text, icon, button,
discount). Refactor SubscriptionPlanCard to use it.
This commit is contained in:
erio
2026-04-09 00:15:25 +08:00
parent e95cc3cbbe
commit 9a0bdaba8e
2 changed files with 159 additions and 128 deletions
@@ -3,20 +3,20 @@
:class="[
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
'hover:shadow-xl hover:-translate-y-0.5',
cardBorderClass,
borderClass,
'bg-white dark:bg-dark-800',
]"
>
<!-- Colored top accent bar -->
<div :class="['h-1.5', accentBarClass]" />
<div :class="['h-1.5', accentClass]" />
<div class="flex flex-1 flex-col p-5">
<!-- Header: name + platform badge -->
<div class="mb-4">
<div class="flex items-center gap-2">
<h3 class="text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
<span :class="['rounded-full px-2 py-0.5 text-[11px] font-medium', platformBadgeClass]">
{{ platformLabel }}
<span :class="['rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]">
{{ pLabel }}
</span>
</div>
<p v-if="plan.group_name" class="mt-1 text-xs text-gray-400 dark:text-dark-500">
@@ -31,14 +31,14 @@
<div class="mb-4">
<div class="flex items-baseline gap-1.5">
<span class="text-sm text-gray-400 dark:text-dark-500">&yen;</span>
<span :class="['text-4xl font-extrabold tracking-tight', priceClass]">{{ plan.price }}</span>
<span :class="['text-4xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
<span class="text-sm text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
</div>
<div v-if="plan.original_price" class="mt-1 flex items-center gap-2">
<span class="text-sm text-gray-400 line-through dark:text-dark-500">
&yen;{{ plan.original_price }}
</span>
<span :class="['rounded px-1.5 py-0.5 text-[11px] font-semibold', discountBadgeClass]">
<span :class="['rounded px-1.5 py-0.5 text-[11px] font-semibold', discountClass]">
{{ discountText }}
</span>
</div>
@@ -46,32 +46,26 @@
<!-- Group quota info -->
<div class="mb-4 space-y-1.5 rounded-lg bg-gray-50 px-3 py-2.5 dark:bg-dark-700/50">
<!-- Rate multiplier -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
</div>
<!-- Daily limit -->
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between text-xs">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
</div>
<!-- Weekly limit -->
<div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between text-xs">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.weekly_limit_usd }}</span>
</div>
<!-- Monthly limit -->
<div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between text-xs">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.monthly_limit_usd }}</span>
</div>
<!-- No limits indicator -->
<div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between text-xs">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.quota') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.planCard.unlimited') }}</span>
</div>
<!-- Model scopes -->
<div v-if="modelScopeLabels.length > 0" class="flex items-center justify-between text-xs">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.models') }}</span>
<div class="flex flex-wrap justify-end gap-1">
@@ -85,24 +79,19 @@
<!-- Features list with checkmarks -->
<div v-if="plan.features.length > 0" class="mb-4 space-y-2">
<div
v-for="feature in plan.features"
:key="feature"
class="flex items-start gap-2"
>
<svg :class="['mt-0.5 h-4 w-4 flex-shrink-0', checkIconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<div v-for="feature in plan.features" :key="feature" class="flex items-start gap-2">
<svg :class="['mt-0.5 h-4 w-4 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="text-sm text-gray-600 dark:text-gray-300">{{ feature }}</span>
</div>
</div>
<!-- Spacer pushes button to bottom -->
<div class="flex-1" />
<!-- Validity badge -->
<div class="mb-3 text-center">
<span :class="['rounded-full px-3 py-1 text-xs font-medium', badgeClass]">
<span :class="['rounded-full px-3 py-1 text-xs font-medium', badgeLightClass]">
{{ validityLabel }}
</span>
</div>
@@ -110,11 +99,7 @@
<!-- Subscribe Button -->
<button
type="button"
:class="[
'w-full rounded-xl py-2.5 text-sm font-semibold transition-all',
'active:scale-[0.98]',
buttonClass,
]"
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@click="emit('select', plan)"
>
{{ t('payment.subscribeNow') }}
@@ -127,44 +112,44 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionPlan } from '@/types/payment'
import {
platformAccentBarClass,
platformBadgeLightClass,
platformBorderClass,
platformTextClass,
platformIconClass,
platformButtonClass,
platformDiscountClass,
platformLabel,
} from '@/utils/platformColors'
const props = defineProps<{
plan: SubscriptionPlan
}>()
const emit = defineEmits<{
select: [plan: SubscriptionPlan]
}>()
const props = defineProps<{ plan: SubscriptionPlan }>()
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
const { t } = useI18n()
const platform = computed(() => props.plan.group_platform || '')
// Discount percentage
// Derived color classes from central config
const accentClass = computed(() => platformAccentBarClass(platform.value))
const borderClass = computed(() => platformBorderClass(platform.value))
const badgeLightClass = computed(() => platformBadgeLightClass(platform.value))
const textClass = computed(() => platformTextClass(platform.value))
const iconClass = computed(() => platformIconClass(platform.value))
const btnClass = computed(() => platformButtonClass(platform.value))
const discountClass = computed(() => platformDiscountClass(platform.value))
const pLabel = computed(() => platformLabel(platform.value))
const discountText = computed(() => {
if (!props.plan.original_price || props.plan.original_price <= 0) return ''
const pct = Math.round((1 - props.plan.price / props.plan.original_price) * 100)
return pct > 0 ? `-${pct}%` : ''
})
// Rate multiplier display
const rateDisplay = computed(() => {
const rate = props.plan.rate_multiplier ?? 1
return `×${Number(rate.toPrecision(10))}`
})
// Platform display label
const platformLabel = computed(() => {
switch (platform.value) {
case 'anthropic': return 'Anthropic'
case 'openai': return 'OpenAI'
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
default: return platform.value || 'API'
}
})
// Model scope labels
const MODEL_SCOPE_LABELS: Record<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',
@@ -177,87 +162,6 @@ const modelScopeLabels = computed(() => {
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
})
// Color schemes per platform
const accentBarClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'bg-gradient-to-r from-orange-400 to-orange-500'
case 'openai': return 'bg-gradient-to-r from-emerald-400 to-emerald-500'
case 'antigravity': return 'bg-gradient-to-r from-purple-400 to-purple-500'
case 'gemini': return 'bg-gradient-to-r from-blue-400 to-blue-500'
default: return 'bg-gradient-to-r from-primary-400 to-primary-500'
}
})
const cardBorderClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'border-orange-100 dark:border-orange-900/30'
case 'openai': return 'border-emerald-100 dark:border-emerald-900/30'
case 'antigravity': return 'border-purple-100 dark:border-purple-900/30'
case 'gemini': return 'border-blue-100 dark:border-blue-900/30'
default: return 'border-gray-200 dark:border-dark-700'
}
})
const platformBadgeClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300'
case 'openai': return 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300'
case 'antigravity': return 'bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-300'
case 'gemini': return 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'
default: return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
}
})
const badgeClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300'
case 'openai': return 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300'
case 'antigravity': return 'bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-300'
case 'gemini': return 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'
default: return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
}
})
const priceClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'text-orange-600 dark:text-orange-400'
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
case 'gemini': return 'text-blue-600 dark:text-blue-400'
default: return 'text-primary-600 dark:text-primary-400'
}
})
const discountBadgeClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
default: return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
}
})
const checkIconClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'text-orange-500 dark:text-orange-400'
case 'openai': return 'text-emerald-500 dark:text-emerald-400'
case 'antigravity': return 'text-purple-500 dark:text-purple-400'
case 'gemini': return 'text-blue-500 dark:text-blue-400'
default: return 'text-primary-500 dark:text-primary-400'
}
})
const buttonClass = computed(() => {
switch (platform.value) {
case 'anthropic': return 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-500'
case 'openai': return 'bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-500'
case 'antigravity': return 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-500'
case 'gemini': return 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500'
default: return 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
}
})
const validityLabel = computed(() => {
const d = props.plan.validity_days
const u = props.plan.validity_unit || 'day'
+127
View File
@@ -0,0 +1,127 @@
/**
* Centralized platform color definitions.
*
* All components that need platform-specific styling should import from here
* instead of defining their own color mappings.
*/
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
// ── Badge (bg + text) ───────────────────────────────────────────────
const BADGE: Record<Platform, string> = {
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
}
const BADGE_DEFAULT = 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
// ── Light badge (softer bg) ─────────────────────────────────────────
const BADGE_LIGHT: Record<Platform, string> = {
anthropic: 'bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300',
openai: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300',
antigravity: 'bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-300',
gemini: 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300',
}
// ── Border ──────────────────────────────────────────────────────────
const BORDER: Record<Platform, string> = {
anthropic: 'border-orange-100 dark:border-orange-900/30',
openai: 'border-emerald-100 dark:border-emerald-900/30',
antigravity: 'border-purple-100 dark:border-purple-900/30',
gemini: 'border-blue-100 dark:border-blue-900/30',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
// ── Accent bar (gradient) ───────────────────────────────────────────
const ACCENT_BAR: Record<Platform, string> = {
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
// ── Text (price, icon) ─────────────────────────────────────────────
const TEXT: Record<Platform, string> = {
anthropic: 'text-orange-600 dark:text-orange-400',
openai: 'text-emerald-600 dark:text-emerald-400',
antigravity: 'text-purple-600 dark:text-purple-400',
gemini: 'text-blue-600 dark:text-blue-400',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
// ── Icon (check mark etc.) ──────────────────────────────────────────
const ICON: Record<Platform, string> = {
anthropic: 'text-orange-500 dark:text-orange-400',
openai: 'text-emerald-500 dark:text-emerald-400',
antigravity: 'text-purple-500 dark:text-purple-400',
gemini: 'text-blue-500 dark:text-blue-400',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
// ── Button (solid bg) ───────────────────────────────────────────────
const BUTTON: Record<Platform, string> = {
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-500',
openai: 'bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-500',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
// ── Discount badge ──────────────────────────────────────────────────
const DISCOUNT: Record<Platform, string> = {
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
}
export function platformBadgeClass(p: string): string {
return isPlatform(p) ? BADGE[p] : BADGE_DEFAULT
}
export function platformBadgeLightClass(p: string): string {
return isPlatform(p) ? BADGE_LIGHT[p] : BADGE_DEFAULT
}
export function platformBorderClass(p: string): string {
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
}
export function platformAccentBarClass(p: string): string {
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
}
export function platformTextClass(p: string): string {
return isPlatform(p) ? TEXT[p] : TEXT_DEFAULT
}
export function platformIconClass(p: string): string {
return isPlatform(p) ? ICON[p] : ICON_DEFAULT
}
export function platformButtonClass(p: string): string {
return isPlatform(p) ? BUTTON[p] : BUTTON_DEFAULT
}
export function platformDiscountClass(p: string): string {
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
}
export function platformLabel(p: string): string {
switch (p) {
case 'anthropic': return 'Anthropic'
case 'openai': return 'OpenAI'
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
default: return p || 'API'
}
}