Merge branch 'main' into test
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.announcements.readStatus')"
|
||||
width="extra-wide"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.announcements.searchUsers')"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<button @click="load" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="items" :loading="loading">
|
||||
<template #cell-email="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-balance="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ Number(value ?? 0).toFixed(2) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-eligible="{ value }">
|
||||
<span :class="['badge', value ? 'badge-success' : 'badge-gray']">
|
||||
{{ value ? t('admin.announcements.eligible') : t('common.no') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-read_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? formatDateTime(value) : t('admin.announcements.unread') }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AnnouncementUserReadStatus } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
announcementId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const items = ref<AnnouncementUserReadStatus[]>([])
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'email', label: t('common.email') },
|
||||
{ key: 'username', label: t('admin.users.columns.username') },
|
||||
{ key: 'balance', label: t('common.balance') },
|
||||
{ key: 'eligible', label: t('admin.announcements.eligible') },
|
||||
{ key: 'read_at', label: t('admin.announcements.readAt') }
|
||||
])
|
||||
|
||||
let currentController: AbortController | null = null
|
||||
|
||||
async function load() {
|
||||
if (!props.show || !props.announcementId) return
|
||||
|
||||
if (currentController) currentController.abort()
|
||||
currentController = new AbortController()
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await adminAPI.announcements.getReadStatus(
|
||||
props.announcementId,
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
search.value
|
||||
)
|
||||
|
||||
items.value = res.items
|
||||
pagination.total = res.total
|
||||
pagination.pages = res.pages
|
||||
pagination.page = res.page
|
||||
pagination.page_size = res.page_size
|
||||
} catch (error: any) {
|
||||
if (currentController.signal.aborted || error?.name === 'AbortError') return
|
||||
console.error('Failed to load read status:', error)
|
||||
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.page = page
|
||||
load()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
let searchDebounceTimer: number | null = null
|
||||
function handleSearch() {
|
||||
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = window.setTimeout(() => {
|
||||
pagination.page = 1
|
||||
load()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.announcementId,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
// noop
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/50">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.announcements.form.targetingMode') }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ mode === 'all' ? t('admin.announcements.form.targetingAll') : t('admin.announcements.form.targetingCustom') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="radio"
|
||||
name="announcement-targeting-mode"
|
||||
value="all"
|
||||
:checked="mode === 'all'"
|
||||
@change="setMode('all')"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ t('admin.announcements.form.targetingAll') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="radio"
|
||||
name="announcement-targeting-mode"
|
||||
value="custom"
|
||||
:checked="mode === 'custom'"
|
||||
@change="setMode('custom')"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ t('admin.announcements.form.targetingCustom') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'custom'" class="mt-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
OR
|
||||
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-dark-400">
|
||||
({{ anyOf.length }}/50)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="anyOf.length >= 50"
|
||||
@click="addOrGroup"
|
||||
>
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.announcements.form.addOrGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="anyOf.length === 0" class="rounded-xl border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400">
|
||||
{{ t('admin.announcements.form.targetingCustom') }}: {{ t('admin.announcements.form.addOrGroup') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(group, groupIndex) in anyOf"
|
||||
:key="groupIndex"
|
||||
class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.announcements.form.targetingCustom') }} #{{ groupIndex + 1 }}
|
||||
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-dark-400">AND ({{ (group.all_of?.length || 0) }}/50)</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.announcements.form.addAndCondition') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="removeOrGroup(groupIndex)"
|
||||
>
|
||||
<Icon name="trash" size="sm" class="mr-1" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="(cond, condIndex) in (group.all_of || [])"
|
||||
:key="condIndex"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div class="w-full md:w-52">
|
||||
<label class="input-label">{{ t('admin.announcements.form.conditionType') }}</label>
|
||||
<Select
|
||||
:model-value="cond.type"
|
||||
:options="conditionTypeOptions"
|
||||
@update:model-value="(v) => setConditionType(groupIndex, condIndex, v as any)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="cond.type === 'subscription'" class="flex-1">
|
||||
<label class="input-label">{{ t('admin.announcements.form.selectPackages') }}</label>
|
||||
<GroupSelector
|
||||
v-model="subscriptionSelections[groupIndex][condIndex]"
|
||||
:groups="groups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-1 flex-col gap-3 sm:flex-row">
|
||||
<div class="w-full sm:w-44">
|
||||
<label class="input-label">{{ t('admin.announcements.form.operator') }}</label>
|
||||
<Select
|
||||
:model-value="cond.operator"
|
||||
:options="balanceOperatorOptions"
|
||||
@update:model-value="(v) => setOperator(groupIndex, condIndex, v as any)"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:flex-1">
|
||||
<label class="input-label">{{ t('admin.announcements.form.balanceValue') }}</label>
|
||||
<input
|
||||
:value="String(cond.value ?? '')"
|
||||
type="number"
|
||||
step="any"
|
||||
class="input"
|
||||
@input="(e) => setBalanceValue(groupIndex, condIndex, (e.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="removeAndCondition(groupIndex, condIndex)"
|
||||
>
|
||||
<Icon name="trash" size="sm" class="mr-1" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="(group.all_of?.length || 0) >= 50"
|
||||
@click="addAndCondition(groupIndex)"
|
||||
>
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.announcements.form.addAndCondition') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="validationError" class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-900/10 dark:text-red-300">
|
||||
{{ validationError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
AdminGroup,
|
||||
AnnouncementTargeting,
|
||||
AnnouncementCondition,
|
||||
AnnouncementConditionGroup,
|
||||
AnnouncementConditionType,
|
||||
AnnouncementOperator
|
||||
} from '@/types'
|
||||
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: AnnouncementTargeting
|
||||
groups: AdminGroup[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: AnnouncementTargeting): void
|
||||
}>()
|
||||
|
||||
const anyOf = computed(() => props.modelValue?.any_of ?? [])
|
||||
|
||||
type Mode = 'all' | 'custom'
|
||||
const mode = computed<Mode>(() => (anyOf.value.length === 0 ? 'all' : 'custom'))
|
||||
|
||||
const conditionTypeOptions = computed(() => [
|
||||
{ value: 'subscription', label: t('admin.announcements.form.conditionSubscription') },
|
||||
{ value: 'balance', label: t('admin.announcements.form.conditionBalance') }
|
||||
])
|
||||
|
||||
const balanceOperatorOptions = computed(() => [
|
||||
{ value: 'gt', label: t('admin.announcements.operators.gt') },
|
||||
{ value: 'gte', label: t('admin.announcements.operators.gte') },
|
||||
{ value: 'lt', label: t('admin.announcements.operators.lt') },
|
||||
{ value: 'lte', label: t('admin.announcements.operators.lte') },
|
||||
{ value: 'eq', label: t('admin.announcements.operators.eq') }
|
||||
])
|
||||
|
||||
function setMode(next: Mode) {
|
||||
if (next === 'all') {
|
||||
emit('update:modelValue', { any_of: [] })
|
||||
return
|
||||
}
|
||||
if (anyOf.value.length === 0) {
|
||||
emit('update:modelValue', { any_of: [{ all_of: [defaultSubscriptionCondition()] }] })
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSubscriptionCondition(): AnnouncementCondition {
|
||||
return {
|
||||
type: 'subscription' as AnnouncementConditionType,
|
||||
operator: 'in' as AnnouncementOperator,
|
||||
group_ids: []
|
||||
}
|
||||
}
|
||||
|
||||
function defaultBalanceCondition(): AnnouncementCondition {
|
||||
return {
|
||||
type: 'balance' as AnnouncementConditionType,
|
||||
operator: 'gte' as AnnouncementOperator,
|
||||
value: 0
|
||||
}
|
||||
}
|
||||
|
||||
type TargetingDraft = {
|
||||
any_of: AnnouncementConditionGroup[]
|
||||
}
|
||||
|
||||
function updateTargeting(mutator: (draft: TargetingDraft) => void) {
|
||||
const draft: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
|
||||
if (!draft.any_of) draft.any_of = []
|
||||
mutator(draft)
|
||||
emit('update:modelValue', draft)
|
||||
}
|
||||
|
||||
function addOrGroup() {
|
||||
updateTargeting((draft) => {
|
||||
if (draft.any_of.length >= 50) return
|
||||
draft.any_of.push({ all_of: [defaultSubscriptionCondition()] })
|
||||
})
|
||||
}
|
||||
|
||||
function removeOrGroup(groupIndex: number) {
|
||||
updateTargeting((draft) => {
|
||||
draft.any_of.splice(groupIndex, 1)
|
||||
})
|
||||
}
|
||||
|
||||
function addAndCondition(groupIndex: number) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group.all_of) group.all_of = []
|
||||
if (group.all_of.length >= 50) return
|
||||
group.all_of.push(defaultSubscriptionCondition())
|
||||
})
|
||||
}
|
||||
|
||||
function removeAndCondition(groupIndex: number, condIndex: number) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
group.all_of.splice(condIndex, 1)
|
||||
})
|
||||
}
|
||||
|
||||
function setConditionType(groupIndex: number, condIndex: number, nextType: AnnouncementConditionType) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
|
||||
if (nextType === 'subscription') {
|
||||
group.all_of[condIndex] = defaultSubscriptionCondition()
|
||||
} else {
|
||||
group.all_of[condIndex] = defaultBalanceCondition()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setOperator(groupIndex: number, condIndex: number, op: AnnouncementOperator) {
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
|
||||
const cond = group.all_of[condIndex]
|
||||
if (!cond) return
|
||||
|
||||
cond.operator = op
|
||||
})
|
||||
}
|
||||
|
||||
function setBalanceValue(groupIndex: number, condIndex: number, raw: string) {
|
||||
const n = raw === '' ? 0 : Number(raw)
|
||||
updateTargeting((draft) => {
|
||||
const group = draft.any_of[groupIndex]
|
||||
if (!group?.all_of) return
|
||||
|
||||
const cond = group.all_of[condIndex]
|
||||
if (!cond) return
|
||||
|
||||
cond.value = Number.isFinite(n) ? n : 0
|
||||
})
|
||||
}
|
||||
|
||||
// We keep group_ids selection in a parallel reactive map because GroupSelector is numeric list.
|
||||
// Then we mirror it back to targeting.group_ids via a watcher.
|
||||
const subscriptionSelections = reactive<Record<number, Record<number, number[]>>>({})
|
||||
|
||||
function ensureSelectionPath(groupIndex: number, condIndex: number) {
|
||||
if (!subscriptionSelections[groupIndex]) subscriptionSelections[groupIndex] = {}
|
||||
if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = []
|
||||
}
|
||||
|
||||
// Sync from modelValue to subscriptionSelections (one-way: model -> local state)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
const groups = v?.any_of ?? []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const allOf = groups[gi]?.all_of ?? []
|
||||
for (let ci = 0; ci < allOf.length; ci++) {
|
||||
const c = allOf[ci]
|
||||
if (c?.type === 'subscription') {
|
||||
ensureSelectionPath(gi, ci)
|
||||
// Only update if different to avoid triggering unnecessary updates
|
||||
const newIds = (c.group_ids ?? []).slice()
|
||||
const currentIds = subscriptionSelections[gi]?.[ci] ?? []
|
||||
if (JSON.stringify(newIds.sort()) !== JSON.stringify(currentIds.sort())) {
|
||||
subscriptionSelections[gi][ci] = newIds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Sync from subscriptionSelections to modelValue (one-way: local state -> model)
|
||||
// Use a debounced approach to avoid infinite loops
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
watch(
|
||||
() => subscriptionSelections,
|
||||
() => {
|
||||
// Debounce the sync to avoid rapid fire updates
|
||||
if (syncTimeout) clearTimeout(syncTimeout)
|
||||
|
||||
syncTimeout = setTimeout(() => {
|
||||
// Build the new targeting state
|
||||
const newTargeting: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
|
||||
if (!newTargeting.any_of) newTargeting.any_of = []
|
||||
|
||||
const groups = newTargeting.any_of ?? []
|
||||
for (let gi = 0; gi < groups.length; gi++) {
|
||||
const allOf = groups[gi]?.all_of ?? []
|
||||
for (let ci = 0; ci < allOf.length; ci++) {
|
||||
const c = allOf[ci]
|
||||
if (c?.type === 'subscription') {
|
||||
ensureSelectionPath(gi, ci)
|
||||
c.operator = 'in' as AnnouncementOperator
|
||||
c.group_ids = (subscriptionSelections[gi]?.[ci] ?? []).slice()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only emit if there's an actual change (deep comparison)
|
||||
if (JSON.stringify(props.modelValue) !== JSON.stringify(newTargeting)) {
|
||||
emit('update:modelValue', newTargeting)
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (mode.value !== 'custom') return ''
|
||||
|
||||
const groups = anyOf.value
|
||||
if (groups.length === 0) return t('admin.announcements.form.addOrGroup')
|
||||
|
||||
if (groups.length > 50) return 'any_of > 50'
|
||||
|
||||
for (const g of groups) {
|
||||
const allOf = g?.all_of ?? []
|
||||
if (allOf.length === 0) return t('admin.announcements.form.addAndCondition')
|
||||
if (allOf.length > 50) return 'all_of > 50'
|
||||
|
||||
for (const c of allOf) {
|
||||
if (c.type === 'subscription') {
|
||||
if (!c.group_ids || c.group_ids.length === 0) return t('admin.announcements.form.selectPackages')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
@@ -21,6 +21,12 @@
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-reasoning_effort="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
{{ formatReasoningEffort(row.reasoning_effort) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
|
||||
{{ row.group.name }}
|
||||
@@ -232,14 +238,14 @@
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AdminUsageLog } from '@/types'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
const { t } = useI18n()
|
||||
@@ -259,6 +265,7 @@ const cols = computed(() => [
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'reasoning_effort', label: t('usage.reasoningEffort'), sortable: false },
|
||||
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
|
||||
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.users.balanceHistoryTitle')" width="wide" :close-on-click-outside="true" :z-index="40" @close="$emit('close')">
|
||||
<div v-if="user" class="space-y-4">
|
||||
<!-- User header: two-row layout with full user info -->
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<!-- Row 1: avatar + email/username/created_at (left) + current balance (right) -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ user.email.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||
<span
|
||||
v-if="user.username"
|
||||
class="flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
|
||||
>
|
||||
{{ user.username }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.users.createdAt') }}: {{ formatDateTime(user.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Current balance: prominent display on the right -->
|
||||
<div class="flex-shrink-0 text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('admin.users.currentBalance') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
${{ user.balance?.toFixed(2) || '0.00' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: notes + total recharged -->
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-gray-200/60 pt-2.5 dark:border-dark-600/60">
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-dark-400" :title="user.notes || ''">
|
||||
<template v-if="user.notes">{{ t('admin.users.notes') }}: {{ user.notes }}</template>
|
||||
<template v-else> </template>
|
||||
</p>
|
||||
<p class="ml-4 flex-shrink-0 text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('admin.users.totalRecharged') }}: <span class="font-semibold text-emerald-600 dark:text-emerald-400">${{ totalRecharged.toFixed(2) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type filter + Action buttons -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Select
|
||||
v-model="typeFilter"
|
||||
:options="typeOptions"
|
||||
class="w-56"
|
||||
@change="loadHistory(1)"
|
||||
/>
|
||||
<!-- Deposit button - matches menu style -->
|
||||
<button
|
||||
@click="emit('deposit')"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<Icon name="plus" size="sm" class="text-emerald-500" :stroke-width="2" />
|
||||
{{ t('admin.users.deposit') }}
|
||||
</button>
|
||||
<!-- Withdraw button - matches menu style -->
|
||||
<button
|
||||
@click="emit('withdraw')"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<svg class="h-8 w-8 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" />
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="history.length === 0" class="py-8 text-center">
|
||||
<p class="text-sm text-gray-500">{{ t('admin.users.noBalanceHistory') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- History list -->
|
||||
<div v-else class="max-h-[28rem] space-y-3 overflow-y-auto">
|
||||
<div
|
||||
v-for="item in history"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- Left: type icon + description -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg',
|
||||
getIconBg(item)
|
||||
]"
|
||||
>
|
||||
<Icon :name="getIconName(item)" size="sm" :class="getIconColor(item)" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ getItemTitle(item) }}
|
||||
</p>
|
||||
<!-- Notes (admin adjustment reason) -->
|
||||
<p
|
||||
v-if="item.notes"
|
||||
class="mt-0.5 text-xs text-gray-500 dark:text-dark-400"
|
||||
:title="item.notes"
|
||||
>
|
||||
{{ item.notes.length > 60 ? item.notes.substring(0, 55) + '...' : item.notes }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ formatDateTime(item.used_at || item.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: value -->
|
||||
<div class="text-right">
|
||||
<p :class="['text-sm font-semibold', getValueColor(item)]">
|
||||
{{ formatValue(item) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="isAdminType(item.type)"
|
||||
class="text-xs text-gray-400 dark:text-dark-500"
|
||||
>
|
||||
{{ t('redeem.adminAdjustment') }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="font-mono text-xs text-gray-400 dark:text-dark-500"
|
||||
>
|
||||
{{ item.code.slice(0, 8) }}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 pt-2">
|
||||
<button
|
||||
:disabled="currentPage <= 1"
|
||||
class="btn btn-secondary px-3 py-1 text-sm"
|
||||
@click="loadHistory(currentPage - 1)"
|
||||
>
|
||||
{{ t('pagination.previous') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ currentPage }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="btn btn-secondary px-3 py-1 text-sm"
|
||||
@click="loadHistory(currentPage + 1)"
|
||||
>
|
||||
{{ t('pagination.next') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI, type BalanceHistoryItem } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
|
||||
const emit = defineEmits(['close', 'deposit', 'withdraw'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const history = ref<BalanceHistoryItem[]>([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const total = ref(0)
|
||||
const totalRecharged = ref(0)
|
||||
const pageSize = 15
|
||||
const typeFilter = ref('')
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
|
||||
|
||||
// Type filter options
|
||||
const typeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.users.allTypes') },
|
||||
{ value: 'balance', label: t('admin.users.typeBalance') },
|
||||
{ value: 'admin_balance', label: t('admin.users.typeAdminBalance') },
|
||||
{ value: 'concurrency', label: t('admin.users.typeConcurrency') },
|
||||
{ value: 'admin_concurrency', label: t('admin.users.typeAdminConcurrency') },
|
||||
{ value: 'subscription', label: t('admin.users.typeSubscription') }
|
||||
])
|
||||
|
||||
// Watch modal open
|
||||
watch(() => props.show, (v) => {
|
||||
if (v && props.user) {
|
||||
typeFilter.value = ''
|
||||
loadHistory(1)
|
||||
}
|
||||
})
|
||||
|
||||
const loadHistory = async (page: number) => {
|
||||
if (!props.user) return
|
||||
loading.value = true
|
||||
currentPage.value = page
|
||||
try {
|
||||
const res = await adminAPI.users.getUserBalanceHistory(
|
||||
props.user.id,
|
||||
page,
|
||||
pageSize,
|
||||
typeFilter.value || undefined
|
||||
)
|
||||
history.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
totalRecharged.value = res.total_recharged || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load balance history:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: check if admin type
|
||||
const isAdminType = (type: string) => type === 'admin_balance' || type === 'admin_concurrency'
|
||||
|
||||
// Helper: check if balance type (includes admin_balance)
|
||||
const isBalanceType = (type: string) => type === 'balance' || type === 'admin_balance'
|
||||
|
||||
// Helper: check if subscription type
|
||||
const isSubscriptionType = (type: string) => type === 'subscription'
|
||||
|
||||
// Icon name based on type
|
||||
const getIconName = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) return 'dollar'
|
||||
if (isSubscriptionType(item.type)) return 'badge'
|
||||
return 'bolt' // concurrency
|
||||
}
|
||||
|
||||
// Icon background color
|
||||
const getIconBg = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
return item.value >= 0
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30'
|
||||
: 'bg-red-100 dark:bg-red-900/30'
|
||||
}
|
||||
if (isSubscriptionType(item.type)) return 'bg-purple-100 dark:bg-purple-900/30'
|
||||
return item.value >= 0
|
||||
? 'bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'bg-orange-100 dark:bg-orange-900/30'
|
||||
}
|
||||
|
||||
// Icon text color
|
||||
const getIconColor = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
return item.value >= 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
if (isSubscriptionType(item.type)) return 'text-purple-600 dark:text-purple-400'
|
||||
return item.value >= 0
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}
|
||||
|
||||
// Value text color
|
||||
const getValueColor = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
return item.value >= 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
if (isSubscriptionType(item.type)) return 'text-purple-600 dark:text-purple-400'
|
||||
return item.value >= 0
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}
|
||||
|
||||
// Item title
|
||||
const getItemTitle = (item: BalanceHistoryItem) => {
|
||||
switch (item.type) {
|
||||
case 'balance':
|
||||
return t('redeem.balanceAddedRedeem')
|
||||
case 'admin_balance':
|
||||
return item.value >= 0 ? t('redeem.balanceAddedAdmin') : t('redeem.balanceDeductedAdmin')
|
||||
case 'concurrency':
|
||||
return t('redeem.concurrencyAddedRedeem')
|
||||
case 'admin_concurrency':
|
||||
return item.value >= 0 ? t('redeem.concurrencyAddedAdmin') : t('redeem.concurrencyReducedAdmin')
|
||||
case 'subscription':
|
||||
return t('redeem.subscriptionAssigned')
|
||||
default:
|
||||
return t('common.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
// Format display value
|
||||
const formatValue = (item: BalanceHistoryItem) => {
|
||||
if (isBalanceType(item.type)) {
|
||||
const sign = item.value >= 0 ? '+' : ''
|
||||
return `${sign}$${item.value.toFixed(2)}`
|
||||
}
|
||||
if (isSubscriptionType(item.type)) {
|
||||
const days = item.validity_days || Math.round(item.value)
|
||||
const groupName = item.group?.name || ''
|
||||
return groupName ? `${days}d - ${groupName}` : `${days}d`
|
||||
}
|
||||
// concurrency types
|
||||
const sign = item.value >= 0 ? '+' : ''
|
||||
return `${sign}${item.value}`
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user