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
0b746501
Commit
0b746501
authored
Apr 16, 2026
by
陈曦
Browse files
1. merge upstream v0.1.113 2.提交migration相关文件
parents
45061102
be7551b9
Changes
225
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/admin/payment/AdminRefundDialog.vue
View file @
0b746501
...
@@ -34,12 +34,16 @@
...
@@ -34,12 +34,16 @@
<span
class=
"font-mono text-gray-900 dark:text-white"
>
#
{{
order
?.
id
}}
</span>
<span
class=
"font-mono text-gray-900 dark:text-white"
>
#
{{
order
?.
id
}}
</span>
</div>
</div>
<div
class=
"mt-1 flex justify-between text-sm"
>
<div
class=
"mt-1 flex justify-between text-sm"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.creditedAmount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
order
?.
pay_amount
?.
toFixed
(
2
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
order
?.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
order
?.
amount
?.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"mt-1 flex justify-between text-sm"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.payAmount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
order
?.
pay_amount
?.
toFixed
(
2
)
}}
</span>
</div>
</div>
<div
v-if=
"actuallyRefunded > 0"
class=
"mt-1 flex justify-between text-sm"
>
<div
v-if=
"actuallyRefunded > 0"
class=
"mt-1 flex justify-between text-sm"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.alreadyRefunded
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.alreadyRefunded
'
)
}}
</span>
<span
class=
"font-medium text-red-600 dark:text-red-400"
>
$
{{
actuallyRefunded
.
toFixed
(
2
)
}}
</span>
<span
class=
"font-medium text-red-600 dark:text-red-400"
>
{{
order
?.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}
{{
actuallyRefunded
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
</div>
...
@@ -66,7 +70,7 @@
...
@@ -66,7 +70,7 @@
</div>
</div>
<div
class=
"rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700"
>
<div
class=
"rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700"
>
<div
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.orderAmount
'
)
}}
</div>
<div
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.orderAmount
'
)
}}
</div>
<div
class=
"mt-1 font-semibold text-gray-900 dark:text-white"
>
$
{{
order
?.
pay_
amount
?.
toFixed
(
2
)
}}
</div>
<div
class=
"mt-1 font-semibold text-gray-900 dark:text-white"
>
{{
order
?.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
order
?.
amount
?.
toFixed
(
2
)
}}
</div>
</div>
</div>
</div>
</div>
...
@@ -91,7 +95,7 @@
...
@@ -91,7 +95,7 @@
<div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
payment.admin.refundAmount
'
)
}}
</label>
<label
class=
"input-label"
>
{{
t
(
'
payment.admin.refundAmount
'
)
}}
</label>
<div
class=
"relative"
>
<div
class=
"relative"
>
<span
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
>
$
</span>
<span
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
>
{{
order
?.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}
</span>
<input
<input
v-model.number=
"form.amount"
v-model.number=
"form.amount"
type=
"number"
type=
"number"
...
@@ -103,7 +107,7 @@
...
@@ -103,7 +107,7 @@
/>
/>
</div>
</div>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.maxRefundable
'
)
}}
:
$
{{
maxRefundable
.
toFixed
(
2
)
}}
{{
t
(
'
payment.admin.maxRefundable
'
)
}}
:
{{
order
?.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}
{{
maxRefundable
.
toFixed
(
2
)
}}
</p>
</p>
</div>
</div>
...
@@ -200,12 +204,12 @@ const actuallyRefunded = computed(() => {
...
@@ -200,12 +204,12 @@ const actuallyRefunded = computed(() => {
const
maxRefundable
=
computed
(()
=>
{
const
maxRefundable
=
computed
(()
=>
{
if
(
!
props
.
order
)
return
0
if
(
!
props
.
order
)
return
0
return
props
.
order
.
pay_
amount
-
actuallyRefunded
.
value
return
props
.
order
.
amount
-
actuallyRefunded
.
value
})
})
const
balanceInsufficient
=
computed
(()
=>
{
const
balanceInsufficient
=
computed
(()
=>
{
if
(
props
.
userBalance
==
null
||
!
props
.
order
)
return
false
if
(
props
.
userBalance
==
null
||
!
props
.
order
)
return
false
return
props
.
userBalance
<
props
.
order
.
pay_
amount
return
props
.
userBalance
<
props
.
order
.
amount
})
})
watch
(()
=>
props
.
show
,
(
val
)
=>
{
watch
(()
=>
props
.
show
,
(
val
)
=>
{
...
...
frontend/src/components/admin/usage/UsageStatsCards.vue
View file @
0b746501
...
@@ -28,17 +28,12 @@
...
@@ -28,17 +28,12 @@
<div
class=
"min-w-0 flex-1"
>
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<p
class=
"text-xl font-bold text-green-600"
>
<p
class=
"text-xl font-bold text-green-600"
>
$
{{
(
(
stats
?.
total_account_cost
??
stats
?.
total_actual_cost
)
||
0
).
toFixed
(
4
)
}}
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</p>
</p>
<p
class=
"text-xs text-gray-400"
v-if=
"stats?.total_account_cost != null"
>
<p
class=
"text-xs text-gray-400"
>
{{
t
(
'
usage.userBilled
'
)
}}
:
<span
class=
"text-orange-500"
>
{{
t
(
'
usage.accountCost
'
)
}}
$
{{
(
stats
?.
total_account_cost
||
0
).
toFixed
(
4
)
}}
</span>
<span
class=
"text-gray-300"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</span>
<span>
·
</span>
·
{{
t
(
'
usage.standardCost
'
)
}}
:
<span>
{{
t
(
'
usage.standardCost
'
)
}}
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
<span
class=
"text-gray-300"
>
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
</p>
<p
class=
"text-xs text-gray-400"
v-else
>
{{
t
(
'
usage.standardCost
'
)
}}
:
<span
class=
"line-through"
>
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
</p>
</p>
</div>
</div>
</div>
</div>
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
0b746501
...
@@ -87,13 +87,13 @@
...
@@ -87,13 +87,13 @@
<
template
#cell-billing_mode=
"{ row }"
>
<
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)"
>
<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
)
}}
{{
getBillingModeLabel
(
row
.
billing_mode
,
t
)
}}
</span>
</span>
</
template
>
</
template
>
<
template
#cell-tokens=
"{ row }"
>
<
template
#cell-tokens=
"{ row }"
>
<!-- 图片生成请求(仅按次计费时显示图片格式) -->
<!-- 图片生成请求(仅按次计费时显示图片格式) -->
<div
v-if=
"row.image_count > 0 && row.billing_mode ===
'image'
"
class=
"flex items-center gap-1.5"
>
<div
v-if=
"row.image_count > 0 && row.billing_mode ===
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>
...
@@ -154,8 +154,8 @@
...
@@ -154,8 +154,8 @@
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-if=
"row.account_rate_multiplier != null"
class=
"mt-0.5 text-[11px] text-
gray
-400"
>
<div
v-if=
"row.account_rate_multiplier != null"
class=
"mt-0.5 text-[11px] text-
orange-500 dark:text-orange
-400"
>
A $
{{
(
row
.
total_cost
*
row
.
account_rate_multiplier
).
toFixed
(
6
)
}}
A $
{{
accountBilled
(
row
).
toFixed
(
6
)
}}
</div>
</div>
</div>
</div>
</
template
>
</
template
>
...
@@ -279,13 +279,21 @@
...
@@ -279,13 +279,21 @@
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputCost') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.output_cost.toFixed(6) }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.output_cost.toFixed(6) }}
</span>
</div>
</div>
<div
v-if=
"tooltipData && tooltipData.input_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<!-- Token billing: show unit prices per 1M tokens -->
<span
class=
"text-gray-400"
>
{{ t('usage.inputTokenPrice') }}
</span>
<
template
v-if=
"!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN"
>
<span
class=
"font-medium text-sky-300"
>
{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}
</span>
<div
v-if=
"tooltipData && tooltipData.input_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
</div>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.inputTokenPrice
'
)
}}
</span>
<div
v-if=
"tooltipData && tooltipData.output_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"font-medium text-sky-300"
>
{{
formatTokenPricePerMillion
(
tooltipData
.
input_cost
,
tooltipData
.
input_tokens
)
}}
{{
t
(
'
usage.perMillionTokens
'
)
}}
</span>
<span
class=
"text-gray-400"
>
{{ t('usage.outputTokenPrice') }}
</span>
</div>
<span
class=
"font-medium text-violet-300"
>
{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}
</span>
<div
v-if=
"tooltipData && tooltipData.output_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.outputTokenPrice
'
)
}}
</span>
<span
class=
"font-medium text-violet-300"
>
{{
formatTokenPricePerMillion
(
tooltipData
.
output_cost
,
tooltipData
.
output_tokens
)
}}
{{
t
(
'
usage.perMillionTokens
'
)
}}
</span>
</div>
</
template
>
<!-- Per-request / image billing: show unit price -->
<div
v-else
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ tooltipData.billing_mode === BILLING_MODE_IMAGE ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}
</span>
<span
class=
"font-medium text-sky-300"
>
${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}
</span>
</div>
</div>
<div
v-if=
"tooltipData && tooltipData.cache_creation_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<div
v-if=
"tooltipData && tooltipData.cache_creation_cost > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationCost') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('admin.usage.cacheCreationCost') }}
</span>
...
@@ -305,10 +313,6 @@
...
@@ -305,10 +313,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"
>
{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}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"
>
<span
class=
"text-gray-400"
>
{{ t('usage.accountMultiplier') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x
</span>
</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>
<span
class=
"font-medium text-white"
>
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
</span>
...
@@ -317,10 +321,19 @@
...
@@ -317,10 +321,19 @@
<span
class=
"text-gray-400"
>
{{ t('usage.userBilled') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('usage.userBilled') }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}
</span>
</div>
</div>
<!-- Account billing (separated from user billing) -->
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<div
class=
"flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5"
>
<span
class=
"text-gray-400"
>
{{ t('usage.accountMultiplier') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x
</span>
</div>
<div
class=
"flex items-center justify-between gap-6"
>
<span
class=
"text-gray-400"
>
{{ t('usage.accountBilled') }}
</span>
<span
class=
"text-gray-400"
>
{{ t('usage.accountBilled') }}
</span>
<span
class=
"font-semibold text-green-400"
>
<span
class=
"font-semibold text-green-400"
>
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</span>
</div>
</div>
</div>
</div>
...
@@ -338,6 +351,15 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
...
@@ -338,6 +351,15 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import
{
formatTokenPricePerMillion
}
from
'
@/utils/usagePricing
'
import
{
formatTokenPricePerMillion
}
from
'
@/utils/usagePricing
'
import
{
getUsageServiceTierLabel
}
from
'
@/utils/usageServiceTier
'
import
{
getUsageServiceTierLabel
}
from
'
@/utils/usageServiceTier
'
import
{
resolveUsageRequestType
}
from
'
@/utils/usageRequestType
'
import
{
resolveUsageRequestType
}
from
'
@/utils/usageRequestType
'
import
{
getBillingModeLabel
,
getBillingModeBadgeClass
,
BILLING_MODE_TOKEN
,
BILLING_MODE_IMAGE
}
from
'
@/utils/billingMode
'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function
accountBilled
(
row
:
{
total_cost
?:
number
|
null
;
account_stats_cost
?:
number
|
null
;
account_rate_multiplier
?:
number
|
null
}):
number
{
const
base
=
row
.
account_stats_cost
!=
null
?
row
.
account_stats_cost
:
(
row
.
total_cost
??
0
)
const
result
=
base
*
(
row
.
account_rate_multiplier
??
1
)
return
Number
.
isNaN
(
result
)
?
0
:
result
}
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
...
@@ -391,17 +413,6 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
...
@@ -391,17 +413,6 @@ 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
getBillingModeLabel
=
(
mode
:
string
|
null
|
undefined
):
string
=>
{
if
(
mode
===
'
per_request
'
)
return
t
(
'
admin.usage.billingModePerRequest
'
)
if
(
mode
===
'
image
'
)
return
t
(
'
admin.usage.billingModeImage
'
)
return
t
(
'
admin.usage.billingModeToken
'
)
}
const
getBillingModeBadgeClass
=
(
mode
:
string
|
null
|
undefined
):
string
=>
{
if
(
mode
===
'
per_request
'
)
return
'
bg-blue-100 text-blue-800 dark:bg-blue-900 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
=>
{
...
...
frontend/src/components/charts/GroupDistributionChart.vue
View file @
0b746501
...
@@ -45,6 +45,7 @@
...
@@ -45,6 +45,7 @@
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.accountCost
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</tr>
</thead>
</thead>
...
@@ -75,13 +76,16 @@
...
@@ -75,13 +76,16 @@
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
group
.
actual_cost
)
}}
$
{{
formatCost
(
group
.
actual_cost
)
}}
</td>
</td>
<td
class=
"py-1.5 text-right text-orange-500 dark:text-orange-400"
>
$
{{
formatCost
(
group
.
account_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
group
.
cost
)
}}
$
{{
formatCost
(
group
.
cost
)
}}
</td>
</td>
</tr>
</tr>
<!-- User breakdown sub-rows -->
<!-- User breakdown sub-rows -->
<tr
v-if=
"expandedKey === `group-$
{group.group_id}`">
<tr
v-if=
"expandedKey === `group-$
{group.group_id}`">
<td
colspan=
"
5
"
class=
"p-0"
>
<td
colspan=
"
6
"
class=
"p-0"
>
<UserBreakdownSubTable
<UserBreakdownSubTable
:items=
"breakdownItems"
:items=
"breakdownItems"
:loading=
"breakdownLoading"
:loading=
"breakdownLoading"
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
0b746501
...
@@ -114,6 +114,7 @@
...
@@ -114,6 +114,7 @@
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.requests
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.tokens
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.accountCost
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</tr>
</thead>
</thead>
...
@@ -142,12 +143,15 @@
...
@@ -142,12 +143,15 @@
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
$
{{
formatCost
(
model
.
actual_cost
)
}}
</td>
</td>
<td
class=
"py-1.5 text-right text-orange-500 dark:text-orange-400"
>
$
{{
formatCost
(
model
.
account_cost
)
}}
</td>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
<td
class=
"py-1.5 text-right text-gray-400 dark:text-gray-500"
>
$
{{
formatCost
(
model
.
cost
)
}}
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</td>
</tr>
</tr>
<tr
v-if=
"expandedKey === `model-$
{model.model}`">
<tr
v-if=
"expandedKey === `model-$
{model.model}`">
<td
colspan=
"
5
"
class=
"p-0"
>
<td
colspan=
"
6
"
class=
"p-0"
>
<UserBreakdownSubTable
<UserBreakdownSubTable
:items=
"breakdownItems"
:items=
"breakdownItems"
:loading=
"breakdownLoading"
:loading=
"breakdownLoading"
...
...
frontend/src/components/charts/UserBreakdownSubTable.vue
View file @
0b746501
...
@@ -25,6 +25,9 @@
...
@@ -25,6 +25,9 @@
<
td
class
=
"
py-1 text-right text-green-600 dark:text-green-400
"
>
<
td
class
=
"
py-1 text-right text-green-600 dark:text-green-400
"
>
$
{{
formatCost
(
user
.
actual_cost
)
}}
$
{{
formatCost
(
user
.
actual_cost
)
}}
<
/td
>
<
/td
>
<
td
class
=
"
py-1 text-right text-orange-500 dark:text-orange-400
"
>
$
{{
formatCost
(
user
.
account_cost
)
}}
<
/td
>
<
td
class
=
"
py-1 pr-1 text-right text-gray-400 dark:text-gray-500
"
>
<
td
class
=
"
py-1 pr-1 text-right text-gray-400 dark:text-gray-500
"
>
$
{{
formatCost
(
user
.
cost
)
}}
$
{{
formatCost
(
user
.
cost
)
}}
<
/td
>
<
/td
>
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
0b746501
...
@@ -823,7 +823,6 @@ onMounted(() => {
...
@@ -823,7 +823,6 @@ onMounted(() => {
.sidebar-brand
{
.sidebar-brand
{
min-width
:
0
;
min-width
:
0
;
flex
:
1
1
auto
;
flex
:
1
1
auto
;
overflow
:
hidden
;
white-space
:
nowrap
;
white-space
:
nowrap
;
transition
:
transition
:
max-width
0.22s
ease
,
max-width
0.22s
ease
,
...
@@ -834,6 +833,7 @@ onMounted(() => {
...
@@ -834,6 +833,7 @@ onMounted(() => {
.sidebar-brand-collapsed
{
.sidebar-brand-collapsed
{
max-width
:
0
;
max-width
:
0
;
overflow
:
hidden
;
opacity
:
0
;
opacity
:
0
;
transform
:
translateX
(
-4px
);
transform
:
translateX
(
-4px
);
pointer-events
:
none
;
pointer-events
:
none
;
...
...
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
View file @
0b746501
...
@@ -22,8 +22,11 @@ describe('AppSidebar custom SVG styles', () => {
...
@@ -22,8 +22,11 @@ describe('AppSidebar custom SVG styles', () => {
describe
(
'
AppSidebar header styles
'
,
()
=>
{
describe
(
'
AppSidebar header styles
'
,
()
=>
{
it
(
'
does not clip the version badge dropdown
'
,
()
=>
{
it
(
'
does not clip the version badge dropdown
'
,
()
=>
{
const
sidebarHeaderBlockMatch
=
styleSource
.
match
(
/
\.
sidebar-header
\s
*
\{[\s\S]
*
?\n
\}
/
)
const
sidebarHeaderBlockMatch
=
styleSource
.
match
(
/
\.
sidebar-header
\s
*
\{[\s\S]
*
?\n
\}
/
)
const
sidebarBrandBlockMatch
=
componentSource
.
match
(
/
\.
sidebar-brand
\s
*
\{[\s\S]
*
?\n\}
/
)
expect
(
sidebarHeaderBlockMatch
).
not
.
toBeNull
()
expect
(
sidebarHeaderBlockMatch
).
not
.
toBeNull
()
expect
(
sidebarBrandBlockMatch
).
not
.
toBeNull
()
expect
(
sidebarHeaderBlockMatch
?.[
0
]).
not
.
toContain
(
'
@apply overflow-hidden;
'
)
expect
(
sidebarHeaderBlockMatch
?.[
0
]).
not
.
toContain
(
'
@apply overflow-hidden;
'
)
expect
(
sidebarBrandBlockMatch
?.[
0
]).
not
.
toContain
(
'
overflow: hidden;
'
)
})
})
})
})
frontend/src/components/payment/OrderTable.vue
View file @
0b746501
...
@@ -12,10 +12,15 @@
...
@@ -12,10 +12,15 @@
<span
v-if=
"row.user_notes"
class=
"ml-1 text-xs text-gray-400"
>
(
{{
row
.
user_notes
}}
)
</span>
<span
v-if=
"row.user_notes"
class=
"ml-1 text-xs text-gray-400"
>
(
{{
row
.
user_notes
}}
)
</span>
</div>
</div>
</
template
>
</
template
>
<
template
#cell-amount=
"{ value, row }"
>
<
template
#cell-
pay_
amount=
"{ value, row }"
>
<div
class=
"text-sm"
>
<div
class=
"text-sm"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
value
.
toFixed
(
2
)
}}
</span>
<span
v-if=
"row.pay_amount !== value"
class=
"ml-1 text-xs text-gray-500"
>
($
{{
row
.
pay_amount
.
toFixed
(
2
)
}}
)
</span>
<span
v-if=
"row.fee_rate > 0"
class=
"ml-1 text-xs text-gray-400"
:title=
"t('payment.orders.fee') + ': ' + row.fee_rate + '%'"
>
(
{{
t
(
'
payment.orders.fee
'
)
}}
{{
row
.
fee_rate
}}
%)
</span>
<div
v-if=
"row.amount !== row.pay_amount"
class=
"text-xs text-gray-500"
>
{{
t
(
'
payment.orders.creditedAmount
'
)
}}
:
{{
row
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
row
.
amount
.
toFixed
(
2
)
}}
</div>
</div>
</div>
</
template
>
</
template
>
<
template
#cell-payment_type=
"{ value }"
>
<
template
#cell-payment_type=
"{ value }"
>
...
@@ -60,7 +65,7 @@ const columns = computed((): Column[] => {
...
@@ -60,7 +65,7 @@ const columns = computed((): Column[] => {
cols
.
push
({
key
:
'
user_email
'
,
label
:
t
(
'
payment.admin.colUser
'
)
})
cols
.
push
({
key
:
'
user_email
'
,
label
:
t
(
'
payment.admin.colUser
'
)
})
}
}
cols
.
push
(
cols
.
push
(
{
key
:
'
amount
'
,
label
:
t
(
'
payment.orders.
a
mount
'
)
},
{
key
:
'
pay_
amount
'
,
label
:
t
(
'
payment.orders.
payA
mount
'
)
},
{
key
:
'
payment_type
'
,
label
:
t
(
'
payment.orders.paymentMethod
'
)
},
{
key
:
'
payment_type
'
,
label
:
t
(
'
payment.orders.paymentMethod
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
payment.orders.status
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
payment.orders.status
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
payment.orders.createdAt
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
payment.orders.createdAt
'
)
},
...
...
frontend/src/components/payment/PaymentProviderDialog.vue
View file @
0b746501
...
@@ -32,7 +32,8 @@
...
@@ -32,7 +32,8 @@
<!-- Toggles + Payment mode + Supported types (single row) -->
<!-- Toggles + Payment mode + Supported types (single row) -->
<div
class=
"flex flex-wrap items-center gap-x-5 gap-y-2"
>
<div
class=
"flex flex-wrap items-center gap-x-5 gap-y-2"
>
<ToggleSwitch
:label=
"t('common.enabled')"
:checked=
"form.enabled"
@
toggle=
"form.enabled = !form.enabled"
/>
<ToggleSwitch
:label=
"t('common.enabled')"
:checked=
"form.enabled"
@
toggle=
"form.enabled = !form.enabled"
/>
<ToggleSwitch
:label=
"t('admin.settings.payment.refundEnabled')"
:checked=
"form.refund_enabled"
@
toggle=
"form.refund_enabled = !form.refund_enabled"
/>
<ToggleSwitch
:label=
"t('admin.settings.payment.refundEnabled')"
:checked=
"form.refund_enabled"
@
toggle=
"form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false"
/>
<ToggleSwitch
v-if=
"form.refund_enabled"
:label=
"t('admin.settings.payment.allowUserRefund')"
:checked=
"form.allow_user_refund"
@
toggle=
"form.allow_user_refund = !form.allow_user_refund"
/>
<div
v-if=
"form.provider_key === 'easypay'"
class=
"flex items-center gap-2"
>
<div
v-if=
"form.provider_key === 'easypay'"
class=
"flex items-center gap-2"
>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.paymentMode
'
)
}}
</span>
<span
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.settings.payment.paymentMode
'
)
}}
</span>
<div
class=
"flex gap-1.5"
>
<div
class=
"flex gap-1.5"
>
...
@@ -243,6 +244,7 @@ const emit = defineEmits<{
...
@@ -243,6 +244,7 @@ const emit = defineEmits<{
enabled
:
boolean
enabled
:
boolean
payment_mode
:
string
payment_mode
:
string
refund_enabled
:
boolean
refund_enabled
:
boolean
allow_user_refund
:
boolean
config
:
Record
<
string
,
string
>
config
:
Record
<
string
,
string
>
limits
:
string
limits
:
string
}]
}]
...
@@ -258,6 +260,7 @@ const form = reactive({
...
@@ -258,6 +260,7 @@ const form = reactive({
enabled
:
true
,
enabled
:
true
,
payment_mode
:
PAYMENT_MODE_QRCODE
,
payment_mode
:
PAYMENT_MODE_QRCODE
,
refund_enabled
:
false
,
refund_enabled
:
false
,
allow_user_refund
:
false
,
})
})
const
config
=
reactive
<
Record
<
string
,
string
>>
({})
const
config
=
reactive
<
Record
<
string
,
string
>>
({})
const
limits
=
reactive
<
Record
<
string
,
Record
<
string
,
number
>>>
({})
const
limits
=
reactive
<
Record
<
string
,
Record
<
string
,
number
>>>
({})
...
@@ -433,6 +436,7 @@ function handleSave() {
...
@@ -433,6 +436,7 @@ function handleSave() {
enabled
:
form
.
enabled
,
enabled
:
form
.
enabled
,
payment_mode
:
form
.
provider_key
===
'
easypay
'
?
form
.
payment_mode
:
''
,
payment_mode
:
form
.
provider_key
===
'
easypay
'
?
form
.
payment_mode
:
''
,
refund_enabled
:
form
.
refund_enabled
,
refund_enabled
:
form
.
refund_enabled
,
allow_user_refund
:
form
.
refund_enabled
?
form
.
allow_user_refund
:
false
,
config
:
filteredConfig
,
config
:
filteredConfig
,
limits
:
serializeLimits
(),
limits
:
serializeLimits
(),
})
})
...
@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
...
@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
form
.
enabled
=
true
form
.
enabled
=
true
form
.
payment_mode
=
defaultKey
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
form
.
payment_mode
=
defaultKey
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
form
.
refund_enabled
=
false
form
.
refund_enabled
=
false
form
.
allow_user_refund
=
false
clearConfig
()
clearConfig
()
applyDefaults
()
applyDefaults
()
}
}
...
@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
...
@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
form
.
enabled
=
provider
.
enabled
form
.
enabled
=
provider
.
enabled
form
.
payment_mode
=
provider
.
payment_mode
||
(
provider
.
provider_key
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
)
form
.
payment_mode
=
provider
.
payment_mode
||
(
provider
.
provider_key
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
)
form
.
refund_enabled
=
provider
.
refund_enabled
form
.
refund_enabled
=
provider
.
refund_enabled
form
.
allow_user_refund
=
provider
.
allow_user_refund
clearConfig
()
clearConfig
()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if
(
provider
.
config
)
{
if
(
provider
.
config
)
{
...
...
frontend/src/components/payment/PaymentProviderList.vue
View file @
0b746501
...
@@ -115,7 +115,7 @@ const emit = defineEmits<{
...
@@ -115,7 +115,7 @@ const emit = defineEmits<{
create
:
[]
create
:
[]
edit
:
[
provider
:
ProviderInstance
]
edit
:
[
provider
:
ProviderInstance
]
delete
:
[
provider
:
ProviderInstance
]
delete
:
[
provider
:
ProviderInstance
]
toggleField
:
[
provider
:
ProviderInstance
,
field
:
'
enabled
'
|
'
refund_enabled
'
]
toggleField
:
[
provider
:
ProviderInstance
,
field
:
'
enabled
'
|
'
refund_enabled
'
|
'
allow_user_refund
'
]
toggleType
:
[
provider
:
ProviderInstance
,
type
:
string
]
toggleType
:
[
provider
:
ProviderInstance
,
type
:
string
]
reorder
:
[
providers
:
{
id
:
number
;
sort_order
:
number
}[]]
reorder
:
[
providers
:
{
id
:
number
;
sort_order
:
number
}[]]
}
>
()
}
>
()
...
...
frontend/src/components/payment/PaymentQRDialog.vue
View file @
0b746501
...
@@ -45,7 +45,11 @@
...
@@ -45,7 +45,11 @@
</div>
</div>
<div
class=
"flex justify-between"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.amount') }}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.amount') }}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
${{ paidOrder.pay_amount.toFixed(2) }}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('payment.orders.payAmount') }}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥{{ paidOrder.pay_amount.toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
...
...
frontend/src/components/payment/PaymentStatusPanel.vue
View file @
0b746501
...
@@ -22,7 +22,11 @@
...
@@ -22,7 +22,11 @@
</div>
</div>
<div
class=
"flex justify-between"
>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
paidOrder
.
pay_amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
paidOrder
.
order_type
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
paidOrder
.
amount
.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.payAmount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
paidOrder
.
pay_amount
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
...
...
frontend/src/components/payment/ProviderCard.vue
View file @
0b746501
...
@@ -46,6 +46,7 @@
...
@@ -46,6 +46,7 @@
<div
class=
"flex items-center gap-4"
>
<div
class=
"flex items-center gap-4"
>
<ToggleSwitch
:label=
"t('common.enabled')"
:checked=
"provider.enabled"
@
toggle=
"emit('toggleField', 'enabled')"
/>
<ToggleSwitch
:label=
"t('common.enabled')"
:checked=
"provider.enabled"
@
toggle=
"emit('toggleField', 'enabled')"
/>
<ToggleSwitch
:label=
"t('admin.settings.payment.refundEnabled')"
:checked=
"provider.refund_enabled"
@
toggle=
"emit('toggleField', 'refund_enabled')"
/>
<ToggleSwitch
:label=
"t('admin.settings.payment.refundEnabled')"
:checked=
"provider.refund_enabled"
@
toggle=
"emit('toggleField', 'refund_enabled')"
/>
<ToggleSwitch
v-if=
"provider.refund_enabled"
:label=
"t('admin.settings.payment.allowUserRefund')"
:checked=
"provider.allow_user_refund"
@
toggle=
"emit('toggleField', 'allow_user_refund')"
/>
<div
class=
"flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600"
>
<div
class=
"flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600"
>
<button
type=
"button"
@
click=
"emit('edit')"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<button
type=
"button"
@
click=
"emit('edit')"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
<Icon
name=
"edit"
size=
"sm"
/>
<Icon
name=
"edit"
size=
"sm"
/>
...
@@ -84,7 +85,7 @@ const props = defineProps<{
...
@@ -84,7 +85,7 @@ const props = defineProps<{
}
>
()
}
>
()
const
emit
=
defineEmits
<
{
const
emit
=
defineEmits
<
{
toggleField
:
[
field
:
'
enabled
'
|
'
refund_enabled
'
]
toggleField
:
[
field
:
'
enabled
'
|
'
refund_enabled
'
|
'
allow_user_refund
'
]
toggleType
:
[
type
:
string
]
toggleType
:
[
type
:
string
]
edit
:
[]
edit
:
[]
delete
:
[]
delete
:
[]
...
...
frontend/src/components/payment/StripePaymentInline.vue
View file @
0b746501
...
@@ -21,9 +21,13 @@
...
@@ -21,9 +21,13 @@
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#
{{
orderId
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
#
{{
orderId
}}
</span>
</div>
</div>
<div
class=
"flex justify-between"
>
<div
v-if=
"amount > 0"
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
payAmount
.
toFixed
(
2
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
orderType
===
'
balance
'
?
'
$
'
:
'
¥
'
}}{{
amount
.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"flex justify-between"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.payAmount
'
)
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
payAmount
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
...
@@ -36,7 +40,7 @@
...
@@ -36,7 +40,7 @@
<div
class=
"card overflow-hidden"
>
<div
class=
"card overflow-hidden"
>
<div
class=
"bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center"
>
<div
class=
"bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center"
>
<p
class=
"text-sm font-medium text-indigo-200"
>
{{
t
(
'
payment.actualPay
'
)
}}
</p>
<p
class=
"text-sm font-medium text-indigo-200"
>
{{
t
(
'
payment.actualPay
'
)
}}
</p>
<p
class=
"mt-1 text-3xl font-bold text-white"
>
$
{{
payAmount
.
toFixed
(
2
)
}}
</p>
<p
class=
"mt-1 text-3xl font-bold text-white"
>
¥
{{
payAmount
.
toFixed
(
2
)
}}
</p>
</div>
</div>
</div>
</div>
<!-- Stripe Payment Element -->
<!-- Stripe Payment Element -->
...
@@ -75,7 +79,9 @@ const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
...
@@ -75,7 +79,9 @@ const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
const
props
=
defineProps
<
{
const
props
=
defineProps
<
{
orderId
:
number
orderId
:
number
amount
:
number
clientSecret
:
string
clientSecret
:
string
orderType
?:
'
balance
'
|
'
subscription
'
publishableKey
:
string
publishableKey
:
string
payAmount
:
number
payAmount
:
number
}
>
()
}
>
()
...
...
frontend/src/components/payment/ToggleSwitch.vue
View file @
0b746501
<
template
>
<
template
>
<label
class=
"flex items-center gap-
1
.5 cursor-pointer"
>
<label
class=
"flex
flex-col
items-center gap-
0
.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
label
}}
</span>
<span
class=
"text-xs text-gray-500 dark:text-gray-400
whitespace-nowrap
"
>
{{
label
}}
</span>
<button
<button
type=
"button"
type=
"button"
role=
"switch"
role=
"switch"
...
...
frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
0 → 100644
View file @
0b746501
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.balanceNotify.title
'
)
}}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.balanceNotify.description
'
)
}}
</p>
</div>
<div
class=
"px-6 py-6 space-y-6"
>
<!-- Enable toggle -->
<div
class=
"flex items-center justify-between"
>
<label
class=
"input-label mb-0"
>
{{
t
(
'
profile.balanceNotify.enabled
'
)
}}
</label>
<label
class=
"relative inline-flex items-center cursor-pointer"
>
<input
type=
"checkbox"
v-model=
"notifyEnabled"
@
change=
"handleToggle"
class=
"sr-only peer"
/>
<div
class=
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary-600"
></div>
</label>
</div>
<template
v-if=
"notifyEnabled"
>
<!-- Custom threshold with save button -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
profile.balanceNotify.threshold
'
)
}}
<span
class=
"text-xs text-gray-400 ml-2"
>
{{
t
(
'
profile.balanceNotify.thresholdHint
'
)
}}
</span>
</label>
<div
class=
"flex items-center gap-2"
>
<span
class=
"text-gray-500"
>
$
</span>
<input
v-model.number=
"customThreshold"
type=
"number"
min=
"0"
step=
"0.01"
class=
"input flex-1"
:placeholder=
"systemDefaultThreshold > 0 ? `$
{t('profile.balanceNotify.systemDefault')} $${systemDefaultThreshold}` : t('profile.balanceNotify.thresholdPlaceholder')"
/>
<button
@
click=
"handleThresholdUpdate"
:disabled=
"savingThreshold"
class=
"btn btn-primary btn-sm whitespace-nowrap"
>
{{
savingThreshold
?
t
(
'
common.saving
'
)
:
t
(
'
common.save
'
)
}}
</button>
</div>
</div>
<!-- Email list with toggles -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
profile.balanceNotify.extraEmails
'
)
}}
</label>
<p
class=
"mb-2 text-xs text-yellow-600 dark:text-yellow-400"
>
{{
t
(
'
profile.balanceNotify.extraEmailsHint
'
)
}}
</p>
<!-- Saved email entries -->
<div
v-if=
"emailEntries.length > 0"
class=
"space-y-2 mb-3"
>
<div
v-for=
"(entry, idx) in emailEntries"
:key=
"idx"
class=
"flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg"
>
<div
class=
"flex items-center gap-2 min-w-0 flex-1"
>
<label
class=
"relative inline-flex items-center cursor-pointer shrink-0"
>
<input
type=
"checkbox"
:checked=
"!entry.disabled"
@
change=
"handleEmailToggle(entry)"
class=
"sr-only peer"
/>
<div
class=
"w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"
></div>
</label>
<span
class=
"text-sm text-gray-700 dark:text-gray-300 truncate"
>
{{
entry
.
email
}}
</span>
</div>
<div
class=
"flex items-center gap-2 shrink-0"
>
<template
v-if=
"!entry.verified"
>
<!-- Inline verify flow for saved unverified emails -->
<template
v-if=
"verifyingEmail === entry.email"
>
<input
v-model=
"verifyCode"
type=
"text"
maxlength=
"6"
class=
"w-20 rounded border border-gray-300 px-2 py-1 text-xs dark:border-dark-500 dark:bg-dark-700"
:placeholder=
"t('profile.balanceNotify.codePlaceholder')"
/>
<button
@
click=
"verifySavedEmail(entry.email)"
:disabled=
"!verifyCode || verifyCode.length !== 6 || verifyingSaved"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
{{
t
(
'
profile.balanceNotify.verify
'
)
}}
</button>
<span
v-if=
"verifyCountdown > 0"
class=
"text-xs text-gray-400"
>
{{
verifyCountdown
}}
s
</span>
<button
v-else
@
click=
"sendCodeForSaved(entry.email)"
:disabled=
"sendingSavedCode"
class=
"text-xs text-gray-500 hover:text-gray-700"
>
{{
t
(
'
profile.balanceNotify.resend
'
)
}}
</button>
<button
@
click=
"verifyingEmail = ''"
class=
"text-xs text-gray-400 hover:text-gray-600"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
</
template
>
<
template
v-else
>
<button
@
click=
"sendCodeForSaved(entry.email)"
:disabled=
"sendingSavedCode"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
{{
t
(
'
profile.balanceNotify.verify
'
)
}}
</button>
<span
class=
"text-xs text-yellow-500"
>
{{
t
(
'
profile.balanceNotify.unverified
'
)
}}
</span>
</
template
>
</template>
<span
v-else
class=
"text-xs text-green-500"
>
{{ t('profile.balanceNotify.verified') }}
</span>
<button
@
click=
"handleRemoveEmail(entry.email)"
class=
"text-red-500 hover:text-red-700 text-xs"
>
{{ t('profile.balanceNotify.removeEmail') }}
</button>
</div>
</div>
</div>
<!-- Pending (unverified) emails in verification flow -->
<div
v-if=
"pendingEmails.length > 0"
class=
"space-y-2 mb-3"
>
<div
v-for=
"(pe, idx) in pendingEmails"
:key=
"pe.email"
class=
"flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/10 rounded-lg border border-yellow-200 dark:border-yellow-800"
>
<span
class=
"flex-1 text-sm text-gray-700 dark:text-gray-300"
>
{{ pe.email }}
</span>
<div
v-if=
"!pe.codeSent"
class=
"flex items-center gap-1"
>
<button
@
click=
"sendCodeFor(idx)"
:disabled=
"pe.sending"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
{{ t('profile.balanceNotify.sendCode') }}
</button>
<button
@
click=
"pendingEmails.splice(idx, 1)"
class=
"text-xs text-red-500 hover:text-red-700 ml-1"
>
{{ t('profile.balanceNotify.removeEmail') }}
</button>
</div>
<div
v-else
class=
"flex items-center gap-1"
>
<input
v-model=
"pe.code"
type=
"text"
maxlength=
"6"
class=
"w-20 rounded border border-gray-300 px-2 py-1 text-xs dark:border-dark-500 dark:bg-dark-700"
:placeholder=
"t('profile.balanceNotify.codePlaceholder')"
/>
<button
@
click=
"verifyPending(idx)"
:disabled=
"!pe.code || pe.code.length !== 6 || pe.verifying"
class=
"text-xs text-primary-600 hover:text-primary-700"
>
{{ t('profile.balanceNotify.verify') }}
</button>
<span
v-if=
"pe.countdown > 0"
class=
"text-xs text-gray-400"
>
{{ pe.countdown }}s
</span>
<button
v-else
@
click=
"sendCodeFor(idx)"
:disabled=
"pe.sending"
class=
"text-xs text-gray-500 hover:text-gray-700"
>
{{ t('profile.balanceNotify.resend') }}
</button>
</div>
</div>
</div>
<!-- Add new email input (hidden when at limit) -->
<div
v-if=
"canAddMore"
class=
"flex gap-2"
>
<input
v-model=
"newEmail"
type=
"email"
class=
"input flex-1"
:placeholder=
"t('profile.balanceNotify.emailPlaceholder')"
@
keyup.enter=
"addPendingEmail"
/>
<button
@
click=
"addPendingEmail"
:disabled=
"!newEmail"
class=
"btn btn-secondary whitespace-nowrap"
>
{{ t('common.add') }}
</button>
</div>
<p
v-else
class=
"text-xs text-gray-400"
>
{{ t('profile.balanceNotify.maxEmailsReached') }}
</p>
</div>
</template>
</div>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
userAPI
}
from
'
@/api
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
import
type
{
NotifyEmailEntry
}
from
'
@/types
'
const
maxTotalEmails
=
3
interface
PendingEmail
{
email
:
string
codeSent
:
boolean
code
:
string
sending
:
boolean
verifying
:
boolean
countdown
:
number
timer
:
ReturnType
<
typeof
setInterval
>
|
null
}
const
props
=
defineProps
<
{
enabled
:
boolean
threshold
:
number
|
null
extraEmails
:
NotifyEmailEntry
[]
systemDefaultThreshold
:
number
userEmail
:
string
}
>
()
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
notifyEnabled
=
ref
(
props
.
enabled
)
const
customThreshold
=
ref
<
number
|
null
>
(
props
.
threshold
)
const
emailEntries
=
ref
<
NotifyEmailEntry
[]
>
([...
props
.
extraEmails
])
const
pendingEmails
=
ref
<
PendingEmail
[]
>
([])
const
newEmail
=
ref
(
''
)
const
savingThreshold
=
ref
(
false
)
// State for verifying saved unverified emails
const
verifyingEmail
=
ref
(
''
)
const
verifyCode
=
ref
(
''
)
const
verifyingSaved
=
ref
(
false
)
const
sendingSavedCode
=
ref
(
false
)
const
verifyCountdown
=
ref
(
0
)
let
verifyTimer
:
ReturnType
<
typeof
setInterval
>
|
null
=
null
const
canAddMore
=
computed
(()
=>
{
return
emailEntries
.
value
.
length
+
pendingEmails
.
value
.
length
<
maxTotalEmails
})
watch
(()
=>
props
.
enabled
,
(
val
)
=>
{
notifyEnabled
.
value
=
val
})
watch
(()
=>
props
.
threshold
,
(
val
)
=>
{
customThreshold
.
value
=
val
})
watch
(()
=>
props
.
extraEmails
,
(
val
)
=>
{
emailEntries
.
value
=
[...
val
]
})
// When list is empty on mount, pre-fill the add input with user's email
onMounted
(()
=>
{
if
(
emailEntries
.
value
.
length
===
0
&&
props
.
userEmail
)
{
newEmail
.
value
=
props
.
userEmail
}
})
onUnmounted
(()
=>
{
for
(
const
pe
of
pendingEmails
.
value
)
{
if
(
pe
.
timer
)
clearInterval
(
pe
.
timer
)
}
if
(
verifyTimer
)
clearInterval
(
verifyTimer
)
})
const
handleToggle
=
async
()
=>
{
try
{
const
updated
=
await
userAPI
.
updateProfile
({
balance_notify_enabled
:
notifyEnabled
.
value
})
authStore
.
user
=
updated
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
notifyEnabled
.
value
=
!
notifyEnabled
.
value
}
}
const
handleThresholdUpdate
=
async
()
=>
{
savingThreshold
.
value
=
true
try
{
const
threshold
=
customThreshold
.
value
&&
customThreshold
.
value
>
0
?
customThreshold
.
value
:
0
const
updated
=
await
userAPI
.
updateProfile
({
balance_notify_threshold
:
threshold
})
authStore
.
user
=
updated
appStore
.
showSuccess
(
t
(
'
common.saved
'
))
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
savingThreshold
.
value
=
false
}
}
async
function
handleEmailToggle
(
entry
:
NotifyEmailEntry
)
{
const
newDisabled
=
!
entry
.
disabled
try
{
const
updated
=
await
userAPI
.
toggleNotifyEmail
(
entry
.
email
,
newDisabled
)
authStore
.
user
=
updated
emailEntries
.
value
=
[...
updated
.
balance_notify_extra_emails
]
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
}
function
addPendingEmail
()
{
const
email
=
newEmail
.
value
.
trim
()
if
(
!
email
)
return
// Check duplicates
const
isDuplicate
=
emailEntries
.
value
.
some
(
e
=>
e
.
email
.
toLowerCase
()
===
email
.
toLowerCase
())
||
pendingEmails
.
value
.
some
(
p
=>
p
.
email
.
toLowerCase
()
===
email
.
toLowerCase
())
if
(
isDuplicate
)
{
appStore
.
showError
(
t
(
'
profile.balanceNotify.emailDuplicate
'
))
return
}
pendingEmails
.
value
.
push
({
email
,
codeSent
:
false
,
code
:
''
,
sending
:
false
,
verifying
:
false
,
countdown
:
0
,
timer
:
null
})
newEmail
.
value
=
''
}
async
function
sendCodeFor
(
idx
:
number
)
{
const
pe
=
pendingEmails
.
value
[
idx
]
if
(
!
pe
)
return
pe
.
sending
=
true
try
{
await
userAPI
.
sendNotifyEmailCode
(
pe
.
email
)
pe
.
codeSent
=
true
pe
.
countdown
=
60
pe
.
timer
=
setInterval
(()
=>
{
pe
.
countdown
--
if
(
pe
.
countdown
<=
0
&&
pe
.
timer
)
{
clearInterval
(
pe
.
timer
)
pe
.
timer
=
null
}
},
1000
)
appStore
.
showSuccess
(
t
(
'
profile.balanceNotify.codeSent
'
))
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
pe
.
sending
=
false
}
}
async
function
verifyPending
(
idx
:
number
)
{
const
pe
=
pendingEmails
.
value
[
idx
]
if
(
!
pe
||
!
pe
.
code
||
pe
.
code
.
length
!==
6
)
return
pe
.
verifying
=
true
try
{
await
userAPI
.
verifyNotifyEmail
(
pe
.
email
,
pe
.
code
)
if
(
pe
.
timer
)
clearInterval
(
pe
.
timer
)
pendingEmails
.
value
.
splice
(
idx
,
1
)
appStore
.
showSuccess
(
t
(
'
profile.balanceNotify.verifySuccess
'
))
const
updated
=
await
userAPI
.
getProfile
()
authStore
.
user
=
updated
emailEntries
.
value
=
[...
updated
.
balance_notify_extra_emails
]
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
pe
.
verifying
=
false
}
}
const
handleRemoveEmail
=
async
(
email
:
string
)
=>
{
try
{
await
userAPI
.
removeNotifyEmail
(
email
)
appStore
.
showSuccess
(
t
(
'
profile.balanceNotify.removeSuccess
'
))
const
updated
=
await
userAPI
.
getProfile
()
authStore
.
user
=
updated
emailEntries
.
value
=
[...
updated
.
balance_notify_extra_emails
]
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
}
// Verify saved unverified emails
async
function
sendCodeForSaved
(
email
:
string
)
{
sendingSavedCode
.
value
=
true
try
{
await
userAPI
.
sendNotifyEmailCode
(
email
)
verifyingEmail
.
value
=
email
verifyCode
.
value
=
''
verifyCountdown
.
value
=
60
if
(
verifyTimer
)
clearInterval
(
verifyTimer
)
verifyTimer
=
setInterval
(()
=>
{
verifyCountdown
.
value
--
if
(
verifyCountdown
.
value
<=
0
&&
verifyTimer
)
{
clearInterval
(
verifyTimer
)
verifyTimer
=
null
}
},
1000
)
appStore
.
showSuccess
(
t
(
'
profile.balanceNotify.codeSent
'
))
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
sendingSavedCode
.
value
=
false
}
}
async
function
verifySavedEmail
(
email
:
string
)
{
if
(
!
verifyCode
.
value
||
verifyCode
.
value
.
length
!==
6
)
return
verifyingSaved
.
value
=
true
try
{
await
userAPI
.
verifyNotifyEmail
(
email
,
verifyCode
.
value
)
verifyingEmail
.
value
=
''
verifyCode
.
value
=
''
if
(
verifyTimer
)
{
clearInterval
(
verifyTimer
);
verifyTimer
=
null
}
appStore
.
showSuccess
(
t
(
'
profile.balanceNotify.verifySuccess
'
))
const
updated
=
await
userAPI
.
getProfile
()
authStore
.
user
=
updated
emailEntries
.
value
=
[...
updated
.
balance_notify_extra_emails
]
}
catch
(
err
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
err
,
t
(
'
common.error
'
)))
}
finally
{
verifyingSaved
.
value
=
false
}
}
</
script
>
frontend/src/composables/useQuotaNotifyState.ts
0 → 100644
View file @
0b746501
import
{
reactive
,
ref
}
from
'
vue
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
QUOTA_THRESHOLD_TYPE_FIXED
,
type
QuotaThresholdType
}
from
'
@/constants/account
'
export
const
QUOTA_NOTIFY_DIMS
=
[
'
daily
'
,
'
weekly
'
,
'
total
'
]
as
const
export
type
QuotaNotifyDim
=
(
typeof
QUOTA_NOTIFY_DIMS
)[
number
]
interface
DimState
{
enabled
:
boolean
|
null
threshold
:
number
|
null
thresholdType
:
QuotaThresholdType
|
null
}
export
function
useQuotaNotifyState
()
{
const
globalEnabled
=
ref
(
false
)
const
state
=
reactive
<
Record
<
QuotaNotifyDim
,
DimState
>>
({
daily
:
{
enabled
:
null
,
threshold
:
null
,
thresholdType
:
null
},
weekly
:
{
enabled
:
null
,
threshold
:
null
,
thresholdType
:
null
},
total
:
{
enabled
:
null
,
threshold
:
null
,
thresholdType
:
null
},
})
function
loadGlobalState
()
{
adminAPI
.
settings
.
getSettings
()
.
then
((
settings
)
=>
{
globalEnabled
.
value
=
settings
.
account_quota_notify_enabled
===
true
})
.
catch
(()
=>
{
globalEnabled
.
value
=
false
})
}
function
loadFromExtra
(
extra
:
Record
<
string
,
unknown
>
|
null
|
undefined
)
{
for
(
const
d
of
QUOTA_NOTIFY_DIMS
)
{
state
[
d
].
enabled
=
(
extra
?.[
`quota_notify_
${
d
}
_enabled`
]
as
boolean
)
??
null
state
[
d
].
threshold
=
(
extra
?.[
`quota_notify_
${
d
}
_threshold`
]
as
number
)
??
null
state
[
d
].
thresholdType
=
(
extra
?.[
`quota_notify_
${
d
}
_threshold_type`
]
as
QuotaThresholdType
)
??
null
}
}
function
writeToExtra
(
extra
:
Record
<
string
,
unknown
>
,
mode
:
'
create
'
|
'
update
'
)
{
for
(
const
d
of
QUOTA_NOTIFY_DIMS
)
{
const
s
=
state
[
d
]
if
(
s
.
enabled
)
{
extra
[
`quota_notify_
${
d
}
_enabled`
]
=
true
if
(
s
.
threshold
!=
null
)
{
extra
[
`quota_notify_
${
d
}
_threshold`
]
=
s
.
threshold
}
else
if
(
mode
===
'
update
'
)
{
delete
extra
[
`quota_notify_
${
d
}
_threshold`
]
}
extra
[
`quota_notify_
${
d
}
_threshold_type`
]
=
s
.
thresholdType
||
QUOTA_THRESHOLD_TYPE_FIXED
}
else
if
(
mode
===
'
update
'
)
{
delete
extra
[
`quota_notify_
${
d
}
_enabled`
]
delete
extra
[
`quota_notify_
${
d
}
_threshold`
]
delete
extra
[
`quota_notify_
${
d
}
_threshold_type`
]
}
}
}
function
reset
()
{
for
(
const
d
of
QUOTA_NOTIFY_DIMS
)
{
state
[
d
].
enabled
=
null
state
[
d
].
threshold
=
null
state
[
d
].
thresholdType
=
null
}
}
return
{
globalEnabled
,
state
,
loadGlobalState
,
loadFromExtra
,
writeToExtra
,
reset
}
}
frontend/src/composables/useTableSelection.ts
View file @
0b746501
...
@@ -76,6 +76,12 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
...
@@ -76,6 +76,12 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
replaceSelectedSet
(
next
)
replaceSelectedSet
(
next
)
}
}
const
batchUpdate
=
(
updater
:
(
draft
:
Set
<
number
>
)
=>
void
)
=>
{
const
draft
=
new
Set
(
selectedSet
.
value
)
updater
(
draft
)
replaceSelectedSet
(
draft
)
}
const
selectVisible
=
()
=>
{
const
selectVisible
=
()
=>
{
toggleVisible
(
true
)
toggleVisible
(
true
)
}
}
...
@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
...
@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
clear
,
clear
,
removeMany
,
removeMany
,
toggleVisible
,
toggleVisible
,
selectVisible
selectVisible
,
batchUpdate
}
}
}
}
frontend/src/constants/account.ts
0 → 100644
View file @
0b746501
/** WebSearch emulation mode values (must match backend WebSearchMode* constants in account.go) */
export
const
WEB_SEARCH_MODE_DEFAULT
=
'
default
'
as
const
export
const
WEB_SEARCH_MODE_ENABLED
=
'
enabled
'
as
const
export
const
WEB_SEARCH_MODE_DISABLED
=
'
disabled
'
as
const
export
type
WebSearchMode
=
typeof
WEB_SEARCH_MODE_DEFAULT
|
typeof
WEB_SEARCH_MODE_ENABLED
|
typeof
WEB_SEARCH_MODE_DISABLED
/** Quota notification threshold type values (must match thresholdType* constants in balance_notify_service.go) */
export
const
QUOTA_THRESHOLD_TYPE_FIXED
=
'
fixed
'
as
const
export
const
QUOTA_THRESHOLD_TYPE_PERCENTAGE
=
'
percentage
'
as
const
export
type
QuotaThresholdType
=
typeof
QUOTA_THRESHOLD_TYPE_FIXED
|
typeof
QUOTA_THRESHOLD_TYPE_PERCENTAGE
/** Quota reset mode values */
export
const
QUOTA_RESET_MODE_ROLLING
=
'
rolling
'
as
const
export
const
QUOTA_RESET_MODE_FIXED
=
'
fixed
'
as
const
export
type
QuotaResetMode
=
typeof
QUOTA_RESET_MODE_ROLLING
|
typeof
QUOTA_RESET_MODE_FIXED
Prev
1
…
6
7
8
9
10
11
12
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