| <template> |
| <BaseDialog :show="show" :title="t('admin.users.groupConfig')" width="wide" @close="$emit('close')"> |
| <div v-if="user" class="space-y-6"> |
| |
| <div class="flex items-center gap-4 rounded-2xl bg-gradient-to-r from-primary-50 to-primary-100 p-5 dark:from-primary-900/30 dark:to-primary-800/20"> |
| <div class="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-sm dark:bg-dark-700"> |
| <span class="text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ user.email.charAt(0).toUpperCase() }}</span> |
| </div> |
| <div class="flex-1"> |
| <p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.email }}</p> |
| <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ t('admin.users.groupConfigHint', { email: user.email }) }}</p> |
| </div> |
| </div> |
| |
| |
| <div v-if="loading" class="flex justify-center py-12"> |
| <svg class="h-10 w-10 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| </div> |
| |
| <div v-else class="space-y-6"> |
| |
| <div v-if="exclusiveGroups.length > 0"> |
| <div class="mb-3 flex items-center gap-2"> |
| <div class="h-1.5 w-1.5 rounded-full bg-purple-500"></div> |
| <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.exclusiveGroups') }}</h4> |
| <span class="text-xs text-gray-400">({{ exclusiveGroupConfigs.filter(c => c.isSelected).length }}/{{ exclusiveGroupConfigs.length }})</span> |
| </div> |
| <div class="grid gap-3"> |
| <div |
| v-for="config in exclusiveGroupConfigs" |
| :key="config.groupId" |
| class="group relative overflow-hidden rounded-xl border-2 p-4 transition-all duration-200" |
| :class="config.isSelected |
| ? 'border-primary-400 bg-primary-50/50 shadow-sm dark:border-primary-500 dark:bg-primary-900/20' |
| : 'border-gray-200 bg-white hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:hover:border-dark-500'" |
| > |
| <div class="flex items-center gap-4"> |
| |
| <div class="flex-shrink-0"> |
| <label class="relative flex h-6 w-6 cursor-pointer items-center justify-center"> |
| <input |
| type="checkbox" |
| :checked="config.isSelected" |
| @change="toggleExclusiveGroup(config.groupId)" |
| class="peer sr-only" |
| /> |
| <div class="h-5 w-5 rounded-md border-2 border-gray-300 transition-all peer-checked:border-primary-500 peer-checked:bg-primary-500 dark:border-dark-500 peer-checked:dark:border-primary-500"> |
| <svg v-if="config.isSelected" class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> |
| </svg> |
| </div> |
| </label> |
| </div> |
| |
| |
| <div class="min-w-0 flex-1"> |
| <div class="flex items-center gap-2"> |
| <span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span> |
| <span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"> |
| {{ t('admin.groups.exclusive') }} |
| </span> |
| </div> |
| <div class="mt-1.5 flex items-center gap-3 text-sm"> |
| <span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400"> |
| <PlatformIcon :platform="config.platform" size="xs" /> |
| <span>{{ config.platform }}</span> |
| </span> |
| <span class="text-gray-300 dark:text-dark-500">•</span> |
| <span class="text-gray-500 dark:text-gray-400"> |
| {{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span> |
| </span> |
| </div> |
| </div> |
| |
| |
| <div class="flex flex-shrink-0 items-center gap-3"> |
| <label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label> |
| <input |
| type="number" |
| step="0.001" |
| min="0.001" |
| :value="config.customRate ?? ''" |
| @input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)" |
| :placeholder="String(config.defaultRate)" |
| class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500" |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="publicGroups.length > 0"> |
| <div class="mb-3 flex items-center gap-2"> |
| <div class="h-1.5 w-1.5 rounded-full bg-green-500"></div> |
| <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.publicGroups') }}</h4> |
| <span class="text-xs text-gray-400">({{ publicGroupConfigs.length }})</span> |
| </div> |
| <div class="grid gap-3"> |
| <div |
| v-for="config in publicGroupConfigs" |
| :key="config.groupId" |
| class="relative overflow-hidden rounded-xl border-2 border-green-200 bg-green-50/50 p-4 dark:border-green-800/50 dark:bg-green-900/10" |
| > |
| <div class="flex items-center gap-4"> |
| |
| <div class="flex-shrink-0"> |
| <div class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600"> |
| <svg class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> |
| </svg> |
| </div> |
| </div> |
| |
| |
| <div class="min-w-0 flex-1"> |
| <div class="flex items-center gap-2"> |
| <span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span> |
| </div> |
| <div class="mt-1.5 flex items-center gap-3 text-sm"> |
| <span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400"> |
| <PlatformIcon :platform="config.platform" size="xs" /> |
| <span>{{ config.platform }}</span> |
| </span> |
| <span class="text-gray-300 dark:text-dark-500">•</span> |
| <span class="text-gray-500 dark:text-gray-400"> |
| {{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span> |
| </span> |
| </div> |
| </div> |
| |
| |
| <div class="flex flex-shrink-0 items-center gap-3"> |
| <label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label> |
| <input |
| type="number" |
| step="0.001" |
| min="0.001" |
| :value="config.customRate ?? ''" |
| @input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)" |
| :placeholder="String(config.defaultRate)" |
| class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500" |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="groups.length === 0" class="flex flex-col items-center justify-center py-12 text-center"> |
| <div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"> |
| <svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> |
| </svg> |
| </div> |
| <p class="text-gray-500 dark:text-gray-400">{{ t('common.noGroupsAvailable') }}</p> |
| </div> |
| </div> |
| </div> |
| |
| <template #footer> |
| <div class="flex justify-end gap-3"> |
| <button @click="$emit('close')" class="btn btn-secondary px-5">{{ t('common.cancel') }}</button> |
| <button @click="handleSave" :disabled="submitting" class="btn btn-primary px-6"> |
| <svg v-if="submitting" class="-ml-1 mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| {{ submitting ? t('common.saving') : t('common.save') }} |
| </button> |
| </div> |
| </template> |
| </BaseDialog> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, watch, computed } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import { useAppStore } from '@/stores/app' |
| import { adminAPI } from '@/api/admin' |
| import type { AdminUser, Group, GroupPlatform } from '@/types' |
| import BaseDialog from '@/components/common/BaseDialog.vue' |
| import PlatformIcon from '@/components/common/PlatformIcon.vue' |
| |
| interface GroupRateConfig { |
| groupId: number |
| groupName: string |
| platform: GroupPlatform |
| isExclusive: boolean |
| defaultRate: number |
| customRate: number | null |
| isSelected: boolean |
| } |
| |
| const props = defineProps<{ show: boolean; user: AdminUser | null }>() |
| const emit = defineEmits(['close', 'success']) |
| const { t } = useI18n() |
| const appStore = useAppStore() |
| |
| const groups = ref<Group[]>([]) |
| const groupConfigs = ref<GroupRateConfig[]>([]) |
| const originalGroupRates = ref<Record<number, number>>({}) |
| const loading = ref(false) |
| const submitting = ref(false) |
| |
| |
| const exclusiveGroups = computed(() => groups.value.filter((g) => g.is_exclusive)) |
| const publicGroups = computed(() => groups.value.filter((g) => !g.is_exclusive)) |
| |
| const exclusiveGroupConfigs = computed(() => groupConfigs.value.filter((c) => c.isExclusive)) |
| const publicGroupConfigs = computed(() => groupConfigs.value.filter((c) => !c.isExclusive)) |
| |
| watch( |
| () => props.show, |
| (v) => { |
| if (v && props.user) { |
| load() |
| } |
| } |
| ) |
| |
| const load = async () => { |
| loading.value = true |
| try { |
| const res = await adminAPI.groups.list(1, 1000) |
| |
| groups.value = res.items.filter((g) => g.subscription_type === 'standard' && g.status === 'active') |
| |
| |
| const userAllowedGroups = props.user?.allowed_groups || [] |
| const userGroupRates = props.user?.group_rates || {} |
| |
| |
| originalGroupRates.value = { ...userGroupRates } |
| |
| groupConfigs.value = groups.value.map((g) => ({ |
| groupId: g.id, |
| groupName: g.name, |
| platform: g.platform, |
| isExclusive: g.is_exclusive, |
| defaultRate: g.rate_multiplier, |
| customRate: userGroupRates[g.id] ?? null, |
| |
| |
| isSelected: g.is_exclusive ? userAllowedGroups.includes(g.id) : true, |
| })) |
| } catch (error) { |
| console.error('Failed to load groups:', error) |
| } finally { |
| loading.value = false |
| } |
| } |
| |
| const toggleExclusiveGroup = (groupId: number) => { |
| const config = groupConfigs.value.find((c) => c.groupId === groupId) |
| if (config && config.isExclusive) { |
| config.isSelected = !config.isSelected |
| } |
| } |
| |
| const updateCustomRate = (groupId: number, value: string) => { |
| const config = groupConfigs.value.find((c) => c.groupId === groupId) |
| if (config) { |
| if (value === '' || value === null || value === undefined) { |
| config.customRate = null |
| } else { |
| const numValue = parseFloat(value) |
| config.customRate = isNaN(numValue) ? null : numValue |
| } |
| } |
| } |
| |
| const handleSave = async () => { |
| if (!props.user) return |
| submitting.value = true |
| |
| try { |
| |
| const allowedGroups = groupConfigs.value.filter((c) => c.isExclusive && c.isSelected).map((c) => c.groupId) |
| |
| |
| |
| |
| const groupRates: Record<number, number | null> = {} |
| for (const c of groupConfigs.value) { |
| const hadOriginalRate = originalGroupRates.value[c.groupId] !== undefined |
| |
| if (c.customRate !== null) { |
| |
| groupRates[c.groupId] = c.customRate |
| } else if (hadOriginalRate) { |
| |
| groupRates[c.groupId] = null |
| } |
| } |
| |
| await adminAPI.users.update(props.user.id, { |
| allowed_groups: allowedGroups, |
| group_rates: Object.keys(groupRates).length > 0 ? groupRates : undefined, |
| }) |
| |
| appStore.showSuccess(t('admin.users.groupConfigUpdated')) |
| emit('success') |
| emit('close') |
| } catch (error) { |
| console.error('Failed to update user group config:', error) |
| } finally { |
| submitting.value = false |
| } |
| } |
| </script> |
| |
| <style scoped> |
| |
| .hide-spinner::-webkit-outer-spin-button, |
| .hide-spinner::-webkit-inner-spin-button { |
| -webkit-appearance: none; |
| margin: 0; |
| } |
| .hide-spinner { |
| -moz-appearance: textfield; |
| } |
| </style> |
| |