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
Show whitespace changes
Inline
Side-by-side
frontend/src/components/admin/payment/AdminRefundDialog.vue
View file @
0b746501
...
...
@@ -34,12 +34,16 @@
<span
class=
"font-mono text-gray-900 dark:text-white"
>
#
{{
order
?.
id
}}
</span>
</div>
<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=
"font-medium text-gray-900 dark:text-white"
>
$
{{
order
?.
pay_amount
?.
toFixed
(
2
)
}}
</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
?.
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
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=
"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>
...
...
@@ -66,7 +70,7 @@
</div>
<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=
"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>
...
...
@@ -91,7 +95,7 @@
<div>
<label
class=
"input-label"
>
{{
t
(
'
payment.admin.refundAmount
'
)
}}
</label>
<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
v-model.number=
"form.amount"
type=
"number"
...
...
@@ -103,7 +107,7 @@
/>
</div>
<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>
</div>
...
...
@@ -200,12 +204,12 @@ const actuallyRefunded = computed(() => {
const
maxRefundable
=
computed
(()
=>
{
if
(
!
props
.
order
)
return
0
return
props
.
order
.
pay_
amount
-
actuallyRefunded
.
value
return
props
.
order
.
amount
-
actuallyRefunded
.
value
})
const
balanceInsufficient
=
computed
(()
=>
{
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
)
=>
{
...
...
frontend/src/components/admin/usage/UsageStatsCards.vue
View file @
0b746501
...
...
@@ -28,17 +28,12 @@
<div
class=
"min-w-0 flex-1"
>
<p
class=
"text-xs font-medium text-gray-500"
>
{{
t
(
'
usage.totalCost
'
)
}}
</p>
<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
class=
"text-xs text-gray-400"
v-if=
"stats?.total_account_cost != null"
>
{{
t
(
'
usage.userBilled
'
)
}}
:
<span
class=
"text-gray-300"
>
$
{{
(
stats
?.
total_actual_cost
||
0
).
toFixed
(
4
)
}}
</span>
·
{{
t
(
'
usage.standardCost
'
)
}}
:
<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
class=
"text-xs text-gray-400"
>
<span
class=
"text-orange-500"
>
{{
t
(
'
usage.accountCost
'
)
}}
$
{{
(
stats
?.
total_account_cost
||
0
).
toFixed
(
4
)
}}
</span>
<span>
·
</span>
<span>
{{
t
(
'
usage.standardCost
'
)
}}
$
{{
(
stats
?.
total_cost
||
0
).
toFixed
(
4
)
}}
</span>
</p>
</div>
</div>
...
...
frontend/src/components/admin/usage/UsageTable.vue
View file @
0b746501
...
...
@@ -87,13 +87,13 @@
<
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
)
}}
{{
getBillingModeLabel
(
row
.
billing_mode
,
t
)
}}
</span>
</
template
>
<
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"
>
<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>
...
...
@@ -154,8 +154,8 @@
</div>
</div>
</div>
<div
v-if=
"row.account_rate_multiplier != null"
class=
"mt-0.5 text-[11px] text-
gray
-400"
>
A $
{{
(
row
.
total_cost
*
row
.
account_rate_multiplier
).
toFixed
(
6
)
}}
<div
v-if=
"row.account_rate_multiplier != null"
class=
"mt-0.5 text-[11px] text-
orange-500 dark:text-orange
-400"
>
A $
{{
accountBilled
(
row
).
toFixed
(
6
)
}}
</div>
</div>
</
template
>
...
...
@@ -279,6 +279,8 @@
<span
class=
"text-gray-400"
>
{{ t('admin.usage.outputCost') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData.output_cost.toFixed(6) }}
</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<
template
v-if=
"!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN"
>
<div
v-if=
"tooltipData && tooltipData.input_tokens > 0"
class=
"flex items-center justify-between gap-4"
>
<span
class=
"text-gray-400"
>
{{
t
(
'
usage.inputTokenPrice
'
)
}}
</span>
<span
class=
"font-medium text-sky-300"
>
{{
formatTokenPricePerMillion
(
tooltipData
.
input_cost
,
tooltipData
.
input_tokens
)
}}
{{
t
(
'
usage.perMillionTokens
'
)
}}
</span>
...
...
@@ -287,6 +289,12 @@
<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
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=
"font-medium text-white"
>
${{ tooltipData.cache_creation_cost.toFixed(6) }}
</span>
...
...
@@ -305,10 +313,6 @@
<span
class=
"text-gray-400"
>
{{ t('usage.rate') }}
</span>
<span
class=
"font-semibold text-blue-400"
>
{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x
</span>
</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"
>
<span
class=
"text-gray-400"
>
{{ t('usage.original') }}
</span>
<span
class=
"font-medium text-white"
>
${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}
</span>
...
...
@@ -317,10 +321,19 @@
<span
class=
"text-gray-400"
>
{{ t('usage.userBilled') }}
</span>
<span
class=
"font-semibold text-green-400"
>
${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}
</span>
</div>
<!-- Account billing (separated from user billing) -->
<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=
"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>
</div>
</div>
...
...
@@ -338,6 +351,15 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import
{
formatTokenPricePerMillion
}
from
'
@/utils/usagePricing
'
import
{
getUsageServiceTierLabel
}
from
'
@/utils/usageServiceTier
'
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
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
...
...
@@ -391,17 +413,6 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
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
=>
{
...
...
frontend/src/components/charts/GroupDistributionChart.vue
View file @
0b746501
...
...
@@ -45,6 +45,7 @@
<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.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.accountCost
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</thead>
...
...
@@ -75,13 +76,16 @@
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
group
.
actual_cost
)
}}
</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"
>
$
{{
formatCost
(
group
.
cost
)
}}
</td>
</tr>
<!-- User breakdown sub-rows -->
<tr
v-if=
"expandedKey === `group-$
{group.group_id}`">
<td
colspan=
"
5
"
class=
"p-0"
>
<td
colspan=
"
6
"
class=
"p-0"
>
<UserBreakdownSubTable
:items=
"breakdownItems"
:loading=
"breakdownLoading"
...
...
frontend/src/components/charts/ModelDistributionChart.vue
View file @
0b746501
...
...
@@ -114,6 +114,7 @@
<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.actual
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.accountCost
'
)
}}
</th>
<th
class=
"pb-2 text-right"
>
{{
t
(
'
admin.dashboard.standard
'
)
}}
</th>
</tr>
</thead>
...
...
@@ -142,12 +143,15 @@
<td
class=
"py-1.5 text-right text-green-600 dark:text-green-400"
>
$
{{
formatCost
(
model
.
actual_cost
)
}}
</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"
>
$
{{
formatCost
(
model
.
cost
)
}}
</td>
</tr>
<tr
v-if=
"expandedKey === `model-$
{model.model}`">
<td
colspan=
"
5
"
class=
"p-0"
>
<td
colspan=
"
6
"
class=
"p-0"
>
<UserBreakdownSubTable
:items=
"breakdownItems"
:loading=
"breakdownLoading"
...
...
frontend/src/components/charts/UserBreakdownSubTable.vue
View file @
0b746501
...
...
@@ -25,6 +25,9 @@
<
td
class
=
"
py-1 text-right text-green-600 dark:text-green-400
"
>
$
{{
formatCost
(
user
.
actual_cost
)
}}
<
/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
"
>
$
{{
formatCost
(
user
.
cost
)
}}
<
/td
>
...
...
frontend/src/components/layout/AppSidebar.vue
View file @
0b746501
...
...
@@ -823,7 +823,6 @@ onMounted(() => {
.sidebar-brand
{
min-width
:
0
;
flex
:
1
1
auto
;
overflow
:
hidden
;
white-space
:
nowrap
;
transition
:
max-width
0.22s
ease
,
...
...
@@ -834,6 +833,7 @@ onMounted(() => {
.sidebar-brand-collapsed
{
max-width
:
0
;
overflow
:
hidden
;
opacity
:
0
;
transform
:
translateX
(
-4px
);
pointer-events
:
none
;
...
...
frontend/src/components/layout/__tests__/AppSidebar.spec.ts
View file @
0b746501
...
...
@@ -22,8 +22,11 @@ describe('AppSidebar custom SVG styles', () => {
describe
(
'
AppSidebar header styles
'
,
()
=>
{
it
(
'
does not clip the version badge dropdown
'
,
()
=>
{
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
(
sidebarBrandBlockMatch
).
not
.
toBeNull
()
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 @@
<span
v-if=
"row.user_notes"
class=
"ml-1 text-xs text-gray-400"
>
(
{{
row
.
user_notes
}}
)
</span>
</div>
</
template
>
<
template
#cell-amount=
"{ value, row }"
>
<
template
#cell-
pay_
amount=
"{ value, row }"
>
<div
class=
"text-sm"
>
<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
class=
"font-medium text-gray-900 dark:text-white"
>
¥
{{
value
.
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>
</
template
>
<
template
#cell-payment_type=
"{ value }"
>
...
...
@@ -60,7 +65,7 @@ const columns = computed((): Column[] => {
cols
.
push
({
key
:
'
user_email
'
,
label
:
t
(
'
payment.admin.colUser
'
)
})
}
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
:
'
status
'
,
label
:
t
(
'
payment.orders.status
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
payment.orders.createdAt
'
)
},
...
...
frontend/src/components/payment/PaymentProviderDialog.vue
View file @
0b746501
...
...
@@ -32,7 +32,8 @@
<!-- Toggles + Payment mode + Supported types (single row) -->
<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('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"
>
<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"
>
...
...
@@ -243,6 +244,7 @@ const emit = defineEmits<{
enabled
:
boolean
payment_mode
:
string
refund_enabled
:
boolean
allow_user_refund
:
boolean
config
:
Record
<
string
,
string
>
limits
:
string
}]
...
...
@@ -258,6 +260,7 @@ const form = reactive({
enabled
:
true
,
payment_mode
:
PAYMENT_MODE_QRCODE
,
refund_enabled
:
false
,
allow_user_refund
:
false
,
})
const
config
=
reactive
<
Record
<
string
,
string
>>
({})
const
limits
=
reactive
<
Record
<
string
,
Record
<
string
,
number
>>>
({})
...
...
@@ -433,6 +436,7 @@ function handleSave() {
enabled
:
form
.
enabled
,
payment_mode
:
form
.
provider_key
===
'
easypay
'
?
form
.
payment_mode
:
''
,
refund_enabled
:
form
.
refund_enabled
,
allow_user_refund
:
form
.
refund_enabled
?
form
.
allow_user_refund
:
false
,
config
:
filteredConfig
,
limits
:
serializeLimits
(),
})
...
...
@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
form
.
enabled
=
true
form
.
payment_mode
=
defaultKey
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
form
.
refund_enabled
=
false
form
.
allow_user_refund
=
false
clearConfig
()
applyDefaults
()
}
...
...
@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
form
.
enabled
=
provider
.
enabled
form
.
payment_mode
=
provider
.
payment_mode
||
(
provider
.
provider_key
===
'
easypay
'
?
PAYMENT_MODE_QRCODE
:
''
)
form
.
refund_enabled
=
provider
.
refund_enabled
form
.
allow_user_refund
=
provider
.
allow_user_refund
clearConfig
()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if
(
provider
.
config
)
{
...
...
frontend/src/components/payment/PaymentProviderList.vue
View file @
0b746501
...
...
@@ -115,7 +115,7 @@ const emit = defineEmits<{
create
:
[]
edit
:
[
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
]
reorder
:
[
providers
:
{
id
:
number
;
sort_order
:
number
}[]]
}
>
()
...
...
frontend/src/components/payment/PaymentQRDialog.vue
View file @
0b746501
...
...
@@ -45,7 +45,11 @@
</div>
<div
class=
"flex justify-between"
>
<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>
...
...
frontend/src/components/payment/PaymentStatusPanel.vue
View file @
0b746501
...
...
@@ -22,7 +22,11 @@
</div>
<div
class=
"flex justify-between"
>
<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>
...
...
frontend/src/components/payment/ProviderCard.vue
View file @
0b746501
...
...
@@ -46,6 +46,7 @@
<div
class=
"flex items-center gap-4"
>
<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
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"
>
<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"
/>
...
...
@@ -84,7 +85,7 @@ const props = defineProps<{
}
>
()
const
emit
=
defineEmits
<
{
toggleField
:
[
field
:
'
enabled
'
|
'
refund_enabled
'
]
toggleField
:
[
field
:
'
enabled
'
|
'
refund_enabled
'
|
'
allow_user_refund
'
]
toggleType
:
[
type
:
string
]
edit
:
[]
delete
:
[]
...
...
frontend/src/components/payment/StripePaymentInline.vue
View file @
0b746501
...
...
@@ -21,9 +21,13 @@
<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>
</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=
"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>
...
...
@@ -36,7 +40,7 @@
<div
class=
"card overflow-hidden"
>
<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=
"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>
<!-- Stripe Payment Element -->
...
...
@@ -75,7 +79,9 @@ const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
const
props
=
defineProps
<
{
orderId
:
number
amount
:
number
clientSecret
:
string
orderType
?:
'
balance
'
|
'
subscription
'
publishableKey
:
string
payAmount
:
number
}
>
()
...
...
frontend/src/components/payment/ToggleSwitch.vue
View file @
0b746501
<
template
>
<label
class=
"flex items-center gap-
1
.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
label
}}
</span>
<label
class=
"flex
flex-col
items-center gap-
0
.5 cursor-pointer"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400
whitespace-nowrap
"
>
{{
label
}}
</span>
<button
type=
"button"
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
replaceSelectedSet
(
next
)
}
const
batchUpdate
=
(
updater
:
(
draft
:
Set
<
number
>
)
=>
void
)
=>
{
const
draft
=
new
Set
(
selectedSet
.
value
)
updater
(
draft
)
replaceSelectedSet
(
draft
)
}
const
selectVisible
=
()
=>
{
toggleVisible
(
true
)
}
...
...
@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
clear
,
removeMany
,
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