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
a04ae28a
Commit
a04ae28a
authored
Apr 13, 2026
by
陈曦
Browse files
merge v0.1.111
parents
68f67198
ad64190b
Changes
302
Hide whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
302 of 302+
files are displayed.
Plain diff
Email patch
frontend/src/api/payment.ts
0 → 100644
View file @
a04ae28a
/**
* User Payment API endpoints
* Handles payment operations for regular users
*/
import
{
apiClient
}
from
'
./client
'
import
type
{
PaymentConfig
,
SubscriptionPlan
,
PaymentChannel
,
MethodLimitsResponse
,
CheckoutInfoResponse
,
CreateOrderRequest
,
CreateOrderResult
,
PaymentOrder
}
from
'
@/types/payment
'
import
type
{
BasePaginationResponse
}
from
'
@/types
'
export
const
paymentAPI
=
{
/** Get payment configuration (enabled types, limits, etc.) */
getConfig
()
{
return
apiClient
.
get
<
PaymentConfig
>
(
'
/payment/config
'
)
},
/** Get available subscription plans */
getPlans
()
{
return
apiClient
.
get
<
SubscriptionPlan
[]
>
(
'
/payment/plans
'
)
},
/** Get available payment channels */
getChannels
()
{
return
apiClient
.
get
<
PaymentChannel
[]
>
(
'
/payment/channels
'
)
},
/** Get all checkout page data in a single call */
getCheckoutInfo
()
{
return
apiClient
.
get
<
CheckoutInfoResponse
>
(
'
/payment/checkout-info
'
)
},
/** Get payment method limits and fee rates */
getLimits
()
{
return
apiClient
.
get
<
MethodLimitsResponse
>
(
'
/payment/limits
'
)
},
/** Create a new payment order */
createOrder
(
data
:
CreateOrderRequest
)
{
return
apiClient
.
post
<
CreateOrderResult
>
(
'
/payment/orders
'
,
data
)
},
/** Get current user's orders */
getMyOrders
(
params
?:
{
page
?:
number
;
page_size
?:
number
;
status
?:
string
})
{
return
apiClient
.
get
<
BasePaginationResponse
<
PaymentOrder
>>
(
'
/payment/orders/my
'
,
{
params
})
},
/** Get a specific order by ID */
getOrder
(
id
:
number
)
{
return
apiClient
.
get
<
PaymentOrder
>
(
`/payment/orders/
${
id
}
`
)
},
/** Cancel a pending order */
cancelOrder
(
id
:
number
)
{
return
apiClient
.
post
(
`/payment/orders/
${
id
}
/cancel`
)
},
/** Verify order payment status with upstream provider */
verifyOrder
(
outTradeNo
:
string
)
{
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/orders/verify
'
,
{
out_trade_no
:
outTradeNo
})
},
/** Verify order payment status without auth (public endpoint for result page) */
verifyOrderPublic
(
outTradeNo
:
string
)
{
return
apiClient
.
post
<
PaymentOrder
>
(
'
/payment/public/orders/verify
'
,
{
out_trade_no
:
outTradeNo
})
},
/** Request a refund for a completed order */
requestRefund
(
id
:
number
,
data
:
{
reason
:
string
})
{
return
apiClient
.
post
(
`/payment/orders/
${
id
}
/refund-request`
,
data
)
}
}
frontend/src/api/usage.ts
View file @
a04ae28a
...
@@ -91,7 +91,7 @@ export async function list(
...
@@ -91,7 +91,7 @@ export async function list(
* @returns Paginated list of usage logs
* @returns Paginated list of usage logs
*/
*/
export
async
function
query
(
export
async
function
query
(
params
:
UsageQueryParams
,
params
:
UsageQueryParams
&
{
sort_by
?:
string
;
sort_order
?:
'
asc
'
|
'
desc
'
}
,
config
:
{
signal
?:
AbortSignal
}
=
{}
config
:
{
signal
?:
AbortSignal
}
=
{}
):
Promise
<
PaginatedResponse
<
UsageLog
>>
{
):
Promise
<
PaginatedResponse
<
UsageLog
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/usage
'
,
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/usage
'
,
{
...
...
frontend/src/assets/icons/alipay.svg
0 → 100644
View file @
a04ae28a
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t=
"1775563099286"
class=
"icon"
viewBox=
"0 0 1024 1024"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
p-id=
"1395"
xmlns:xlink=
"http://www.w3.org/1999/xlink"
width=
"200"
height=
"200"
><path
d=
"M902.095 652.871l-250.96-84.392s19.287-28.87 39.874-85.472c20.59-56.606 23.539-87.689 23.539-87.689l-162.454-1.339v-55.487l196.739-1.387v-39.227H552.055v-89.29h-96.358v89.294H272.133v39.227l183.564-1.304v59.513h-147.24v31.079h303.064s-3.337 25.223-14.955 56.606c-11.615 31.38-23.58 58.862-23.58 58.862s-142.3-49.804-217.285-49.804c-74.985 0-166.182 30.123-175.024 117.55-8.8 87.383 42.481 134.716 114.728 152.139 72.256 17.513 138.962-0.173 197.04-28.607 58.087-28.391 115.081-92.933 115.081-92.933l292.486 142.041c-11.932 69.3-72.067 119.914-142.387 119.844H266.37c-79.714 0.078-144.392-64.483-144.466-144.194V266.374c-0.074-79.72 64.493-144.399 144.205-144.47h491.519c79.714-0.073 144.396 64.49 144.466 144.203v386.764z m-365.76-48.895s-91.302 115.262-198.879 115.262c-107.623 0-130.218-54.767-130.218-94.155 0-39.34 22.373-82.144 113.943-88.333 91.519-6.18 215.2 67.226 215.2 67.226h-0.047z"
fill=
"#02A9F1"
p-id=
"1396"
></path></svg>
\ No newline at end of file
frontend/src/assets/icons/easypay.svg
0 → 100644
View file @
a04ae28a
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t=
"1775563141699"
class=
"icon"
viewBox=
"0 0 1024 1024"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
p-id=
"2705"
xmlns:xlink=
"http://www.w3.org/1999/xlink"
width=
"200"
height=
"200"
><path
d=
"M647.3728 287.744c-2.048-4.9152-5.7344-9.0112-11.0592-12.0832-5.3248-3.072-12.9024-4.7104-22.9376-4.7104h-221.184c-0.2048 0-0.2048 0.2048-0.2048 0.2048V305.152h260.096c-1.024-6.7584-2.6624-12.4928-4.7104-17.408zM634.0608 400.9984c6.9632-3.2768 11.264-8.192 13.1072-14.5408l5.12-14.7456h-260.096c-0.2048 0-0.2048 0.2048-0.2048 0.2048V405.504c0 0.2048 0.2048 0.2048 0.2048 0.2048h220.9792c6.9632 0.4096 13.9264-1.4336 20.8896-4.7104z"
fill=
"#48D8FF"
p-id=
"2706"
></path><path
d=
"M512 1.6384C230.1952 1.6384 1.6384 230.1952 1.6384 512S230.1952 1022.3616 512 1022.3616 1022.3616 793.8048 1022.3616 512 793.8048 1.6384 512 1.6384z m289.5872 644.3008c-0.2048 4.3008-1.2288 20.48-3.2768 48.5376s-4.9152 50.7904-8.8064 67.9936c-3.8912 17.408-13.5168 31.3344-29.0816 41.984-15.5648 10.6496-30.72 16.384-45.2608 17.408-12.9024 0.8192-25.1904-1.024-36.2496-5.9392-11.264-4.9152-20.48-10.8544-27.8528-18.2272-7.3728-7.3728-13.7216-16.5888-19.0464-28.0576-5.3248-11.4688-6.9632-18.0224-4.9152-20.0704 2.048-1.8432 4.096-3.2768 6.3488-3.8912 3.072-0.6144 8.3968-0.2048 15.9744 1.6384s13.9264 2.8672 19.2512 3.072c5.7344 0.4096 12.0832 0 18.8416-1.4336 6.7584-1.4336 12.0832-3.6864 16.384-6.9632 4.096-3.2768 7.3728-7.7824 9.8304-13.7216 2.2528-5.9392 3.8912-13.1072 4.7104-21.504 0.8192-8.3968 1.8432-22.3232 3.072-41.984s2.048-32.5632 2.2528-39.1168v-29.2864c0-6.7584-1.024-11.8784-3.2768-15.36-2.2528-3.4816-7.5776-5.12-15.7696-5.12h-2.4576c-22.3232 0-43.8272 8.6016-60.2112 23.9616-7.9872 7.5776-16.1792 17.408-23.9616 29.4912-7.9872 12.0832-15.5648 25.6-22.9376 40.7552l-18.2272 38.7072c-16.5888 33.9968-36.0448 61.2352-58.5728 81.3056-22.528 20.0704-65.1264 30.72-127.5904 31.9488-14.336 0.4096-24.9856-0.4096-32.1536-2.2528-6.9632-2.048-11.264-4.096-12.4928-6.144-1.2288-2.048-1.024-4.7104 0.4096-7.5776 1.024-2.2528 3.072-3.8912 5.9392-4.9152s11.4688-3.2768 25.8048-6.5536 30.5152-11.0592 48.3328-22.9376c17.8176-11.8784 27.2384-20.0704 35.4304-30.1056 8.192-10.0352 20.6848-26.4192 30.72-44.2368 10.0352-17.8176 24.1664-45.056 34.6112-60.6208 10.0352-15.1552 41.984-53.0432 81.5104-60.0064 0.4096 0 0.4096-0.6144 0-0.6144h-75.1616c-9.4208 0-18.8416 1.8432-27.2384 6.144-7.168 3.6864-10.8544 8.8064-27.0336 36.2496-15.9744 26.8288-36.864 51.2-36.864 51.2-20.2752 25.6-36.6592 43.6224-57.344 56.9344-20.6848 13.312-49.5616 19.0464-87.04 16.9984-12.288-0.4096-23.3472-1.8432-33.3824-4.096-9.8304-2.2528-15.1552-4.3008-15.7696-6.144-0.6144-1.8432-0.4096-3.8912 1.024-5.9392 0.8192-1.4336 4.9152-2.8672 12.288-4.7104 7.3728-1.8432 15.36-4.5056 24.1664-7.9872 8.8064-3.6864 40.3456-15.7696 72.9088-48.5376 28.2624-28.2624 50.3808-60.416 63.8976-73.1136 4.096-3.6864 16.384-13.7216 32.5632-17.408h-54.272c-11.4688 0-20.48 3.4816-29.4912 14.1312-15.9744 18.8416-31.1296 31.1296-46.08 36.0448-18.432 6.144-33.9968 9.4208-46.2848 9.8304h-30.5152c-16.7936 0.6144-25.6-0.6144-26.624-4.096-0.8192-3.2768-0.4096-6.144 1.4336-8.3968 1.8432-2.048 6.144-4.3008 12.6976-6.5536s14.336-6.3488 22.7328-12.0832c8.3968-5.7344 15.5648-11.4688 21.504-16.9984 5.9392-5.5296 12.288-12.9024 19.2512-22.1184l21.0944-29.4912c8.192-11.0592 19.456-22.3232 33.5872-33.9968l31.5392-25.6c0.2048-0.2048 0-0.6144-0.2048-0.6144h-69.0176c-0.2048 0-0.2048-0.2048-0.2048-0.2048V201.9328c0-0.2048 0.2048-0.2048 0.2048-0.2048h288.1536c66.3552 0 103.8336 16.9984 112.2304 50.9952s12.6976 63.0784 12.4928 87.6544c-0.2048 23.7568-2.6624 44.032-7.5776 61.0304-4.9152 16.9984-16.5888 33.5872-35.2256 50.176-18.6368 16.5888-46.08 24.7808-81.92 24.7808h-110.7968c-9.8304 0-19.456 2.8672-27.648 7.9872l-25.1904 15.7696c-3.2768 2.2528-6.144 4.5056-8.3968 6.9632h268.9024c22.9376 0 41.1648 1.2288 54.8864 3.4816 13.7216 2.2528 23.9616 7.7824 30.9248 16.384 6.9632 8.6016 11.0592 18.432 12.288 29.4912 1.2288 11.0592 1.8432 24.3712 1.8432 39.936-0.4096 28.672-0.6144 45.056-0.6144 49.5616z"
fill=
"#48D8FF"
p-id=
"2707"
></path></svg>
\ No newline at end of file
frontend/src/assets/icons/stripe.svg
0 → 100644
View file @
a04ae28a
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t=
"1775563184449"
class=
"icon"
viewBox=
"0 0 1024 1024"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
p-id=
"3692"
xmlns:xlink=
"http://www.w3.org/1999/xlink"
width=
"200"
height=
"200"
><path
d=
"M512 512m-448 0a448 448 0 1 0 896 0 448 448 0 1 0-896 0Z"
fill=
"#676BE5"
p-id=
"3693"
></path><path
d=
"M471.616 417.296c0-20.96 17.488-29.04 45.488-29.04a300.8 300.8 0 0 1 133.36 34.496v-126.224a353.6 353.6 0 0 0-133.264-24.528c-108.8 0-181.2 56.768-181.2 151.696 0 148.4 203.76 124.336 203.76 188.352 0 24.816-21.52 32.8-51.408 32.8a338.368 338.368 0 0 1-146.704-42.768v120.768a372.272 372.272 0 0 0 146.624 30.4c111.472 0 188.256-48 188.256-144.368 0-160-204.88-131.296-204.88-191.632"
fill=
"#FFFFFF"
p-id=
"3694"
></path></svg>
\ No newline at end of file
frontend/src/assets/icons/wxpay.svg
0 → 100644
View file @
a04ae28a
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
t=
"1775563104166"
class=
"icon"
viewBox=
"0 0 1024 1024"
version=
"1.1"
xmlns=
"http://www.w3.org/2000/svg"
p-id=
"1545"
xmlns:xlink=
"http://www.w3.org/1999/xlink"
width=
"200"
height=
"200"
><path
d=
"M395.846 603.585c-3.921 1.98-7.936 2.925-12.81 2.925-10.9 0-19.791-5.85-24.764-14.625l-2.006-3.864-78.106-167.913c-0.956-1.98-0.956-3.865-0.956-5.845 0-7.83 5.928-13.68 13.863-13.68 2.965 0 5.928 0.944 8.893 2.924l91.965 64.43c6.884 3.864 14.82 6.79 23.708 6.79 4.972 0 9.85-0.945 14.822-2.926L861.71 282.479c-77.149-89.804-204.684-148.384-349.135-148.384-235.371 0-427.242 157.158-427.242 351.294 0 105.368 57.361 201.017 147.323 265.447 6.88 4.905 11.852 13.68 11.852 22.45 0 2.925-0.957 5.85-2.006 8.775-6.881 26.318-18.831 69.334-18.831 71.223-0.958 2.92-2.013 6.79-2.013 10.75 0 7.83 5.929 13.68 13.865 13.68 2.963 0 5.928-0.944 7.935-2.925l92.922-53.674c6.885-3.87 14.82-6.794 22.756-6.794 3.916 0 8.889 0.944 12.81 1.98 43.496 12.644 91.012 19.53 139.48 19.53 235.372 0 427.24-157.158 427.24-351.294 0-58.58-17.78-114.143-48.467-163.003l-491.39 280.07-2.963 1.98z"
fill=
"#09BB07"
p-id=
"1546"
></path></svg>
\ No newline at end of file
frontend/src/components/admin/account/AccountTableFilters.vue
View file @
a04ae28a
...
@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u
...
@@ -27,7 +27,7 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u
const
updateGroup
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
group
:
value
})
}
const
updateGroup
=
(
value
:
string
|
number
|
boolean
|
null
)
=>
{
emit
(
'
update:filters
'
,
{
...
props
.
filters
,
group
:
value
})
}
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}])
const
pOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allPlatforms
'
)
},
{
value
:
'
anthropic
'
,
label
:
'
Anthropic
'
},
{
value
:
'
openai
'
,
label
:
'
OpenAI
'
},
{
value
:
'
gemini
'
,
label
:
'
Gemini
'
},
{
value
:
'
antigravity
'
,
label
:
'
Antigravity
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
},
{
value
:
'
bedrock
'
,
label
:
'
AWS Bedrock
'
}])
const
tOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allTypes
'
)
},
{
value
:
'
oauth
'
,
label
:
t
(
'
admin.accounts.oauthType
'
)
},
{
value
:
'
setup-token
'
,
label
:
t
(
'
admin.accounts.setupToken
'
)
},
{
value
:
'
apikey
'
,
label
:
t
(
'
admin.accounts.apiKey
'
)
},
{
value
:
'
bedrock
'
,
label
:
'
AWS Bedrock
'
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
}])
const
sOpts
=
computed
(()
=>
[{
value
:
''
,
label
:
t
(
'
admin.accounts.allStatus
'
)
},
{
value
:
'
active
'
,
label
:
t
(
'
admin.accounts.status.active
'
)
},
{
value
:
'
inactive
'
,
label
:
t
(
'
admin.accounts.status.inactive
'
)
},
{
value
:
'
error
'
,
label
:
t
(
'
admin.accounts.status.error
'
)
},
{
value
:
'
rate_limited
'
,
label
:
t
(
'
admin.accounts.status.rateLimited
'
)
},
{
value
:
'
temp_unschedulable
'
,
label
:
t
(
'
admin.accounts.status.tempUnschedulable
'
)
},
{
value
:
'
unschedulable
'
,
label
:
t
(
'
admin.accounts.status.unschedulable
'
)
}])
const
privacyOpts
=
computed
(()
=>
[
const
privacyOpts
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.accounts.allPrivacyModes
'
)
},
{
value
:
''
,
label
:
t
(
'
admin.accounts.allPrivacyModes
'
)
},
{
value
:
'
__unset__
'
,
label
:
t
(
'
admin.accounts.privacyUnset
'
)
},
{
value
:
'
__unset__
'
,
label
:
t
(
'
admin.accounts.privacyUnset
'
)
},
...
...
frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts
deleted
100644 → 0
View file @
68f67198
import
{
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
mount
}
from
'
@vue/test-utils
'
import
AccountTableFilters
from
'
../AccountTableFilters.vue
'
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
})
}
})
describe
(
'
AccountTableFilters
'
,
()
=>
{
it
(
'
renders privacy mode options and emits privacy_mode updates
'
,
async
()
=>
{
const
wrapper
=
mount
(
AccountTableFilters
,
{
props
:
{
searchQuery
:
''
,
filters
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
group
:
''
,
privacy_mode
:
''
},
groups
:
[]
},
global
:
{
stubs
:
{
SearchInput
:
{
template
:
'
<div />
'
},
Select
:
{
props
:
[
'
modelValue
'
,
'
options
'
],
emits
:
[
'
update:modelValue
'
,
'
change
'
],
template
:
'
<div class="select-stub" :data-options="JSON.stringify(options)" />
'
}
}
}
})
const
selects
=
wrapper
.
findAll
(
'
.select-stub
'
)
expect
(
selects
).
toHaveLength
(
5
)
const
privacyOptions
=
JSON
.
parse
(
selects
[
3
].
attributes
(
'
data-options
'
))
expect
(
privacyOptions
).
toEqual
([
{
value
:
''
,
label
:
'
admin.accounts.allPrivacyModes
'
},
{
value
:
'
__unset__
'
,
label
:
'
admin.accounts.privacyUnset
'
},
{
value
:
'
training_off
'
,
label
:
'
Privacy
'
},
{
value
:
'
training_set_cf_blocked
'
,
label
:
'
CF
'
},
{
value
:
'
training_set_failed
'
,
label
:
'
Fail
'
}
])
})
})
frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue
View file @
a04ae28a
...
@@ -21,7 +21,15 @@
...
@@ -21,7 +21,15 @@
</button>
</button>
</div>
</div>
<DataTable
:columns=
"columns"
:data=
"items"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"items"
:loading=
"loading"
:server-side-sort=
"true"
default-sort-key=
"email"
default-sort-order=
"asc"
@
sort=
"handleSort"
>
<template
#cell-email
="
{ value }">
<template
#cell-email
="
{ value }">
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
</
template
>
...
@@ -62,7 +70,7 @@
...
@@ -62,7 +70,7 @@
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
on
M
ounted
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
computed
,
on
Unm
ounted
,
reactive
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
...
@@ -98,23 +106,54 @@ const pagination = reactive({
...
@@ -98,23 +106,54 @@ const pagination = reactive({
pages
:
0
pages
:
0
})
})
const
sortState
=
reactive
({
sort_by
:
'
email
'
,
sort_order
:
'
asc
'
as
'
asc
'
|
'
desc
'
})
const
items
=
ref
<
AnnouncementUserReadStatus
[]
>
([])
const
items
=
ref
<
AnnouncementUserReadStatus
[]
>
([])
const
columns
=
computed
<
Column
[]
>
(()
=>
[
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
email
'
,
label
:
t
(
'
common.email
'
)
},
{
key
:
'
email
'
,
label
:
t
(
'
common.email
'
)
,
sortable
:
true
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
)
},
{
key
:
'
username
'
,
label
:
t
(
'
admin.users.columns.username
'
)
,
sortable
:
true
},
{
key
:
'
balance
'
,
label
:
t
(
'
common.balance
'
)
},
{
key
:
'
balance
'
,
label
:
t
(
'
common.balance
'
)
,
sortable
:
true
},
{
key
:
'
eligible
'
,
label
:
t
(
'
admin.announcements.eligible
'
)
},
{
key
:
'
eligible
'
,
label
:
t
(
'
admin.announcements.eligible
'
)
},
{
key
:
'
read_at
'
,
label
:
t
(
'
admin.announcements.readAt
'
)
}
{
key
:
'
read_at
'
,
label
:
t
(
'
admin.announcements.readAt
'
)
}
])
])
let
currentController
:
AbortController
|
null
=
null
let
currentController
:
AbortController
|
null
=
null
let
searchDebounceTimer
:
number
|
null
=
null
function
resetDialogState
()
{
loading
.
value
=
false
search
.
value
=
''
items
.
value
=
[]
pagination
.
page
=
1
pagination
.
total
=
0
pagination
.
pages
=
0
sortState
.
sort_by
=
'
email
'
sortState
.
sort_order
=
'
asc
'
}
function
cancelPendingLoad
(
resetState
=
false
)
{
if
(
searchDebounceTimer
)
{
window
.
clearTimeout
(
searchDebounceTimer
)
searchDebounceTimer
=
null
}
currentController
?.
abort
()
currentController
=
null
if
(
resetState
)
{
resetDialogState
()
}
}
async
function
load
()
{
async
function
load
()
{
if
(
!
props
.
show
||
!
props
.
announcementId
)
return
if
(
!
props
.
show
||
!
props
.
announcementId
)
return
if
(
currentController
)
currentController
.
abort
()
currentController
?.
abort
()
currentController
=
new
AbortController
()
const
requestController
=
new
AbortController
()
currentController
=
requestController
const
{
signal
}
=
requestController
try
{
try
{
loading
.
value
=
true
loading
.
value
=
true
...
@@ -122,20 +161,37 @@ async function load() {
...
@@ -122,20 +161,37 @@ async function load() {
props
.
announcementId
,
props
.
announcementId
,
pagination
.
page
,
pagination
.
page
,
pagination
.
page_size
,
pagination
.
page_size
,
search
.
value
{
search
:
search
.
value
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
},
{
signal
}
)
)
if
(
signal
.
aborted
||
currentController
!==
requestController
)
return
items
.
value
=
res
.
items
items
.
value
=
res
.
items
pagination
.
total
=
res
.
total
pagination
.
total
=
res
.
total
pagination
.
pages
=
res
.
pages
pagination
.
pages
=
res
.
pages
pagination
.
page
=
res
.
page
pagination
.
page
=
res
.
page
pagination
.
page_size
=
res
.
page_size
pagination
.
page_size
=
res
.
page_size
}
catch
(
error
:
any
)
{
}
catch
(
error
:
any
)
{
if
(
currentController
.
signal
.
aborted
||
error
?.
name
===
'
AbortError
'
)
return
if
(
signal
.
aborted
||
currentController
!==
requestController
||
error
?.
name
===
'
AbortError
'
||
error
?.
code
===
'
ERR_CANCELED
'
)
{
return
}
console
.
error
(
'
Failed to load read status:
'
,
error
)
console
.
error
(
'
Failed to load read status:
'
,
error
)
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToLoadReadStatus
'
))
appStore
.
showError
(
error
.
response
?.
data
?.
detail
||
t
(
'
admin.announcements.failedToLoadReadStatus
'
))
}
finally
{
}
finally
{
loading
.
value
=
false
if
(
currentController
===
requestController
)
{
loading
.
value
=
false
currentController
=
null
}
}
}
}
}
...
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
...
@@ -150,7 +206,13 @@ function handlePageSizeChange(pageSize: number) {
load
()
load
()
}
}
let
searchDebounceTimer
:
number
|
null
=
null
function
handleSort
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
load
()
}
function
handleSearch
()
{
function
handleSearch
()
{
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
if
(
searchDebounceTimer
)
window
.
clearTimeout
(
searchDebounceTimer
)
searchDebounceTimer
=
window
.
setTimeout
(()
=>
{
searchDebounceTimer
=
window
.
setTimeout
(()
=>
{
...
@@ -160,13 +222,17 @@ function handleSearch() {
...
@@ -160,13 +222,17 @@ function handleSearch() {
}
}
function
handleClose
()
{
function
handleClose
()
{
cancelPendingLoad
(
true
)
emit
(
'
close
'
)
emit
(
'
close
'
)
}
}
watch
(
watch
(
()
=>
props
.
show
,
()
=>
props
.
show
,
(
v
)
=>
{
(
v
)
=>
{
if
(
!
v
)
return
if
(
!
v
)
{
cancelPendingLoad
(
true
)
return
}
pagination
.
page
=
1
pagination
.
page
=
1
load
()
load
()
}
}
...
@@ -181,7 +247,7 @@ watch(
...
@@ -181,7 +247,7 @@ watch(
}
}
)
)
on
M
ounted
(()
=>
{
on
Unm
ounted
(()
=>
{
// noop
cancelPendingLoad
()
})
})
</
script
>
</
script
>
frontend/src/components/admin/announcements/__tests__/AnnouncementReadStatusDialog.spec.ts
0 → 100644
View file @
a04ae28a
import
{
describe
,
it
,
expect
,
vi
,
beforeEach
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
import
AnnouncementReadStatusDialog
from
'
../AnnouncementReadStatusDialog.vue
'
const
{
getReadStatus
,
showError
}
=
vi
.
hoisted
(()
=>
({
getReadStatus
:
vi
.
fn
(),
showError
:
vi
.
fn
(),
}))
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
announcements
:
{
getReadStatus
,
},
},
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
}),
}))
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
,
}),
}
})
vi
.
mock
(
'
@/composables/usePersistedPageSize
'
,
()
=>
({
getPersistedPageSize
:
()
=>
20
,
}))
const
BaseDialogStub
=
{
props
:
[
'
show
'
,
'
title
'
,
'
width
'
],
emits
:
[
'
close
'
],
template
:
'
<div><slot /><slot name="footer" /></div>
'
,
}
describe
(
'
AnnouncementReadStatusDialog
'
,
()
=>
{
beforeEach
(()
=>
{
getReadStatus
.
mockReset
()
showError
.
mockReset
()
vi
.
useFakeTimers
()
})
it
(
'
closes by aborting active requests and clearing debounced reloads
'
,
async
()
=>
{
let
activeSignal
:
AbortSignal
|
undefined
getReadStatus
.
mockImplementation
(
async
(...
args
:
any
[])
=>
{
activeSignal
=
args
[
4
]?.
signal
return
new
Promise
(()
=>
{})
})
const
wrapper
=
mount
(
AnnouncementReadStatusDialog
,
{
props
:
{
show
:
false
,
announcementId
:
1
,
},
global
:
{
stubs
:
{
BaseDialog
:
BaseDialogStub
,
DataTable
:
true
,
Pagination
:
true
,
Icon
:
true
,
},
},
})
await
wrapper
.
setProps
({
show
:
true
})
await
flushPromises
()
expect
(
getReadStatus
).
toHaveBeenCalledTimes
(
1
)
expect
(
activeSignal
?.
aborted
).
toBe
(
false
)
const
setupState
=
(
wrapper
.
vm
as
any
).
$
?.
setupState
setupState
.
search
=
'
alice
'
setupState
.
handleSearch
()
setupState
.
handleClose
()
await
flushPromises
()
expect
(
activeSignal
?.
aborted
).
toBe
(
true
)
expect
(
wrapper
.
emitted
(
'
close
'
)).
toHaveLength
(
1
)
vi
.
advanceTimersByTime
(
350
)
await
flushPromises
()
expect
(
getReadStatus
).
toHaveBeenCalledTimes
(
1
)
})
})
frontend/src/components/admin/group/GroupRateMultipliersModal.vue
View file @
a04ae28a
...
@@ -196,7 +196,6 @@
...
@@ -196,7 +196,6 @@
:total=
"localEntries.length"
:total=
"localEntries.length"
:page=
"currentPage"
:page=
"currentPage"
:page-size=
"pageSize"
:page-size=
"pageSize"
:page-size-options=
"[10, 20, 50]"
@
update:page=
"currentPage = $event"
@
update:page=
"currentPage = $event"
@
update:pageSize=
"handlePageSizeChange"
@
update:pageSize=
"handlePageSizeChange"
/>
/>
...
...
frontend/src/components/admin/payment/AdminOrderDetail.vue
0 → 100644
View file @
a04ae28a
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('payment.admin.orderDetail')"
width=
"wide"
@
close=
"emit('close')"
>
<div
v-if=
"order"
class=
"space-y-4"
>
<div
class=
"grid grid-cols-2 gap-4"
>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</p>
<p
class=
"font-mono text-sm font-medium text-gray-900 dark:text-white"
>
#
{{
order
.
id
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.status
'
)
}}
</p>
<span
:class=
"['badge', statusBadgeClass(order.status)]"
>
{{
t
(
'
payment.status.
'
+
order
.
status
.
toLowerCase
(),
order
.
status
)
}}
</span>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.amount
'
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
order
.
amount
.
toFixed
(
2
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.payAmount
'
)
}}
</p>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
order
.
pay_amount
.
toFixed
(
2
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.paymentMethod
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.methods.
'
+
order
.
payment_type
,
order
.
payment_type
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.feeRate
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
(
order
.
fee_rate
*
100
).
toFixed
(
1
)
}}
%
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.orderType
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.admin.
'
+
order
.
order_type
+
'
Order
'
,
order
.
order_type
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.userId
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
#
{{
order
.
user_id
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.createdAt
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
formatDateTime
(
order
.
created_at
)
}}
</p>
</div>
<div>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.expiresAt
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
formatDateTime
(
order
.
expires_at
)
}}
</p>
</div>
<div
v-if=
"order.paid_at"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.paidAt
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
formatDateTime
(
order
.
paid_at
)
}}
</p>
</div>
<div
v-if=
"order.completed_at"
>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.completedAt
'
)
}}
</p>
<p
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
formatDateTime
(
order
.
completed_at
)
}}
</p>
</div>
</div>
<div
v-if=
"order.refund_amount"
class=
"rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"
>
<h4
class=
"mb-2 text-sm font-semibold text-red-700 dark:text-red-400"
>
{{
t
(
'
payment.admin.refundInfo
'
)
}}
</h4>
<div
class=
"grid grid-cols-2 gap-2 text-sm"
>
<div>
<span
class=
"text-red-600 dark:text-red-400"
>
{{
t
(
'
payment.admin.refundAmount
'
)
}}
:
</span>
<span
class=
"ml-1 font-medium text-red-700 dark:text-red-300"
>
$
{{
order
.
refund_amount
.
toFixed
(
2
)
}}
</span>
</div>
<div
v-if=
"order.refund_reason"
class=
"col-span-2"
>
<span
class=
"text-red-600 dark:text-red-400"
>
{{
t
(
'
payment.admin.refundReason
'
)
}}
:
</span>
<span
class=
"ml-1 text-red-700 dark:text-red-300"
>
{{
order
.
refund_reason
}}
</span>
</div>
</div>
</div>
<div
class=
"flex items-center justify-end gap-2 border-t border-gray-200 pt-4 dark:border-dark-700"
>
<button
v-if=
"order.status === 'PENDING'"
@
click=
"emit('cancel', order)"
class=
"btn btn-sm rounded-md bg-yellow-50 px-3 py-1.5 text-sm text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-900/20 dark:text-yellow-400 dark:hover:bg-yellow-900/30"
>
{{
t
(
'
payment.orders.cancel
'
)
}}
</button>
<button
v-if=
"order.status === 'FAILED'"
@
click=
"emit('retry', order)"
class=
"btn btn-sm btn-secondary"
>
{{
t
(
'
payment.admin.retry
'
)
}}
</button>
<button
v-if=
"canRefund(order)"
@
click=
"emit('refund', order)"
class=
"btn btn-sm rounded-md bg-red-50 px-3 py-1.5 text-sm text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30"
>
{{
t
(
'
payment.admin.refund
'
)
}}
</button>
</div>
</div>
</BaseDialog>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
{
statusBadgeClass
,
canRefund
as
canRefundStatus
,
formatOrderDateTime
}
from
'
@/components/payment/orderUtils
'
const
{
t
}
=
useI18n
()
defineProps
<
{
show
:
boolean
order
:
PaymentOrder
|
null
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
close
'
):
void
(
e
:
'
cancel
'
,
order
:
PaymentOrder
):
void
(
e
:
'
retry
'
,
order
:
PaymentOrder
):
void
(
e
:
'
refund
'
,
order
:
PaymentOrder
):
void
}
>
()
function
canRefund
(
order
:
PaymentOrder
):
boolean
{
return
canRefundStatus
(
order
.
status
)
}
function
formatDateTime
(
dateStr
:
string
):
string
{
return
formatOrderDateTime
(
dateStr
)
}
</
script
>
frontend/src/components/admin/payment/AdminOrderTable.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"space-y-4"
>
<div
class=
"card p-4"
>
<div
class=
"flex flex-wrap items-center gap-3"
>
<div
class=
"flex-1 sm:max-w-64"
>
<input
v-model=
"searchQuery"
type=
"text"
:placeholder=
"t('payment.admin.searchOrders')"
class=
"input"
@
input=
"handleSearch"
/>
</div>
<Select
v-model=
"filters.status"
:options=
"statusFilterOptions"
class=
"w-36"
@
change=
"emitFiltersChanged"
/>
<Select
v-model=
"filters.payment_type"
:options=
"paymentTypeFilterOptions"
class=
"w-40"
@
change=
"emitFiltersChanged"
/>
<Select
v-model=
"filters.order_type"
:options=
"orderTypeFilterOptions"
class=
"w-36"
@
change=
"emitFiltersChanged"
/>
<div
class=
"flex flex-1 flex-wrap items-center justify-end gap-2"
>
<button
@
click=
"emit('refresh')"
:disabled=
"loading"
class=
"btn btn-secondary"
:title=
"t('common.refresh')"
>
<Icon
name=
"refresh"
size=
"md"
:class=
"loading ? 'animate-spin' : ''"
/>
</button>
</div>
</div>
</div>
<DataTable
:columns=
"columns"
:data=
"orders"
:loading=
"loading"
>
<template
#cell-id
="
{ value }">
<span
class=
"font-mono text-sm"
>
#
{{
value
}}
</span>
</
template
>
<
template
#cell-user_id=
"{ value }"
>
<span
class=
"text-sm text-gray-600 dark:text-gray-400"
>
#
{{
value
}}
</span>
</
template
>
<
template
#cell-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"
>
(
{{
t
(
'
payment.orders.payAmount
'
)
}}
: $
{{
row
.
pay_amount
.
toFixed
(
2
)
}}
)
</span>
</div>
</
template
>
<
template
#cell-payment_type=
"{ value }"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.methods.
'
+
value
,
value
)
}}
</span>
</
template
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', statusBadgeClass(value)]"
>
{{
t
(
'
payment.status.
'
+
value
.
toLowerCase
(),
value
)
}}
</span>
</
template
>
<
template
#cell-order_type=
"{ value }"
>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.admin.
'
+
value
+
'
Order
'
,
value
)
}}
</span>
</
template
>
<
template
#cell-created_at=
"{ value }"
>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-actions=
"{ row }"
>
<div
class=
"flex items-center gap-2"
>
<button
@
click=
"emit('detail', row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800/50 dark:hover:text-gray-300"
>
<Icon
name=
"eye"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
common.view
'
)
}}
</span>
</button>
<button
v-if=
"row.status === 'PENDING'"
@
click=
"emit('cancel', row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400"
>
<Icon
name=
"x"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
payment.orders.cancel
'
)
}}
</span>
</button>
<button
v-if=
"row.status === 'FAILED'"
@
click=
"emit('retry', row)"
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=
"refresh"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
payment.admin.retry
'
)
}}
</span>
</button>
<button
v-if=
"canRefundRow(row)"
@
click=
"emit('refund', row)"
class=
"flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<Icon
name=
"dollar"
size=
"sm"
/>
<span
class=
"text-xs"
>
{{
t
(
'
payment.admin.refund
'
)
}}
</span>
</button>
</div>
</
template
>
</DataTable>
<Pagination
v-if=
"total > 0"
:page=
"page"
:total=
"total"
:page-size=
"pageSize"
@
update:page=
"emit('update:page', $event)"
@
update:pageSize=
"emit('update:pageSize', $event)"
/>
</div>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
statusBadgeClass
,
canRefund
,
formatOrderDateTime
}
from
'
@/components/payment/orderUtils
'
const
{
t
}
=
useI18n
()
defineProps
<
{
orders
:
PaymentOrder
[]
loading
:
boolean
page
:
number
pageSize
:
number
total
:
number
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
detail
'
,
order
:
PaymentOrder
):
void
(
e
:
'
cancel
'
,
order
:
PaymentOrder
):
void
(
e
:
'
retry
'
,
order
:
PaymentOrder
):
void
(
e
:
'
refund
'
,
order
:
PaymentOrder
):
void
(
e
:
'
refresh
'
):
void
(
e
:
'
update:page
'
,
page
:
number
):
void
(
e
:
'
update:pageSize
'
,
size
:
number
):
void
(
e
:
'
filter
'
,
filters
:
{
keyword
?:
string
;
status
?:
string
;
payment_type
?:
string
;
order_type
?:
string
}):
void
}
>
()
const
searchQuery
=
ref
(
''
)
const
filters
=
reactive
({
status
:
''
,
payment_type
:
''
,
order_type
:
''
})
let
debounceTimer
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
function
handleSearch
()
{
if
(
debounceTimer
)
clearTimeout
(
debounceTimer
)
debounceTimer
=
setTimeout
(()
=>
emitFiltersChanged
(),
300
)
}
function
emitFiltersChanged
()
{
emit
(
'
filter
'
,
{
keyword
:
searchQuery
.
value
||
undefined
,
status
:
filters
.
status
||
undefined
,
payment_type
:
filters
.
payment_type
||
undefined
,
order_type
:
filters
.
order_type
||
undefined
,
})
}
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
id
'
,
label
:
t
(
'
payment.orders.orderId
'
)
},
{
key
:
'
user_id
'
,
label
:
t
(
'
payment.orders.userId
'
)
},
{
key
:
'
amount
'
,
label
:
t
(
'
payment.orders.amount
'
)
},
{
key
:
'
payment_type
'
,
label
:
t
(
'
payment.orders.paymentMethod
'
)
},
{
key
:
'
status
'
,
label
:
t
(
'
payment.orders.status
'
)
},
{
key
:
'
order_type
'
,
label
:
t
(
'
payment.orders.orderType
'
)
},
{
key
:
'
created_at
'
,
label
:
t
(
'
payment.orders.createdAt
'
)
},
{
key
:
'
actions
'
,
label
:
t
(
'
payment.orders.actions
'
)
},
])
const
statusFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
payment.admin.allStatuses
'
)
},
{
value
:
'
PENDING
'
,
label
:
t
(
'
payment.status.pending
'
)
},
{
value
:
'
PAID
'
,
label
:
t
(
'
payment.status.paid
'
)
},
{
value
:
'
COMPLETED
'
,
label
:
t
(
'
payment.status.completed
'
)
},
{
value
:
'
EXPIRED
'
,
label
:
t
(
'
payment.status.expired
'
)
},
{
value
:
'
CANCELLED
'
,
label
:
t
(
'
payment.status.cancelled
'
)
},
{
value
:
'
FAILED
'
,
label
:
t
(
'
payment.status.failed
'
)
},
{
value
:
'
REFUNDED
'
,
label
:
t
(
'
payment.status.refunded
'
)
},
{
value
:
'
REFUND_REQUESTED
'
,
label
:
t
(
'
payment.status.refund_requested
'
)
},
{
value
:
'
REFUND_FAILED
'
,
label
:
t
(
'
payment.status.refund_failed
'
)
},
])
const
paymentTypeFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
payment.admin.allPaymentTypes
'
)
},
{
value
:
'
alipay
'
,
label
:
t
(
'
payment.methods.alipay
'
)
},
{
value
:
'
wxpay
'
,
label
:
t
(
'
payment.methods.wxpay
'
)
},
{
value
:
'
stripe
'
,
label
:
t
(
'
payment.methods.stripe
'
)
},
])
const
orderTypeFilterOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
payment.admin.allOrderTypes
'
)
},
{
value
:
'
balance
'
,
label
:
t
(
'
payment.admin.balanceOrder
'
)
},
{
value
:
'
subscription
'
,
label
:
t
(
'
payment.admin.subscriptionOrder
'
)
},
])
function
canRefundRow
(
order
:
PaymentOrder
):
boolean
{
return
canRefund
(
order
.
status
)
}
function
formatDateTime
(
dateStr
:
string
):
string
{
return
formatOrderDateTime
(
dateStr
)
}
</
script
>
frontend/src/components/admin/payment/AdminRefundDialog.vue
0 → 100644
View file @
a04ae28a
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('payment.admin.refundOrder')"
width=
"normal"
@
close=
"emit('cancel')"
>
<form
id=
"refund-form"
@
submit.prevent=
"handleSubmit"
class=
"space-y-4"
>
<!-- Refund Request Info -->
<div
v-if=
"order?.refund_requested_at || order?.refund_request_reason"
class=
"rounded-lg border border-violet-200 bg-violet-50 p-3 dark:border-violet-800 dark:bg-violet-900/20"
>
<div
class=
"flex items-center gap-2 text-sm font-medium text-violet-700 dark:text-violet-300"
>
<svg
class=
"h-4 w-4"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{
t
(
'
payment.admin.refundRequestInfo
'
)
}}
</div>
<div
v-if=
"order?.refund_requested_at"
class=
"mt-2 flex justify-between text-sm"
>
<span
class=
"text-violet-600 dark:text-violet-400"
>
{{
t
(
'
payment.admin.refundRequestedAt
'
)
}}
</span>
<span
class=
"text-violet-800 dark:text-violet-200"
>
{{
formatDateTime
(
order
.
refund_requested_at
)
}}
</span>
</div>
<div
v-if=
"order?.refund_request_reason"
class=
"mt-1 text-sm"
>
<span
class=
"text-violet-600 dark:text-violet-400"
>
{{
t
(
'
payment.admin.refundRequestReason
'
)
}}
:
</span>
<span
class=
"ml-1 text-violet-800 dark:text-violet-200"
>
{{
order
.
refund_request_reason
}}
</span>
</div>
</div>
<!-- Order Info -->
<div
class=
"rounded-lg bg-gray-50 p-3 dark:bg-dark-700"
>
<div
class=
"flex justify-between text-sm"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.orders.orderId
'
)
}}
</span>
<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>
</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>
</div>
</div>
<!-- Deduct Balance -->
<div>
<div
class=
"flex items-center gap-2"
>
<input
id=
"deduct-balance"
v-model=
"form.deduct_balance"
type=
"checkbox"
class=
"h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label
for=
"deduct-balance"
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.admin.deductBalance
'
)
}}
</label>
<span
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.deductBalanceHint
'
)
}}
</span>
</div>
<!-- User Balance Info (when deduct_balance is checked) -->
<div
v-if=
"form.deduct_balance && userBalance != null"
class=
"mt-3 grid grid-cols-2 gap-3"
>
<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.userBalance
'
)
}}
</div>
<div
class=
"mt-1 font-semibold text-gray-900 dark:text-white"
>
$
{{
userBalance
.
toFixed
(
2
)
}}
</div>
</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>
</div>
<!-- Insufficient balance warning -->
<div
v-if=
"form.deduct_balance && balanceInsufficient"
class=
"mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
{{
t
(
'
payment.admin.insufficientBalance
'
)
}}
</div>
<!-- No deduction info -->
<div
v-if=
"!form.deduct_balance"
class=
"mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{{
t
(
'
payment.admin.noDeduction
'
)
}}
</div>
</div>
<!-- Refund Amount -->
<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>
<input
v-model.number=
"form.amount"
type=
"number"
step=
"0.01"
min=
"0.01"
:max=
"maxRefundable"
class=
"input pl-7"
required
/>
</div>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.maxRefundable
'
)
}}
: $
{{
maxRefundable
.
toFixed
(
2
)
}}
</p>
</div>
<!-- Reason -->
<div>
<label
class=
"input-label"
>
{{
t
(
'
payment.admin.refundReason
'
)
}}
</label>
<textarea
v-model=
"form.reason"
rows=
"3"
class=
"input"
:placeholder=
"t('payment.admin.refundReasonPlaceholder')"
required
></textarea>
</div>
<!-- Warning -->
<div
v-if=
"warning"
class=
"rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300"
>
{{
warning
}}
</div>
<!-- Force Refund -->
<div
v-if=
"requireForce"
class=
"flex items-center gap-2"
>
<input
id=
"force-refund"
v-model=
"form.force"
type=
"checkbox"
class=
"h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<label
for=
"force-refund"
class=
"text-sm font-medium text-red-600 dark:text-red-400"
>
{{
t
(
'
payment.admin.forceRefund
'
)
}}
</label>
</div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<button
type=
"button"
@
click=
"emit('cancel')"
class=
"btn btn-secondary"
>
{{
t
(
'
common.cancel
'
)
}}
</button>
<button
type=
"submit"
form=
"refund-form"
:disabled=
"submitting || form.amount
<
=
0
||
(
requireForce
&&
!
form.force
)"
class=
"rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-dark-800"
>
{{
submitting
?
t
(
'
common.processing
'
)
:
t
(
'
payment.admin.confirmRefund
'
)
}}
</button>
</div>
</
template
>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
reactive
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
{
formatOrderDateTime
}
from
'
@/components/payment/orderUtils
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
show
:
boolean
order
:
PaymentOrder
|
null
submitting
?:
boolean
userBalance
?:
number
|
null
requireForce
?:
boolean
warning
?:
string
}
>
()
const
emit
=
defineEmits
<
{
(
e
:
'
confirm
'
,
data
:
{
amount
:
number
;
reason
:
string
;
deduct_balance
:
boolean
;
force
:
boolean
}):
void
(
e
:
'
cancel
'
):
void
}
>
()
const
form
=
reactive
({
amount
:
0
,
reason
:
''
,
deduct_balance
:
true
,
force
:
false
,
})
// In REFUND_REQUESTED status, refund_amount is the REQUESTED amount, not actually refunded.
// Only PARTIALLY_REFUNDED / REFUNDED have real refund amounts.
const
actuallyRefunded
=
computed
(()
=>
{
if
(
!
props
.
order
)
return
0
const
s
=
props
.
order
.
status
if
(
s
===
'
PARTIALLY_REFUNDED
'
||
s
===
'
REFUNDED
'
)
return
props
.
order
.
refund_amount
||
0
return
0
})
const
maxRefundable
=
computed
(()
=>
{
if
(
!
props
.
order
)
return
0
return
props
.
order
.
pay_amount
-
actuallyRefunded
.
value
})
const
balanceInsufficient
=
computed
(()
=>
{
if
(
props
.
userBalance
==
null
||
!
props
.
order
)
return
false
return
props
.
userBalance
<
props
.
order
.
pay_amount
})
watch
(()
=>
props
.
show
,
(
val
)
=>
{
if
(
val
&&
props
.
order
)
{
// For REFUND_REQUESTED, pre-fill with the requested amount
if
(
props
.
order
.
status
===
'
REFUND_REQUESTED
'
&&
props
.
order
.
refund_amount
)
{
form
.
amount
=
props
.
order
.
refund_amount
}
else
{
form
.
amount
=
maxRefundable
.
value
}
form
.
reason
=
props
.
order
.
refund_request_reason
||
''
form
.
deduct_balance
=
true
form
.
force
=
false
}
})
function
formatDateTime
(
dateStr
:
string
):
string
{
return
formatOrderDateTime
(
dateStr
)
}
function
handleSubmit
()
{
if
(
form
.
amount
<=
0
||
form
.
amount
>
maxRefundable
.
value
)
return
if
(
props
.
requireForce
&&
!
form
.
force
)
return
emit
(
'
confirm
'
,
{
...
form
})
}
</
script
>
frontend/src/components/admin/payment/DailyRevenueChart.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.admin.dailyRevenue
'
)
}}
</h3>
<div
class=
"h-64"
>
<div
v-if=
"loading"
class=
"flex h-full items-center justify-center"
>
<LoadingSpinner
size=
"md"
/>
</div>
<Line
v-else-if=
"chartData"
:data=
"chartData"
:options=
"chartOptions"
/>
<div
v-else
class=
"flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.noData
'
)
}}
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Chart
as
ChartJS
,
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
Tooltip
,
Legend
,
Filler
}
from
'
chart.js
'
import
{
Line
}
from
'
vue-chartjs
'
import
LoadingSpinner
from
'
@/components/common/LoadingSpinner.vue
'
ChartJS
.
register
(
CategoryScale
,
LinearScale
,
PointElement
,
LineElement
,
Tooltip
,
Legend
,
Filler
)
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
data
:
{
date
:
string
;
amount
:
number
;
count
:
number
}[]
loading
?:
boolean
}
>
()
const
chartData
=
computed
(()
=>
{
if
(
!
props
.
data
||
props
.
data
.
length
===
0
)
return
null
return
{
labels
:
props
.
data
.
map
(
d
=>
d
.
date
),
datasets
:
[
{
label
:
t
(
'
payment.admin.revenue
'
),
data
:
props
.
data
.
map
(
d
=>
d
.
amount
),
borderColor
:
'
rgb(59, 130, 246)
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
tension
:
0.3
,
pointRadius
:
3
,
pointHoverRadius
:
5
,
},
{
label
:
t
(
'
payment.admin.orderCount
'
),
data
:
props
.
data
.
map
(
d
=>
d
.
count
),
borderColor
:
'
rgb(16, 185, 129)
'
,
backgroundColor
:
'
rgba(16, 185, 129, 0.1)
'
,
fill
:
false
,
tension
:
0.3
,
pointRadius
:
3
,
pointHoverRadius
:
5
,
yAxisID
:
'
y1
'
,
}
]
}
})
const
chartOptions
=
{
responsive
:
true
,
maintainAspectRatio
:
false
,
interaction
:
{
mode
:
'
index
'
as
const
,
intersect
:
false
},
scales
:
{
y
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
left
'
as
const
,
title
:
{
display
:
true
,
text
:
t
(
'
payment.admin.revenue
'
)
},
},
y1
:
{
type
:
'
linear
'
as
const
,
display
:
true
,
position
:
'
right
'
as
const
,
title
:
{
display
:
true
,
text
:
t
(
'
payment.admin.orderCount
'
)
},
grid
:
{
drawOnChartArea
:
false
},
}
},
plugins
:
{
legend
:
{
position
:
'
top
'
as
const
},
}
}
</
script
>
frontend/src/components/admin/payment/OrderStatsCards.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<!-- Today Revenue -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-green-100 p-2 dark:bg-green-900/30"
>
<Icon
name=
"dollar"
size=
"md"
class=
"text-green-600 dark:text-green-400"
:stroke-width=
"2"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.todayRevenue
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatMoney
(
stats
.
today_amount
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
stats
.
today_count
}}
{{
t
(
'
payment.admin.orders
'
)
}}
</p>
</div>
</div>
</div>
<!-- Total Revenue -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30"
>
<Icon
name=
"creditCard"
size=
"md"
class=
"text-blue-600 dark:text-blue-400"
:stroke-width=
"2"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.totalRevenue
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatMoney
(
stats
.
total_amount
)
}}
</p>
<p
class=
"text-xs text-gray-500 dark:text-gray-400"
>
{{
stats
.
total_count
}}
{{
t
(
'
payment.admin.orders
'
)
}}
</p>
</div>
</div>
</div>
<!-- Today Orders -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30"
>
<Icon
name=
"chart"
size=
"md"
class=
"text-purple-600 dark:text-purple-400"
:stroke-width=
"2"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.todayOrders
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
{{
stats
.
today_count
}}
</p>
</div>
</div>
</div>
<!-- Average Amount -->
<div
class=
"card p-4"
>
<div
class=
"flex items-center gap-3"
>
<div
class=
"rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30"
>
<Icon
name=
"chart"
size=
"md"
class=
"text-amber-600 dark:text-amber-400"
:stroke-width=
"2"
/>
</div>
<div>
<p
class=
"text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.avgAmount
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatMoney
(
stats
.
avg_amount
)
}}
</p>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
DashboardStats
}
from
'
@/types/payment
'
const
{
t
}
=
useI18n
()
defineProps
<
{
stats
:
DashboardStats
}
>
()
function
formatMoney
(
value
:
number
):
string
{
return
value
.
toFixed
(
2
)
}
</
script
>
frontend/src/components/admin/payment/PaymentMethodChart.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.admin.paymentDistribution
'
)
}}
</h3>
<div
v-if=
"!methods?.length"
class=
"flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.noData
'
)
}}
</div>
<div
v-else
class=
"space-y-3"
>
<div
v-for=
"method in methods"
:key=
"method.type"
class=
"space-y-1"
>
<div
class=
"flex items-center justify-between"
>
<div
class=
"flex items-center gap-2"
>
<span
:class=
"['inline-block h-3 w-3 rounded-full', colorMap[method.type] || 'bg-gray-400']"
></span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
t
(
'
payment.methods.
'
+
method
.
type
,
method
.
type
)
}}
</span>
</div>
<div
class=
"text-right"
>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
method
.
amount
.
toFixed
(
2
)
}}
</span>
<span
class=
"ml-2 text-xs text-gray-500 dark:text-gray-400"
>
(
{{
method
.
count
}}
)
</span>
</div>
</div>
<div
class=
"h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700"
>
<div
:class=
"['h-full rounded-full transition-all', barColorMap[method.type] || 'bg-gray-400']"
:style=
"
{ width: barWidth(method.amount) + '%' }"
>
</div>
</div>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
const
props
=
defineProps
<
{
methods
:
{
type
:
string
;
amount
:
number
;
count
:
number
}[]
}
>
()
const
colorMap
:
Record
<
string
,
string
>
=
{
alipay
:
'
bg-blue-500
'
,
wxpay
:
'
bg-green-500
'
,
alipay_direct
:
'
bg-blue-400
'
,
wxpay_direct
:
'
bg-green-400
'
,
stripe
:
'
bg-purple-500
'
,
}
const
barColorMap
:
Record
<
string
,
string
>
=
{
alipay
:
'
bg-blue-500
'
,
wxpay
:
'
bg-green-500
'
,
alipay_direct
:
'
bg-blue-400
'
,
wxpay_direct
:
'
bg-green-400
'
,
stripe
:
'
bg-purple-500
'
,
}
const
maxAmount
=
computed
(()
=>
{
if
(
!
props
.
methods
?.
length
)
return
1
return
Math
.
max
(...
props
.
methods
.
map
(
m
=>
m
.
amount
),
1
)
})
function
barWidth
(
amount
:
number
):
number
{
return
Math
.
min
((
amount
/
maxAmount
.
value
)
*
100
,
100
)
}
</
script
>
frontend/src/components/admin/payment/TopUsersLeaderboard.vue
0 → 100644
View file @
a04ae28a
<
template
>
<div
class=
"card p-4"
>
<h3
class=
"mb-4 text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
payment.admin.topUsers
'
)
}}
</h3>
<div
v-if=
"!users?.length"
class=
"flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.admin.noData
'
)
}}
</div>
<div
v-else
class=
"space-y-2"
>
<div
v-for=
"(user, idx) in users"
:key=
"user.user_id"
class=
"flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700"
>
<div
class=
"flex items-center gap-3"
>
<span
:class=
"[
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
rankClass(idx),
]"
>
{{
idx
+
1
}}
</span>
<span
class=
"text-sm text-gray-700 dark:text-gray-300"
>
{{
user
.
email
}}
</span>
</div>
<span
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
$
{{
user
.
amount
.
toFixed
(
2
)
}}
</span>
</div>
</div>
</div>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
useI18n
}
from
'
vue-i18n
'
const
{
t
}
=
useI18n
()
defineProps
<
{
users
:
{
user_id
:
number
;
email
:
string
;
amount
:
number
}[]
}
>
()
function
rankClass
(
idx
:
number
):
string
{
if
(
idx
===
0
)
return
'
bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400
'
if
(
idx
===
1
)
return
'
bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300
'
if
(
idx
===
2
)
return
'
bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400
'
return
'
bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400
'
}
</
script
>
frontend/src/components/admin/usage/UsageTable.vue
View file @
a04ae28a
<
template
>
<
template
>
<div
class=
"card overflow-hidden"
>
<div
class=
"card overflow-hidden"
>
<div
class=
"overflow-auto"
>
<div
class=
"overflow-auto"
>
<DataTable
:columns=
"columns"
:data=
"data"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"data"
:loading=
"loading"
:server-side-sort=
"serverSideSort"
:default-sort-key=
"defaultSortKey"
:default-sort-order=
"defaultSortOrder"
@
sort=
"(key, order) => $emit('sort', key, order)"
>
<template
#cell-user
="
{ row }">
<template
#cell-user
="
{ row }">
<div
class=
"text-sm"
>
<div
class=
"text-sm"
>
<button
<button
...
@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
...
@@ -334,9 +342,27 @@ 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
'
import
type
{
AdminUsageLog
}
from
'
@/types
'
import
type
{
AdminUsageLog
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
interface
Props
{
data
:
AdminUsageLog
[]
loading
?:
boolean
columns
:
Column
[]
serverSideSort
?:
boolean
defaultSortKey
?:
string
defaultSortOrder
?:
'
asc
'
|
'
desc
'
}
defineProps
([
'
data
'
,
'
loading
'
,
'
columns
'
])
withDefaults
(
defineProps
<
Props
>
(),
{
defineEmits
([
'
userClick
'
])
loading
:
false
,
serverSideSort
:
false
,
defaultSortKey
:
''
,
defaultSortOrder
:
'
asc
'
})
defineEmits
<
{
userClick
:
[
userID
:
number
,
email
?:
string
]
sort
:
[
key
:
string
,
order
:
'
asc
'
|
'
desc
'
]
}
>
()
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
// Tooltip state - cost
// Tooltip state - cost
...
...
frontend/src/components/auth/LinuxDoOAuthSection.vue
View file @
a04ae28a
...
@@ -29,10 +29,10 @@
...
@@ -29,10 +29,10 @@
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
{{
t
(
'
auth.linuxdo.signIn
'
)
}}
</button>
</button>
<div
class=
"flex items-center gap-3"
>
<div
v-if=
"showDivider"
class=
"flex items-center gap-3"
>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
<span
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
auth.
linuxdo.o
rContinue
'
)
}}
{{
t
(
'
auth.
oauthO
rContinue
'
)
}}
</span>
</span>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
<div
class=
"h-px flex-1 bg-gray-200 dark:bg-dark-700"
></div>
</div>
</div>
...
@@ -43,9 +43,12 @@
...
@@ -43,9 +43,12 @@
import
{
useRoute
}
from
'
vue-router
'
import
{
useRoute
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
defineProps
<
{
withDefaults
(
defineProps
<
{
disabled
?:
boolean
disabled
?:
boolean
}
>
()
showDivider
?:
boolean
}
>
(),
{
showDivider
:
true
})
const
route
=
useRoute
()
const
route
=
useRoute
()
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
@@ -58,4 +61,3 @@ function startLogin(): void {
...
@@ -58,4 +61,3 @@ function startLogin(): void {
window
.
location
.
href
=
startURL
window
.
location
.
href
=
startURL
}
}
</
script
>
</
script
>
Prev
1
…
9
10
11
12
13
14
15
16
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