"...internal/payment/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "8eb3f9e78997d8a04f8247b6e4eb6ab328c3cb34"
Unverified Commit d402e722 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1637 from touwaeriol/feat/websearch-notify-pricing

feat: web search emulation, balance/quota notify, account stats pricing, per-provider refund control, Stripe fix / Web 搜索模拟、余额配额通知、渠道统计计费、按服务商退款控制、Stripe 修复
parents e534e9ba 8548a130
...@@ -89,6 +89,7 @@ export interface PaymentOrder { ...@@ -89,6 +89,7 @@ export interface PaymentOrder {
refund_requested_by?: number refund_requested_by?: number
refund_request_reason?: string refund_request_reason?: string
plan_id?: number plan_id?: number
provider_instance_id?: string
} }
// ==================== Plans & Channels ==================== // ==================== Plans & Channels ====================
...@@ -138,6 +139,7 @@ export interface ProviderInstance { ...@@ -138,6 +139,7 @@ export interface ProviderInstance {
enabled: boolean enabled: boolean
payment_mode: string payment_mode: string
refund_enabled: boolean refund_enabled: boolean
allow_user_refund: boolean
limits: string limits: string
sort_order: number sort_order: number
} }
......
import { describe, expect, it } from 'vitest'
import { enqueueUsageRequest } from '../usageLoadQueue'
import type { Account } from '@/types'
/** Helper to create a minimal Account with proxy info */
function makeAccount(
platform: string,
type: string = 'oauth',
proxy?: { host: string; port: number; username?: string | null } | null
): Account {
return {
id: Math.floor(Math.random() * 10000),
platform,
type,
name: 'test',
status: 'active',
proxy_id: proxy ? 1 : null,
proxy: proxy
? { id: 1, name: 'p', protocol: 'http', host: proxy.host, port: proxy.port, username: proxy.username ?? null, status: 'active', created_at: '', updated_at: '' }
: undefined,
credentials: {},
created_at: '',
updated_at: ''
} as unknown as Account
}
describe('usageLoadQueue', () => {
// ─── Anthropic 账号:按代理出口排队 ───
it('Anthropic 同代理出口串行执行,间隔 >= 1s', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc = makeAccount('anthropic', 'oauth', { host: '1.2.3.4', port: 8080, username: 'u1' })
const p1 = enqueueUsageRequest(acc, makeFn())
const p2 = enqueueUsageRequest(acc, makeFn())
const p3 = enqueueUsageRequest(acc, makeFn())
await Promise.all([p1, p2, p3])
expect(timestamps).toHaveLength(3)
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
expect(timestamps[1] - timestamps[0]).toBeLessThan(2100)
expect(timestamps[2] - timestamps[1]).toBeGreaterThanOrEqual(950)
expect(timestamps[2] - timestamps[1]).toBeLessThan(2100)
})
it('Anthropic 不同代理出口并行执行', async () => {
const timestamps: Record<string, number> = {}
const makeTracked = (key: string) => async () => {
timestamps[key] = Date.now()
return key
}
const acc1 = makeAccount('anthropic', 'oauth', { host: '1.2.3.4', port: 8080, username: 'u1' })
const acc2 = makeAccount('anthropic', 'oauth', { host: '5.6.7.8', port: 3128, username: 'u2' })
const p1 = enqueueUsageRequest(acc1, makeTracked('proxy1'))
const p2 = enqueueUsageRequest(acc2, makeTracked('proxy2'))
await Promise.all([p1, p2])
const spread = Math.abs(timestamps['proxy1'] - timestamps['proxy2'])
expect(spread).toBeLessThan(50)
})
it('Anthropic 相同代理连接信息的不同账号归为同一队列', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('anthropic', 'oauth', { host: '10.0.0.1', port: 3128, username: 'admin' })
const acc2 = makeAccount('anthropic', 'setup-token', { host: '10.0.0.1', port: 3128, username: 'admin' })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(950)
})
it('Anthropic 直连(无代理)的账号归为同一队列', async () => {
const order: number[] = []
const makeFn = (n: number) => async () => {
order.push(n)
return n
}
const acc1 = makeAccount('anthropic', 'oauth')
const acc2 = makeAccount('anthropic', 'setup-token')
const p1 = enqueueUsageRequest(acc1, makeFn(1))
const p2 = enqueueUsageRequest(acc2, makeFn(2))
await Promise.all([p1, p2])
expect(order).toEqual([1, 2])
})
it('Anthropic 请求失败时 reject,后续任务继续执行', async () => {
const results: string[] = []
const acc = makeAccount('anthropic', 'oauth', { host: '99.99.99.99', port: 1234 })
const p1 = enqueueUsageRequest(acc, async () => {
throw new Error('fail')
})
const p2 = enqueueUsageRequest(acc, async () => {
results.push('second')
return 'ok'
})
await expect(p1).rejects.toThrow('fail')
await p2
expect(results).toEqual(['second'])
})
// ─── 非 Anthropic 平台:直接执行,不排队 ───
it('非 Anthropic 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
// 同一代理的 Gemini 账号 — 应当并行,不排队
const acc1 = makeAccount('gemini', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('gemini', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
// 并行执行,几乎同时完成
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
it('OpenAI 平台直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('openai', 'oauth', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('openai', 'oauth', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
// ─── Anthropic apikey 类型不排队 ───
it('Anthropic apikey 类型直接执行,不排队', async () => {
const timestamps: number[] = []
const makeFn = () => async () => {
timestamps.push(Date.now())
return 'ok'
}
const acc1 = makeAccount('anthropic', 'apikey', { host: '1.2.3.4', port: 8080 })
const acc2 = makeAccount('anthropic', 'apikey', { host: '1.2.3.4', port: 8080 })
const p1 = enqueueUsageRequest(acc1, makeFn())
const p2 = enqueueUsageRequest(acc2, makeFn())
await Promise.all([p1, p2])
expect(timestamps).toHaveLength(2)
expect(Math.abs(timestamps[1] - timestamps[0])).toBeLessThan(50)
})
// ─── 返回值透传 ───
it('返回值正确透传', async () => {
const acc = makeAccount('anthropic', 'oauth')
const result = await enqueueUsageRequest(acc, async () => {
return { usage: 42 }
})
expect(result).toEqual({ usage: 42 })
})
it('非 Anthropic 返回值正确透传', async () => {
const acc = makeAccount('gemini', 'oauth')
const result = await enqueueUsageRequest(acc, async () => {
return { quota: 100 }
})
expect(result).toEqual({ quota: 100 })
})
})
export const BILLING_MODE_TOKEN = 'token'
export const BILLING_MODE_PER_REQUEST = 'per_request'
export const BILLING_MODE_IMAGE = 'image'
export function getBillingModeLabel(mode: string | null | undefined, t: (key: string) => string): string {
switch (mode) {
case BILLING_MODE_PER_REQUEST: return t('admin.usage.billingModePerRequest')
case BILLING_MODE_IMAGE: return t('admin.usage.billingModeImage')
default: return t('admin.usage.billingModeToken')
}
}
export function getBillingModeBadgeClass(mode: string | null | undefined): string {
switch (mode) {
case BILLING_MODE_PER_REQUEST: return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
case BILLING_MODE_IMAGE: return 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300'
default: return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
}
}
/**
* Usage request scheduler — throttles Anthropic API calls by proxy exit.
*
* Anthropic OAuth/setup-token accounts sharing the same proxy exit are placed
* into a serial queue with a random 1–2s delay between requests, preventing
* upstream 429 rate-limit errors.
*
* Proxy identity = host:port:username — two proxy records pointing to the
* same exit share a single queue. Accounts without a proxy go into a
* "direct" queue.
*
* All other platforms bypass the queue and execute immediately.
*/
import type { Account } from '@/types'
const GROUP_DELAY_MIN_MS = 1000
const GROUP_DELAY_MAX_MS = 2000
type Task<T> = {
fn: () => Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
}
const queues = new Map<string, Task<unknown>[]>()
const running = new Set<string>()
/** Whether this account needs throttled queuing. */
function needsThrottle(account: Account): boolean {
return (
account.platform === 'anthropic' &&
(account.type === 'oauth' || account.type === 'setup-token')
)
}
/** Build a queue key from proxy connection details. */
function buildGroupKey(account: Account): string {
const proxy = account.proxy
const proxyIdentity = proxy
? `${proxy.host}:${proxy.port}:${proxy.username || ''}`
: 'direct'
return `anthropic:${proxyIdentity}`
}
async function drain(groupKey: string) {
if (running.has(groupKey)) return
running.add(groupKey)
const queue = queues.get(groupKey)
while (queue && queue.length > 0) {
const task = queue.shift()!
try {
const result = await task.fn()
task.resolve(result)
} catch (err) {
task.reject(err)
}
if (queue.length > 0) {
const jitter = GROUP_DELAY_MIN_MS + Math.random() * (GROUP_DELAY_MAX_MS - GROUP_DELAY_MIN_MS)
await new Promise((r) => setTimeout(r, jitter))
}
}
running.delete(groupKey)
queues.delete(groupKey)
}
/**
* Schedule a usage fetch. Anthropic accounts are queued by proxy exit;
* all other platforms execute immediately.
*/
export function enqueueUsageRequest<T>(
account: Account,
fn: () => Promise<T>
): Promise<T> {
// Non-Anthropic → fire immediately, no queuing
if (!needsThrottle(account)) {
return fn()
}
const key = buildGroupKey(account)
return new Promise<T>((resolve, reject) => {
let queue = queues.get(key)
if (!queue) {
queue = []
queues.set(key, queue)
}
queue.push({ fn, resolve, reject } as Task<unknown>)
drain(key)
})
}
...@@ -144,6 +144,7 @@ ...@@ -144,6 +144,7 @@
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" /> <AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden"> <div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable <DataTable
ref="dataTableRef"
:columns="cols" :columns="cols"
:data="accounts" :data="accounts"
:loading="loading" :loading="loading"
...@@ -153,6 +154,8 @@ ...@@ -153,6 +154,8 @@
default-sort-key="name" default-sort-key="name"
default-sort-order="asc" default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY" :sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
:estimate-row-height="72"
:overscan="5"
> >
<template #header-select> <template #header-select>
<input <input
...@@ -164,7 +167,7 @@ ...@@ -164,7 +167,7 @@
/> />
</template> </template>
<template #cell-select="{ row }"> <template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" /> <input type="checkbox" :checked="isSelected(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template> </template>
<template #cell-name="{ row, value }"> <template #cell-name="{ row, value }">
<div class="flex flex-col"> <div class="flex flex-col">
...@@ -197,7 +200,9 @@ ...@@ -197,7 +200,9 @@
<AccountCapacityCell :account="row" /> <AccountCapacityCell :account="row" />
</template> </template>
<template #cell-status="{ row }"> <template #cell-status="{ row }">
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" /> <div class="flex items-center gap-1.5">
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</div>
</template> </template>
<template #cell-schedulable="{ row }"> <template #cell-schedulable="{ row }">
<button @click="handleToggleSchedulable(row)" :disabled="togglingSchedulable === row.id" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800" :class="[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']" :title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"> <button @click="handleToggleSchedulable(row)" :disabled="togglingSchedulable === row.id" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800" :class="[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']" :title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')">
...@@ -313,7 +318,7 @@ import { useAppStore } from '@/stores/app' ...@@ -313,7 +318,7 @@ import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader' import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect } from '@/composables/useSwipeSelect' import { useSwipeSelect, type SwipeSelectVirtualContext } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection' import { useTableSelection } from '@/composables/useTableSelection'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
...@@ -351,6 +356,7 @@ const authStore = useAuthStore() ...@@ -351,6 +356,7 @@ const authStore = useAuthStore()
const proxies = ref<AccountProxy[]>([]) const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([]) const groups = ref<AdminGroup[]>([])
const accountTableRef = ref<HTMLElement | null>(null) const accountTableRef = ref<HTMLElement | null>(null)
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
const selPlatforms = computed<AccountPlatform[]>(() => { const selPlatforms = computed<AccountPlatform[]>(() => {
const platforms = new Set( const platforms = new Set(
accounts.value accounts.value
...@@ -650,17 +656,25 @@ const { ...@@ -650,17 +656,25 @@ const {
clear: clearSelection, clear: clearSelection,
removeMany: removeSelectedAccounts, removeMany: removeSelectedAccounts,
toggleVisible, toggleVisible,
selectVisible: selectPage selectVisible: selectPage,
batchUpdate
} = useTableSelection<Account>({ } = useTableSelection<Account>({
rows: accounts, rows: accounts,
getId: (account) => account.id getId: (account) => account.id
}) })
const swipeVirtualContext: SwipeSelectVirtualContext = {
getVirtualizer: () => dataTableRef.value?.virtualizer ?? null,
getSortedData: () => dataTableRef.value?.sortedData ?? accounts.value,
getRowId: (row: any) => row.id,
}
useSwipeSelect(accountTableRef, { useSwipeSelect(accountTableRef, {
isSelected, isSelected,
select, select,
deselect deselect,
}) batchUpdate
}, swipeVirtualContext)
const resetAutoRefreshCache = () => { const resetAutoRefreshCache = () => {
autoRefreshETag.value = null autoRefreshETag.value = null
......
...@@ -166,8 +166,8 @@ ...@@ -166,8 +166,8 @@
class="channel-tab group" class="channel-tab group"
:class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'" :class="activeTab === section.platform ? 'channel-tab-active' : 'channel-tab-inactive'"
> >
<PlatformIcon :platform="section.platform" size="xs" :class="getPlatformTextColor(section.platform)" /> <PlatformIcon :platform="section.platform" size="xs" :class="platformTextClass(section.platform)" />
<span :class="getPlatformTextColor(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span> <span :class="platformTextClass(section.platform)">{{ t('admin.groups.platforms.' + section.platform, section.platform) }}</span>
</button> </button>
</div> </div>
...@@ -246,11 +246,29 @@ ...@@ -246,11 +246,29 @@
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="togglePlatform(p)" @change="togglePlatform(p)"
/> />
<PlatformIcon :platform="p" size="xs" :class="getPlatformTextColor(p)" /> <PlatformIcon :platform="p" size="xs" :class="platformTextClass(p)" />
<span :class="getPlatformTextColor(p)">{{ t('admin.groups.platforms.' + p, p) }}</span> <span :class="platformTextClass(p)">{{ t('admin.groups.platforms.' + p, p) }}</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Apply Pricing to Account Stats (toggle only in basic settings) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channels.form.applyPricingToAccountStats') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.applyPricingToAccountStatsDesc') }}
</p>
</div>
<Toggle
:modelValue="form.apply_pricing_to_account_stats"
@update:modelValue="form.apply_pricing_to_account_stats = $event"
/>
</div>
</div>
</div> </div>
<!-- Platform Tab Content --> <!-- Platform Tab Content -->
...@@ -292,9 +310,9 @@ ...@@ -292,9 +310,9 @@
class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500" class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
@change="toggleGroupInSection(sIdx, group.id)" @change="toggleGroupInSection(sIdx, group.id)"
/> />
<span :class="['font-medium', getPlatformTextColor(group.platform)]">{{ group.name }}</span> <span :class="['font-medium', platformTextClass(group.platform)]">{{ group.name }}</span>
<span <span
:class="['rounded-full px-1 py-0 text-[10px]', getRateBadgeClass(group.platform)]" :class="['rounded-full px-1 py-0 text-[10px]', platformBadgeLightClass(group.platform)]"
>{{ group.rate_multiplier }}x</span> >{{ group.rate_multiplier }}x</span>
<span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span> <span class="text-[10px] text-gray-400">{{ group.account_count || 0 }}</span>
<span <span
...@@ -306,6 +324,21 @@ ...@@ -306,6 +324,21 @@
</div> </div>
</div> </div>
<!-- Web Search Emulation (Anthropic only, hidden when global disabled) -->
<div v-if="section.platform === 'anthropic' && webSearchGlobalEnabled" class="border-t border-gray-200 pt-3 dark:border-dark-600">
<div class="flex items-center justify-between">
<div>
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channels.form.webSearchEmulation') }}
</label>
<p class="mt-0.5 text-[11px] text-red-500 dark:text-red-400">
{{ t('admin.channels.form.webSearchEmulationHint') }}
</p>
</div>
<Toggle v-model="section.web_search_emulation" />
</div>
</div>
<!-- Model Mapping --> <!-- Model Mapping -->
<div> <div>
<div class="mb-1 flex items-center justify-between"> <div class="mb-1 flex items-center justify-between">
...@@ -330,7 +363,7 @@ ...@@ -330,7 +363,7 @@
:value="srcModel" :value="srcModel"
type="text" type="text"
class="input flex-1 text-xs" class="input flex-1 text-xs"
:class="getPlatformTextColor(section.platform)" :class="platformTextClass(section.platform)"
:placeholder="t('admin.channels.form.mappingSource', 'Source model')" :placeholder="t('admin.channels.form.mappingSource', 'Source model')"
@change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)" @change="renameMappingKey(sIdx, srcModel, ($event.target as HTMLInputElement).value)"
/> />
...@@ -339,7 +372,7 @@ ...@@ -339,7 +372,7 @@
:value="section.model_mapping[srcModel]" :value="section.model_mapping[srcModel]"
type="text" type="text"
class="input flex-1 text-xs" class="input flex-1 text-xs"
:class="getPlatformTextColor(section.platform)" :class="platformTextClass(section.platform)"
:placeholder="t('admin.channels.form.mappingTarget', 'Target model')" :placeholder="t('admin.channels.form.mappingTarget', 'Target model')"
@input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value" @input="section.model_mapping[srcModel] = ($event.target as HTMLInputElement).value"
/> />
...@@ -379,6 +412,138 @@ ...@@ -379,6 +412,138 @@
/> />
</div> </div>
</div> </div>
<!-- Account Stats Pricing Rules (per-platform, always visible) -->
<div class="mt-4 border-t border-gray-200 pt-4 dark:border-dark-700 space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channels.form.accountStatsPricingRules') }}
</h4>
<button
type="button"
@click="addAccountStatsRule(sIdx)"
class="rounded-lg border border-primary-300 px-3 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:border-primary-600 dark:text-primary-400 dark:hover:bg-primary-900/20"
>
+ {{ t('admin.channels.form.addRule') }}
</button>
</div>
<!-- Filter rules for this platform's groups -->
<p
v-if="section.account_stats_pricing_rules.length === 0"
class="text-xs italic text-gray-400 dark:text-gray-500"
>
{{ t('admin.channels.form.noRulesConfigured') }}
</p>
<div
v-for="(rule, ruleIndex) in section.account_stats_pricing_rules"
:key="ruleIndex"
class="space-y-3 rounded-lg border border-gray-200 p-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<input
v-model="rule.name"
:placeholder="t('admin.channels.form.ruleName')"
class="bg-transparent text-sm font-medium text-gray-700 placeholder-gray-400 outline-none dark:text-gray-300"
/>
<button type="button" @click="removeAccountStatsRule(sIdx, ruleIndex)" class="text-xs text-red-500 hover:text-red-700">
{{ t('common.delete') }}
</button>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleGroups') }}</label>
<div class="mt-1 flex flex-wrap gap-1">
<label
v-for="gid in section.group_ids"
:key="gid"
class="inline-flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors"
:class="rule.group_ids.includes(gid)
? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700'"
>
<input type="checkbox" :checked="rule.group_ids.includes(gid)" class="h-3 w-3 rounded border-gray-300 text-primary-600 focus:ring-primary-500" @change="rule.group_ids.includes(gid) ? rule.group_ids.splice(rule.group_ids.indexOf(gid), 1) : rule.group_ids.push(gid)" />
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getGroupNameById(gid) }}</span>
</label>
</div>
<p v-if="section.group_ids.length === 0" class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.noGroupsInChannel') }}
</p>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleAccounts') }}</label>
<!-- Selected account chips -->
<div class="mt-1 flex flex-wrap gap-1">
<span
v-for="accountId in rule.account_ids"
:key="accountId"
class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20"
>
<span :class="['font-medium', platformTextClass(section.platform)]">{{ getRuleAccountLabel(accountId) }}</span>
<button type="button" @click="removeRuleAccount(rule, accountId)" class="text-gray-400 hover:text-red-500">
<Icon name="x" size="xs" />
</button>
</span>
</div>
<!-- Account search input -->
<div class="relative mt-1 rule-account-search-container">
<input
v-model="ruleAccountSearchKeyword[`${section.platform}-${ruleIndex}`]"
type="text"
class="input text-sm"
:placeholder="t('admin.channels.form.searchAccountPlaceholder')"
@input="onRuleAccountSearchInput(section.platform, ruleIndex)"
@focus="onRuleAccountSearchFocus(section.platform, ruleIndex)"
/>
<!-- Search results dropdown -->
<div
v-if="showRuleAccountDropdown[`${section.platform}-${ruleIndex}`] && (ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]?.length ?? 0) > 0"
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
>
<button
v-for="account in ruleAccountSearchResults[`${section.platform}-${ruleIndex}`]"
:key="account.id"
type="button"
@click="selectRuleAccount(rule, account, section.platform, ruleIndex)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
:class="{ 'opacity-50': rule.account_ids.includes(account.id) }"
:disabled="rule.account_ids.includes(account.id)"
>
<span :class="platformTextClass(account.platform)">{{ account.name }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ account.id }}</span>
</button>
</div>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channels.form.ruleAccountsHint') }}
</p>
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.channels.form.ruleModelPricing') }}</label>
<button type="button" @click="addRulePricingEntry(sIdx, ruleIndex)" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('common.add') }}
</button>
</div>
<div v-if="rule.pricing.length === 0" class="rounded border border-dashed border-gray-300 p-2 text-center text-xs text-gray-400 dark:border-dark-500">
{{ t('admin.channels.form.noPricingRules') }}
</div>
<div v-else class="space-y-2">
<PricingEntryCard
v-for="(entry, pIdx) in rule.pricing"
:key="pIdx"
:entry="entry"
:platform="section.platform"
@update="rule.pricing.splice(pIdx, 1, $event)"
@remove="removeRulePricingEntry(sIdx, ruleIndex, pIdx)"
/>
</div>
</div>
</div>
</div>
</div> </div>
</form> </form>
</div> </div>
...@@ -423,12 +588,14 @@ ...@@ -423,12 +588,14 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest } from '@/api/admin/channels' import type { Channel, ChannelModelPricing, CreateChannelRequest, UpdateChannelRequest, AccountStatsPricingRule } from '@/api/admin/channels'
import type { PricingFormEntry } from '@/components/admin/channel/types' import type { PricingFormEntry } from '@/components/admin/channel/types'
import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types' import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types'
import type { AdminGroup, GroupPlatform } from '@/types' import type { AdminGroup, GroupPlatform } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
...@@ -442,10 +609,31 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue' ...@@ -442,10 +609,31 @@ import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Toggle from '@/components/common/Toggle.vue' import Toggle from '@/components/common/Toggle.vue'
import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue' import PricingEntryCard from '@/components/admin/channel/PricingEntryCard.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize' import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
// Web Search global enabled state (loaded once on mount)
const webSearchGlobalEnabled = ref(false)
async function loadWebSearchGlobalState() {
try {
const cfg = await adminAPI.settings.getWebSearchEmulationConfig()
webSearchGlobalEnabled.value = cfg?.enabled === true && (cfg?.providers?.length ?? 0) > 0
} catch (err: unknown) {
console.warn('Failed to load web search global state:', err)
webSearchGlobalEnabled.value = false
}
}
// ── Form-level pricing rule type (per-platform) ──
interface FormPricingRule {
name: string
group_ids: number[]
account_ids: number[]
pricing: PricingFormEntry[]
}
// ── Platform Section type ── // ── Platform Section type ──
interface PlatformSection { interface PlatformSection {
platform: GroupPlatform platform: GroupPlatform
...@@ -454,6 +642,8 @@ interface PlatformSection { ...@@ -454,6 +642,8 @@ interface PlatformSection {
group_ids: number[] group_ids: number[]
model_mapping: Record<string, string> model_mapping: Record<string, string>
model_pricing: PricingFormEntry[] model_pricing: PricingFormEntry[]
web_search_emulation: boolean
account_stats_pricing_rules: FormPricingRule[]
} }
// ── Table columns ── // ── Table columns ──
...@@ -521,7 +711,8 @@ const form = reactive({ ...@@ -521,7 +711,8 @@ const form = reactive({
status: 'active', status: 'active',
restrict_models: false, restrict_models: false,
billing_model_source: 'channel_mapped' as string, billing_model_source: 'channel_mapped' as string,
platforms: [] as PlatformSection[] platforms: [] as PlatformSection[],
apply_pricing_to_account_stats: false,
}) })
let abortController: AbortController | null = null let abortController: AbortController | null = null
...@@ -529,26 +720,6 @@ let abortController: AbortController | null = null ...@@ -529,26 +720,6 @@ let abortController: AbortController | null = null
// ── Platform config ── // ── Platform config ──
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity'] const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
function getPlatformTextColor(platform: string): string {
switch (platform) {
case 'anthropic': return 'text-orange-600 dark:text-orange-400'
case 'openai': return 'text-emerald-600 dark:text-emerald-400'
case 'gemini': return 'text-blue-600 dark:text-blue-400'
case 'antigravity': return 'text-purple-600 dark:text-purple-400'
default: return 'text-gray-600 dark:text-gray-400'
}
}
function getRateBadgeClass(platform: string): string {
switch (platform) {
case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
// ── Helpers ── // ── Helpers ──
function formatDate(value: string): string { function formatDate(value: string): string {
if (!value) return '-' if (!value) return '-'
...@@ -565,7 +736,9 @@ function addPlatformSection(platform: GroupPlatform) { ...@@ -565,7 +736,9 @@ function addPlatformSection(platform: GroupPlatform) {
collapsed: false, collapsed: false,
group_ids: [], group_ids: [],
model_mapping: {}, model_mapping: {},
model_pricing: [] model_pricing: [],
web_search_emulation: false,
account_stats_pricing_rules: [],
}) })
} }
...@@ -678,11 +851,158 @@ function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) { ...@@ -678,11 +851,158 @@ function renameMappingKey(sectionIdx: number, oldKey: string, newKey: string) {
mapping[newKey] = value mapping[newKey] = value
} }
// ── Account Stats Pricing helpers ──
function addAccountStatsRule(sectionIdx: number) {
form.platforms[sectionIdx].account_stats_pricing_rules.push({
name: '',
group_ids: [],
account_ids: [],
pricing: []
})
}
function addRulePricingEntry(sectionIdx: number, ruleIndex: number) {
form.platforms[sectionIdx].account_stats_pricing_rules[ruleIndex].pricing.push({
models: [],
billing_mode: 'token',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
})
}
function removeAccountStatsRule(sectionIdx: number, ruleIndex: number) {
form.platforms[sectionIdx].account_stats_pricing_rules.splice(ruleIndex, 1)
// Clear all search state since indices shift after removal
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
}
function removeRulePricingEntry(sectionIdx: number, ruleIndex: number, pricingIndex: number) {
form.platforms[sectionIdx].account_stats_pricing_rules[ruleIndex].pricing.splice(pricingIndex, 1)
}
function getGroupNameById(groupId: number): string {
const group = allGroups.value.find(g => g.id === groupId)
return group ? group.name : `#${groupId}`
}
// ── Account search for pricing rules ──
interface SimpleAccount { id: number; name: string; platform: string }
const ruleAccountSearchKeyword = ref<Record<string, string>>({})
const ruleAccountSearchResults = ref<Record<string, SimpleAccount[]>>({})
const showRuleAccountDropdown = ref<Record<string, boolean>>({})
// Cache: account ID → name, populated when search results are selected
const ruleAccountNameCache = ref<Record<number, string>>({})
const ruleAccountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
delay: 300,
search: async (keyword, { key, signal }) => {
const platform = key.split('-')[0]
const res = await adminAPI.accounts.list(1, 20, { platform, search: keyword }, { signal })
return res.items.map(a => ({ id: a.id, name: a.name, platform: a.platform }))
},
onSuccess: (key, result) => { ruleAccountSearchResults.value[key] = result },
onError: (key) => { ruleAccountSearchResults.value[key] = [] },
})
function onRuleAccountSearchInput(platform: string, ruleIndex: number) {
const key = `${platform}-${ruleIndex}`
showRuleAccountDropdown.value[key] = true
ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '')
}
function onRuleAccountSearchFocus(platform: string, ruleIndex: number) {
const key = `${platform}-${ruleIndex}`
showRuleAccountDropdown.value[key] = true
if (!ruleAccountSearchResults.value[key]?.length) {
ruleAccountSearchRunner.trigger(key, ruleAccountSearchKeyword.value[key] || '')
}
}
function selectRuleAccount(
rule: { account_ids: number[] },
account: SimpleAccount,
platform: string,
ruleIndex: number,
) {
if (!rule.account_ids.includes(account.id)) {
rule.account_ids.push(account.id)
ruleAccountNameCache.value[account.id] = account.name
}
const key = `${platform}-${ruleIndex}`
ruleAccountSearchKeyword.value[key] = ''
showRuleAccountDropdown.value[key] = false
}
function removeRuleAccount(rule: { account_ids: number[] }, accountId: number) {
const idx = rule.account_ids.indexOf(accountId)
if (idx !== -1) rule.account_ids.splice(idx, 1)
}
function getRuleAccountLabel(accountId: number): string {
const name = ruleAccountNameCache.value[accountId]
return name ? `${name} #${accountId}` : `#${accountId}`
}
function handleRuleAccountClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.rule-account-search-container')) {
Object.keys(showRuleAccountDropdown.value).forEach(key => {
showRuleAccountDropdown.value[key] = false
})
}
}
function clearAllRuleAccountSearchState() {
ruleAccountSearchKeyword.value = {}
ruleAccountSearchResults.value = {}
showRuleAccountDropdown.value = {}
}
function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
const rules: AccountStatsPricingRule[] = []
for (const section of form.platforms) {
if (!section.enabled) continue
for (const rule of section.account_stats_pricing_rules) {
rules.push({
name: rule.name,
group_ids: rule.group_ids,
account_ids: rule.account_ids,
pricing: rule.pricing
.filter(p => p.models.length > 0)
.map(p => ({
platform: section.platform,
models: p.models,
billing_mode: p.billing_mode,
input_price: mTokToPerToken(p.input_price),
output_price: mTokToPerToken(p.output_price),
cache_write_price: mTokToPerToken(p.cache_write_price),
cache_read_price: mTokToPerToken(p.cache_read_price),
image_output_price: mTokToPerToken(p.image_output_price),
per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null,
intervals: formIntervalsToAPI(p.intervals || [])
}))
})
}
}
return rules
}
// ── Form ↔ API conversion ── // ── Form ↔ API conversion ──
function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>> } { function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[], model_mapping: Record<string, Record<string, string>>, features_config: Record<string, unknown> } {
const group_ids: number[] = [] const group_ids: number[] = []
const model_pricing: ChannelModelPricing[] = [] const model_pricing: ChannelModelPricing[] = []
const model_mapping: Record<string, Record<string, string>> = {} const model_mapping: Record<string, Record<string, string>> = {}
// Preserve existing features_config fields not managed by the form
const featuresConfig: Record<string, unknown> = editingChannel.value?.features_config
? { ...editingChannel.value.features_config }
: {}
for (const section of form.platforms) { for (const section of form.platforms) {
if (!section.enabled) continue if (!section.enabled) continue
...@@ -711,7 +1031,23 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[ ...@@ -711,7 +1031,23 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
} }
} }
return { group_ids, model_pricing, model_mapping } // Collect web_search_emulation (only anthropic platform supports it)
// Always write the key so that disabling in the UI correctly sets platform to false,
// rather than leaving a stale true value from the cloned features_config.
const wsEmulation: Record<string, boolean> = {}
for (const section of form.platforms) {
if (!section.enabled) continue
if (section.platform === 'anthropic') {
wsEmulation[section.platform] = !!section.web_search_emulation
}
}
if (Object.keys(wsEmulation).length > 0) {
featuresConfig.web_search_emulation = wsEmulation
} else {
delete featuresConfig.web_search_emulation
}
return { group_ids, model_pricing, model_mapping, features_config: featuresConfig }
} }
function apiToForm(channel: Channel): PlatformSection[] { function apiToForm(channel: Channel): PlatformSection[] {
...@@ -755,13 +1091,20 @@ function apiToForm(channel: Channel): PlatformSection[] { ...@@ -755,13 +1091,20 @@ function apiToForm(channel: Channel): PlatformSection[] {
intervals: apiIntervalsToForm(p.intervals || []) intervals: apiIntervalsToForm(p.intervals || [])
} as PricingFormEntry)) } as PricingFormEntry))
// Read web_search_emulation from features_config
const fc = channel.features_config
const wsEmulation = fc?.web_search_emulation as Record<string, boolean> | undefined
const webSearchEnabled = wsEmulation?.[platform] === true
sections.push({ sections.push({
platform, platform,
enabled: true, enabled: true,
collapsed: false, collapsed: false,
group_ids: groupIds, group_ids: groupIds,
model_mapping: { ...mapping }, model_mapping: { ...mapping },
model_pricing: pricing model_pricing: pricing,
web_search_emulation: webSearchEnabled,
account_stats_pricing_rules: [],
}) })
} }
...@@ -786,10 +1129,10 @@ async function loadChannels() { ...@@ -786,10 +1129,10 @@ async function loadChannels() {
if (ctrl.signal.aborted || abortController !== ctrl) return if (ctrl.signal.aborted || abortController !== ctrl) return
channels.value = response.items || [] channels.value = response.items || []
pagination.total = response.total pagination.total = response.total
} catch (error: any) { } catch (error: unknown) {
if (error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return const e = error as { name?: string; code?: string }
appStore.showError(t('admin.channels.loadError', 'Failed to load channels')) if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
console.error('Error loading channels:', error) appStore.showError(extractApiErrorMessage(error, t('admin.channels.loadError', 'Failed to load channels')))
} finally { } finally {
if (abortController === ctrl) { if (abortController === ctrl) {
loading.value = false loading.value = false
...@@ -854,7 +1197,11 @@ function resetForm() { ...@@ -854,7 +1197,11 @@ function resetForm() {
form.restrict_models = false form.restrict_models = false
form.billing_model_source = 'channel_mapped' form.billing_model_source = 'channel_mapped'
form.platforms = [] form.platforms = []
form.apply_pricing_to_account_stats = false
activeTab.value = 'basic' activeTab.value = 'basic'
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
ruleAccountNameCache.value = {}
} }
async function openCreateDialog() { async function openCreateDialog() {
...@@ -871,12 +1218,92 @@ async function openEditDialog(channel: Channel) { ...@@ -871,12 +1218,92 @@ async function openEditDialog(channel: Channel) {
form.status = channel.status form.status = channel.status
form.restrict_models = channel.restrict_models || false form.restrict_models = channel.restrict_models || false
form.billing_model_source = channel.billing_model_source || 'channel_mapped' form.billing_model_source = channel.billing_model_source || 'channel_mapped'
form.apply_pricing_to_account_stats = channel.apply_pricing_to_account_stats || false
// Must load groups first so apiToForm can map groupID → platform // Must load groups first so apiToForm can map groupID → platform
await Promise.all([loadGroups(), loadAllChannelsForConflict()]) await Promise.all([loadGroups(), loadAllChannelsForConflict()])
form.platforms = apiToForm(channel) form.platforms = apiToForm(channel)
// Distribute channel-level rules into per-platform sections
distributeRulesToPlatforms(channel.account_stats_pricing_rules || [])
// Populate ruleAccountNameCache for existing rule accounts
await populateRuleAccountNameCache()
showDialog.value = true showDialog.value = true
} }
/** Distribute flat channel-level rules into the matching platform section based on group_ids */
function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) {
// Build groupID → platform lookup
const groupPlatformMap = new Map<number, GroupPlatform>()
for (const g of allGroups.value) {
groupPlatformMap.set(g.id, g.platform)
}
for (const apiRule of apiRules) {
// Infer platform from group_ids
const platforms = new Set<GroupPlatform>()
for (const gid of apiRule.group_ids || []) {
const p = groupPlatformMap.get(gid)
if (p) platforms.add(p)
}
// If pricing has a platform field, use that as fallback
if (platforms.size === 0 && apiRule.pricing?.length > 0) {
const p = apiRule.pricing[0].platform as GroupPlatform | undefined
if (p) platforms.add(p)
}
const targetPlatform = platforms.size >= 1 ? [...platforms][0] : null
if (!targetPlatform) continue
const section = form.platforms.find(s => s.platform === targetPlatform)
if (!section) continue
const formRule: FormPricingRule = {
name: apiRule.name || '',
group_ids: [...(apiRule.group_ids || [])],
account_ids: [...(apiRule.account_ids || [])],
pricing: (apiRule.pricing || []).map(p => ({
models: [...(p.models || [])],
billing_mode: p.billing_mode,
input_price: perTokenToMTok(p.input_price),
output_price: perTokenToMTok(p.output_price),
cache_write_price: perTokenToMTok(p.cache_write_price),
cache_read_price: perTokenToMTok(p.cache_read_price),
image_output_price: perTokenToMTok(p.image_output_price),
per_request_price: p.per_request_price,
intervals: apiIntervalsToForm(p.intervals || [])
} as PricingFormEntry))
}
section.account_stats_pricing_rules.push(formRule)
}
}
/** Populate ruleAccountNameCache by fetching account details for all account_ids in rules */
async function populateRuleAccountNameCache() {
const allAccountIds = new Set<number>()
for (const section of form.platforms) {
for (const rule of section.account_stats_pricing_rules) {
for (const id of rule.account_ids) {
allAccountIds.add(id)
}
}
}
if (allAccountIds.size === 0) return
// Fetch account details in parallel (batch of individual getById calls)
const ids = [...allAccountIds]
const results = await Promise.allSettled(
ids.map(id => adminAPI.accounts.getById(id))
)
for (let i = 0; i < ids.length; i++) {
const result = results[i]
if (result.status === 'fulfilled') {
ruleAccountNameCache.value[ids[i]] = result.value.name
}
// If rejected, the cache won't have the name, so it'll show "#ID" which is acceptable
}
}
function closeDialog() { function closeDialog() {
showDialog.value = false showDialog.value = false
editingChannel.value = null editingChannel.value = null
...@@ -961,7 +1388,7 @@ async function handleSubmit() { ...@@ -961,7 +1388,7 @@ async function handleSubmit() {
const intervalErr = validateIntervals(entry.intervals) const intervalErr = validateIntervals(entry.intervals)
if (intervalErr) { if (intervalErr) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform) const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
const modelLabel = entry.models.join(', ') || '未命名' const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')
appStore.showError(`${platformLabel} - ${modelLabel}: ${intervalErr}`) appStore.showError(`${platformLabel} - ${modelLabel}: ${intervalErr}`)
activeTab.value = section.platform activeTab.value = section.platform
return return
...@@ -969,7 +1396,7 @@ async function handleSubmit() { ...@@ -969,7 +1396,7 @@ async function handleSubmit() {
} }
} }
const { group_ids, model_pricing, model_mapping } = formToAPI() const { group_ids, model_pricing, model_mapping, features_config } = formToAPI()
submitting.value = true submitting.value = true
try { try {
...@@ -982,7 +1409,10 @@ async function handleSubmit() { ...@@ -982,7 +1409,10 @@ async function handleSubmit() {
model_pricing, model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {}, model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source, billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
} }
await adminAPI.channels.update(editingChannel.value.id, req) await adminAPI.channels.update(editingChannel.value.id, req)
appStore.showSuccess(t('admin.channels.updateSuccess', 'Channel updated')) appStore.showSuccess(t('admin.channels.updateSuccess', 'Channel updated'))
...@@ -994,19 +1424,20 @@ async function handleSubmit() { ...@@ -994,19 +1424,20 @@ async function handleSubmit() {
model_pricing, model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {}, model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source, billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models restrict_models: form.restrict_models,
features_config,
apply_pricing_to_account_stats: form.apply_pricing_to_account_stats,
account_stats_pricing_rules: accountStatsRulesToAPI()
} }
await adminAPI.channels.create(req) await adminAPI.channels.create(req)
appStore.showSuccess(t('admin.channels.createSuccess', 'Channel created')) appStore.showSuccess(t('admin.channels.createSuccess', 'Channel created'))
} }
closeDialog() closeDialog()
loadChannels() loadChannels()
} catch (error: any) { } catch (error: unknown) {
const msg = error.response?.data?.detail || (editingChannel.value appStore.showError(extractApiErrorMessage(error, editingChannel.value
? t('admin.channels.updateError', 'Failed to update channel') ? t('admin.channels.updateError', 'Failed to update channel')
: t('admin.channels.createError', 'Failed to create channel')) : t('admin.channels.createError', 'Failed to create channel')))
appStore.showError(msg)
console.error('Error saving channel:', error)
} finally { } finally {
submitting.value = false submitting.value = false
} }
...@@ -1044,9 +1475,8 @@ async function confirmDelete() { ...@@ -1044,9 +1475,8 @@ async function confirmDelete() {
showDeleteDialog.value = false showDeleteDialog.value = false
deletingChannel.value = null deletingChannel.value = null
loadChannels() loadChannels()
} catch (error: any) { } catch (error: unknown) {
appStore.showError(error.response?.data?.detail || t('admin.channels.deleteError', 'Failed to delete channel')) appStore.showError(extractApiErrorMessage(error, t('admin.channels.deleteError', 'Failed to delete channel')))
console.error('Error deleting channel:', error)
} }
} }
...@@ -1054,11 +1484,16 @@ async function confirmDelete() { ...@@ -1054,11 +1484,16 @@ async function confirmDelete() {
onMounted(() => { onMounted(() => {
loadChannels() loadChannels()
loadGroups() loadGroups()
loadWebSearchGlobalState()
document.addEventListener('click', handleRuleAccountClickOutside)
}) })
onUnmounted(() => { onUnmounted(() => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
abortController?.abort() abortController?.abort()
document.removeEventListener('click', handleRuleAccountClickOutside)
ruleAccountSearchRunner.clearAll()
clearAllRuleAccountSearchState()
}) })
</script> </script>
......
...@@ -3253,6 +3253,7 @@ const editForm = reactive({ ...@@ -3253,6 +3253,7 @@ const editForm = reactive({
fallback_group_id_on_invalid_request: null as number | null, fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false, allow_messages_dispatch: false,
default_mapped_model: '',
opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model, opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model,
sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model, sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model,
haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model, haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model,
...@@ -3732,6 +3733,19 @@ watch( ...@@ -3732,6 +3733,19 @@ watch(
}, },
); );
watch(
() => editForm.platform,
(newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) {
editForm.fallback_group_id_on_invalid_request = null
}
if (newVal !== 'openai') {
editForm.allow_messages_dispatch = false
editForm.default_mapped_model = ''
}
}
)
// 点击外部关闭账号搜索下拉框 // 点击外部关闭账号搜索下拉框
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
......
...@@ -985,7 +985,8 @@ const { ...@@ -985,7 +985,8 @@ const {
deselect, deselect,
clear: clearSelectedProxies, clear: clearSelectedProxies,
removeMany: removeSelectedProxies, removeMany: removeSelectedProxies,
toggleVisible toggleVisible,
batchUpdate
} = useTableSelection<Proxy>({ } = useTableSelection<Proxy>({
rows: proxies, rows: proxies,
getId: (proxy) => proxy.id getId: (proxy) => proxy.id
...@@ -993,7 +994,8 @@ const { ...@@ -993,7 +994,8 @@ const {
useSwipeSelect(proxyTableRef, { useSwipeSelect(proxyTableRef, {
isSelected, isSelected,
select, select,
deselect deselect,
batchUpdate
}) })
const accountsProxy = ref<Proxy | null>(null) const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([]) const proxyAccounts = ref<ProxyAccountSummary[]>([])
......
...@@ -1711,6 +1711,231 @@ ...@@ -1711,6 +1711,231 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Web Search Emulation -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.webSearchEmulation.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.webSearchEmulation.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Global Toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.webSearchEmulation.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.webSearchEmulation.enabledHint') }}
</p>
</div>
<Toggle v-model="webSearchConfig.enabled" />
</div>
<!-- Providers -->
<div v-if="webSearchConfig.enabled" class="space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.webSearchEmulation.providers') }}
</label>
<button type="button" class="btn btn-secondary btn-sm" @click="addWebSearchProvider">
{{ t('admin.settings.webSearchEmulation.addProvider') }}
</button>
</div>
<div v-if="webSearchConfig.providers.length === 0" class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-400 dark:border-dark-600">
{{ t('admin.settings.webSearchEmulation.noProviders') }}
</div>
<div v-for="(provider, pIdx) in webSearchConfig.providers" :key="pIdx"
class="rounded-lg border border-gray-200 dark:border-dark-600">
<!-- Collapsible header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-3"
@click="toggleProviderExpand(pIdx)"
>
<div class="flex items-center gap-3">
<svg
class="h-4 w-4 text-gray-400 transition-transform"
:class="{ 'rotate-90': expandedProviders[pIdx] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<Select
v-model="provider.type"
:options="[
{ value: 'brave', label: 'Brave Search' },
{ value: 'tavily', label: 'Tavily' },
]"
class="w-36"
@click.stop
/>
<!-- Quota summary (always visible) -->
<span class="text-xs text-gray-400">
{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit != null && provider.quota_limit > 0 ? provider.quota_limit : '' }}
</span>
<span v-if="!expandedProviders[pIdx] && provider.api_key_configured" class="text-xs text-green-500">
{{ t('admin.settings.webSearchEmulation.apiKeyConfigured') }}
</span>
</div>
<button type="button" class="text-red-500 hover:text-red-700 text-xs" @click.stop="removeWebSearchProvider(pIdx)">
{{ t('admin.settings.webSearchEmulation.removeProvider') }}
</button>
</div>
<!-- Expanded content -->
<div v-if="expandedProviders[pIdx]" class="space-y-3 border-t border-gray-100 px-4 pb-4 pt-3 dark:border-dark-700">
<!-- API Key with inline show/copy -->
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.apiKey') }}</label>
<div class="relative">
<input
v-model="provider.api_key"
:type="apiKeyVisible[pIdx] ? 'text' : 'password'"
class="input w-full text-sm"
:class="(provider.api_key || provider.api_key_configured) ? 'pr-16' : ''"
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
/>
<div v-if="provider.api_key || provider.api_key_configured" class="absolute inset-y-0 right-0 flex items-center pr-1.5">
<button
type="button"
class="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:title="apiKeyVisible[pIdx] ? t('admin.settings.webSearchEmulation.hideApiKey') : t('admin.settings.webSearchEmulation.showApiKey')"
@click="apiKeyVisible[pIdx] = !apiKeyVisible[pIdx]"
>
<svg v-if="!apiKeyVisible[pIdx]" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else 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.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
</button>
<button
type="button"
class="rounded p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
:class="{ 'opacity-30 cursor-not-allowed': !provider.api_key }"
:title="t('admin.settings.webSearchEmulation.copyApiKey')"
:disabled="!provider.api_key"
@click="copyApiKey(pIdx)"
>
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
</div>
<!-- Quota + Subscription in compact row -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaLimit') }}</label>
<input v-model="provider.quota_limit" type="number" min="1" class="input text-sm" :placeholder="'∞'" />
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}</p>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.subscribedAt') }}</label>
<input
:value="formatSubscribedAt(provider.subscribed_at)"
type="date"
class="input text-sm"
@input="provider.subscribed_at = parseSubscribedAt(($event.target as HTMLInputElement).value)"
/>
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.subscribedAtHint') }}</p>
</div>
</div>
<!-- Usage display -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaUsage') }}:</span>
<div v-if="provider.quota_limit != null && provider.quota_limit > 0" class="flex-1 rounded-full bg-gray-200 dark:bg-dark-600" style="height: 6px">
<div
class="h-full rounded-full transition-all"
:class="quotaPercentage(provider) > 90 ? 'bg-red-500' : quotaPercentage(provider) > 70 ? 'bg-yellow-500' : 'bg-green-500'"
:style="{ width: Math.min(quotaPercentage(provider), 100) + '%' }"
/>
</div>
<div v-else class="flex-1" />
<span class="text-xs text-gray-500">{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit != null && provider.quota_limit > 0 ? provider.quota_limit : '' }}</span>
<button
v-if="(provider.quota_used ?? 0) > 0"
type="button"
class="text-xs text-primary-600 hover:text-primary-700"
@click="resetWebSearchUsage(pIdx)"
>
{{ t('admin.settings.webSearchEmulation.resetUsage') }}
</button>
</div>
<!-- Proxy + Test on same row -->
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
<ProxySelector v-model="provider.proxy_id" :proxies="webSearchProxies" />
</div>
<button
type="button"
class="btn btn-secondary btn-sm whitespace-nowrap"
@click="openTestDialog()"
>
{{ t('admin.settings.webSearchEmulation.test') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Web Search Test Dialog -->
<div v-if="wsTestDialogOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="wsTestDialogOpen = false">
<div class="mx-4 w-full max-w-lg rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.webSearchEmulation.testResultTitle') }}
</h3>
<div class="flex items-center gap-2">
<input
v-model="wsTestQuery"
type="text"
class="input flex-1 text-sm"
:placeholder="t('admin.settings.webSearchEmulation.testDefaultQuery')"
@keyup.enter="testWebSearchProvider()"
/>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="wsTestLoading"
@click="testWebSearchProvider()"
>
{{ wsTestLoading ? t('admin.settings.webSearchEmulation.testing') : t('admin.settings.webSearchEmulation.test') }}
</button>
</div>
<!-- Test results -->
<div v-if="wsTestResult" class="mt-4 max-h-80 overflow-y-auto rounded-lg bg-gray-50 p-4 dark:bg-dark-700">
<p class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.webSearchEmulation.testResultProvider') }}: {{ wsTestResult.provider }}
</p>
<div v-if="wsTestResult.results.length === 0" class="text-sm text-gray-400">
{{ t('admin.settings.webSearchEmulation.testNoResults') }}
</div>
<div v-for="(r, rIdx) in wsTestResult.results" :key="rIdx" class="mt-2 border-t border-gray-200 pt-2 first:mt-0 first:border-0 first:pt-0 dark:border-dark-600">
<a :href="r.url" target="_blank" class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400">{{ r.title }}</a>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ r.snippet }}</p>
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="button" class="btn btn-secondary btn-sm" @click="wsTestDialogOpen = false">
{{ t('common.close') }}
</button>
</div>
</div>
</div>
</div><!-- /Tab: Gateway Claude Code, Scheduling --> </div><!-- /Tab: Gateway Claude Code, Scheduling -->
<!-- Tab: General --> <!-- Tab: General -->
...@@ -2468,6 +2693,73 @@ ...@@ -2468,6 +2693,73 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Balance Low Notification -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.balanceNotify.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.balanceNotify.description') }}
</p>
</div>
<div class="px-6 py-6 space-y-4">
<div class="flex items-center justify-between">
<label class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.enabled') }}</label>
<Toggle v-model="form.balance_low_notify_enabled" />
</div>
<div v-if="form.balance_low_notify_enabled">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.threshold') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input v-model.number="form.balance_low_notify_threshold" type="number" min="0" step="0.01" class="input pl-7" />
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.thresholdHint') }}</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.rechargeUrl') }}</label>
<input v-model="form.balance_low_notify_recharge_url" type="url" class="input" :placeholder="currentOrigin" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.rechargeUrlHint') }}</p>
</div>
</div>
</div>
<!-- Account Quota Notification -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.quotaNotify.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.quotaNotify.description') }}
</p>
</div>
<div class="px-6 py-6 space-y-4">
<div class="flex items-center justify-between">
<label class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.enabled') }}</label>
<Toggle v-model="form.account_quota_notify_enabled" />
</div>
<div v-if="form.account_quota_notify_enabled">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
<div class="space-y-2">
<div v-for="(entry, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input type="checkbox" :checked="!entry.disabled" @change="entry.disabled = !entry.disabled" class="sr-only peer" />
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
</label>
<input v-model="entry.email" type="email" class="input flex-1" :placeholder="t('admin.settings.quotaNotify.emailPlaceholder')" />
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
<Icon name="x" size="xs" class="h-4 w-4" />
</button>
</div>
<button @click="addQuotaNotifyEmail" class="btn btn-secondary btn-sm" type="button">
+ {{ t('admin.settings.quotaNotify.addEmail') }}
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.quotaNotify.emailsHint') }}</p>
</div>
</div>
</div>
</div><!-- /Tab: Email --> </div><!-- /Tab: Email -->
<!-- Tab: Backup --> <!-- Tab: Backup -->
...@@ -2523,9 +2815,12 @@ import { adminAPI } from '@/api' ...@@ -2523,9 +2815,12 @@ import { adminAPI } from '@/api'
import type { import type {
SystemSettings, SystemSettings,
UpdateSettingsRequest, UpdateSettingsRequest,
DefaultSubscriptionSetting DefaultSubscriptionSetting,
WebSearchEmulationConfig,
WebSearchProviderConfig,
WebSearchTestResult,
} from '@/api/admin/settings' } from '@/api/admin/settings'
import type { AdminGroup } from '@/types' import type { AdminGroup, Proxy, NotifyEmailEntry } from '@/types'
import type { ProviderInstance } from '@/types/payment' import type { ProviderInstance } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
...@@ -2536,6 +2831,7 @@ import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vu ...@@ -2536,6 +2831,7 @@ import PaymentProviderDialog from '@/components/payment/PaymentProviderDialog.vu
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue' import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import Toggle from '@/components/common/Toggle.vue' import Toggle from '@/components/common/Toggle.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import ImageUpload from '@/components/common/ImageUpload.vue' import ImageUpload from '@/components/common/ImageUpload.vue'
import BackupSettings from '@/views/admin/BackupView.vue' import BackupSettings from '@/views/admin/BackupView.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
...@@ -2743,9 +3039,177 @@ const form = reactive<SettingsForm>({ ...@@ -2743,9 +3039,177 @@ const form = reactive<SettingsForm>({
// Gateway forwarding behavior // Gateway forwarding behavior
enable_fingerprint_unification: true, enable_fingerprint_unification: true,
enable_metadata_passthrough: false, enable_metadata_passthrough: false,
enable_cch_signing: false enable_cch_signing: false,
// Balance & quota notification
balance_low_notify_enabled: false,
balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: '',
account_quota_notify_enabled: false,
account_quota_notify_emails: [] as NotifyEmailEntry[]
})
// Proxies for web search emulation ProxySelector
const webSearchProxies = ref<Proxy[]>([])
// Web Search Emulation config (loaded/saved separately)
const DEFAULT_WEB_SEARCH_QUOTA_LIMIT = 1000
const webSearchConfig = reactive<WebSearchEmulationConfig>({
enabled: false,
providers: [],
}) })
const expandedProviders = reactive<Record<number, boolean>>({})
const apiKeyVisible = reactive<Record<number, boolean>>({})
const wsTestQuery = ref('')
const wsTestLoading = ref(false)
const wsTestResult = ref<WebSearchTestResult | null>(null)
const wsTestDialogOpen = ref(false)
function openTestDialog() {
wsTestResult.value = null
wsTestDialogOpen.value = true
}
function toggleProviderExpand(idx: number) {
expandedProviders[idx] = !expandedProviders[idx]
}
function removeWebSearchProvider(idx: number) {
webSearchConfig.providers.splice(idx, 1)
// Re-index expandedProviders and apiKeyVisible after removal
const newExpanded: Record<number, boolean> = {}
const newVisible: Record<number, boolean> = {}
for (let i = 0; i < webSearchConfig.providers.length; i++) {
const oldIdx = i >= idx ? i + 1 : i
newExpanded[i] = expandedProviders[oldIdx] ?? false
newVisible[i] = apiKeyVisible[oldIdx] ?? false
}
Object.keys(expandedProviders).forEach((k) => delete expandedProviders[Number(k)])
Object.keys(apiKeyVisible).forEach((k) => delete apiKeyVisible[Number(k)])
Object.assign(expandedProviders, newExpanded)
Object.assign(apiKeyVisible, newVisible)
}
function addWebSearchProvider() {
const idx = webSearchConfig.providers.length
webSearchConfig.providers.push({
type: 'brave',
api_key: '',
api_key_configured: false,
quota_limit: DEFAULT_WEB_SEARCH_QUOTA_LIMIT,
subscribed_at: null,
proxy_id: null,
expires_at: null,
} as WebSearchProviderConfig)
expandedProviders[idx] = true
}
function formatSubscribedAt(ts: number | null): string {
if (!ts) return ''
// Use UTC to avoid timezone drift on repeated edits
const d = new Date(ts * 1000)
const y = d.getUTCFullYear()
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
const day = String(d.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function parseSubscribedAt(dateStr: string): number | null {
if (!dateStr) return null
// Parse as UTC to match formatSubscribedAt
return Math.floor(new Date(dateStr + 'T00:00:00Z').getTime() / 1000)
}
function quotaPercentage(provider: WebSearchProviderConfig): number {
if (!provider.quota_limit || provider.quota_limit <= 0) return 0
return ((provider.quota_used ?? 0) / provider.quota_limit) * 100
}
async function resetWebSearchUsage(idx: number) {
const provider = webSearchConfig.providers[idx]
if (!provider) return
if (!confirm(t('admin.settings.webSearchEmulation.resetUsageConfirm'))) return
try {
await adminAPI.settings.resetWebSearchUsage({ provider_type: provider.type })
provider.quota_used = 0
appStore.showSuccess(t('admin.settings.webSearchEmulation.resetUsageSuccess'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
async function copyApiKey(idx: number) {
const key = webSearchConfig.providers[idx]?.api_key
if (!key) {
appStore.showError(t('admin.settings.webSearchEmulation.apiKeyPlaceholder'))
return
}
try {
await navigator.clipboard.writeText(key)
appStore.showSuccess(t('admin.settings.webSearchEmulation.copied'))
} catch {
appStore.showError(t('common.error'))
}
}
async function testWebSearchProvider() {
wsTestLoading.value = true
wsTestResult.value = null
try {
const query = wsTestQuery.value.trim() || t('admin.settings.webSearchEmulation.testDefaultQuery')
wsTestResult.value = await adminAPI.settings.testWebSearchEmulation(query)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
wsTestLoading.value = false
}
}
async function loadWebSearchConfig() {
try {
const [resp, proxiesResp] = await Promise.all([
adminAPI.settings.getWebSearchEmulationConfig(),
adminAPI.proxies.list().catch(() => ({ items: [] as Proxy[] })),
])
if (resp) {
webSearchConfig.enabled = resp.enabled || false
webSearchConfig.providers = resp.providers || []
}
webSearchProxies.value = proxiesResp.items || []
} catch (err: unknown) {
// 404 is expected when config hasn't been created yet; show error for other failures
const status = (err as { status?: number })?.status
if (status !== 404 && status !== undefined) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
}
async function saveWebSearchConfig(): Promise<boolean> {
try {
for (const p of webSearchConfig.providers) {
const raw = p.quota_limit
if (raw != null && Number(raw) !== 0 && Number(raw) < 1) {
appStore.showError(t('admin.settings.webSearchEmulation.quotaLimitMustBePositive'))
return false
}
}
const providers = webSearchConfig.providers.map((p: WebSearchProviderConfig) => ({
...p,
quota_limit: Number(p.quota_limit) > 0 ? Number(p.quota_limit) : null,
}))
await adminAPI.settings.updateWebSearchEmulationConfig({
enabled: webSearchConfig.enabled,
providers,
})
return true
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
return false
}
}
const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() => const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() =>
subscriptionGroups.value.map((group) => ({ subscriptionGroups.value.map((group) => ({
value: group.id, value: group.id,
...@@ -2825,6 +3289,16 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) { ...@@ -2825,6 +3289,16 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
} }
} }
// Quota notify email helpers
const addQuotaNotifyEmail = () => {
if (!form.account_quota_notify_emails) {
form.account_quota_notify_emails = []
}
form.account_quota_notify_emails.push({ email: '', disabled: false, verified: true })
}
const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''
// LinuxDo OAuth redirect URL suggestion // LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => { const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
...@@ -2960,6 +3434,9 @@ async function loadSettings() { ...@@ -2960,6 +3434,9 @@ async function loadSettings() {
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = '' form.oidc_connect_client_secret = ''
// Load web search emulation config separately
await loadWebSearchConfig()
} catch (error: unknown) { } catch (error: unknown) {
loadFailed.value = true loadFailed.value = true
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad'))) appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad')))
...@@ -3161,6 +3638,12 @@ async function saveSettings() { ...@@ -3161,6 +3638,12 @@ async function saveSettings() {
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1, payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit, payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode, payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
// Balance & quota notification
balance_low_notify_enabled: form.balance_low_notify_enabled,
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
balance_low_notify_recharge_url: (form.balance_low_notify_recharge_url = form.balance_low_notify_recharge_url || currentOrigin),
account_quota_notify_enabled: form.account_quota_notify_enabled,
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''),
} }
const updated = await adminAPI.settings.updateSettings(payload) const updated = await adminAPI.settings.updateSettings(payload)
...@@ -3181,10 +3664,14 @@ async function saveSettings() { ...@@ -3181,10 +3664,14 @@ async function saveSettings() {
form.turnstile_secret_key = '' form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = '' form.linuxdo_connect_client_secret = ''
form.oidc_connect_client_secret = '' form.oidc_connect_client_secret = ''
// Save web search emulation config separately (errors handled internally)
const wsOk = await saveWebSearchConfig()
// Refresh cached settings so sidebar/header update immediately // Refresh cached settings so sidebar/header update immediately
await appStore.fetchPublicSettings(true) await appStore.fetchPublicSettings(true)
await adminSettingsStore.fetch(true) await adminSettingsStore.fetch(true)
appStore.showSuccess(t('admin.settings.settingsSaved')) if (wsOk) {
appStore.showSuccess(t('admin.settings.settingsSaved'))
}
} catch (error: unknown) { } catch (error: unknown) {
appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave'))) appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave')))
} finally { } finally {
...@@ -3624,12 +4111,26 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) { ...@@ -3624,12 +4111,26 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) {
} }
} }
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled') { async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund') {
const newValue = field === 'enabled' ? !provider.enabled : !provider.refund_enabled let newValue: boolean
if (field === 'enabled') newValue = !provider.enabled
else if (field === 'refund_enabled') newValue = !provider.refund_enabled
else newValue = !provider.allow_user_refund
const payload: Record<string, boolean> = { [field]: newValue }
// Cascade: turning off refund_enabled also turns off allow_user_refund
if (field === 'refund_enabled' && !newValue) {
payload.allow_user_refund = false
}
try { try {
await adminAPI.payment.updateProvider(provider.id, { [field]: newValue }) await adminAPI.payment.updateProvider(provider.id, payload)
if (field === 'enabled') provider.enabled = newValue if (field === 'enabled') provider.enabled = newValue
else provider.refund_enabled = newValue else if (field === 'refund_enabled') {
provider.refund_enabled = newValue
if (!newValue) provider.allow_user_refund = false
} else {
provider.allow_user_refund = newValue
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) } } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
} }
......
...@@ -495,7 +495,7 @@ const exportToExcel = async () => { ...@@ -495,7 +495,7 @@ const exportToExcel = async () => {
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000', log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4), log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4),
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000', log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms, ((log.account_stats_cost ?? log.total_cost) * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
log.request_id || '', log.user_agent || '', log.ip_address || '' log.request_id || '', log.user_agent || '', log.ip_address || ''
]) ])
if (rows.length) { if (rows.length) {
......
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span> <span class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + method.type, method.type) }}</span>
</div> </div>
<div class="text-right"> <div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ method.amount.toFixed(2) }}</span> <span class="text-sm font-medium text-gray-900 dark:text-white">&yen;{{ method.amount.toFixed(2) }}</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span> <span class="ml-2 text-xs text-gray-500 dark:text-gray-400">({{ method.count }})</span>
</div> </div>
</div> </div>
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
<span :class="['flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold', rankClass(idx)]">{{ idx + 1 }}</span> <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> <span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
</div> </div>
<span class="text-sm font-medium text-gray-900 dark:text-white">${{ user.amount.toFixed(2) }}</span> <span class="text-sm font-medium text-gray-900 dark:text-white">&yen;{{ user.amount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
</template> </template>
<template #cell-price="{ value, row }"> <template #cell-price="{ value, row }">
<div class="text-sm"> <div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">${{ (value ?? 0).toFixed(2) }}</span>
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span> <span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
</div> </div>
</template> </template>
...@@ -67,86 +67,14 @@ ...@@ -67,86 +67,14 @@
</div> </div>
<!-- Plan Edit Dialog --> <!-- Plan Edit Dialog -->
<BaseDialog :show="showPlanDialog" :title="editingPlan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="showPlanDialog = false"> <PlanEditDialog :show="showPlanDialog" :plan="editingPlan" :groups="groups" @close="showPlanDialog = false" @saved="loadPlans" />
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('payment.admin.planName') }}</label>
<input v-model="planForm.name" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('payment.admin.group') }}</label>
<Select v-model="planForm.group_id" :options="groupOptions" class="w-full">
<template #selected="{ option }">
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
</template>
<template #option="{ option, selected }">
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
</template>
</Select>
</div>
</div>
<!-- Group Info Preview -->
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<div class="mb-2 flex items-center gap-2">
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
</div>
</div>
<div><label class="input-label">{{ t('payment.admin.planDescription') }}</label><textarea v-model="planForm.description" rows="2" class="input"></textarea></div>
<div class="grid grid-cols-3 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }}</label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.validityDays') }}</label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }}</label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.features') }}</label>
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
</div>
<div class="flex items-center gap-3">
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@click="planForm.for_sale = !planForm.for_sale"
>
<span :class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
]" />
</button>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="showPlanDialog = false" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="plan-form" :disabled="planSaving" class="btn btn-primary">{{ planSaving ? t('common.saving') : t('common.save') }}</button>
</div>
</template>
</BaseDialog>
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" /> <ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
</AppLayout> </AppLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment' import { adminPaymentAPI } from '@/api/admin/payment'
...@@ -157,11 +85,10 @@ import type { AdminGroup } from '@/types' ...@@ -157,11 +85,10 @@ import type { AdminGroup } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import PlanEditDialog from './PlanEditDialog.vue'
import { platformTextClass } from '@/utils/platformColors' import { platformTextClass } from '@/utils/platformColors'
const { t } = useI18n() const { t } = useI18n()
...@@ -190,21 +117,6 @@ function getPlanNameClass(groupId: number): string { ...@@ -190,21 +117,6 @@ function getPlanNameClass(groupId: number): string {
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white' return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
} }
const groupOptions = computed(() => [
{ value: 0, label: t('payment.admin.selectGroup'), platform: '' },
...groups.value
.filter(g => g.subscription_type === 'subscription')
.map(g => ({
value: g.id,
label: `${g.name}${g.platform} (${g.rate_multiplier}x)`,
platform: g.platform,
})),
])
const selectedGroupInfo = computed(() => {
if (!planForm.group_id) return null
return groups.value.find(g => g.id === planForm.group_id) || null
})
// ==================== Plans ==================== // ==================== Plans ====================
...@@ -212,17 +124,8 @@ const plansLoading = ref(false) ...@@ -212,17 +124,8 @@ const plansLoading = ref(false)
const plans = ref<SubscriptionPlan[]>([]) const plans = ref<SubscriptionPlan[]>([])
const showPlanDialog = ref(false) const showPlanDialog = ref(false)
const showDeletePlanDialog = ref(false) const showDeletePlanDialog = ref(false)
const planSaving = ref(false)
const editingPlan = ref<SubscriptionPlan | null>(null) const editingPlan = ref<SubscriptionPlan | null>(null)
const deletingPlanId = ref<number | null>(null) const deletingPlanId = ref<number | null>(null)
const planForm = reactive({ name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
const planFeaturesText = ref('')
const validityUnitOptions = computed(() => [
{ value: 'days', label: t('payment.admin.days') },
{ value: 'weeks', label: t('payment.admin.weeks') },
{ value: 'months', label: t('payment.admin.months') },
])
const planColumns = computed((): Column[] => [ const planColumns = computed((): Column[] => [
{ key: 'id', label: 'ID' }, { key: 'id', label: 'ID' },
...@@ -253,43 +156,9 @@ async function loadPlans() { ...@@ -253,43 +156,9 @@ async function loadPlans() {
function openPlanEdit(plan: SubscriptionPlan | null) { function openPlanEdit(plan: SubscriptionPlan | null) {
editingPlan.value = plan editingPlan.value = plan
if (plan) {
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale, sort_order: plan.sort_order })
planFeaturesText.value = (plan.features || []).join('\n')
} else {
Object.assign(planForm, { name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
planFeaturesText.value = ''
}
showPlanDialog.value = true showPlanDialog.value = true
} }
/** Build request payload with snake_case keys matching backend JSON tags */
function buildPlanPayload() {
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
return {
name: planForm.name,
group_id: planForm.group_id,
description: planForm.description,
price: planForm.price,
original_price: planForm.original_price || 0,
validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit,
for_sale: planForm.for_sale,
sort_order: planForm.sort_order,
features,
}
}
async function handleSavePlan() {
planSaving.value = true
try {
const data = buildPlanPayload()
if (editingPlan.value) { await adminPaymentAPI.updatePlan(editingPlan.value.id, data) }
else { await adminPaymentAPI.createPlan(data) }
appStore.showSuccess(t('common.saved')); showPlanDialog.value = false; loadPlans()
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { planSaving.value = false }
}
/** Quick toggle for_sale from the list */ /** Quick toggle for_sale from the list */
async function toggleForSale(plan: SubscriptionPlan) { async function toggleForSale(plan: SubscriptionPlan) {
......
<template>
<BaseDialog :show="show" :title="plan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="emit('close')">
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('payment.admin.planName') }} <span class="text-red-500">*</span></label>
<input v-model="planForm.name" type="text" class="input" required />
</div>
<div>
<label class="input-label">{{ t('payment.admin.group') }} <span class="text-red-500">*</span></label>
<Select v-model="planForm.group_id" :options="groupOptions" :placeholder="t('payment.admin.selectGroup')" class="w-full">
<template #selected="{ option }">
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
</template>
<template #option="{ option, selected }">
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
</template>
</Select>
</div>
</div>
<!-- Group Info Preview -->
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<div class="mb-2 flex items-center gap-2">
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
</div>
</div>
<div><label class="input-label">{{ t('payment.admin.planDescription') }} <span class="text-red-500">*</span></label><textarea v-model="planForm.description" rows="2" class="input" required></textarea></div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.price') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.price" type="number" step="0.01" min="0.01" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div>
<label class="input-label">{{ t('payment.admin.features') }}</label>
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
</div>
<div class="flex items-center gap-3">
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
@click="planForm.for_sale = !planForm.for_sale"
>
<span :class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
]" />
</button>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="plan-form" :disabled="saving" class="btn btn-primary">{{ saving ? t('common.saving') : t('common.save') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminPaymentAPI } from '@/api/admin/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { SubscriptionPlan } from '@/types/payment'
import type { AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import { platformTextClass } from '@/utils/platformColors'
const props = defineProps<{
show: boolean
plan: SubscriptionPlan | null
groups: AdminGroup[]
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const saving = ref(false)
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
const planFeaturesText = ref('')
const validityUnitOptions = computed(() => [
{ value: 'days', label: t('payment.admin.days') },
{ value: 'weeks', label: t('payment.admin.weeks') },
{ value: 'months', label: t('payment.admin.months') },
])
const groupOptions = computed(() =>
props.groups
.filter(g => g.subscription_type === 'subscription')
.map(g => ({
value: g.id,
label: `${g.name}${g.platform} (${g.rate_multiplier}x)`,
platform: g.platform,
})),
)
const selectedGroupInfo = computed(() => {
if (!planForm.group_id) return null
return props.groups.find(g => g.id === planForm.group_id) || null
})
// Reset form when dialog opens
watch(() => props.show, (visible) => {
if (!visible) return
if (props.plan) {
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale })
planFeaturesText.value = (props.plan.features || []).join('\n')
} else {
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
planFeaturesText.value = ''
}
})
/** Build request payload with snake_case keys matching backend JSON tags */
function buildPlanPayload() {
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
return {
name: planForm.name,
group_id: planForm.group_id,
description: planForm.description,
price: planForm.price,
original_price: planForm.original_price || 0,
validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit,
sort_order: planForm.sort_order,
for_sale: planForm.for_sale,
features,
}
}
async function handleSavePlan() {
if (!planForm.group_id) {
appStore.showError(t('payment.admin.groupRequired'))
return
}
if (!planForm.price || planForm.price <= 0) {
appStore.showError(t('payment.admin.priceRequired'))
return
}
if (!planForm.validity_days || planForm.validity_days < 1) {
appStore.showError(t('payment.admin.validityDaysRequired'))
return
}
saving.value = true
try {
const data = buildPlanPayload()
if (props.plan) { await adminPaymentAPI.updatePlan(props.plan.id, data) }
else { await adminPaymentAPI.createPlan(data) }
appStore.showSuccess(t('common.saved'))
emit('close')
emit('saved')
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { saving.value = false }
}
</script>
...@@ -71,15 +71,15 @@ ...@@ -71,15 +71,15 @@
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ validAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ feeAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"> <div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ totalAmount.toFixed(2) }}</span> <span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -88,7 +88,7 @@ ...@@ -88,7 +88,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span> <span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }} {{ t('common.processing') }}
</span> </span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span> <span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
</button> </button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"> <div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p> <p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
...@@ -110,9 +110,9 @@ ...@@ -110,9 +110,9 @@
<!-- Price --> <!-- Price -->
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500"> <span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
${{ selectedPlan.original_price }} ¥{{ selectedPlan.original_price }}
</span> </span>
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span> <span :class="['text-3xl font-bold', planTextClass]">¥{{ selectedPlan.price }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span> <span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div> </div>
<!-- Description --> <!-- Description -->
...@@ -156,15 +156,15 @@ ...@@ -156,15 +156,15 @@
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ selectedPlan.price.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ selectedPlan.price.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ subFeeAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ subFeeAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"> <div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ subTotalAmount.toFixed(2) }}</span> <span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ subTotalAmount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -173,7 +173,7 @@ ...@@ -173,7 +173,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span> <span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }} {{ t('common.processing') }}
</span> </span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span> <span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
</button> </button>
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button> <button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"> <div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
......
...@@ -14,6 +14,14 @@ ...@@ -14,6 +14,14 @@
</div> </div>
</div> </div>
<ProfileEditForm :initial-username="user?.username || ''" /> <ProfileEditForm :initial-username="user?.username || ''" />
<ProfileBalanceNotifyCard
v-if="user && balanceLowNotifyEnabled"
:enabled="user.balance_notify_enabled ?? true"
:threshold="user.balance_notify_threshold"
:extra-emails="user.balance_notify_extra_emails ?? []"
:system-default-threshold="systemDefaultThreshold"
:user-email="user.email"
/>
<ProfilePasswordForm /> <ProfilePasswordForm />
<ProfileTotpCard /> <ProfileTotpCard />
</div> </div>
...@@ -27,17 +35,20 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL ...@@ -27,17 +35,20 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL
import StatCard from '@/components/common/StatCard.vue' import StatCard from '@/components/common/StatCard.vue'
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue' import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue' import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue' import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
import { Icon } from '@/components/icons' import { Icon } from '@/components/icons'
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user) const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
const contactInfo = ref('') const contactInfo = ref('')
const balanceLowNotifyEnabled = ref(false)
const systemDefaultThreshold = ref(0)
const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) } const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) }
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) } const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) } const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch (error) { console.error('Failed to load contact info:', error) } }) onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || ''; balanceLowNotifyEnabled.value = s.balance_low_notify_enabled ?? false; systemDefaultThreshold.value = s.balance_low_notify_threshold ?? 0 } catch (error) { console.error('Failed to load settings:', error) } })
const formatCurrency = (v: number) => `$${v.toFixed(2)}` const formatCurrency = (v: number) => `$${v.toFixed(2)}`
</script> </script>
\ No newline at end of file
...@@ -192,7 +192,7 @@ ...@@ -192,7 +192,7 @@
<template #cell-billing_mode="{ row }"> <template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium" <span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
:class="getBillingModeBadgeClass(row.billing_mode)"> :class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode) }} {{ getBillingModeLabel(row.billing_mode, t) }}
</span> </span>
</template> </template>
...@@ -447,13 +447,21 @@ ...@@ -447,13 +447,21 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <!-- Token billing: show unit prices per 1M tokens -->
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span> <template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === 'token'">
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
</div> <span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4"> <span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span> </div>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4"> <div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
...@@ -516,6 +524,7 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters' ...@@ -516,6 +524,7 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType' import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
...@@ -636,17 +645,6 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => { ...@@ -636,17 +645,6 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
} }
const getBillingModeLabel = (mode: string | null | undefined): string => {
if (mode === 'per_request') return t('admin.usage.billingModePerRequest')
if (mode === 'image') return t('admin.usage.billingModeImage')
return t('admin.usage.billingModeToken')
}
const getBillingModeBadgeClass = (mode: string | null | undefined): string => {
if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200'
if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}
const getRequestTypeExportText = (log: UsageLog): string => { const getRequestTypeExportText = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log) const requestType = resolveUsageRequestType(log)
...@@ -858,7 +856,7 @@ const exportToCSV = async () => { ...@@ -858,7 +856,7 @@ const exportToCSV = async () => {
formatReasoningEffort(log.reasoning_effort), formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '', log.inbound_endpoint || '',
getRequestTypeExportText(log), getRequestTypeExportText(log),
getBillingModeLabel(log.billing_mode), getBillingModeLabel(log.billing_mode, t),
log.input_tokens, log.input_tokens,
log.output_tokens, log.output_tokens,
log.cache_read_tokens, log.cache_read_tokens,
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
<Icon name="x" size="sm" /> <Icon name="x" size="sm" />
<span>{{ t('payment.orders.cancel') }}</span> <span>{{ t('payment.orders.cancel') }}</span>
</button> </button>
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20"> <button v-if="canRequestRefund(row)" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="dollar" size="sm" /> <Icon name="dollar" size="sm" />
<span>{{ t('payment.orders.requestRefund') }}</span> <span>{{ t('payment.orders.requestRefund') }}</span>
</button> </button>
...@@ -102,6 +102,7 @@ const appStore = useAppStore() ...@@ -102,6 +102,7 @@ const appStore = useAppStore()
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
const orders = ref<PaymentOrder[]>([]) const orders = ref<PaymentOrder[]>([])
const refundEligibleProviders = ref<Set<string>>(new Set())
const currentFilter = ref('') const currentFilter = ref('')
const cancelTargetId = ref<number | null>(null) const cancelTargetId = ref<number | null>(null)
const refundTarget = ref<PaymentOrder | null>(null) const refundTarget = ref<PaymentOrder | null>(null)
...@@ -171,5 +172,18 @@ async function confirmRefund() { ...@@ -171,5 +172,18 @@ async function confirmRefund() {
} }
} }
onMounted(() => fetchOrders()) function canRequestRefund(order: PaymentOrder): boolean {
if (order.status !== 'COMPLETED') return false
if (!order.provider_instance_id) return false
return refundEligibleProviders.value.has(order.provider_instance_id)
}
async function loadRefundEligibility() {
try {
const res = await paymentAPI.getRefundEligibleProviders()
refundEligibleProviders.value = new Set(res.data.provider_instance_ids || [])
} catch { /* ignore — default to hiding refund button */ }
}
onMounted(() => { fetchOrders(); loadRefundEligibility() })
</script> </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