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
7e288acc
Unverified
Commit
7e288acc
authored
Mar 13, 2026
by
Connie Borer
Committed by
GitHub
Mar 13, 2026
Browse files
Merge branch 'Wei-Shaw:main' into main
parents
f16910d6
1ee98447
Changes
29
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/admin/groups.ts
View file @
7e288acc
...
...
@@ -153,6 +153,30 @@ export async function getGroupApiKeys(
return
data
}
/**
* Rate multiplier entry for a user in a group
*/
export
interface
GroupRateMultiplierEntry
{
user_id
:
number
user_name
:
string
user_email
:
string
user_notes
:
string
user_status
:
string
rate_multiplier
:
number
}
/**
* Get rate multipliers for users in a group
* @param id - Group ID
* @returns List of user rate multiplier entries
*/
export
async
function
getGroupRateMultipliers
(
id
:
number
):
Promise
<
GroupRateMultiplierEntry
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
GroupRateMultiplierEntry
[]
>
(
`/admin/groups/
${
id
}
/rate-multipliers`
)
return
data
}
/**
* Update group sort orders
* @param updates - Array of { id, sort_order } objects
...
...
@@ -167,6 +191,33 @@ export async function updateSortOrder(
return
data
}
/**
* Clear all rate multipliers for a group
* @param id - Group ID
* @returns Success confirmation
*/
export
async
function
clearGroupRateMultipliers
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/groups/
${
id
}
/rate-multipliers`
)
return
data
}
/**
* Batch set rate multipliers for users in a group
* @param id - Group ID
* @param entries - Array of { user_id, rate_multiplier }
* @returns Success confirmation
*/
export
async
function
batchSetGroupRateMultipliers
(
id
:
number
,
entries
:
Array
<
{
user_id
:
number
;
rate_multiplier
:
number
}
>
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
put
<
{
message
:
string
}
>
(
`/admin/groups/
${
id
}
/rate-multipliers`
,
{
entries
}
)
return
data
}
export
const
groupsAPI
=
{
list
,
getAll
,
...
...
@@ -178,6 +229,9 @@ export const groupsAPI = {
toggleStatus
,
getStats
,
getGroupApiKeys
,
getGroupRateMultipliers
,
clearGroupRateMultipliers
,
batchSetGroupRateMultipliers
,
updateSortOrder
}
...
...
frontend/src/api/admin/subscriptions.ts
View file @
7e288acc
...
...
@@ -121,14 +121,14 @@ export async function revoke(id: number): Promise<{ message: string }> {
}
/**
* Reset daily and/or
week
ly usage quota for a subscription
* Reset daily
, weekly,
and/or
month
ly usage quota for a subscription
* @param id - Subscription ID
* @param options - Which windows to reset
* @returns Updated subscription
*/
export
async
function
resetQuota
(
id
:
number
,
options
:
{
daily
:
boolean
;
weekly
:
boolean
}
options
:
{
daily
:
boolean
;
weekly
:
boolean
;
monthly
:
boolean
}
):
Promise
<
UserSubscription
>
{
const
{
data
}
=
await
apiClient
.
post
<
UserSubscription
>
(
`/admin/subscriptions/
${
id
}
/reset-quota`
,
...
...
frontend/src/components/admin/group/GroupRateMultipliersModal.vue
0 → 100644
View file @
7e288acc
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.groups.rateMultipliersTitle')"
width=
"wide"
@
close=
"handleClose"
>
<div
v-if=
"group"
class=
"space-y-4"
>
<!-- 分组信息 -->
<div
class=
"flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-2.5 text-sm dark:bg-dark-700"
>
<span
class=
"inline-flex items-center gap-1.5"
:class=
"platformColorClass"
>
<PlatformIcon
:platform=
"group.platform"
size=
"sm"
/>
{{
t
(
'
admin.groups.platforms.
'
+
group
.
platform
)
}}
</span>
<span
class=
"text-gray-400"
>
|
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
group
.
name
}}
</span>
<span
class=
"text-gray-400"
>
|
</span>
<span
class=
"text-gray-600 dark:text-gray-400"
>
{{
t
(
'
admin.groups.columns.rateMultiplier
'
)
}}
:
{{
group
.
rate_multiplier
}}
x
</span>
</div>
<!-- 操作区 -->
<div
class=
"rounded-lg border border-gray-200 p-3 dark:border-dark-600"
>
<!-- 添加用户 -->
<h4
class=
"mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.groups.addUserRate
'
)
}}
</h4>
<div
class=
"flex items-end gap-2"
>
<div
class=
"relative flex-1"
>
<input
v-model=
"searchQuery"
type=
"text"
autocomplete=
"off"
class=
"input w-full"
:placeholder=
"t('admin.groups.searchUserPlaceholder')"
@
input=
"handleSearchUsers"
@
focus=
"showDropdown = true"
/>
<div
v-if=
"showDropdown && searchResults.length > 0"
class=
"absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
>
<button
v-for=
"user in searchResults"
:key=
"user.id"
type=
"button"
class=
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
@
click=
"selectUser(user)"
>
<span
class=
"text-gray-400"
>
#
{{
user
.
id
}}
</span>
<span
class=
"text-gray-900 dark:text-white"
>
{{
user
.
username
||
user
.
email
}}
</span>
<span
v-if=
"user.username"
class=
"text-xs text-gray-400"
>
{{
user
.
email
}}
</span>
</button>
</div>
</div>
<div
class=
"w-24"
>
<input
v-model.number=
"newRate"
type=
"number"
step=
"0.001"
min=
"0"
autocomplete=
"off"
class=
"hide-spinner input w-full"
placeholder=
"1.0"
/>
</div>
<button
type=
"button"
class=
"btn btn-primary shrink-0"
:disabled=
"!selectedUser || !newRate"
@
click=
"handleAddLocal"
>
{{
t
(
'
common.add
'
)
}}
</button>
</div>
<!-- 批量调整 + 全部清空 -->
<div
v-if=
"localEntries.length > 0"
class=
"mt-3 flex items-center gap-3 border-t border-gray-100 pt-3 dark:border-dark-600"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.batchAdjust
'
)
}}
</span>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"text-xs text-gray-400"
>
×
</span>
<input
v-model.number=
"batchFactor"
type=
"number"
step=
"0.1"
min=
"0"
autocomplete=
"off"
class=
"hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
placeholder=
"0.5"
/>
<button
type=
"button"
class=
"btn btn-primary btn-sm shrink-0 px-2.5 py-1 text-xs"
:disabled=
"!batchFactor || batchFactor
<
=
0"
@
click=
"applyBatchFactor"
>
{{
t
(
'
admin.groups.applyMultiplier
'
)
}}
</button>
</div>
<div
class=
"ml-auto"
>
<button
type=
"button"
class=
"rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
@
click=
"clearAllLocal"
>
{{
t
(
'
admin.groups.clearAll
'
)
}}
</button>
</div>
</div>
</div>
<!-- 加载状态 -->
<div
v-if=
"loading"
class=
"flex justify-center py-6"
>
<svg
class=
"h-6 w-6 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
></circle>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<!-- 已设置的用户列表 -->
<div
v-else
>
<h4
class=
"mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.groups.rateMultipliers
'
)
}}
(
{{
localEntries
.
length
}}
)
</h4>
<div
v-if=
"localEntries.length === 0"
class=
"py-6 text-center text-sm text-gray-400 dark:text-gray-500"
>
{{
t
(
'
admin.groups.noRateMultipliers
'
)
}}
</div>
<div
v-else
>
<!-- 表格 -->
<div
class=
"overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600"
>
<div
class=
"max-h-[420px] overflow-y-auto"
>
<table
class=
"w-full text-sm"
>
<thead
class=
"sticky top-0 z-[1]"
>
<tr
class=
"border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700"
>
<th
class=
"px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.columns.userEmail
'
)
}}
</th>
<th
class=
"px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
>
ID
</th>
<th
class=
"px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.columns.userName
'
)
}}
</th>
<th
class=
"px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.columns.userNotes
'
)
}}
</th>
<th
class=
"px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.columns.userStatus
'
)
}}
</th>
<th
class=
"px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.groups.columns.rateMultiplier
'
)
}}
</th>
<th
v-if=
"showFinalRate"
class=
"px-3 py-2 text-left text-xs font-medium text-primary-600 dark:text-primary-400"
>
{{
t
(
'
admin.groups.finalRate
'
)
}}
</th>
<th
class=
"w-10 px-2 py-2"
></th>
</tr>
</thead>
<tbody
class=
"divide-y divide-gray-100 dark:divide-dark-600"
>
<tr
v-for=
"entry in paginatedLocalEntries"
:key=
"entry.user_id"
class=
"hover:bg-gray-50 dark:hover:bg-dark-700/50"
>
<td
class=
"px-3 py-2 text-gray-600 dark:text-gray-400"
>
{{
entry
.
user_email
}}
</td>
<td
class=
"whitespace-nowrap px-3 py-2 text-gray-400 dark:text-gray-500"
>
{{
entry
.
user_id
}}
</td>
<td
class=
"whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white"
>
{{
entry
.
user_name
||
'
-
'
}}
</td>
<td
class=
"max-w-[160px] truncate px-3 py-2 text-gray-500 dark:text-gray-400"
:title=
"entry.user_notes"
>
{{
entry
.
user_notes
||
'
-
'
}}
</td>
<td
class=
"whitespace-nowrap px-3 py-2"
>
<span
:class=
"[
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
entry.user_status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-400'
]"
>
{{
entry
.
user_status
}}
</span>
</td>
<td
class=
"whitespace-nowrap px-3 py-2"
>
<input
type=
"number"
step=
"0.001"
min=
"0"
autocomplete=
"off"
:value=
"entry.rate_multiplier"
class=
"hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
@
change=
"updateLocalRate(entry.user_id, ($event.target as HTMLInputElement).value)"
/>
</td>
<td
v-if=
"showFinalRate"
class=
"whitespace-nowrap px-3 py-2 font-medium text-primary-600 dark:text-primary-400"
>
{{
computeFinalRate
(
entry
.
rate_multiplier
)
}}
</td>
<td
class=
"px-2 py-2"
>
<button
type=
"button"
class=
"rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
@
click=
"removeLocal(entry.user_id)"
>
<Icon
name=
"trash"
size=
"sm"
/>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页 -->
<Pagination
:total=
"localEntries.length"
:page=
"currentPage"
:page-size=
"pageSize"
:page-size-options=
"[10, 20, 50]"
@
update:page=
"currentPage = $event"
@
update:pageSize=
"handlePageSizeChange"
/>
</div>
</div>
<!-- 底部操作栏 -->
<div
class=
"flex items-center gap-3 border-t border-gray-200 pt-4 dark:border-dark-600"
>
<!-- 左侧:未保存提示 + 撤销 -->
<template
v-if=
"isDirty"
>
<span
class=
"text-xs text-amber-600 dark:text-amber-400"
>
{{
t
(
'
admin.groups.unsavedChanges
'
)
}}
</span>
<button
type=
"button"
class=
"text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@
click=
"handleCancel"
>
{{
t
(
'
admin.groups.revertChanges
'
)
}}
</button>
</
template
>
<!-- 右侧:关闭 / 保存 -->
<div
class=
"ml-auto flex items-center gap-3"
>
<button
type=
"button"
class=
"btn btn-sm px-4 py-1.5"
@
click=
"handleClose"
>
{{ t('common.close') }}
</button>
<button
v-if=
"isDirty"
type=
"button"
class=
"btn btn-primary btn-sm px-4 py-1.5"
:disabled=
"saving"
@
click=
"handleSave"
>
<Icon
v-if=
"saving"
name=
"refresh"
size=
"sm"
class=
"mr-1 animate-spin"
/>
{{ t('common.save') }}
</button>
</div>
</div>
</div>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
GroupRateMultiplierEntry
}
from
'
@/api/admin/groups
'
import
type
{
AdminGroup
,
AdminUser
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
PlatformIcon
from
'
@/components/common/PlatformIcon.vue
'
interface
LocalEntry
extends
GroupRateMultiplierEntry
{}
const
props
=
defineProps
<
{
show
:
boolean
group
:
AdminGroup
|
null
}
>
()
const
emit
=
defineEmits
<
{
close
:
[]
success
:
[]
}
>
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
loading
=
ref
(
false
)
const
saving
=
ref
(
false
)
const
serverEntries
=
ref
<
GroupRateMultiplierEntry
[]
>
([])
const
localEntries
=
ref
<
LocalEntry
[]
>
([])
const
searchQuery
=
ref
(
''
)
const
searchResults
=
ref
<
AdminUser
[]
>
([])
const
showDropdown
=
ref
(
false
)
const
selectedUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
newRate
=
ref
<
number
|
null
>
(
null
)
const
currentPage
=
ref
(
1
)
const
pageSize
=
ref
(
10
)
const
batchFactor
=
ref
<
number
|
null
>
(
null
)
let
searchTimeout
:
ReturnType
<
typeof
setTimeout
>
const
platformColorClass
=
computed
(()
=>
{
switch
(
props
.
group
?.
platform
)
{
case
'
anthropic
'
:
return
'
text-orange-700 dark:text-orange-400
'
case
'
openai
'
:
return
'
text-emerald-700 dark:text-emerald-400
'
case
'
antigravity
'
:
return
'
text-purple-700 dark:text-purple-400
'
default
:
return
'
text-blue-700 dark:text-blue-400
'
}
})
// 是否显示"最终倍率"预览列
const
showFinalRate
=
computed
(()
=>
{
return
batchFactor
.
value
!=
null
&&
batchFactor
.
value
>
0
&&
batchFactor
.
value
!==
1
})
// 计算最终倍率预览
const
computeFinalRate
=
(
rate
:
number
)
=>
{
if
(
!
batchFactor
.
value
)
return
rate
return
parseFloat
((
rate
*
batchFactor
.
value
).
toFixed
(
6
))
}
// 检测是否有未保存的修改
const
isDirty
=
computed
(()
=>
{
if
(
localEntries
.
value
.
length
!==
serverEntries
.
value
.
length
)
return
true
const
serverMap
=
new
Map
(
serverEntries
.
value
.
map
(
e
=>
[
e
.
user_id
,
e
.
rate_multiplier
]))
return
localEntries
.
value
.
some
(
e
=>
{
const
serverRate
=
serverMap
.
get
(
e
.
user_id
)
return
serverRate
===
undefined
||
serverRate
!==
e
.
rate_multiplier
})
})
const
paginatedLocalEntries
=
computed
(()
=>
{
const
start
=
(
currentPage
.
value
-
1
)
*
pageSize
.
value
return
localEntries
.
value
.
slice
(
start
,
start
+
pageSize
.
value
)
})
const
cloneEntries
=
(
entries
:
GroupRateMultiplierEntry
[]):
LocalEntry
[]
=>
{
return
entries
.
map
(
e
=>
({
...
e
}))
}
const
loadEntries
=
async
()
=>
{
if
(
!
props
.
group
)
return
loading
.
value
=
true
try
{
serverEntries
.
value
=
await
adminAPI
.
groups
.
getGroupRateMultipliers
(
props
.
group
.
id
)
localEntries
.
value
=
cloneEntries
(
serverEntries
.
value
)
adjustPage
()
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
admin.groups.failedToLoad
'
))
console
.
error
(
'
Error loading group rate multipliers:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
const
adjustPage
=
()
=>
{
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
localEntries
.
value
.
length
/
pageSize
.
value
))
if
(
currentPage
.
value
>
totalPages
)
{
currentPage
.
value
=
totalPages
}
}
watch
(()
=>
props
.
show
,
(
val
)
=>
{
if
(
val
&&
props
.
group
)
{
currentPage
.
value
=
1
batchFactor
.
value
=
null
searchQuery
.
value
=
''
searchResults
.
value
=
[]
selectedUser
.
value
=
null
newRate
.
value
=
null
loadEntries
()
}
})
const
handlePageSizeChange
=
(
newSize
:
number
)
=>
{
pageSize
.
value
=
newSize
currentPage
.
value
=
1
}
const
handleSearchUsers
=
()
=>
{
clearTimeout
(
searchTimeout
)
selectedUser
.
value
=
null
if
(
!
searchQuery
.
value
.
trim
())
{
searchResults
.
value
=
[]
showDropdown
.
value
=
false
return
}
searchTimeout
=
setTimeout
(
async
()
=>
{
try
{
const
res
=
await
adminAPI
.
users
.
list
(
1
,
10
,
{
search
:
searchQuery
.
value
.
trim
()
})
searchResults
.
value
=
res
.
items
showDropdown
.
value
=
true
}
catch
{
searchResults
.
value
=
[]
}
},
300
)
}
const
selectUser
=
(
user
:
AdminUser
)
=>
{
selectedUser
.
value
=
user
searchQuery
.
value
=
user
.
email
showDropdown
.
value
=
false
searchResults
.
value
=
[]
}
// 本地添加(或覆盖已有用户)
const
handleAddLocal
=
()
=>
{
if
(
!
selectedUser
.
value
||
!
newRate
.
value
)
return
const
user
=
selectedUser
.
value
const
idx
=
localEntries
.
value
.
findIndex
(
e
=>
e
.
user_id
===
user
.
id
)
const
entry
:
LocalEntry
=
{
user_id
:
user
.
id
,
user_name
:
user
.
username
||
''
,
user_email
:
user
.
email
,
user_notes
:
user
.
notes
||
''
,
user_status
:
user
.
status
||
'
active
'
,
rate_multiplier
:
newRate
.
value
}
if
(
idx
>=
0
)
{
localEntries
.
value
[
idx
]
=
entry
}
else
{
localEntries
.
value
.
push
(
entry
)
}
searchQuery
.
value
=
''
selectedUser
.
value
=
null
newRate
.
value
=
null
adjustPage
()
}
// 本地修改倍率
const
updateLocalRate
=
(
userId
:
number
,
value
:
string
)
=>
{
const
num
=
parseFloat
(
value
)
if
(
isNaN
(
num
))
return
const
entry
=
localEntries
.
value
.
find
(
e
=>
e
.
user_id
===
userId
)
if
(
entry
)
{
entry
.
rate_multiplier
=
num
}
}
// 本地删除
const
removeLocal
=
(
userId
:
number
)
=>
{
localEntries
.
value
=
localEntries
.
value
.
filter
(
e
=>
e
.
user_id
!==
userId
)
adjustPage
()
}
// 批量乘数应用到本地
const
applyBatchFactor
=
()
=>
{
if
(
!
batchFactor
.
value
||
batchFactor
.
value
<=
0
)
return
for
(
const
entry
of
localEntries
.
value
)
{
entry
.
rate_multiplier
=
parseFloat
((
entry
.
rate_multiplier
*
batchFactor
.
value
).
toFixed
(
6
))
}
batchFactor
.
value
=
null
}
// 本地清空
const
clearAllLocal
=
()
=>
{
localEntries
.
value
=
[]
}
// 取消:恢复到服务器数据
const
handleCancel
=
()
=>
{
localEntries
.
value
=
cloneEntries
(
serverEntries
.
value
)
batchFactor
.
value
=
null
adjustPage
()
}
// 保存:一次性提交所有数据
const
handleSave
=
async
()
=>
{
if
(
!
props
.
group
)
return
saving
.
value
=
true
try
{
const
entries
=
localEntries
.
value
.
map
(
e
=>
({
user_id
:
e
.
user_id
,
rate_multiplier
:
e
.
rate_multiplier
}))
await
adminAPI
.
groups
.
batchSetGroupRateMultipliers
(
props
.
group
.
id
,
entries
)
appStore
.
showSuccess
(
t
(
'
admin.groups.rateSaved
'
))
emit
(
'
success
'
)
emit
(
'
close
'
)
}
catch
(
error
)
{
appStore
.
showError
(
t
(
'
admin.groups.failedToSave
'
))
console
.
error
(
'
Error saving rate multipliers:
'
,
error
)
}
finally
{
saving
.
value
=
false
}
}
// 关闭时如果有未保存修改,先恢复
const
handleClose
=
()
=>
{
if
(
isDirty
.
value
)
{
localEntries
.
value
=
cloneEntries
(
serverEntries
.
value
)
}
emit
(
'
close
'
)
}
// 点击外部关闭下拉
const
handleClickOutside
=
()
=>
{
showDropdown
.
value
=
false
}
if
(
typeof
document
!==
'
undefined
'
)
{
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
}
</
script
>
<
style
scoped
>
.hide-spinner
::-webkit-outer-spin-button
,
.hide-spinner
::-webkit-inner-spin-button
{
-webkit-appearance
:
none
;
margin
:
0
;
}
.hide-spinner
{
-moz-appearance
:
textfield
;
}
</
style
>
frontend/src/components/common/PlatformTypeBadge.vue
View file @
7e288acc
<
template
>
<div
class=
"inline-flex items-center overflow-hidden rounded-md text-xs font-medium"
>
<!-- Platform part -->
<span
:class=
"['inline-flex items-center gap-1 px-2 py-1', platformClass]"
>
<PlatformIcon
:platform=
"platform"
size=
"xs"
/>
<span>
{{
platformLabel
}}
</span>
</span>
<!-- Type part -->
<span
:class=
"['inline-flex items-center gap-1 px-1.5 py-1', typeClass]"
>
<!-- OAuth icon -->
<svg
v-if=
"type === 'oauth'"
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
<div
class=
"inline-flex flex-col gap-0.5 text-xs font-medium"
>
<!-- Row 1: Platform + Type -->
<div
class=
"inline-flex items-center overflow-hidden rounded-md"
>
<span
:class=
"['inline-flex items-center gap-1 px-2 py-1', platformClass]"
>
<PlatformIcon
:platform=
"platform"
size=
"xs"
/>
<span>
{{
platformLabel
}}
</span>
</span>
<span
:class=
"['inline-flex items-center gap-1 px-1.5 py-1', typeClass]"
>
<!-- OAuth icon -->
<svg
v-if=
"type === 'oauth'"
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<!-- Setup Token icon -->
<Icon
v-else-if=
"type === 'setup-token'"
name=
"shield"
size=
"xs"
/>
<!-- API Key icon -->
<Icon
v-else
name=
"key"
size=
"xs"
/>
<span>
{{
typeLabel
}}
</span>
</span>
</div>
<!-- Row 2: Plan type + Privacy mode (only if either exists) -->
<div
v-if=
"planLabel || privacyBadge"
class=
"inline-flex items-center overflow-hidden rounded-md"
>
<span
v-if=
"planLabel"
:class=
"['inline-flex items-center gap-1 px-1.5 py-1', typeClass]"
>
<span>
{{
planLabel
}}
</span>
</span>
<span
v-if=
"privacyBadge"
:class=
"['inline-flex items-center gap-1 px-1.5 py-1', privacyBadge.class]"
:title=
"privacyBadge.title"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<!-- Setup Token icon -->
<Icon
v-else-if=
"type === 'setup-token'"
name=
"shield"
size=
"xs"
/>
<!-- API Key icon -->
<Icon
v-else
name=
"key"
size=
"xs"
/>
<span>
{{
typeLabel
}}
</span>
</span>
<!-- Plan type part (optional) -->
<span
v-if=
"planLabel"
:class=
"['inline-flex items-center gap-1 px-1.5 py-1 border-l border-white/20', typeClass]"
>
<span>
{{
planLabel
}}
</span>
</span>
<svg
class=
"h-3 w-3"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
stroke-width=
"2"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
:d=
"privacyBadge.icon"
/>
</svg>
<span>
{{
privacyBadge
.
label
}}
</span>
</span>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
PlatformIcon
from
'
./PlatformIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
interface
Props
{
platform
:
AccountPlatform
type
:
AccountType
planType
?:
string
privacyMode
?:
string
}
const
props
=
defineProps
<
Props
>
()
...
...
@@ -119,4 +136,21 @@ const typeClass = computed(() => {
}
return
'
bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400
'
})
// Privacy badge — shows different states for OpenAI OAuth training setting
const
privacyBadge
=
computed
(()
=>
{
if
(
props
.
platform
!==
'
openai
'
||
props
.
type
!==
'
oauth
'
||
!
props
.
privacyMode
)
return
null
const
shieldCheck
=
'
M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z
'
const
shieldX
=
'
M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285zM12 18h.008v.008H12V18z
'
switch
(
props
.
privacyMode
)
{
case
'
training_off
'
:
return
{
label
:
'
Privacy
'
,
icon
:
shieldCheck
,
title
:
t
(
'
admin.accounts.privacyTrainingOff
'
),
class
:
'
bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400
'
}
case
'
training_set_cf_blocked
'
:
return
{
label
:
'
CF
'
,
icon
:
shieldX
,
title
:
t
(
'
admin.accounts.privacyCfBlocked
'
),
class
:
'
bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400
'
}
case
'
training_set_failed
'
:
return
{
label
:
'
Fail
'
,
icon
:
shieldX
,
title
:
t
(
'
admin.accounts.privacyFailed
'
),
class
:
'
bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400
'
}
default
:
return
null
}
})
</
script
>
frontend/src/i18n/locales/en.ts
View file @
7e288acc
...
...
@@ -1372,7 +1372,11 @@ export default {
accounts
:
'
Accounts
'
,
status
:
'
Status
'
,
actions
:
'
Actions
'
,
billingType
:
'
Billing Type
'
billingType
:
'
Billing Type
'
,
userName
:
'
Username
'
,
userEmail
:
'
Email
'
,
userNotes
:
'
Notes
'
,
userStatus
:
'
Status
'
},
rateAndAccounts
:
'
{rate}x rate · {count} accounts
'
,
accountsCount
:
'
{count} accounts
'
,
...
...
@@ -1411,6 +1415,26 @@ export default {
failedToUpdate
:
'
Failed to update group
'
,
failedToDelete
:
'
Failed to delete group
'
,
nameRequired
:
'
Please enter group name
'
,
rateMultipliers
:
'
Rate Multipliers
'
,
rateMultipliersTitle
:
'
Group Rate Multipliers
'
,
addUserRate
:
'
Add User Rate Multiplier
'
,
searchUserPlaceholder
:
'
Search user email...
'
,
noRateMultipliers
:
'
No user rate multipliers configured
'
,
rateUpdated
:
'
Rate multiplier updated
'
,
rateDeleted
:
'
Rate multiplier removed
'
,
rateAdded
:
'
Rate multiplier added
'
,
clearAll
:
'
Clear All
'
,
confirmClearAll
:
'
Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.
'
,
rateCleared
:
'
All rate multipliers cleared
'
,
batchAdjust
:
'
Batch Adjust Rates
'
,
multiplierFactor
:
'
Factor
'
,
applyMultiplier
:
'
Apply
'
,
rateAdjusted
:
'
Rates adjusted successfully
'
,
rateSaved
:
'
Rate multipliers saved
'
,
finalRate
:
'
Final Rate
'
,
unsavedChanges
:
'
Unsaved changes
'
,
revertChanges
:
'
Revert
'
,
userInfo
:
'
User Info
'
,
platforms
:
{
all
:
'
All Platforms
'
,
anthropic
:
'
Anthropic
'
,
...
...
@@ -1574,7 +1598,7 @@ export default {
revoke
:
'
Revoke
'
,
resetQuota
:
'
Reset Quota
'
,
resetQuotaTitle
:
'
Reset Usage Quota
'
,
resetQuotaConfirm
:
"
Reset the daily
and
weekly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.
"
,
resetQuotaConfirm
:
"
Reset the daily
,
weekly
, and monthly
usage quota for '{user}'? Usage will be zeroed and windows restarted from today.
"
,
quotaResetSuccess
:
'
Quota reset successfully
'
,
failedToResetQuota
:
'
Failed to reset quota
'
,
noSubscriptionsYet
:
'
No subscriptions yet
'
,
...
...
@@ -1743,6 +1767,9 @@ export default {
expiresAt
:
'
Expires At
'
,
actions
:
'
Actions
'
},
privacyTrainingOff
:
'
Training data sharing disabled
'
,
privacyCfBlocked
:
'
Blocked by Cloudflare, training may still be on
'
,
privacyFailed
:
'
Failed to disable training
'
,
// Capacity status tooltips
capacity
:
{
windowCost
:
{
...
...
frontend/src/i18n/locales/zh.ts
View file @
7e288acc
...
...
@@ -1428,7 +1428,11 @@ export default {
accounts
:
'
账号数
'
,
status
:
'
状态
'
,
actions
:
'
操作
'
,
billingType
:
'
计费类型
'
billingType
:
'
计费类型
'
,
userName
:
'
用户名
'
,
userEmail
:
'
邮箱
'
,
userNotes
:
'
备注
'
,
userStatus
:
'
状态
'
},
form
:
{
name
:
'
名称
'
,
...
...
@@ -1510,6 +1514,26 @@ export default {
failedToCreate
:
'
创建分组失败
'
,
failedToUpdate
:
'
更新分组失败
'
,
nameRequired
:
'
请输入分组名称
'
,
rateMultipliers
:
'
专属倍率
'
,
rateMultipliersTitle
:
'
分组专属倍率管理
'
,
addUserRate
:
'
添加用户专属倍率
'
,
searchUserPlaceholder
:
'
搜索用户邮箱...
'
,
noRateMultipliers
:
'
暂无用户设置了专属倍率
'
,
rateUpdated
:
'
专属倍率已更新
'
,
rateDeleted
:
'
专属倍率已删除
'
,
rateAdded
:
'
专属倍率已添加
'
,
clearAll
:
'
全部清空
'
,
confirmClearAll
:
'
确定要清空该分组所有用户的专属倍率设置吗?此操作不可撤销。
'
,
rateCleared
:
'
已清空所有专属倍率
'
,
batchAdjust
:
'
批量调整倍率
'
,
multiplierFactor
:
'
乘数
'
,
applyMultiplier
:
'
应用
'
,
rateAdjusted
:
'
倍率已批量调整
'
,
rateSaved
:
'
专属倍率已保存
'
,
finalRate
:
'
最终倍率
'
,
unsavedChanges
:
'
有未保存的修改
'
,
revertChanges
:
'
撤销修改
'
,
userInfo
:
'
用户信息
'
,
subscription
:
{
title
:
'
订阅设置
'
,
type
:
'
计费类型
'
,
...
...
@@ -1662,7 +1686,7 @@ export default {
revoke
:
'
撤销
'
,
resetQuota
:
'
重置配额
'
,
resetQuotaTitle
:
'
重置用量配额
'
,
resetQuotaConfirm
:
"
确定要重置 '{user}' 的每日和每
周
用量配额吗?用量将归零并从今天开始重新计算。
"
,
resetQuotaConfirm
:
"
确定要重置 '{user}' 的每日
、每周
和每
月
用量配额吗?用量将归零并从今天开始重新计算。
"
,
quotaResetSuccess
:
'
配额重置成功
'
,
failedToResetQuota
:
'
重置配额失败
'
,
noSubscriptionsYet
:
'
暂无订阅
'
,
...
...
@@ -1792,6 +1816,9 @@ export default {
expiresAt
:
'
过期时间
'
,
actions
:
'
操作
'
},
privacyTrainingOff
:
'
已关闭训练数据共享
'
,
privacyCfBlocked
:
'
被 Cloudflare 拦截,训练可能仍开启
'
,
privacyFailed
:
'
关闭训练数据共享失败
'
,
// 容量状态提示
capacity
:
{
windowCost
:
{
...
...
frontend/src/views/admin/AccountsView.vue
View file @
7e288acc
...
...
@@ -171,7 +171,7 @@
<
span
v
-
else
class
=
"
text-sm text-gray-400 dark:text-dark-500
"
>-<
/span
>
<
/template
>
<
template
#
cell
-
platform_type
=
"
{ row
}
"
>
<
PlatformTypeBadge
:
platform
=
"
row.platform
"
:
type
=
"
row.type
"
:
plan
-
type
=
"
row.credentials?.plan_type
"
/>
<
PlatformTypeBadge
:
platform
=
"
row.platform
"
:
type
=
"
row.type
"
:
plan
-
type
=
"
row.credentials?.plan_type
"
:
privacy
-
mode
=
"
row.extra?.privacy_mode
"
/>
<
/template
>
<
template
#
cell
-
capacity
=
"
{ row
}
"
>
<
AccountCapacityCell
:
account
=
"
row
"
/>
...
...
frontend/src/views/admin/GroupsView.vue
View file @
7e288acc
...
...
@@ -181,6 +181,13 @@
<
Icon
name
=
"
edit
"
size
=
"
sm
"
/>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
common.edit
'
)
}}
<
/span
>
<
/button
>
<
button
@
click
=
"
handleRateMultipliers(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-purple-600 dark:hover:bg-dark-700 dark:hover:text-purple-400
"
>
<
Icon
name
=
"
dollar
"
size
=
"
sm
"
/>
<
span
class
=
"
text-xs
"
>
{{
t
(
'
admin.groups.rateMultipliers
'
)
}}
<
/span
>
<
/button
>
<
button
@
click
=
"
handleDelete(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400
"
...
...
@@ -1775,6 +1782,14 @@
<
/div
>
<
/template
>
<
/BaseDialog
>
<!--
Group
Rate
Multipliers
Modal
-->
<
GroupRateMultipliersModal
:
show
=
"
showRateMultipliersModal
"
:
group
=
"
rateMultipliersGroup
"
@
close
=
"
showRateMultipliersModal = false
"
@
success
=
"
loadGroups
"
/>
<
/AppLayout
>
<
/template
>
...
...
@@ -1796,6 +1811,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
import
Select
from
'
@/components/common/Select.vue
'
import
PlatformIcon
from
'
@/components/common/PlatformIcon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
GroupRateMultipliersModal
from
'
@/components/admin/group/GroupRateMultipliersModal.vue
'
import
{
VueDraggable
}
from
'
vue-draggable-plus
'
import
{
createStableObjectKeyResolver
}
from
'
@/utils/stableObjectKey
'
import
{
useKeyedDebouncedSearch
}
from
'
@/composables/useKeyedDebouncedSearch
'
...
...
@@ -1970,6 +1986,8 @@ const submitting = ref(false)
const
sortSubmitting
=
ref
(
false
)
const
editingGroup
=
ref
<
AdminGroup
|
null
>
(
null
)
const
deletingGroup
=
ref
<
AdminGroup
|
null
>
(
null
)
const
showRateMultipliersModal
=
ref
(
false
)
const
rateMultipliersGroup
=
ref
<
AdminGroup
|
null
>
(
null
)
const
sortableGroups
=
ref
<
AdminGroup
[]
>
([])
const
createForm
=
reactive
({
...
...
@@ -2459,6 +2477,11 @@ const handleUpdateGroup = async () => {
}
}
const
handleRateMultipliers
=
(
group
:
AdminGroup
)
=>
{
rateMultipliersGroup
.
value
=
group
showRateMultipliersModal
.
value
=
true
}
const
handleDelete
=
(
group
:
AdminGroup
)
=>
{
deletingGroup
.
value
=
group
showDeleteDialog
.
value
=
true
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
7e288acc
...
...
@@ -1154,7 +1154,7 @@ const confirmResetQuota = async () => {
if
(
resettingQuota
.
value
)
return
resettingQuota
.
value
=
true
try
{
await
adminAPI
.
subscriptions
.
resetQuota
(
resettingSubscription
.
value
.
id
,
{
daily
:
true
,
weekly
:
true
}
)
await
adminAPI
.
subscriptions
.
resetQuota
(
resettingSubscription
.
value
.
id
,
{
daily
:
true
,
weekly
:
true
,
monthly
:
true
}
)
appStore
.
showSuccess
(
t
(
'
admin.subscriptions.quotaResetSuccess
'
))
showResetQuotaConfirm
.
value
=
false
resettingSubscription
.
value
=
null
...
...
Prev
1
2
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