Commit a04ae28a authored by 陈曦's avatar 陈曦
Browse files

merge v0.1.111

parents 68f67198 ad64190b
/**
* User Payment API endpoints
* Handles payment operations for regular users
*/
import { apiClient } from './client'
import type {
PaymentConfig,
SubscriptionPlan,
PaymentChannel,
MethodLimitsResponse,
CheckoutInfoResponse,
CreateOrderRequest,
CreateOrderResult,
PaymentOrder
} from '@/types/payment'
import type { BasePaginationResponse } from '@/types'
export const paymentAPI = {
/** Get payment configuration (enabled types, limits, etc.) */
getConfig() {
return apiClient.get<PaymentConfig>('/payment/config')
},
/** Get available subscription plans */
getPlans() {
return apiClient.get<SubscriptionPlan[]>('/payment/plans')
},
/** Get available payment channels */
getChannels() {
return apiClient.get<PaymentChannel[]>('/payment/channels')
},
/** Get all checkout page data in a single call */
getCheckoutInfo() {
return apiClient.get<CheckoutInfoResponse>('/payment/checkout-info')
},
/** Get payment method limits and fee rates */
getLimits() {
return apiClient.get<MethodLimitsResponse>('/payment/limits')
},
/** Create a new payment order */
createOrder(data: CreateOrderRequest) {
return apiClient.post<CreateOrderResult>('/payment/orders', data)
},
/** Get current user's orders */
getMyOrders(params?: { page?: number; page_size?: number; status?: string }) {
return apiClient.get<BasePaginationResponse<PaymentOrder>>('/payment/orders/my', { params })
},
/** Get a specific order by ID */
getOrder(id: number) {
return apiClient.get<PaymentOrder>(`/payment/orders/${id}`)
},
/** Cancel a pending order */
cancelOrder(id: number) {
return apiClient.post(`/payment/orders/${id}/cancel`)
},
/** Verify order payment status with upstream provider */
verifyOrder(outTradeNo: string) {
return apiClient.post<PaymentOrder>('/payment/orders/verify', { out_trade_no: outTradeNo })
},
/** Verify order payment status without auth (public endpoint for result page) */
verifyOrderPublic(outTradeNo: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
},
/** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data)
}
}
......@@ -91,7 +91,7 @@ export async function list(
* @returns Paginated list of usage logs
*/
export async function query(
params: UsageQueryParams,
params: UsageQueryParams & { sort_by?: string; sort_order?: 'asc' | 'desc' },
config: { signal?: AbortSignal } = {}
): Promise<PaginatedResponse<UsageLog>> {
const { data } = await apiClient.get<PaginatedResponse<UsageLog>>('/usage', {
......
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563099286" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1395" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M902.095 652.871l-250.96-84.392s19.287-28.87 39.874-85.472c20.59-56.606 23.539-87.689 23.539-87.689l-162.454-1.339v-55.487l196.739-1.387v-39.227H552.055v-89.29h-96.358v89.294H272.133v39.227l183.564-1.304v59.513h-147.24v31.079h303.064s-3.337 25.223-14.955 56.606c-11.615 31.38-23.58 58.862-23.58 58.862s-142.3-49.804-217.285-49.804c-74.985 0-166.182 30.123-175.024 117.55-8.8 87.383 42.481 134.716 114.728 152.139 72.256 17.513 138.962-0.173 197.04-28.607 58.087-28.391 115.081-92.933 115.081-92.933l292.486 142.041c-11.932 69.3-72.067 119.914-142.387 119.844H266.37c-79.714 0.078-144.392-64.483-144.466-144.194V266.374c-0.074-79.72 64.493-144.399 144.205-144.47h491.519c79.714-0.073 144.396 64.49 144.466 144.203v386.764z m-365.76-48.895s-91.302 115.262-198.879 115.262c-107.623 0-130.218-54.767-130.218-94.155 0-39.34 22.373-82.144 113.943-88.333 91.519-6.18 215.2 67.226 215.2 67.226h-0.047z" fill="#02A9F1" p-id="1396"></path></svg>
\ No newline at end of file
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563141699" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2705" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M647.3728 287.744c-2.048-4.9152-5.7344-9.0112-11.0592-12.0832-5.3248-3.072-12.9024-4.7104-22.9376-4.7104h-221.184c-0.2048 0-0.2048 0.2048-0.2048 0.2048V305.152h260.096c-1.024-6.7584-2.6624-12.4928-4.7104-17.408zM634.0608 400.9984c6.9632-3.2768 11.264-8.192 13.1072-14.5408l5.12-14.7456h-260.096c-0.2048 0-0.2048 0.2048-0.2048 0.2048V405.504c0 0.2048 0.2048 0.2048 0.2048 0.2048h220.9792c6.9632 0.4096 13.9264-1.4336 20.8896-4.7104z" fill="#48D8FF" p-id="2706"></path><path d="M512 1.6384C230.1952 1.6384 1.6384 230.1952 1.6384 512S230.1952 1022.3616 512 1022.3616 1022.3616 793.8048 1022.3616 512 793.8048 1.6384 512 1.6384z m289.5872 644.3008c-0.2048 4.3008-1.2288 20.48-3.2768 48.5376s-4.9152 50.7904-8.8064 67.9936c-3.8912 17.408-13.5168 31.3344-29.0816 41.984-15.5648 10.6496-30.72 16.384-45.2608 17.408-12.9024 0.8192-25.1904-1.024-36.2496-5.9392-11.264-4.9152-20.48-10.8544-27.8528-18.2272-7.3728-7.3728-13.7216-16.5888-19.0464-28.0576-5.3248-11.4688-6.9632-18.0224-4.9152-20.0704 2.048-1.8432 4.096-3.2768 6.3488-3.8912 3.072-0.6144 8.3968-0.2048 15.9744 1.6384s13.9264 2.8672 19.2512 3.072c5.7344 0.4096 12.0832 0 18.8416-1.4336 6.7584-1.4336 12.0832-3.6864 16.384-6.9632 4.096-3.2768 7.3728-7.7824 9.8304-13.7216 2.2528-5.9392 3.8912-13.1072 4.7104-21.504 0.8192-8.3968 1.8432-22.3232 3.072-41.984s2.048-32.5632 2.2528-39.1168v-29.2864c0-6.7584-1.024-11.8784-3.2768-15.36-2.2528-3.4816-7.5776-5.12-15.7696-5.12h-2.4576c-22.3232 0-43.8272 8.6016-60.2112 23.9616-7.9872 7.5776-16.1792 17.408-23.9616 29.4912-7.9872 12.0832-15.5648 25.6-22.9376 40.7552l-18.2272 38.7072c-16.5888 33.9968-36.0448 61.2352-58.5728 81.3056-22.528 20.0704-65.1264 30.72-127.5904 31.9488-14.336 0.4096-24.9856-0.4096-32.1536-2.2528-6.9632-2.048-11.264-4.096-12.4928-6.144-1.2288-2.048-1.024-4.7104 0.4096-7.5776 1.024-2.2528 3.072-3.8912 5.9392-4.9152s11.4688-3.2768 25.8048-6.5536 30.5152-11.0592 48.3328-22.9376c17.8176-11.8784 27.2384-20.0704 35.4304-30.1056 8.192-10.0352 20.6848-26.4192 30.72-44.2368 10.0352-17.8176 24.1664-45.056 34.6112-60.6208 10.0352-15.1552 41.984-53.0432 81.5104-60.0064 0.4096 0 0.4096-0.6144 0-0.6144h-75.1616c-9.4208 0-18.8416 1.8432-27.2384 6.144-7.168 3.6864-10.8544 8.8064-27.0336 36.2496-15.9744 26.8288-36.864 51.2-36.864 51.2-20.2752 25.6-36.6592 43.6224-57.344 56.9344-20.6848 13.312-49.5616 19.0464-87.04 16.9984-12.288-0.4096-23.3472-1.8432-33.3824-4.096-9.8304-2.2528-15.1552-4.3008-15.7696-6.144-0.6144-1.8432-0.4096-3.8912 1.024-5.9392 0.8192-1.4336 4.9152-2.8672 12.288-4.7104 7.3728-1.8432 15.36-4.5056 24.1664-7.9872 8.8064-3.6864 40.3456-15.7696 72.9088-48.5376 28.2624-28.2624 50.3808-60.416 63.8976-73.1136 4.096-3.6864 16.384-13.7216 32.5632-17.408h-54.272c-11.4688 0-20.48 3.4816-29.4912 14.1312-15.9744 18.8416-31.1296 31.1296-46.08 36.0448-18.432 6.144-33.9968 9.4208-46.2848 9.8304h-30.5152c-16.7936 0.6144-25.6-0.6144-26.624-4.096-0.8192-3.2768-0.4096-6.144 1.4336-8.3968 1.8432-2.048 6.144-4.3008 12.6976-6.5536s14.336-6.3488 22.7328-12.0832c8.3968-5.7344 15.5648-11.4688 21.504-16.9984 5.9392-5.5296 12.288-12.9024 19.2512-22.1184l21.0944-29.4912c8.192-11.0592 19.456-22.3232 33.5872-33.9968l31.5392-25.6c0.2048-0.2048 0-0.6144-0.2048-0.6144h-69.0176c-0.2048 0-0.2048-0.2048-0.2048-0.2048V201.9328c0-0.2048 0.2048-0.2048 0.2048-0.2048h288.1536c66.3552 0 103.8336 16.9984 112.2304 50.9952s12.6976 63.0784 12.4928 87.6544c-0.2048 23.7568-2.6624 44.032-7.5776 61.0304-4.9152 16.9984-16.5888 33.5872-35.2256 50.176-18.6368 16.5888-46.08 24.7808-81.92 24.7808h-110.7968c-9.8304 0-19.456 2.8672-27.648 7.9872l-25.1904 15.7696c-3.2768 2.2528-6.144 4.5056-8.3968 6.9632h268.9024c22.9376 0 41.1648 1.2288 54.8864 3.4816 13.7216 2.2528 23.9616 7.7824 30.9248 16.384 6.9632 8.6016 11.0592 18.432 12.288 29.4912 1.2288 11.0592 1.8432 24.3712 1.8432 39.936-0.4096 28.672-0.6144 45.056-0.6144 49.5616z" fill="#48D8FF" p-id="2707"></path></svg>
\ No newline at end of file
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563184449" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3692" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 512m-448 0a448 448 0 1 0 896 0 448 448 0 1 0-896 0Z" fill="#676BE5" p-id="3693"></path><path d="M471.616 417.296c0-20.96 17.488-29.04 45.488-29.04a300.8 300.8 0 0 1 133.36 34.496v-126.224a353.6 353.6 0 0 0-133.264-24.528c-108.8 0-181.2 56.768-181.2 151.696 0 148.4 203.76 124.336 203.76 188.352 0 24.816-21.52 32.8-51.408 32.8a338.368 338.368 0 0 1-146.704-42.768v120.768a372.272 372.272 0 0 0 146.624 30.4c111.472 0 188.256-48 188.256-144.368 0-160-204.88-131.296-204.88-191.632" fill="#FFFFFF" p-id="3694"></path></svg>
\ No newline at end of file
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1775563104166" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1545" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M395.846 603.585c-3.921 1.98-7.936 2.925-12.81 2.925-10.9 0-19.791-5.85-24.764-14.625l-2.006-3.864-78.106-167.913c-0.956-1.98-0.956-3.865-0.956-5.845 0-7.83 5.928-13.68 13.863-13.68 2.965 0 5.928 0.944 8.893 2.924l91.965 64.43c6.884 3.864 14.82 6.79 23.708 6.79 4.972 0 9.85-0.945 14.822-2.926L861.71 282.479c-77.149-89.804-204.684-148.384-349.135-148.384-235.371 0-427.242 157.158-427.242 351.294 0 105.368 57.361 201.017 147.323 265.447 6.88 4.905 11.852 13.68 11.852 22.45 0 2.925-0.957 5.85-2.006 8.775-6.881 26.318-18.831 69.334-18.831 71.223-0.958 2.92-2.013 6.79-2.013 10.75 0 7.83 5.929 13.68 13.865 13.68 2.963 0 5.928-0.944 7.935-2.925l92.922-53.674c6.885-3.87 14.82-6.794 22.756-6.794 3.916 0 8.889 0.944 12.81 1.98 43.496 12.644 91.012 19.53 139.48 19.53 235.372 0 427.24-157.158 427.24-351.294 0-58.58-17.78-114.143-48.467-163.003l-491.39 280.07-2.963 1.98z" fill="#09BB07" p-id="1546"></path></svg>
\ No newline at end of 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') },
......
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' }
])
})
})
......@@ -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 {
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>
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)
})
})
......@@ -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"
/>
......
<template>
<BaseDialog
:show="show"
:title="t('payment.admin.orderDetail')"
width="wide"
@close="emit('close')"
>
<div v-if="order" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p>
<p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ order.id }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p>
<span :class="['badge', statusBadgeClass(order.status)]">
{{ t('payment.status.' + order.status.toLowerCase(), order.status) }}
</span>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.amount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.pay_amount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.methods.' + order.payment_type, order.payment_type) }}
</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ (order.fee_rate * 100).toFixed(1) }}%</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.admin.' + order.order_type + 'Order', order.order_type) }}
</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.userId') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">#{{ order.user_id }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.created_at) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.expires_at) }}</p>
</div>
<div v-if="order.paid_at">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.paid_at) }}</p>
</div>
<div v-if="order.completed_at">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.completedAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.completed_at) }}</p>
</div>
</div>
<div
v-if="order.refund_amount"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"
>
<h4 class="mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
{{ t('payment.admin.refundInfo') }}
</h4>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">${{ order.refund_amount.toFixed(2) }}</span>
</div>
<div v-if="order.refund_reason" class="col-span-2">
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
<span class="ml-1 text-red-700 dark:text-red-300">{{ order.refund_reason }}</span>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-gray-200 pt-4 dark:border-dark-700">
<button
v-if="order.status === 'PENDING'"
@click="emit('cancel', order)"
class="btn btn-sm rounded-md bg-yellow-50 px-3 py-1.5 text-sm text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-900/20 dark:text-yellow-400 dark:hover:bg-yellow-900/30"
>
{{ t('payment.orders.cancel') }}
</button>
<button
v-if="order.status === 'FAILED'"
@click="emit('retry', order)"
class="btn btn-sm btn-secondary"
>
{{ t('payment.admin.retry') }}
</button>
<button
v-if="canRefund(order)"
@click="emit('refund', order)"
class="btn btn-sm rounded-md bg-red-50 px-3 py-1.5 text-sm text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30"
>
{{ t('payment.admin.refund') }}
</button>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment'
import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } from '@/components/payment/orderUtils'
const { t } = useI18n()
defineProps<{
show: boolean
order: PaymentOrder | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'cancel', order: PaymentOrder): void
(e: 'retry', order: PaymentOrder): void
(e: 'refund', order: PaymentOrder): void
}>()
function canRefund(order: PaymentOrder): boolean {
return canRefundStatus(order.status)
}
function formatDateTime(dateStr: string): string {
return formatOrderDateTime(dateStr)
}
</script>
<template>
<div class="space-y-4">
<div class="card p-4">
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1 sm:max-w-64">
<input
v-model="searchQuery"
type="text"
:placeholder="t('payment.admin.searchOrders')"
class="input"
@input="handleSearch"
/>
</div>
<Select
v-model="filters.status"
:options="statusFilterOptions"
class="w-36"
@change="emitFiltersChanged"
/>
<Select
v-model="filters.payment_type"
:options="paymentTypeFilterOptions"
class="w-40"
@change="emitFiltersChanged"
/>
<Select
v-model="filters.order_type"
:options="orderTypeFilterOptions"
class="w-36"
@change="emitFiltersChanged"
/>
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
<button
@click="emit('refresh')"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</div>
</div>
<DataTable :columns="columns" :data="orders" :loading="loading">
<template #cell-id="{ value }">
<span class="font-mono text-sm">#{{ value }}</span>
</template>
<template #cell-user_id="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span>
</template>
<template #cell-amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }})
</span>
</div>
</template>
<template #cell-payment_type="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.methods.' + value, value) }}
</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', statusBadgeClass(value)]">
{{ t('payment.status.' + value.toLowerCase(), value) }}
</span>
</template>
<template #cell-order_type="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.admin.' + value + 'Order', value) }}
</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<button
@click="emit('detail', row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800/50 dark:hover:text-gray-300"
>
<Icon name="eye" size="sm" />
<span class="text-xs">{{ t('common.view') }}</span>
</button>
<button
v-if="row.status === 'PENDING'"
@click="emit('cancel', row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400"
>
<Icon name="x" size="sm" />
<span class="text-xs">{{ t('payment.orders.cancel') }}</span>
</button>
<button
v-if="row.status === 'FAILED'"
@click="emit('retry', row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<Icon name="refresh" size="sm" />
<span class="text-xs">{{ t('payment.admin.retry') }}</span>
</button>
<button
v-if="canRefundRow(row)"
@click="emit('refund', row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon name="dollar" size="sm" />
<span class="text-xs">{{ t('payment.admin.refund') }}</span>
</button>
</div>
</template>
</DataTable>
<Pagination
v-if="total > 0"
:page="page"
:total="total"
:page-size="pageSize"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PaymentOrder } from '@/types/payment'
import type { Column } from '@/components/common/types'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import { statusBadgeClass, canRefund, formatOrderDateTime } from '@/components/payment/orderUtils'
const { t } = useI18n()
defineProps<{
orders: PaymentOrder[]
loading: boolean
page: number
pageSize: number
total: number
}>()
const emit = defineEmits<{
(e: 'detail', order: PaymentOrder): void
(e: 'cancel', order: PaymentOrder): void
(e: 'retry', order: PaymentOrder): void
(e: 'refund', order: PaymentOrder): void
(e: 'refresh'): void
(e: 'update:page', page: number): void
(e: 'update:pageSize', size: number): void
(e: 'filter', filters: { keyword?: string; status?: string; payment_type?: string; order_type?: string }): void
}>()
const searchQuery = ref('')
const filters = reactive({ status: '', payment_type: '', order_type: '' })
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function handleSearch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => emitFiltersChanged(), 300)
}
function emitFiltersChanged() {
emit('filter', {
keyword: searchQuery.value || undefined,
status: filters.status || undefined,
payment_type: filters.payment_type || undefined,
order_type: filters.order_type || undefined,
})
}
const columns = computed<Column[]>(() => [
{ key: 'id', label: t('payment.orders.orderId') },
{ key: 'user_id', label: t('payment.orders.userId') },
{ key: 'amount', label: t('payment.orders.amount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') },
{ key: 'order_type', label: t('payment.orders.orderType') },
{ key: 'created_at', label: t('payment.orders.createdAt') },
{ key: 'actions', label: t('payment.orders.actions') },
])
const statusFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allStatuses') },
{ value: 'PENDING', label: t('payment.status.pending') },
{ value: 'PAID', label: t('payment.status.paid') },
{ value: 'COMPLETED', label: t('payment.status.completed') },
{ value: 'EXPIRED', label: t('payment.status.expired') },
{ value: 'CANCELLED', label: t('payment.status.cancelled') },
{ value: 'FAILED', label: t('payment.status.failed') },
{ value: 'REFUNDED', label: t('payment.status.refunded') },
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
])
const paymentTypeFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allPaymentTypes') },
{ value: 'alipay', label: t('payment.methods.alipay') },
{ value: 'wxpay', label: t('payment.methods.wxpay') },
{ value: 'stripe', label: t('payment.methods.stripe') },
])
const orderTypeFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allOrderTypes') },
{ value: 'balance', label: t('payment.admin.balanceOrder') },
{ value: 'subscription', label: t('payment.admin.subscriptionOrder') },
])
function canRefundRow(order: PaymentOrder): boolean {
return canRefund(order.status)
}
function formatDateTime(dateStr: string): string {
return formatOrderDateTime(dateStr)
}
</script>
<template>
<BaseDialog
:show="show"
:title="t('payment.admin.refundOrder')"
width="normal"
@close="emit('cancel')"
>
<form id="refund-form" @submit.prevent="handleSubmit" class="space-y-4">
<!-- Refund Request Info -->
<div
v-if="order?.refund_requested_at || order?.refund_request_reason"
class="rounded-lg border border-violet-200 bg-violet-50 p-3 dark:border-violet-800 dark:bg-violet-900/20"
>
<div class="flex items-center gap-2 text-sm font-medium text-violet-700 dark:text-violet-300">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ t('payment.admin.refundRequestInfo') }}
</div>
<div v-if="order?.refund_requested_at" class="mt-2 flex justify-between text-sm">
<span class="text-violet-600 dark:text-violet-400">{{ t('payment.admin.refundRequestedAt') }}</span>
<span class="text-violet-800 dark:text-violet-200">{{ formatDateTime(order.refund_requested_at) }}</span>
</div>
<div v-if="order?.refund_request_reason" class="mt-1 text-sm">
<span class="text-violet-600 dark:text-violet-400">{{ t('payment.admin.refundRequestReason') }}:</span>
<span class="ml-1 text-violet-800 dark:text-violet-200">{{ order.refund_request_reason }}</span>
</div>
</div>
<!-- Order Info -->
<div class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700">
<div class="flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-mono text-gray-900 dark:text-white">#{{ order?.id }}</span>
</div>
<div class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</span>
</div>
<div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
<span class="font-medium text-red-600 dark:text-red-400">${{ actuallyRefunded.toFixed(2) }}</span>
</div>
</div>
<!-- Deduct Balance -->
<div>
<div class="flex items-center gap-2">
<input
id="deduct-balance"
v-model="form.deduct_balance"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label for="deduct-balance" class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.admin.deductBalance') }}
</label>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.deductBalanceHint') }}</span>
</div>
<!-- User Balance Info (when deduct_balance is checked) -->
<div v-if="form.deduct_balance && userBalance != null" class="mt-3 grid grid-cols-2 gap-3">
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.userBalance') }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ userBalance.toFixed(2) }}</div>
</div>
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</div>
</div>
</div>
<!-- Insufficient balance warning -->
<div
v-if="form.deduct_balance && balanceInsufficient"
class="mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
{{ t('payment.admin.insufficientBalance') }}
</div>
<!-- No deduction info -->
<div
v-if="!form.deduct_balance"
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{ t('payment.admin.noDeduction') }}
</div>
</div>
<!-- Refund Amount -->
<div>
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="form.amount"
type="number"
step="0.01"
min="0.01"
:max="maxRefundable"
class="input pl-7"
required
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('payment.admin.maxRefundable') }}: ${{ maxRefundable.toFixed(2) }}
</p>
</div>
<!-- Reason -->
<div>
<label class="input-label">{{ t('payment.admin.refundReason') }}</label>
<textarea
v-model="form.reason"
rows="3"
class="input"
:placeholder="t('payment.admin.refundReasonPlaceholder')"
required
></textarea>
</div>
<!-- Warning -->
<div
v-if="warning"
class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300"
>
{{ warning }}
</div>
<!-- Force Refund -->
<div v-if="requireForce" class="flex items-center gap-2">
<input
id="force-refund"
v-model="form.force"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<label for="force-refund" class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('payment.admin.forceRefund') }}
</label>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="emit('cancel')" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
form="refund-form"
:disabled="submitting || form.amount <= 0 || (requireForce && !form.force)"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-dark-800"
>
{{ submitting ? t('common.processing') : t('payment.admin.confirmRefund') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment'
import { formatOrderDateTime } from '@/components/payment/orderUtils'
const { t } = useI18n()
const props = defineProps<{
show: boolean
order: PaymentOrder | null
submitting?: boolean
userBalance?: number | null
requireForce?: boolean
warning?: string
}>()
const emit = defineEmits<{
(e: 'confirm', data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }): void
(e: 'cancel'): void
}>()
const form = reactive({
amount: 0,
reason: '',
deduct_balance: true,
force: false,
})
// In REFUND_REQUESTED status, refund_amount is the REQUESTED amount, not actually refunded.
// Only PARTIALLY_REFUNDED / REFUNDED have real refund amounts.
const actuallyRefunded = computed(() => {
if (!props.order) return 0
const s = props.order.status
if (s === 'PARTIALLY_REFUNDED' || s === 'REFUNDED') return props.order.refund_amount || 0
return 0
})
const maxRefundable = computed(() => {
if (!props.order) return 0
return props.order.pay_amount - actuallyRefunded.value
})
const balanceInsufficient = computed(() => {
if (props.userBalance == null || !props.order) return false
return props.userBalance < props.order.pay_amount
})
watch(() => props.show, (val) => {
if (val && props.order) {
// For REFUND_REQUESTED, pre-fill with the requested amount
if (props.order.status === 'REFUND_REQUESTED' && props.order.refund_amount) {
form.amount = props.order.refund_amount
} else {
form.amount = maxRefundable.value
}
form.reason = props.order.refund_request_reason || ''
form.deduct_balance = true
form.force = false
}
})
function formatDateTime(dateStr: string): string {
return formatOrderDateTime(dateStr)
}
function handleSubmit() {
if (form.amount <= 0 || form.amount > maxRefundable.value) return
if (props.requireForce && !form.force) return
emit('confirm', { ...form })
}
</script>
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('payment.admin.dailyRevenue') }}
</h3>
<div class="h-64">
<div v-if="loading" class="flex h-full items-center justify-center">
<LoadingSpinner size="md" />
</div>
<Line v-else-if="chartData" :data="chartData" :options="chartOptions" />
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.noData') }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
const { t } = useI18n()
const props = defineProps<{
data: { date: string; amount: number; count: number }[]
loading?: boolean
}>()
const chartData = computed(() => {
if (!props.data || props.data.length === 0) return null
return {
labels: props.data.map(d => d.date),
datasets: [
{
label: t('payment.admin.revenue'),
data: props.data.map(d => d.amount),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5,
},
{
label: t('payment.admin.orderCount'),
data: props.data.map(d => d.count),
borderColor: 'rgb(16, 185, 129)',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: false,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5,
yAxisID: 'y1',
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
scales: {
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
title: { display: true, text: t('payment.admin.revenue') },
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
title: { display: true, text: t('payment.admin.orderCount') },
grid: { drawOnChartArea: false },
}
},
plugins: {
legend: { position: 'top' as const },
}
}
</script>
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Today Revenue -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
<Icon name="dollar" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayRevenue') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.today_amount) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ stats.today_count }} {{ t('payment.admin.orders') }}
</p>
</div>
</div>
</div>
<!-- Total Revenue -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<Icon name="creditCard" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.totalRevenue') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.total_amount) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ stats.total_count }} {{ t('payment.admin.orders') }}
</p>
</div>
</div>
</div>
<!-- Today Orders -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<Icon name="chart" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayOrders') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_count }}</p>
</div>
</div>
</div>
<!-- Average Amount -->
<div class="card p-4">
<div class="flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
<Icon name="chart" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
</div>
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.avgAmount') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.avg_amount) }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { DashboardStats } from '@/types/payment'
const { t } = useI18n()
defineProps<{
stats: DashboardStats
}>()
function formatMoney(value: number): string {
return value.toFixed(2)
}
</script>
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('payment.admin.paymentDistribution') }}
</h3>
<div
v-if="!methods?.length"
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.noData') }}
</div>
<div v-else class="space-y-3">
<div v-for="method in methods" :key="method.type" class="space-y-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span :class="['inline-block h-3 w-3 rounded-full', colorMap[method.type] || 'bg-gray-400']"></span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.methods.' + method.type, method.type) }}
</span>
</div>
<div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">
${{ method.amount.toFixed(2) }}
</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
({{ method.count }})
</span>
</div>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
<div
:class="['h-full rounded-full transition-all', barColorMap[method.type] || 'bg-gray-400']"
:style="{ width: barWidth(method.amount) + '%' }"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
methods: { type: string; amount: number; count: number }[]
}>()
const colorMap: Record<string, string> = {
alipay: 'bg-blue-500',
wxpay: 'bg-green-500',
alipay_direct: 'bg-blue-400',
wxpay_direct: 'bg-green-400',
stripe: 'bg-purple-500',
}
const barColorMap: Record<string, string> = {
alipay: 'bg-blue-500',
wxpay: 'bg-green-500',
alipay_direct: 'bg-blue-400',
wxpay_direct: 'bg-green-400',
stripe: 'bg-purple-500',
}
const maxAmount = computed(() => {
if (!props.methods?.length) return 1
return Math.max(...props.methods.map(m => m.amount), 1)
})
function barWidth(amount: number): number {
return Math.min((amount / maxAmount.value) * 100, 100)
}
</script>
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('payment.admin.topUsers') }}
</h3>
<div
v-if="!users?.length"
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.noData') }}
</div>
<div v-else class="space-y-2">
<div
v-for="(user, idx) in users"
:key="user.user_id"
class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700"
>
<div class="flex items-center gap-3">
<span
:class="[
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
rankClass(idx),
]"
>
{{ idx + 1 }}
</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
${{ user.amount.toFixed(2) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
users: { user_id: number; email: string; amount: number }[]
}>()
function rankClass(idx: number): string {
if (idx === 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
if (idx === 1) return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
}
</script>
<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'
interface Props {
data: AdminUsageLog[]
loading?: boolean
columns: Column[]
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
}
defineProps(['data', 'loading', 'columns'])
defineEmits(['userClick'])
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
......
......@@ -29,10 +29,10 @@
{{ t('auth.linuxdo.signIn') }}
</button>
<div class="flex items-center gap-3">
<div v-if="showDivider" class="flex items-center gap-3">
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
<span class="text-xs text-gray-500 dark:text-dark-400">
{{ t('auth.linuxdo.orContinue') }}
{{ t('auth.oauthOrContinue') }}
</span>
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
</div>
......@@ -43,9 +43,12 @@
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
defineProps<{
withDefaults(defineProps<{
disabled?: boolean
}>()
showDivider?: boolean
}>(), {
showDivider: true
})
const route = useRoute()
const { t } = useI18n()
......@@ -58,4 +61,3 @@ function startLogin(): void {
window.location.href = startURL
}
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment