"frontend/src/vscode:/vscode.git/clone" did not exist on "0c660f8335f58cf7222883be5dbda6c30aa860d6"
Commit 91c9b8d0 authored by erio's avatar erio
Browse files

feat(channel): 渠道管理系统 — 多模式定价 + 统一计费解析

Cherry-picked from release/custom-0.1.106: a9117600
parent b384570d
......@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys'
import scheduledTestsAPI from './scheduledTests'
import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
/**
* Unified admin API object for convenient access
......@@ -51,7 +52,8 @@ export const adminAPI = {
apiKeys: apiKeysAPI,
scheduledTests: scheduledTestsAPI,
backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI
}
export {
......@@ -76,7 +78,8 @@ export {
apiKeysAPI,
scheduledTestsAPI,
backupAPI,
tlsFingerprintProfileAPI
tlsFingerprintProfileAPI,
channelsAPI
}
export default adminAPI
......
<template>
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 dark:border-dark-500 dark:bg-dark-700">
<!-- Token mode: context range + prices -->
<template v-if="mode === 'token'">
<div class="w-20">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens', 'Min (K)') }}</label>
<input
:value="interval.min_tokens"
@input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
type="number"
min="0"
class="input mt-0.5 text-xs"
/>
</div>
<div class="w-20">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens', 'Max (K)') }}</label>
<input
:value="interval.max_tokens ?? ''"
@input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type="number"
min="0"
class="input mt-0.5 text-xs"
:placeholder="'∞'"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', 'Input') }}</label>
<input
:value="interval.input_price"
@input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-0.5 text-xs"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', 'Output') }}</label>
<input
:value="interval.output_price"
@input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-0.5 text-xs"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', 'Cache W') }}</label>
<input
:value="interval.cache_write_price"
@input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-0.5 text-xs"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', 'Cache R') }}</label>
<input
:value="interval.cache_read_price"
@input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-0.5 text-xs"
/>
</div>
</template>
<!-- Per-request / Image mode: tier label + price -->
<template v-else>
<div class="w-24">
<label class="text-xs text-gray-400">
{{ mode === 'image'
? t('admin.channels.form.resolution', 'Resolution')
: t('admin.channels.form.tierLabel', 'Tier')
}}
</label>
<input
:value="interval.tier_label"
@input="emitField('tier_label', ($event.target as HTMLInputElement).value)"
type="text"
class="input mt-0.5 text-xs"
:placeholder="mode === 'image' ? '1K / 2K / 4K' : ''"
/>
</div>
<div class="w-20">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens', 'Min') }}</label>
<input
:value="interval.min_tokens"
@input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
type="number"
min="0"
class="input mt-0.5 text-xs"
/>
</div>
<div class="w-20">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens', 'Max') }}</label>
<input
:value="interval.max_tokens ?? ''"
@input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type="number"
min="0"
class="input mt-0.5 text-xs"
:placeholder="'∞'"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', 'Price') }}</label>
<input
:value="interval.per_request_price"
@input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-0.5 text-xs"
/>
</div>
</template>
<button
type="button"
@click="emit('remove')"
class="mt-4 rounded p-0.5 text-gray-400 hover:text-red-500"
>
<Icon name="x" size="sm" />
</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { IntervalFormEntry } from './types'
import type { BillingMode } from '@/api/admin/channels'
const { t } = useI18n()
const props = defineProps<{
interval: IntervalFormEntry
mode: BillingMode
}>()
const emit = defineEmits<{
update: [interval: IntervalFormEntry]
remove: []
}>()
function emitField(field: keyof IntervalFormEntry, value: string | number | null) {
emit('update', { ...props.interval, [field]: value === '' ? null : value })
}
function toInt(val: string): number {
const n = parseInt(val, 10)
return isNaN(n) ? 0 : n
}
function toIntOrNull(val: string): number | null {
if (val === '') return null
const n = parseInt(val, 10)
return isNaN(n) ? null : n
}
</script>
<template>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
<!-- Header: Models + Billing Mode + Remove -->
<div class="mb-2 flex items-start gap-2">
<div class="flex-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.models', 'Models (comma separated, supports *)') }}
</label>
<textarea
:value="entry.modelsInput"
@input="emit('update', { ...entry, modelsInput: ($event.target as HTMLTextAreaElement).value })"
rows="2"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.modelsPlaceholder', 'claude-sonnet-4-20250514, claude-opus-4-20250514, *')"
></textarea>
</div>
<div class="w-40">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.billingMode', 'Billing Mode') }}
</label>
<Select
:modelValue="entry.billing_mode"
@update:modelValue="emit('update', { ...entry, billing_mode: $event as BillingMode, intervals: [] })"
:options="billingModeOptions"
class="mt-1"
/>
</div>
<button
type="button"
@click="emit('remove')"
class="mt-5 rounded p-1 text-gray-400 hover:text-red-500"
>
<Icon name="trash" size="sm" />
</button>
</div>
<!-- Token mode: flat prices + intervals -->
<div v-if="entry.billing_mode === 'token'">
<!-- Flat prices (used when no intervals) -->
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.inputPrice', 'Input Price') }}
</label>
<input
:value="entry.input_price"
@input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.outputPrice', 'Output Price') }}
</label>
<input
:value="entry.output_price"
@input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.cacheWritePrice', 'Cache Write') }}
</label>
<input
:value="entry.cache_write_price"
@input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.cacheReadPrice', 'Cache Read') }}
</label>
<input
:value="entry.cache_read_price"
@input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
</div>
<!-- Token intervals -->
<div class="mt-3">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.intervals', 'Context Intervals (optional)') }}
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addInterval', 'Add Interval') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
<IntervalRow
v-for="(iv, idx) in entry.intervals"
:key="idx"
:interval="iv"
:mode="entry.billing_mode"
@update="updateInterval(idx, $event)"
@remove="removeInterval(idx)"
/>
</div>
</div>
</div>
<!-- Per-request mode: tiers -->
<div v-else-if="entry.billing_mode === 'per_request'">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.requestTiers', 'Request Tiers') }}
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', 'Add Tier') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
<IntervalRow
v-for="(iv, idx) in entry.intervals"
:key="idx"
:interval="iv"
:mode="entry.billing_mode"
@update="updateInterval(idx, $event)"
@remove="removeInterval(idx)"
/>
</div>
<div v-else class="mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500">
{{ t('admin.channels.form.noTiersYet', 'No tiers. Add one to configure per-request pricing.') }}
</div>
</div>
<!-- Image mode: tiers -->
<div v-else-if="entry.billing_mode === 'image'">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.imageTiers', 'Image Tiers') }}
</label>
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', 'Add Tier') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
<IntervalRow
v-for="(iv, idx) in entry.intervals"
:key="idx"
:interval="iv"
:mode="entry.billing_mode"
@update="updateInterval(idx, $event)"
@remove="removeInterval(idx)"
/>
</div>
<div v-else>
<!-- Legacy image_output_price fallback -->
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.imageOutputPrice', 'Image Output Price') }}
</label>
<input
:value="entry.image_output_price"
@input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
type="number"
step="any" min="0"
class="input mt-1 text-sm"
:placeholder="t('admin.channels.form.pricePlaceholder', 'Default')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import IntervalRow from './IntervalRow.vue'
import type { PricingFormEntry, IntervalFormEntry } from './types'
import type { BillingMode } from '@/api/admin/channels'
const { t } = useI18n()
const props = defineProps<{
entry: PricingFormEntry
}>()
const emit = defineEmits<{
update: [entry: PricingFormEntry]
remove: []
}>()
const billingModeOptions = computed(() => [
{ value: 'token', label: t('admin.channels.billingMode.token', 'Token') },
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', 'Per Request') },
{ value: 'image', label: t('admin.channels.billingMode.image', 'Image') }
])
function emitField(field: keyof PricingFormEntry, value: string) {
emit('update', { ...props.entry, [field]: value === '' ? null : value })
}
function addInterval() {
const intervals = [...(props.entry.intervals || [])]
intervals.push({
min_tokens: 0,
max_tokens: null,
tier_label: '',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: null,
sort_order: intervals.length
})
emit('update', { ...props.entry, intervals })
}
function addImageTier() {
const intervals = [...(props.entry.intervals || [])]
const labels = ['1K', '2K', '4K', 'HD']
const nextLabel = labels[intervals.length] || ''
intervals.push({
min_tokens: 0,
max_tokens: null,
tier_label: nextLabel,
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: null,
sort_order: intervals.length
})
emit('update', { ...props.entry, intervals })
}
function updateInterval(idx: number, updated: IntervalFormEntry) {
const intervals = [...(props.entry.intervals || [])]
intervals[idx] = updated
emit('update', { ...props.entry, intervals })
}
function removeInterval(idx: number) {
const intervals = [...(props.entry.intervals || [])]
intervals.splice(idx, 1)
emit('update', { ...props.entry, intervals })
}
</script>
import type { BillingMode, PricingInterval } from '@/api/admin/channels'
export interface IntervalFormEntry {
min_tokens: number
max_tokens: number | null
tier_label: string
input_price: number | string | null
output_price: number | string | null
cache_write_price: number | string | null
cache_read_price: number | string | null
per_request_price: number | string | null
sort_order: number
}
export interface PricingFormEntry {
modelsInput: string
billing_mode: BillingMode
input_price: number | string | null
output_price: number | string | null
cache_write_price: number | string | null
cache_read_price: number | string | null
per_request_price: number | string | null
image_output_price: number | string | null
intervals: IntervalFormEntry[]
}
export function toNullableNumber(val: number | string | null | undefined): number | null {
if (val === null || val === undefined || val === '') return null
const num = Number(val)
return isNaN(num) ? null : num
}
export function apiIntervalsToForm(intervals: PricingInterval[]): IntervalFormEntry[] {
return (intervals || []).map(iv => ({
min_tokens: iv.min_tokens,
max_tokens: iv.max_tokens,
tier_label: iv.tier_label || '',
input_price: iv.input_price,
output_price: iv.output_price,
cache_write_price: iv.cache_write_price,
cache_read_price: iv.cache_read_price,
per_request_price: iv.per_request_price,
sort_order: iv.sort_order
}))
}
export function formIntervalsToAPI(intervals: IntervalFormEntry[]): PricingInterval[] {
return (intervals || []).map(iv => ({
min_tokens: iv.min_tokens,
max_tokens: iv.max_tokens,
tier_label: iv.tier_label,
input_price: toNullableNumber(iv.input_price),
output_price: toNullableNumber(iv.output_price),
cache_write_price: toNullableNumber(iv.cache_write_price),
cache_read_price: toNullableNumber(iv.cache_read_price),
per_request_price: toNullableNumber(iv.per_request_price),
sort_order: iv.sort_order
}))
}
......@@ -287,6 +287,21 @@ const FolderIcon = {
)
}
const ChannelIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0l4.179 2.25L12 17.25 2.25 12m15.321-2.25l4.179 2.25L12 17.25l-9.75-5.25'
})
]
)
}
const CreditCardIcon = {
render: () =>
h(
......@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => {
: []),
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{ path: '/admin/channels', label: t('nav.channels', '渠道管理'), icon: ChannelIcon, hideInSimpleMode: true },
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
......
......@@ -278,6 +278,16 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'admin.groups.description'
}
},
{
path: '/admin/channels',
name: 'AdminChannels',
component: () => import('@/views/admin/ChannelsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Channel Management'
}
},
{
path: '/admin/subscriptions',
name: 'AdminSubscriptions',
......
This diff is collapsed.
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