Merge remote-tracking branch 'upstream/main' into feat/payment-system-v2

# Conflicts:
#	frontend/src/api/admin/settings.ts
#	frontend/src/stores/app.ts
#	frontend/src/types/index.ts
#	frontend/src/views/admin/SettingsView.vue
This commit is contained in:
erio
2026-04-11 18:24:49 +08:00
115 changed files with 3422 additions and 396 deletions

View File

@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
const privacyOpts = computed(() => [
{ value: '', label: t('admin.accounts.allPrivacyModes') },
{ value: '__unset__', label: t('admin.accounts.privacyUnset') },

View File

@@ -1,56 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AccountTableFilters from '../AccountTableFilters.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
describe('AccountTableFilters', () => {
it('renders privacy mode options and emits privacy_mode updates', async () => {
const wrapper = mount(AccountTableFilters, {
props: {
searchQuery: '',
filters: {
platform: '',
type: '',
status: '',
group: '',
privacy_mode: ''
},
groups: []
},
global: {
stubs: {
SearchInput: {
template: '<div />'
},
Select: {
props: ['modelValue', 'options'],
emits: ['update:modelValue', 'change'],
template: '<div class="select-stub" :data-options="JSON.stringify(options)" />'
}
}
}
})
const selects = wrapper.findAll('.select-stub')
expect(selects).toHaveLength(5)
const privacyOptions = JSON.parse(selects[3].attributes('data-options'))
expect(privacyOptions).toEqual([
{ value: '', label: 'admin.accounts.allPrivacyModes' },
{ value: '__unset__', label: 'admin.accounts.privacyUnset' },
{ value: 'training_off', label: 'Privacy' },
{ value: 'training_set_cf_blocked', label: 'CF' },
{ value: 'training_set_failed', label: 'Fail' }
])
})
})

View File

@@ -21,7 +21,15 @@
</button>
</div>
<DataTable :columns="columns" :data="items" :loading="loading">
<DataTable
:columns="columns"
:data="items"
:loading="loading"
:server-side-sort="true"
default-sort-key="email"
default-sort-order="asc"
@sort="handleSort"
>
<template #cell-email="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
@@ -62,7 +70,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
@@ -98,23 +106,54 @@ const pagination = reactive({
pages: 0
})
const sortState = reactive({
sort_by: 'email',
sort_order: 'asc' as 'asc' | 'desc'
})
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: 'email', label: t('common.email'), sortable: true },
{ key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'balance', label: t('common.balance'), sortable: true },
{ key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') }
])
let currentController: AbortController | null = null
let searchDebounceTimer: number | null = null
function resetDialogState() {
loading.value = false
search.value = ''
items.value = []
pagination.page = 1
pagination.total = 0
pagination.pages = 0
sortState.sort_by = 'email'
sortState.sort_order = 'asc'
}
function cancelPendingLoad(resetState = false) {
if (searchDebounceTimer) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
currentController?.abort()
currentController = null
if (resetState) {
resetDialogState()
}
}
async function load() {
if (!props.show || !props.announcementId) return
if (currentController) currentController.abort()
currentController = new AbortController()
currentController?.abort()
const requestController = new AbortController()
currentController = requestController
const { signal } = requestController
try {
loading.value = true
@@ -122,20 +161,37 @@ async function load() {
props.announcementId,
pagination.page,
pagination.page_size,
search.value
{
search: search.value,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{ signal }
)
if (signal.aborted || currentController !== requestController) return
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
if (
signal.aborted ||
currentController !== requestController ||
error?.name === 'AbortError' ||
error?.code === 'ERR_CANCELED'
) {
return
}
console.error('Failed to load read status:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
} finally {
loading.value = false
if (currentController === requestController) {
loading.value = false
currentController = null
}
}
}
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
load()
}
let searchDebounceTimer: number | null = null
function handleSort(key: string, order: 'asc' | 'desc') {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
load()
}
function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
@@ -160,13 +222,17 @@ function handleSearch() {
}
function handleClose() {
cancelPendingLoad(true)
emit('close')
}
watch(
() => props.show,
(v) => {
if (!v) return
if (!v) {
cancelPendingLoad(true)
return
}
pagination.page = 1
load()
}
@@ -181,7 +247,7 @@ watch(
}
)
onMounted(() => {
// noop
onUnmounted(() => {
cancelPendingLoad()
})
</script>

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue'
const { getReadStatus, showError } = vi.hoisted(() => ({
getReadStatus: vi.fn(),
showError: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
announcements: {
getReadStatus,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/composables/usePersistedPageSize', () => ({
getPersistedPageSize: () => 20,
}))
const BaseDialogStub = {
props: ['show', 'title', 'width'],
emits: ['close'],
template: '<div><slot /><slot name="footer" /></div>',
}
describe('AnnouncementReadStatusDialog', () => {
beforeEach(() => {
getReadStatus.mockReset()
showError.mockReset()
vi.useFakeTimers()
})
it('closes by aborting active requests and clearing debounced reloads', async () => {
let activeSignal: AbortSignal | undefined
getReadStatus.mockImplementation(async (...args: any[]) => {
activeSignal = args[4]?.signal
return new Promise(() => {})
})
const wrapper = mount(AnnouncementReadStatusDialog, {
props: {
show: false,
announcementId: 1,
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
DataTable: true,
Pagination: true,
Icon: true,
},
},
})
await wrapper.setProps({ show: true })
await flushPromises()
expect(getReadStatus).toHaveBeenCalledTimes(1)
expect(activeSignal?.aborted).toBe(false)
const setupState = (wrapper.vm as any).$?.setupState
setupState.search = 'alice'
setupState.handleSearch()
setupState.handleClose()
await flushPromises()
expect(activeSignal?.aborted).toBe(true)
expect(wrapper.emitted('close')).toHaveLength(1)
vi.advanceTimersByTime(350)
await flushPromises()
expect(getReadStatus).toHaveBeenCalledTimes(1)
})
})

View File

@@ -196,7 +196,6 @@
:total="localEntries.length"
:page="currentPage"
:page-size="pageSize"
:page-size-options="[10, 20, 50]"
@update:page="currentPage = $event"
@update:pageSize="handlePageSizeChange"
/>

View File

@@ -1,7 +1,15 @@
<template>
<div class="card overflow-hidden">
<div class="overflow-auto">
<DataTable :columns="columns" :data="data" :loading="loading">
<DataTable
:columns="columns"
:data="data"
:loading="loading"
:server-side-sort="serverSideSort"
:default-sort-key="defaultSortKey"
:default-sort-order="defaultSortOrder"
@sort="(key, order) => $emit('sort', key, order)"
>
<template #cell-user="{ row }">
<div class="text-sm">
<button
@@ -334,9 +342,27 @@ 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'
import type { Column } from '@/components/common/types'
defineProps(['data', 'loading', 'columns'])
defineEmits(['userClick'])
interface Props {
data: AdminUsageLog[]
loading?: boolean
columns: Column[]
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
}
withDefaults(defineProps<Props>(), {
loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n()
// Tooltip state - cost