Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
7b83d6e7
"frontend/src/components/vscode:/vscode.git/clone" did not exist on "49a3c43741be43a84147075f1d9f1e0a7500d84b"
Commit
7b83d6e7
authored
Apr 05, 2026
by
陈曦
Browse files
Merge remote-tracking branch 'upstream/main'
parents
daa2e6df
dbb248df
Changes
106
Hide whitespace changes
Inline
Side-by-side
backend/migrations/086_channel_platform_pricing.sql
0 → 100644
View file @
7b83d6e7
-- 086_channel_platform_pricing.sql
-- 渠道按平台维度:model_pricing 加 platform 列,model_mapping 改为嵌套格式
-- 1. channel_model_pricing 加 platform 列
ALTER
TABLE
channel_model_pricing
ADD
COLUMN
IF
NOT
EXISTS
platform
VARCHAR
(
50
)
NOT
NULL
DEFAULT
'anthropic'
;
CREATE
INDEX
IF
NOT
EXISTS
idx_channel_model_pricing_platform
ON
channel_model_pricing
(
platform
);
-- 2. model_mapping: 从扁平 {"src":"dst"} 迁移为嵌套 {"anthropic":{"src":"dst"}}
-- 仅迁移非空、非 '{}' 的旧格式数据(通过检查第一个 value 是否为字符串来判断是否为旧格式)
UPDATE
channels
SET
model_mapping
=
jsonb_build_object
(
'anthropic'
,
model_mapping
)
WHERE
model_mapping
IS
NOT
NULL
AND
model_mapping
::
text
NOT
IN
(
'{}'
,
'null'
,
''
)
AND
NOT
EXISTS
(
SELECT
1
FROM
jsonb_each
(
model_mapping
)
AS
kv
WHERE
jsonb_typeof
(
kv
.
value
)
=
'object'
LIMIT
1
);
backend/migrations/087_usage_log_billing_mode.sql
0 → 100644
View file @
7b83d6e7
-- Add billing_mode to usage_logs (records the billing mode: token/per_request/image)
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
billing_mode
VARCHAR
(
20
);
backend/migrations/088_channel_billing_model_source_channel_mapped.sql
0 → 100644
View file @
7b83d6e7
-- Change default billing_model_source for new channels to 'channel_mapped'
-- Existing channels keep their current setting (no UPDATE on existing rows)
ALTER
TABLE
channels
ALTER
COLUMN
billing_model_source
SET
DEFAULT
'channel_mapped'
;
backend/migrations/089_usage_log_image_output_tokens.sql
0 → 100644
View file @
7b83d6e7
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
image_output_tokens
INTEGER
NOT
NULL
DEFAULT
0
;
ALTER
TABLE
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
image_output_cost
DECIMAL
(
20
,
10
)
NOT
NULL
DEFAULT
0
;
frontend/src/api/admin/channels.ts
0 → 100644
View file @
7b83d6e7
/**
* Admin Channels API endpoints
* Handles channel management for administrators
*/
import
{
apiClient
}
from
'
../client
'
export
type
BillingMode
=
'
token
'
|
'
per_request
'
|
'
image
'
export
interface
PricingInterval
{
id
?:
number
min_tokens
:
number
max_tokens
:
number
|
null
tier_label
:
string
input_price
:
number
|
null
output_price
:
number
|
null
cache_write_price
:
number
|
null
cache_read_price
:
number
|
null
per_request_price
:
number
|
null
sort_order
:
number
}
export
interface
ChannelModelPricing
{
id
?:
number
platform
:
string
models
:
string
[]
billing_mode
:
BillingMode
input_price
:
number
|
null
output_price
:
number
|
null
cache_write_price
:
number
|
null
cache_read_price
:
number
|
null
image_output_price
:
number
|
null
per_request_price
:
number
|
null
intervals
:
PricingInterval
[]
}
export
interface
Channel
{
id
:
number
name
:
string
description
:
string
status
:
string
billing_model_source
:
string
// "requested" | "upstream"
restrict_models
:
boolean
group_ids
:
number
[]
model_pricing
:
ChannelModelPricing
[]
model_mapping
:
Record
<
string
,
Record
<
string
,
string
>>
// platform → {src→dst}
created_at
:
string
updated_at
:
string
}
export
interface
CreateChannelRequest
{
name
:
string
description
?:
string
group_ids
?:
number
[]
model_pricing
?:
ChannelModelPricing
[]
model_mapping
?:
Record
<
string
,
Record
<
string
,
string
>>
billing_model_source
?:
string
restrict_models
?:
boolean
}
export
interface
UpdateChannelRequest
{
name
?:
string
description
?:
string
status
?:
string
group_ids
?:
number
[]
model_pricing
?:
ChannelModelPricing
[]
model_mapping
?:
Record
<
string
,
Record
<
string
,
string
>>
billing_model_source
?:
string
restrict_models
?:
boolean
}
interface
PaginatedResponse
<
T
>
{
items
:
T
[]
total
:
number
}
/**
* List channels with pagination
*/
export
async
function
list
(
page
:
number
=
1
,
pageSize
:
number
=
20
,
filters
?:
{
status
?:
string
search
?:
string
},
options
?:
{
signal
?:
AbortSignal
}
):
Promise
<
PaginatedResponse
<
Channel
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
Channel
>>
(
'
/admin/channels
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
},
signal
:
options
?.
signal
})
return
data
}
/**
* Get channel by ID
*/
export
async
function
getById
(
id
:
number
):
Promise
<
Channel
>
{
const
{
data
}
=
await
apiClient
.
get
<
Channel
>
(
`/admin/channels/
${
id
}
`
)
return
data
}
/**
* Create a new channel
*/
export
async
function
create
(
req
:
CreateChannelRequest
):
Promise
<
Channel
>
{
const
{
data
}
=
await
apiClient
.
post
<
Channel
>
(
'
/admin/channels
'
,
req
)
return
data
}
/**
* Update a channel
*/
export
async
function
update
(
id
:
number
,
req
:
UpdateChannelRequest
):
Promise
<
Channel
>
{
const
{
data
}
=
await
apiClient
.
put
<
Channel
>
(
`/admin/channels/
${
id
}
`
,
req
)
return
data
}
/**
* Delete a channel
*/
export
async
function
remove
(
id
:
number
):
Promise
<
void
>
{
await
apiClient
.
delete
(
`/admin/channels/
${
id
}
`
)
}
export
interface
ModelDefaultPricing
{
found
:
boolean
input_price
?:
number
// per-token price
output_price
?:
number
cache_write_price
?:
number
cache_read_price
?:
number
image_output_price
?:
number
}
export
async
function
getModelDefaultPricing
(
model
:
string
):
Promise
<
ModelDefaultPricing
>
{
const
{
data
}
=
await
apiClient
.
get
<
ModelDefaultPricing
>
(
'
/admin/channels/model-pricing
'
,
{
params
:
{
model
}
})
return
data
}
const
channelsAPI
=
{
list
,
getById
,
create
,
update
,
remove
,
getModelDefaultPricing
}
export
default
channelsAPI
frontend/src/api/admin/dashboard.ts
View file @
7b83d6e7
...
@@ -167,6 +167,13 @@ export interface UserBreakdownParams {
...
@@ -167,6 +167,13 @@ export interface UserBreakdownParams {
endpoint
?:
string
endpoint
?:
string
endpoint_type
?:
'
inbound
'
|
'
upstream
'
|
'
path
'
endpoint_type
?:
'
inbound
'
|
'
upstream
'
|
'
path
'
limit
?:
number
limit
?:
number
// Additional filter conditions
user_id
?:
number
api_key_id
?:
number
account_id
?:
number
request_type
?:
number
stream
?:
boolean
billing_type
?:
number
|
null
}
}
export
interface
UserBreakdownResponse
{
export
interface
UserBreakdownResponse
{
...
...
frontend/src/api/admin/index.ts
View file @
7b83d6e7
...
@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys'
...
@@ -25,6 +25,7 @@ import apiKeysAPI from './apiKeys'
import
scheduledTestsAPI
from
'
./scheduledTests
'
import
scheduledTestsAPI
from
'
./scheduledTests
'
import
backupAPI
from
'
./backup
'
import
backupAPI
from
'
./backup
'
import
tlsFingerprintProfileAPI
from
'
./tlsFingerprintProfile
'
import
tlsFingerprintProfileAPI
from
'
./tlsFingerprintProfile
'
import
channelsAPI
from
'
./channels
'
/**
/**
* Unified admin API object for convenient access
* Unified admin API object for convenient access
...
@@ -51,7 +52,8 @@ export const adminAPI = {
...
@@ -51,7 +52,8 @@ export const adminAPI = {
apiKeys
:
apiKeysAPI
,
apiKeys
:
apiKeysAPI
,
scheduledTests
:
scheduledTestsAPI
,
scheduledTests
:
scheduledTestsAPI
,
backup
:
backupAPI
,
backup
:
backupAPI
,
tlsFingerprintProfiles
:
tlsFingerprintProfileAPI
tlsFingerprintProfiles
:
tlsFingerprintProfileAPI
,
channels
:
channelsAPI
}
}
export
{
export
{
...
@@ -76,7 +78,8 @@ export {
...
@@ -76,7 +78,8 @@ export {
apiKeysAPI
,
apiKeysAPI
,
scheduledTestsAPI
,
scheduledTestsAPI
,
backupAPI
,
backupAPI
,
tlsFingerprintProfileAPI
tlsFingerprintProfileAPI
,
channelsAPI
}
}
export
default
adminAPI
export
default
adminAPI
...
...
frontend/src/api/admin/usage.ts
View file @
7b83d6e7
...
@@ -80,6 +80,7 @@ export interface CreateUsageCleanupTaskRequest {
...
@@ -80,6 +80,7 @@ export interface CreateUsageCleanupTaskRequest {
export
interface
AdminUsageQueryParams
extends
UsageQueryParams
{
export
interface
AdminUsageQueryParams
extends
UsageQueryParams
{
user_id
?:
number
user_id
?:
number
exact_total
?:
boolean
exact_total
?:
boolean
billing_mode
?:
string
}
}
// ==================== API Functions ====================
// ==================== API Functions ====================
...
...
frontend/src/components/admin/channel/IntervalRow.vue
0 → 100644
View file @
7b83d6e7
<
template
>
<div
class=
"flex items-start gap-2 rounded border p-2"
:class=
"isEmpty ? 'border-red-400 bg-red-50 dark:border-red-500 dark:bg-red-950/20' : 'border-gray-200 bg-white dark:border-dark-500 dark:bg-dark-700'"
>
<!-- Token mode: context range + prices ($/MTok) -->
<template
v-if=
"mode === 'token'"
>
<div
class=
"w-20"
>
<label
class=
"text-xs text-gray-400"
>
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"
>
Max
<span
class=
"text-gray-300"
>
(含)
</span></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
'
,
'
输入
'
)
}}
<span
v-if=
"isEmpty"
class=
"text-red-500"
>
*
</span>
<span
class=
"text-gray-300"
>
$/M
</span></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
'
,
'
输出
'
)
}}
<span
v-if=
"isEmpty"
class=
"text-red-500"
>
*
</span>
<span
class=
"text-gray-300"
>
$/M
</span></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
'
,
'
缓存W
'
)
}}
<span
class=
"text-gray-300"
>
$/M
</span></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
'
,
'
缓存R
'
)
}}
<span
class=
"text-gray-300"
>
$/M
</span></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 + context range + price -->
<
template
v-else
>
<div
class=
"w-24"
>
<label
class=
"text-xs text-gray-400"
>
{{
mode
===
'
image
'
?
t
(
'
admin.channels.form.resolution
'
,
'
分辨率
'
)
:
t
(
'
admin.channels.form.tierLabel
'
,
'
层级
'
)
}}
</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"
>
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"
>
Max
<span
class=
"text-gray-300"
>
(含)
</span></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
'
,
'
单次价格
'
)
}}
<span
v-if=
"isEmpty"
class=
"text-red-500"
>
*
</span>
<span
class=
"text-gray-300"
>
$
</span></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
{
computed
}
from
'
vue
'
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
:
[]
}
>
()
// 检测所有价格字段是否都为空
const
isEmpty
=
computed
(()
=>
{
const
iv
=
props
.
interval
return
(
iv
.
input_price
==
null
||
iv
.
input_price
===
''
)
&&
(
iv
.
output_price
==
null
||
iv
.
output_price
===
''
)
&&
(
iv
.
cache_write_price
==
null
||
iv
.
cache_write_price
===
''
)
&&
(
iv
.
cache_read_price
==
null
||
iv
.
cache_read_price
===
''
)
&&
(
iv
.
per_request_price
==
null
||
iv
.
per_request_price
===
''
)
})
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
>
frontend/src/components/admin/channel/ModelTagInput.vue
0 → 100644
View file @
7b83d6e7
<
template
>
<div>
<!-- Tags display -->
<div
class=
"flex flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-white p-2 dark:border-dark-600 dark:bg-dark-800 min-h-[2.5rem]"
>
<span
v-for=
"(model, idx) in models"
:key=
"idx"
class=
"inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-sm"
:class=
"getPlatformTagClass(props.platform || '')"
>
{{
model
}}
<button
type=
"button"
@
click=
"removeModel(idx)"
class=
"ml-0.5 rounded-full p-0.5 hover:bg-primary-200 dark:hover:bg-primary-800"
>
<Icon
name=
"x"
size=
"xs"
/>
</button>
</span>
<input
ref=
"inputRef"
v-model=
"inputValue"
type=
"text"
class=
"flex-1 min-w-[120px] border-none bg-transparent text-sm outline-none placeholder:text-gray-400 dark:text-white"
:placeholder=
"models.length === 0 ? placeholder : ''"
@
keydown.enter.prevent=
"addModel"
@
keydown.tab.prevent=
"addModel"
@
keydown.delete=
"handleBackspace"
@
paste=
"handlePaste"
/>
</div>
<p
class=
"mt-1 text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.modelInputHint
'
,
'
Press Enter to add, supports paste for batch import.
'
)
}}
</p>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
getPlatformTagClass
}
from
'
./types
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
models
:
string
[]
placeholder
?:
string
platform
?:
string
}
>
()
const
emit
=
defineEmits
<
{
'
update:models
'
:
[
models
:
string
[]]
}
>
()
const
inputValue
=
ref
(
''
)
const
inputRef
=
ref
<
HTMLInputElement
>
()
function
addModel
()
{
const
val
=
inputValue
.
value
.
trim
()
if
(
!
val
)
return
if
(
!
props
.
models
.
includes
(
val
))
{
emit
(
'
update:models
'
,
[...
props
.
models
,
val
])
}
inputValue
.
value
=
''
}
function
removeModel
(
idx
:
number
)
{
const
newModels
=
[...
props
.
models
]
newModels
.
splice
(
idx
,
1
)
emit
(
'
update:models
'
,
newModels
)
}
function
handleBackspace
()
{
if
(
inputValue
.
value
===
''
&&
props
.
models
.
length
>
0
)
{
removeModel
(
props
.
models
.
length
-
1
)
}
}
function
handlePaste
(
e
:
ClipboardEvent
)
{
e
.
preventDefault
()
const
text
=
e
.
clipboardData
?.
getData
(
'
text
'
)
||
''
const
items
=
text
.
split
(
/
[
,
\n
;
]
+/
).
map
(
s
=>
s
.
trim
()).
filter
(
Boolean
)
if
(
items
.
length
===
0
)
return
const
unique
=
[...
new
Set
([...
props
.
models
,
...
items
])]
emit
(
'
update:models
'
,
unique
)
inputValue
.
value
=
''
}
</
script
>
frontend/src/components/admin/channel/PricingEntryCard.vue
0 → 100644
View file @
7b83d6e7
<
template
>
<div
class=
"rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800"
>
<!-- Collapsed summary header (clickable) -->
<div
class=
"flex cursor-pointer select-none items-center gap-2"
@
click=
"collapsed = !collapsed"
>
<Icon
:name=
"collapsed ? 'chevronRight' : 'chevronDown'"
size=
"sm"
:stroke-width=
"2"
class=
"flex-shrink-0 text-gray-400 transition-transform duration-200"
/>
<!-- Summary: model tags + billing badge -->
<div
v-if=
"collapsed"
class=
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden"
>
<!-- Compact model tags (show first 3) -->
<div
class=
"flex min-w-0 flex-1 flex-wrap items-center gap-1"
>
<span
v-for=
"(m, i) in entry.models.slice(0, 3)"
:key=
"i"
class=
"inline-flex shrink-0 rounded px-1.5 py-0.5 text-xs"
:class=
"getPlatformTagClass(props.platform || '')"
>
{{
m
}}
</span>
<span
v-if=
"entry.models.length > 3"
class=
"whitespace-nowrap text-xs text-gray-400"
>
+
{{
entry
.
models
.
length
-
3
}}
</span>
<span
v-if=
"entry.models.length === 0"
class=
"text-xs italic text-gray-400"
>
{{
t
(
'
admin.channels.form.noModels
'
,
'
未添加模型
'
)
}}
</span>
</div>
<!-- Billing mode badge -->
<span
class=
"flex-shrink-0 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
>
{{
billingModeLabel
}}
</span>
</div>
<!-- Expanded: show the label "Pricing Entry" or similar -->
<div
v-else
class=
"flex-1 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.pricingEntry
'
,
'
定价配置
'
)
}}
</div>
<!-- Remove button (always visible, stop propagation) -->
<button
type=
"button"
@
click.stop=
"emit('remove')"
class=
"flex-shrink-0 rounded p-1 text-gray-400 hover:text-red-500"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</div>
<!-- Expandable content with transition -->
<div
class=
"collapsible-content"
:class=
"
{ 'collapsible-content--collapsed': collapsed }"
>
<div
class=
"collapsible-inner"
>
<!-- Header: Models + Billing Mode -->
<div
class=
"mt-3 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
'
,
'
模型列表
'
)
}}
<span
class=
"text-red-500"
>
*
</span>
</label>
<ModelTagInput
:models=
"entry.models"
:platform=
"props.platform"
@
update:models=
"onModelsUpdate($event)"
:placeholder=
"t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *')"
class=
"mt-1"
/>
</div>
<div
class=
"w-40"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.billingMode
'
,
'
计费模式
'
)
}}
</label>
<Select
:modelValue=
"entry.billing_mode"
@
update:modelValue=
"emit('update',
{ ...entry, billing_mode: $event as BillingMode, intervals: [] })"
:options="billingModeOptions"
class="mt-1"
/>
</div>
</div>
<!-- Token mode -->
<div
v-if=
"entry.billing_mode === 'token'"
>
<!-- Default prices (fallback when no interval matches) -->
<label
class=
"mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.defaultPrices
'
,
'
默认价格(未命中区间时使用)
'
)
}}
<span
class=
"ml-1 font-normal text-gray-400"
>
$/MTok
</span>
</label>
<div
class=
"mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5"
>
<div>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.inputPrice
'
,
'
输入
'
)
}}
</label>
<input
:value=
"entry.input_price"
@
input=
"emitField('input_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.outputPrice
'
,
'
输出
'
)
}}
</label>
<input
:value=
"entry.output_price"
@
input=
"emitField('output_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input mt-0.5 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.cacheWritePrice
'
,
'
缓存写入
'
)
}}
</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-0.5 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.cacheReadPrice
'
,
'
缓存读取
'
)
}}
</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-0.5 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</div>
<div>
<label
class=
"text-xs text-gray-400"
>
{{
t
(
'
admin.channels.form.imageTokenPrice
'
,
'
图片输出
'
)
}}
</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-0.5 text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</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
'
,
'
上下文区间定价(可选)
'
)
}}
<span
class=
"ml-1 font-normal text-gray-400"
>
(min, max]
</span>
</label>
<button
type=
"button"
@
click=
"addInterval"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+
{{
t
(
'
admin.channels.form.addInterval
'
,
'
添加区间
'
)
}}
</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 -->
<div
v-else-if=
"entry.billing_mode === 'per_request'"
>
<!-- Default per-request price -->
<label
class=
"mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.defaultPerRequestPrice
'
,
'
默认单次价格(未命中层级时使用)
'
)
}}
<span
class=
"ml-1 font-normal text-gray-400"
>
$
</span>
</label>
<div
class=
"mt-1 w-48"
>
<input
:value=
"entry.per_request_price"
@
input=
"emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</div>
<!-- Tiers -->
<div
class=
"mt-3 flex items-center justify-between"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.requestTiers
'
,
'
按次计费层级
'
)
}}
</label>
<button
type=
"button"
@
click=
"addInterval"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+
{{
t
(
'
admin.channels.form.addTier
'
,
'
添加层级
'
)
}}
</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
'
,
'
暂无层级,点击添加配置按次计费价格
'
)
}}
</div>
</div>
<!-- Image mode -->
<div
v-else-if=
"entry.billing_mode === 'image'"
>
<!-- Default image price (per-request, same as per_request mode) -->
<label
class=
"mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.defaultImagePrice
'
,
'
默认图片价格(未命中层级时使用)
'
)
}}
<span
class=
"ml-1 font-normal text-gray-400"
>
$
</span>
</label>
<div
class=
"mt-1 w-48"
>
<input
:value=
"entry.per_request_price"
@
input=
"emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type=
"number"
step=
"any"
min=
"0"
class=
"input text-sm"
:placeholder=
"t('admin.channels.form.pricePlaceholder', '默认')"
/>
</div>
<!-- Image tiers -->
<div
class=
"mt-3 flex items-center justify-between"
>
<label
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.channels.form.imageTiers
'
,
'
图片计费层级(按次)
'
)
}}
</label>
<button
type=
"button"
@
click=
"addImageTier"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
+
{{
t
(
'
admin.channels.form.addTier
'
,
'
添加层级
'
)
}}
</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>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
ref
,
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
ModelTagInput
from
'
./ModelTagInput.vue
'
import
type
{
PricingFormEntry
,
IntervalFormEntry
}
from
'
./types
'
import
{
perTokenToMTok
,
getPlatformTagClass
}
from
'
./types
'
import
type
{
BillingMode
}
from
'
@/api/admin/channels
'
import
channelsAPI
from
'
@/api/admin/channels
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
entry
:
PricingFormEntry
platform
?:
string
}
>
()
const
emit
=
defineEmits
<
{
update
:
[
entry
:
PricingFormEntry
]
remove
:
[]
}
>
()
// Collapse state: entries with existing models default to collapsed
const
collapsed
=
ref
(
props
.
entry
.
models
.
length
>
0
)
const
billingModeOptions
=
computed
(()
=>
[
{
value
:
'
token
'
,
label
:
'
Token
'
},
{
value
:
'
per_request
'
,
label
:
t
(
'
admin.channels.billingMode.perRequest
'
,
'
按次
'
)
},
{
value
:
'
image
'
,
label
:
t
(
'
admin.channels.billingMode.image
'
,
'
图片(按次)
'
)
}
])
const
billingModeLabel
=
computed
(()
=>
{
const
opt
=
billingModeOptions
.
value
.
find
(
o
=>
o
.
value
===
props
.
entry
.
billing_mode
)
return
opt
?
opt
.
label
:
props
.
entry
.
billing_mode
})
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
'
]
intervals
.
push
({
min_tokens
:
0
,
max_tokens
:
null
,
tier_label
:
labels
[
intervals
.
length
]
||
''
,
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
})
}
async
function
onModelsUpdate
(
newModels
:
string
[])
{
const
oldModels
=
props
.
entry
.
models
emit
(
'
update
'
,
{
...
props
.
entry
,
models
:
newModels
})
// 只在新增模型且当前无价格时自动填充
const
addedModels
=
newModels
.
filter
(
m
=>
!
oldModels
.
includes
(
m
))
if
(
addedModels
.
length
===
0
)
return
// 检查是否所有价格字段都为空
const
e
=
props
.
entry
const
hasPrice
=
e
.
input_price
!=
null
||
e
.
output_price
!=
null
||
e
.
cache_write_price
!=
null
||
e
.
cache_read_price
!=
null
if
(
hasPrice
)
return
// 查询第一个新增模型的默认价格
try
{
const
result
=
await
channelsAPI
.
getModelDefaultPricing
(
addedModels
[
0
])
if
(
result
.
found
)
{
emit
(
'
update
'
,
{
...
props
.
entry
,
models
:
newModels
,
input_price
:
perTokenToMTok
(
result
.
input_price
??
null
),
output_price
:
perTokenToMTok
(
result
.
output_price
??
null
),
cache_write_price
:
perTokenToMTok
(
result
.
cache_write_price
??
null
),
cache_read_price
:
perTokenToMTok
(
result
.
cache_read_price
??
null
),
image_output_price
:
perTokenToMTok
(
result
.
image_output_price
??
null
),
})
}
}
catch
{
// 查询失败不影响用户操作
}
}
</
script
>
<
style
scoped
>
.collapsible-content
{
display
:
grid
;
grid-template-rows
:
1
fr
;
transition
:
grid-template-rows
0.25s
ease
;
}
.collapsible-content--collapsed
{
grid-template-rows
:
0
fr
;
}
.collapsible-inner
{
overflow
:
hidden
;
}
</
style
>
frontend/src/components/admin/channel/types.ts
0 → 100644
View file @
7b83d6e7
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
{
models
:
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
image_output_price
:
number
|
string
|
null
per_request_price
:
number
|
string
|
null
intervals
:
IntervalFormEntry
[]
}
// 价格转换:后端存 per-token,前端显示 per-MTok ($/1M tokens)
const
MTOK
=
1
_000_000
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
}
/** 前端显示值($/MTok) → 后端存储值(per-token) */
export
function
mTokToPerToken
(
val
:
number
|
string
|
null
|
undefined
):
number
|
null
{
const
num
=
toNullableNumber
(
val
)
return
num
===
null
?
null
:
parseFloat
((
num
/
MTOK
).
toPrecision
(
10
))
}
/** 后端存储值(per-token) → 前端显示值($/MTok) */
export
function
perTokenToMTok
(
val
:
number
|
null
|
undefined
):
number
|
null
{
if
(
val
===
null
||
val
===
undefined
)
return
null
// toPrecision(10) 消除 IEEE 754 浮点乘法精度误差,如 5e-8 * 1e6 = 0.04999...96 → 0.05
return
parseFloat
((
val
*
MTOK
).
toPrecision
(
10
))
}
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
:
perTokenToMTok
(
iv
.
input_price
),
output_price
:
perTokenToMTok
(
iv
.
output_price
),
cache_write_price
:
perTokenToMTok
(
iv
.
cache_write_price
),
cache_read_price
:
perTokenToMTok
(
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
:
mTokToPerToken
(
iv
.
input_price
),
output_price
:
mTokToPerToken
(
iv
.
output_price
),
cache_write_price
:
mTokToPerToken
(
iv
.
cache_write_price
),
cache_read_price
:
mTokToPerToken
(
iv
.
cache_read_price
),
per_request_price
:
toNullableNumber
(
iv
.
per_request_price
),
sort_order
:
iv
.
sort_order
}))
}
// ── 模型模式冲突检测 ──────────────────────────────────────
interface
ModelPattern
{
pattern
:
string
prefix
:
string
// lowercase, 通配符去掉尾部 *
wildcard
:
boolean
}
function
toModelPattern
(
model
:
string
):
ModelPattern
{
const
lower
=
model
.
toLowerCase
()
const
wildcard
=
lower
.
endsWith
(
'
*
'
)
return
{
pattern
:
model
,
prefix
:
wildcard
?
lower
.
slice
(
0
,
-
1
)
:
lower
,
wildcard
,
}
}
function
patternsConflict
(
a
:
ModelPattern
,
b
:
ModelPattern
):
boolean
{
if
(
!
a
.
wildcard
&&
!
b
.
wildcard
)
return
a
.
prefix
===
b
.
prefix
if
(
a
.
wildcard
&&
!
b
.
wildcard
)
return
b
.
prefix
.
startsWith
(
a
.
prefix
)
if
(
!
a
.
wildcard
&&
b
.
wildcard
)
return
a
.
prefix
.
startsWith
(
b
.
prefix
)
// 双通配符:任一前缀是另一前缀的前缀即冲突
return
a
.
prefix
.
startsWith
(
b
.
prefix
)
||
b
.
prefix
.
startsWith
(
a
.
prefix
)
}
/** 检测模型模式列表中的冲突,返回冲突的两个模式名;无冲突返回 null */
export
function
findModelConflict
(
models
:
string
[]):
[
string
,
string
]
|
null
{
const
patterns
=
models
.
map
(
toModelPattern
)
for
(
let
i
=
0
;
i
<
patterns
.
length
;
i
++
)
{
for
(
let
j
=
i
+
1
;
j
<
patterns
.
length
;
j
++
)
{
if
(
patternsConflict
(
patterns
[
i
],
patterns
[
j
]))
{
return
[
patterns
[
i
].
pattern
,
patterns
[
j
].
pattern
]
}
}
}
return
null
}
// ── 区间校验 ──────────────────────────────────────────────
/** 校验区间列表的合法性,返回错误消息;通过则返回 null */
export
function
validateIntervals
(
intervals
:
IntervalFormEntry
[]):
string
|
null
{
if
(
!
intervals
||
intervals
.
length
===
0
)
return
null
// 按 min_tokens 排序(不修改原数组)
const
sorted
=
[...
intervals
].
sort
((
a
,
b
)
=>
a
.
min_tokens
-
b
.
min_tokens
)
for
(
let
i
=
0
;
i
<
sorted
.
length
;
i
++
)
{
const
err
=
validateSingleInterval
(
sorted
[
i
],
i
)
if
(
err
)
return
err
}
return
checkIntervalOverlap
(
sorted
)
}
function
validateSingleInterval
(
iv
:
IntervalFormEntry
,
idx
:
number
):
string
|
null
{
if
(
iv
.
min_tokens
<
0
)
{
return
`区间 #
${
idx
+
1
}
: 最小 token 数 (
${
iv
.
min_tokens
}
) 不能为负数`
}
if
(
iv
.
max_tokens
!=
null
)
{
if
(
iv
.
max_tokens
<=
0
)
{
return
`区间 #
${
idx
+
1
}
: 最大 token 数 (
${
iv
.
max_tokens
}
) 必须大于 0`
}
if
(
iv
.
max_tokens
<=
iv
.
min_tokens
)
{
return
`区间 #
${
idx
+
1
}
: 最大 token 数 (
${
iv
.
max_tokens
}
) 必须大于最小 token 数 (
${
iv
.
min_tokens
}
)`
}
}
return
validateIntervalPrices
(
iv
,
idx
)
}
function
validateIntervalPrices
(
iv
:
IntervalFormEntry
,
idx
:
number
):
string
|
null
{
const
prices
:
[
string
,
number
|
string
|
null
][]
=
[
[
'
输入价格
'
,
iv
.
input_price
],
[
'
输出价格
'
,
iv
.
output_price
],
[
'
缓存写入价格
'
,
iv
.
cache_write_price
],
[
'
缓存读取价格
'
,
iv
.
cache_read_price
],
[
'
单次价格
'
,
iv
.
per_request_price
],
]
for
(
const
[
name
,
val
]
of
prices
)
{
if
(
val
!=
null
&&
val
!==
''
&&
Number
(
val
)
<
0
)
{
return
`区间 #
${
idx
+
1
}
:
${
name
}
不能为负数`
}
}
return
null
}
function
checkIntervalOverlap
(
sorted
:
IntervalFormEntry
[]):
string
|
null
{
for
(
let
i
=
0
;
i
<
sorted
.
length
;
i
++
)
{
// 无上限区间必须是最后一个
if
(
sorted
[
i
].
max_tokens
==
null
&&
i
<
sorted
.
length
-
1
)
{
return
`区间 #
${
i
+
1
}
: 无上限区间(最大 token 数为空)只能是最后一个`
}
if
(
i
===
0
)
continue
const
prev
=
sorted
[
i
-
1
]
// (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
if
(
prev
.
max_tokens
==
null
||
prev
.
max_tokens
>
sorted
[
i
].
min_tokens
)
{
const
prevMax
=
prev
.
max_tokens
==
null
?
'
∞
'
:
String
(
prev
.
max_tokens
)
return
`区间 #
${
i
}
和 #
${
i
+
1
}
重叠:前一个区间上界 (
${
prevMax
}
) 大于当前区间下界 (
${
sorted
[
i
].
min_tokens
}
)`
}
}
return
null
}
/** 平台对应的模型 tag 样式(背景+文字) */
export
function
getPlatformTagClass
(
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
'
case
'
sora
'
:
return
'
bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400
'
default
:
return
'
bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400
'
}
}
frontend/src/components/admin/usage/UsageFilters.vue
View file @
7b83d6e7
...
@@ -133,6 +133,12 @@
...
@@ -133,6 +133,12 @@
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
Select
v
-
model
=
"
filters.billing_type
"
:
options
=
"
billingTypeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<
/div
>
<!--
Billing
Mode
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.billingMode
'
)
}}
<
/label
>
<
Select
v
-
model
=
"
filters.billing_mode
"
:
options
=
"
billingModeOptions
"
@
change
=
"
emitChange
"
/>
<
/div
>
<!--
Group
Filter
-->
<!--
Group
Filter
-->
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
div
class
=
"
w-full sm:w-auto sm:min-w-[200px]
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.usage.group
'
)
}}
<
/label
>
...
@@ -232,6 +238,13 @@ const billingTypeOptions = ref<SelectOption[]>([
...
@@ -232,6 +238,13 @@ const billingTypeOptions = ref<SelectOption[]>([
{
value
:
1
,
label
:
t
(
'
admin.usage.billingTypeSubscription
'
)
}
{
value
:
1
,
label
:
t
(
'
admin.usage.billingTypeSubscription
'
)
}
])
])
const
billingModeOptions
=
ref
<
SelectOption
[]
>
([
{
value
:
null
,
label
:
t
(
'
admin.usage.allBillingModes
'
)
}
,
{
value
:
'
token
'
,
label
:
t
(
'
admin.usage.billingModeToken
'
)
}
,
{
value
:
'
per_request
'
,
label
:
t
(
'
admin.usage.billingModePerRequest
'
)
}
,
{
value
:
'
image
'
,
label
:
t
(
'
admin.usage.billingModeImage
'
)
}
])
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
emitChange
=
()
=>
emit
(
'
change
'
)
const
debounceUserSearch
=
()
=>
{
const
debounceUserSearch
=
()
=>
{
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
7b83d6e7
...
@@ -26,7 +26,15 @@
...
@@ -26,7 +26,15 @@
</
template
>
</
template
>
<
template
#cell-model=
"{ row }"
>
<
template
#cell-model=
"{ row }"
>
<div
v-if=
"row.upstream_model && row.upstream_model !== row.model"
class=
"space-y-0.5 text-xs"
>
<div
v-if=
"row.model_mapping_chain && row.model_mapping_chain.includes('→')"
class=
"space-y-0.5 text-xs"
>
<div
v-for=
"(step, i) in row.model_mapping_chain.split('→')"
:key=
"i"
class=
"break-all"
:class=
"i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style=
"i > 0 ? `padding-left: $
{i * 0.75}rem` : ''">
<span
v-if=
"i > 0"
class=
"mr-0.5"
>
↳
</span>
{{
step
}}
</div>
</div>
<div
v-else-if=
"row.upstream_model && row.upstream_model !== row.model"
class=
"space-y-0.5 text-xs"
>
<div
class=
"break-all font-medium text-gray-900 dark:text-white"
>
<div
class=
"break-all font-medium text-gray-900 dark:text-white"
>
{{
row
.
model
}}
{{
row
.
model
}}
</div>
</div>
...
@@ -69,9 +77,15 @@
...
@@ -69,9 +77,15 @@
</span>
</span>
</
template
>
</
template
>
<
template
#cell-billing_mode=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
:class=
"getBillingModeBadgeClass(row.billing_mode)"
>
{{
getBillingModeLabel
(
row
.
billing_mode
)
}}
</span>
</
template
>
<
template
#cell-tokens=
"{ row }"
>
<
template
#cell-tokens=
"{ row }"
>
<!-- 图片生成请求 -->
<!-- 图片生成请求
(仅按次计费时显示图片格式)
-->
<div
v-if=
"row.image_count > 0"
class=
"flex items-center gap-1.5"
>
<div
v-if=
"row.image_count > 0
&& row.billing_mode === 'image'
"
class=
"flex items-center gap-1.5"
>
<svg
class=
"h-4 w-4 text-indigo-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<svg
class=
"h-4 w-4 text-indigo-500"
fill=
"none"
stroke=
"currentColor"
viewBox=
"0 0 24 24"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</svg>
...
@@ -281,11 +295,11 @@
...
@@ -281,11 +295,11 @@
</div>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ (tooltipData?.rate_multiplier || 1)
.toFixed(2)
}}x
</span>
<span
class=
"font-semibold text-blue-400"
>
{{
formatMultiplier
(tooltipData?.rate_multiplier || 1) }}x
</span>
</div>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.accountMultiplier') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('usage.accountMultiplier') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ (tooltipData?.account_rate_multiplier ?? 1)
.toFixed(2)
}}x
</span>
<span
class=
"font-semibold text-blue-400"
>
{{
formatMultiplier
(tooltipData?.account_rate_multiplier ?? 1) }}x
</span>
</div>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.original') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('usage.original') }}
</span>
...
@@ -312,6 +326,7 @@
...
@@ -312,6 +326,7 @@
import
{
ref
}
from
'
vue
'
import
{
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
formatDateTime
,
formatReasoningEffort
}
from
'
@/utils/format
'
import
{
formatDateTime
,
formatReasoningEffort
}
from
'
@/utils/format
'
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
'
...
@@ -350,12 +365,19 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
...
@@ -350,12 +365,19 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): 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
formatCacheTokens
=
(
tokens
:
number
):
string
=>
{
const
getBillingModeLabel
=
(
mode
:
string
|
null
|
undefined
):
string
=>
{
if
(
tokens
>=
1000000
)
return
`
${(
tokens
/
1000000
).
toFixed
(
1
)}
M`
if
(
mode
===
'
per_request
'
)
return
t
(
'
admin.usage.billingModePerRequest
'
)
if
(
tokens
>=
1000
)
return
`
${(
tokens
/
1000
).
toFixed
(
1
)}
K`
if
(
mode
===
'
image
'
)
return
t
(
'
admin.usage.billingModeImage
'
)
return
tokens
.
toString
()
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 dark:text-blue-200
'
if
(
mode
===
'
image
'
)
return
'
bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
'
return
'
bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200
'
}
}
const
formatUserAgent
=
(
ua
:
string
):
string
=>
{
const
formatUserAgent
=
(
ua
:
string
):
string
=>
{
return
ua
return
ua
}
}
...
...
frontend/src/components/charts/EndpointDistributionChart.vue
View file @
7b83d6e7
...
@@ -161,6 +161,7 @@ const props = withDefaults(
...
@@ -161,6 +161,7 @@ const props = withDefaults(
showSourceToggle
?:
boolean
showSourceToggle
?:
boolean
startDate
?:
string
startDate
?:
string
endDate
?:
string
endDate
?:
string
filters
?:
Record
<
string
,
any
>
}
>
(),
}
>
(),
{
{
upstreamEndpointStats
:
()
=>
[],
upstreamEndpointStats
:
()
=>
[],
...
@@ -193,6 +194,7 @@ const toggleBreakdown = async (endpoint: string) => {
...
@@ -193,6 +194,7 @@ const toggleBreakdown = async (endpoint: string) => {
breakdownItems
.
value
=
[]
breakdownItems
.
value
=
[]
try
{
try
{
const
res
=
await
getUserBreakdown
({
const
res
=
await
getUserBreakdown
({
...
props
.
filters
,
start_date
:
props
.
startDate
,
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
,
end_date
:
props
.
endDate
,
endpoint
,
endpoint
,
...
...
frontend/src/components/charts/GroupDistributionChart.vue
View file @
7b83d6e7
...
@@ -125,6 +125,7 @@ const props = withDefaults(defineProps<{
...
@@ -125,6 +125,7 @@ const props = withDefaults(defineProps<{
showMetricToggle
?:
boolean
showMetricToggle
?:
boolean
startDate
?:
string
startDate
?:
string
endDate
?:
string
endDate
?:
string
filters
?:
Record
<
string
,
any
>
}
>
(),
{
}
>
(),
{
loading
:
false
,
loading
:
false
,
metric
:
'
tokens
'
,
metric
:
'
tokens
'
,
...
@@ -150,6 +151,7 @@ const toggleBreakdown = async (type: string, id: number | string) => {
...
@@ -150,6 +151,7 @@ const toggleBreakdown = async (type: string, id: number | string) => {
breakdownItems
.
value
=
[]
breakdownItems
.
value
=
[]
try
{
try
{
const
res
=
await
getUserBreakdown
({
const
res
=
await
getUserBreakdown
({
...
props
.
filters
,
start_date
:
props
.
startDate
,
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
,
end_date
:
props
.
endDate
,
group_id
:
Number
(
id
),
group_id
:
Number
(
id
),
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
7b83d6e7
...
@@ -270,6 +270,7 @@ const props = withDefaults(defineProps<{
...
@@ -270,6 +270,7 @@ const props = withDefaults(defineProps<{
rankingError
?:
boolean
rankingError
?:
boolean
startDate
?:
string
startDate
?:
string
endDate
?:
string
endDate
?:
string
filters
?:
Record
<
string
,
any
>
}
>
(),
{
}
>
(),
{
upstreamModelStats
:
()
=>
[],
upstreamModelStats
:
()
=>
[],
mappingModelStats
:
()
=>
[],
mappingModelStats
:
()
=>
[],
...
@@ -302,6 +303,7 @@ const toggleBreakdown = async (type: string, id: string) => {
...
@@ -302,6 +303,7 @@ const toggleBreakdown = async (type: string, id: string) => {
breakdownItems
.
value
=
[]
breakdownItems
.
value
=
[]
try
{
try
{
const
res
=
await
getUserBreakdown
({
const
res
=
await
getUserBreakdown
({
...
props
.
filters
,
start_date
:
props
.
startDate
,
start_date
:
props
.
startDate
,
end_date
:
props
.
endDate
,
end_date
:
props
.
endDate
,
model
:
id
,
model
:
id
,
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
7b83d6e7
...
@@ -287,6 +287,21 @@ const FolderIcon = {
...
@@ -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
=
{
const
CreditCardIcon
=
{
render
:
()
=>
render
:
()
=>
h
(
h
(
...
@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => {
...
@@ -568,6 +583,7 @@ const adminNavItems = computed((): NavItem[] => {
:
[]),
:
[]),
{
path
:
'
/admin/users
'
,
label
:
t
(
'
nav.users
'
),
icon
:
UsersIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/users
'
,
label
:
t
(
'
nav.users
'
),
icon
:
UsersIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/groups
'
,
label
:
t
(
'
nav.groups
'
),
icon
:
FolderIcon
,
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/subscriptions
'
,
label
:
t
(
'
nav.subscriptions
'
),
icon
:
CreditCardIcon
,
hideInSimpleMode
:
true
},
{
path
:
'
/admin/accounts
'
,
label
:
t
(
'
nav.accounts
'
),
icon
:
GlobeIcon
},
{
path
:
'
/admin/accounts
'
,
label
:
t
(
'
nav.accounts
'
),
icon
:
GlobeIcon
},
{
path
:
'
/admin/announcements
'
,
label
:
t
(
'
nav.announcements
'
),
icon
:
BellIcon
},
{
path
:
'
/admin/announcements
'
,
label
:
t
(
'
nav.announcements
'
),
icon
:
BellIcon
},
...
...
frontend/src/i18n/locales/en.ts
View file @
7b83d6e7
...
@@ -342,6 +342,7 @@ export default {
...
@@ -342,6 +342,7 @@ export default {
profile
:
'
Profile
'
,
profile
:
'
Profile
'
,
users
:
'
Users
'
,
users
:
'
Users
'
,
groups
:
'
Groups
'
,
groups
:
'
Groups
'
,
channels
:
'
Channels
'
,
subscriptions
:
'
Subscriptions
'
,
subscriptions
:
'
Subscriptions
'
,
accounts
:
'
Accounts
'
,
accounts
:
'
Accounts
'
,
proxies
:
'
Proxies
'
,
proxies
:
'
Proxies
'
,
...
@@ -1726,6 +1727,107 @@ export default {
...
@@ -1726,6 +1727,107 @@ export default {
}
}
},
},
// Channel Management
channels
:
{
title
:
'
Channel Management
'
,
description
:
'
Manage channels and custom model pricing
'
,
searchChannels
:
'
Search channels...
'
,
createChannel
:
'
Create Channel
'
,
editChannel
:
'
Edit Channel
'
,
deleteChannel
:
'
Delete Channel
'
,
statusActive
:
'
Active
'
,
statusDisabled
:
'
Disabled
'
,
allStatus
:
'
All Status
'
,
groupsUnit
:
'
groups
'
,
pricingUnit
:
'
pricing rules
'
,
noChannelsYet
:
'
No Channels Yet
'
,
createFirstChannel
:
'
Create your first channel to manage model pricing
'
,
loadError
:
'
Failed to load channels
'
,
createSuccess
:
'
Channel created
'
,
updateSuccess
:
'
Channel updated
'
,
deleteSuccess
:
'
Channel deleted
'
,
createError
:
'
Failed to create channel
'
,
updateError
:
'
Failed to update channel
'
,
deleteError
:
'
Failed to delete channel
'
,
nameRequired
:
'
Please enter a channel name
'
,
duplicateModels
:
'
Model "{0}" appears in multiple pricing entries
'
,
modelConflict
:
"
Model patterns '{model1}' and '{model2}' conflict: overlapping match range
"
,
mappingConflict
:
"
Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range
"
,
deleteConfirm
:
'
Are you sure you want to delete channel "{name}"? This cannot be undone.
'
,
columns
:
{
name
:
'
Name
'
,
description
:
'
Description
'
,
status
:
'
Status
'
,
groups
:
'
Groups
'
,
pricing
:
'
Pricing
'
,
createdAt
:
'
Created
'
,
actions
:
'
Actions
'
},
billingMode
:
{
token
:
'
Token
'
,
perRequest
:
'
Per Request
'
,
image
:
'
Image (Per Request)
'
},
form
:
{
name
:
'
Name
'
,
namePlaceholder
:
'
Enter channel name
'
,
description
:
'
Description
'
,
descriptionPlaceholder
:
'
Optional description
'
,
status
:
'
Status
'
,
groups
:
'
Associated Groups
'
,
noGroupsAvailable
:
'
No groups available
'
,
inOtherChannel
:
'
In "{name}"
'
,
modelPricing
:
'
Model Pricing
'
,
models
:
'
Models
'
,
modelsPlaceholder
:
'
Type full model name and press Enter
'
,
modelInputHint
:
'
Press Enter to add, supports paste for batch import.
'
,
billingMode
:
'
Billing Mode
'
,
defaultPrices
:
'
Default prices (fallback when no interval matches)
'
,
inputPrice
:
'
Input
'
,
outputPrice
:
'
Output
'
,
cacheWritePrice
:
'
Cache Write
'
,
cacheReadPrice
:
'
Cache Read
'
,
imageTokenPrice
:
'
Image Output
'
,
imageOutputPrice
:
'
Image Output Price
'
,
pricePlaceholder
:
'
Default
'
,
intervals
:
'
Context Intervals (optional)
'
,
addInterval
:
'
Add Interval
'
,
requestTiers
:
'
Request Tiers
'
,
imageTiers
:
'
Image Tiers (Per Request)
'
,
addTier
:
'
Add Tier
'
,
noTiersYet
:
'
No tiers yet. Click add to configure per-request pricing.
'
,
noPricingRules
:
'
No pricing rules yet. Click "Add" to create one.
'
,
perRequestPrice
:
'
Price per Request
'
,
perRequestPriceRequired
:
'
Per-request price or billing tiers required for per-request/image billing mode
'
,
tierLabel
:
'
Tier
'
,
resolution
:
'
Resolution
'
,
modelMapping
:
'
Model Mapping
'
,
modelMappingHint
:
'
Map request model names to actual model names. Runs before account-level mapping.
'
,
noMappingRules
:
'
No mapping rules. Click "Add" to create one.
'
,
mappingSource
:
'
Source model
'
,
mappingTarget
:
'
Target model
'
,
billingModelSource
:
'
Billing Model
'
,
billingModelSourceChannelMapped
:
'
Bill by channel-mapped model
'
,
billingModelSourceRequested
:
'
Bill by requested model
'
,
billingModelSourceUpstream
:
'
Bill by final upstream model
'
,
billingModelSourceHint
:
'
Controls which model name is used for pricing lookup
'
,
selectedCount
:
'
{count} selected
'
,
searchGroups
:
'
Search groups...
'
,
noGroupsMatch
:
'
No groups match your search
'
,
restrictModels
:
'
Restrict Models
'
,
restrictModelsHint
:
'
When enabled, only models in the pricing list are allowed. Others will be rejected.
'
,
defaultPerRequestPrice
:
'
Default per-request price (fallback when no tier matches)
'
,
defaultImagePrice
:
'
Default image price (fallback when no tier matches)
'
,
platformConfig
:
'
Platform Configuration
'
,
basicSettings
:
'
Basic Settings
'
,
addPlatform
:
'
Add Platform
'
,
noPlatforms
:
'
Click "Add Platform" to start configuring the channel
'
,
mappingCount
:
'
mappings
'
,
pricingEntry
:
'
Pricing Entry
'
,
noModels
:
'
No models added
'
}
},
// Subscriptions
// Subscriptions
subscriptions
:
{
subscriptions
:
{
title
:
'
Subscription Management
'
,
title
:
'
Subscription Management
'
,
...
@@ -3265,6 +3367,11 @@ export default {
...
@@ -3265,6 +3367,11 @@ export default {
allBillingTypes
:
'
All Billing Types
'
,
allBillingTypes
:
'
All Billing Types
'
,
billingTypeBalance
:
'
Balance
'
,
billingTypeBalance
:
'
Balance
'
,
billingTypeSubscription
:
'
Subscription
'
,
billingTypeSubscription
:
'
Subscription
'
,
billingMode
:
'
Billing Mode
'
,
billingModeToken
:
'
Token
'
,
billingModePerRequest
:
'
Per Request
'
,
billingModeImage
:
'
Image
'
,
allBillingModes
:
'
All Billing Modes
'
,
ipAddress
:
'
IP
'
,
ipAddress
:
'
IP
'
,
clickToViewBalance
:
'
Click to view balance history
'
,
clickToViewBalance
:
'
Click to view balance history
'
,
failedToLoadUser
:
'
Failed to load user info
'
,
failedToLoadUser
:
'
Failed to load user info
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
7b83d6e7
...
@@ -342,6 +342,7 @@ export default {
...
@@ -342,6 +342,7 @@ export default {
profile
:
'
个人资料
'
,
profile
:
'
个人资料
'
,
users
:
'
用户管理
'
,
users
:
'
用户管理
'
,
groups
:
'
分组管理
'
,
groups
:
'
分组管理
'
,
channels
:
'
渠道管理
'
,
subscriptions
:
'
订阅管理
'
,
subscriptions
:
'
订阅管理
'
,
accounts
:
'
账号管理
'
,
accounts
:
'
账号管理
'
,
proxies
:
'
IP管理
'
,
proxies
:
'
IP管理
'
,
...
@@ -1806,6 +1807,107 @@ export default {
...
@@ -1806,6 +1807,107 @@ export default {
}
}
},
},
// Channel Management
channels
:
{
title
:
'
渠道管理
'
,
description
:
'
管理渠道和自定义模型定价
'
,
searchChannels
:
'
搜索渠道...
'
,
createChannel
:
'
创建渠道
'
,
editChannel
:
'
编辑渠道
'
,
deleteChannel
:
'
删除渠道
'
,
statusActive
:
'
启用
'
,
statusDisabled
:
'
停用
'
,
allStatus
:
'
全部状态
'
,
groupsUnit
:
'
个分组
'
,
pricingUnit
:
'
条定价
'
,
noChannelsYet
:
'
暂无渠道
'
,
createFirstChannel
:
'
创建第一个渠道来管理模型定价
'
,
loadError
:
'
加载渠道列表失败
'
,
createSuccess
:
'
渠道创建成功
'
,
updateSuccess
:
'
渠道更新成功
'
,
deleteSuccess
:
'
渠道删除成功
'
,
createError
:
'
创建渠道失败
'
,
updateError
:
'
更新渠道失败
'
,
deleteError
:
'
删除渠道失败
'
,
nameRequired
:
'
请输入渠道名称
'
,
duplicateModels
:
'
模型「{0}」在多个定价条目中重复
'
,
modelConflict
:
"
模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠
"
,
mappingConflict
:
"
模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠
"
,
deleteConfirm
:
'
确定要删除渠道「{name}」吗?此操作不可撤销。
'
,
columns
:
{
name
:
'
名称
'
,
description
:
'
描述
'
,
status
:
'
状态
'
,
groups
:
'
分组
'
,
pricing
:
'
定价
'
,
createdAt
:
'
创建时间
'
,
actions
:
'
操作
'
},
billingMode
:
{
token
:
'
Token
'
,
perRequest
:
'
按次
'
,
image
:
'
图片(按次)
'
},
form
:
{
name
:
'
名称
'
,
namePlaceholder
:
'
输入渠道名称
'
,
description
:
'
描述
'
,
descriptionPlaceholder
:
'
可选描述
'
,
status
:
'
状态
'
,
groups
:
'
关联分组
'
,
noGroupsAvailable
:
'
暂无可用分组
'
,
inOtherChannel
:
'
已属于「{name}」
'
,
modelPricing
:
'
模型定价
'
,
models
:
'
模型列表
'
,
modelsPlaceholder
:
'
输入完整模型名后按回车添加
'
,
modelInputHint
:
'
按回车添加,支持粘贴批量导入
'
,
billingMode
:
'
计费模式
'
,
defaultPrices
:
'
默认价格(未命中区间时使用)
'
,
inputPrice
:
'
输入
'
,
outputPrice
:
'
输出
'
,
cacheWritePrice
:
'
缓存写入
'
,
cacheReadPrice
:
'
缓存读取
'
,
imageTokenPrice
:
'
图片输出
'
,
imageOutputPrice
:
'
图片输出价格
'
,
pricePlaceholder
:
'
默认
'
,
intervals
:
'
上下文区间定价(可选)
'
,
addInterval
:
'
添加区间
'
,
requestTiers
:
'
按次计费层级
'
,
imageTiers
:
'
图片计费层级(按次)
'
,
addTier
:
'
添加层级
'
,
noTiersYet
:
'
暂无层级,点击添加配置按次计费价格
'
,
noPricingRules
:
'
暂无定价规则,点击"添加"创建
'
,
perRequestPrice
:
'
单次价格
'
,
perRequestPriceRequired
:
'
按次/图片计费模式必须设置默认价格或至少一个计费层级
'
,
tierLabel
:
'
层级
'
,
resolution
:
'
分辨率
'
,
modelMapping
:
'
模型映射
'
,
modelMappingHint
:
'
将请求中的模型名映射为实际模型名。在账号级别映射之前执行。
'
,
noMappingRules
:
'
暂无映射规则,点击"添加"创建
'
,
mappingSource
:
'
源模型
'
,
mappingTarget
:
'
目标模型
'
,
billingModelSource
:
'
计费基准
'
,
billingModelSourceChannelMapped
:
'
以渠道映射后的模型计费
'
,
billingModelSourceRequested
:
'
以请求模型计费
'
,
billingModelSourceUpstream
:
'
以最终模型计费
'
,
billingModelSourceHint
:
'
控制使用哪个模型名称进行定价查找
'
,
selectedCount
:
'
已选 {count} 个
'
,
searchGroups
:
'
搜索分组...
'
,
noGroupsMatch
:
'
没有匹配的分组
'
,
restrictModels
:
'
限制模型
'
,
restrictModelsHint
:
'
开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。
'
,
defaultPerRequestPrice
:
'
默认单次价格(未命中层级时使用)
'
,
defaultImagePrice
:
'
默认图片价格(未命中层级时使用)
'
,
platformConfig
:
'
平台配置
'
,
basicSettings
:
'
基础设置
'
,
addPlatform
:
'
添加平台
'
,
noPlatforms
:
'
点击"添加平台"开始配置渠道
'
,
mappingCount
:
'
条映射
'
,
pricingEntry
:
'
定价配置
'
,
noModels
:
'
未添加模型
'
}
},
// Subscriptions Management
// Subscriptions Management
subscriptions
:
{
subscriptions
:
{
title
:
'
订阅管理
'
,
title
:
'
订阅管理
'
,
...
@@ -3424,6 +3526,11 @@ export default {
...
@@ -3424,6 +3526,11 @@ export default {
allBillingTypes
:
'
全部计费类型
'
,
allBillingTypes
:
'
全部计费类型
'
,
billingTypeBalance
:
'
钱包余额
'
,
billingTypeBalance
:
'
钱包余额
'
,
billingTypeSubscription
:
'
订阅套餐
'
,
billingTypeSubscription
:
'
订阅套餐
'
,
billingMode
:
'
计费模式
'
,
billingModeToken
:
'
按量
'
,
billingModePerRequest
:
'
按次
'
,
billingModeImage
:
'
按次(图片)
'
,
allBillingModes
:
'
全部计费模式
'
,
ipAddress
:
'
IP
'
,
ipAddress
:
'
IP
'
,
clickToViewBalance
:
'
点击查看充值记录
'
,
clickToViewBalance
:
'
点击查看充值记录
'
,
failedToLoadUser
:
'
加载用户信息失败
'
,
failedToLoadUser
:
'
加载用户信息失败
'
,
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment